ConversationTaskRow.tsx7.3 KBView on GitHub
/**
 * ConversationTaskRow — renders a user task in the same visual style as the
 * Thread component row (avatar slot → checkbox, subject → description, date →
 * due date, hover actions for complete / open thread).
 */

import type { ConversationUserTask } from '@/modules/crm/types';
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
import { useTRPC } from '@/providers/query-provider';
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { useCedarStore } from '@/modules/store';
import { Checkbox } from '@/components/ui/checkbox';
import { format } from 'date-fns';
import { TaskLine } from '@/modules/userTasks/components/TaskLine';
import { cn } from '@/lib/utils';
import { Check } from 'lucide-react';
import { toast } from 'sonner';
import { memo, useCallback } from 'react';

interface ConversationTaskRowProps {
  task: ConversationUserTask;
  className?: string;
  /** Removes outer mx-2, reduces inner gap/padding, and shrinks checkbox hover area */
  compact?: boolean;
}

export const ConversationTaskRow = memo(function ConversationTaskRow({
  task,
  className,
  compact = false,
}: ConversationTaskRowProps) {
  const trpc = useTRPC();
  const queryClient = useQueryClient();
  const selectThreadId = useCedarStore((state) => state.selectThreadId);
  const setIsThreadOpen = useCedarStore((state) => state.setIsThreadOpen);

  const isDone = task.status === 'done';

  const completeMutation = useMutation({
    ...trpc.userTasks.completeTask.mutationOptions(),
    /**
     * All four reads carry `userTasks`, and leaving any of them stale is how a ticked row comes
     * straight back. This row used to refresh only `conversationInbox.list` — so the Strategic
     * Overview (`crm.getConversation`), the task lists (`userTasks.listUserTasks`) and the CRM
     * table (`crm.listConversations`) kept serving the task as `todo` after it was completed
     * here. The main checkbox path has always invalidated all three; this one is the same
     * capability and needs the same convergence (design: zach-sept10-bugs.md §1).
     */
    onSuccess: () => {
      void Promise.all([
        queryClient.invalidateQueries({ queryKey: [['conversationInbox', 'list']] }),
        queryClient.invalidateQueries({ queryKey=[redacted] }),
        queryClient.invalidateQueries({
          queryKey=[redacted] id: task.conversationId }),
        }),
        queryClient.invalidateQueries({ queryKey: [['crm', 'listConversations']] }),
      ]);
    },
    /**
     * A failure here means the row on screen disagrees with the server, so the useful response is
     * to go and find out, not just to complain. Without the refetch the stale row stays put and
     * the next tick fails identically — the loop from Zach's report, in miniature.
     */
    onError: () => {
      toast.error('Failed to complete task');
      void queryClient.invalidateQueries({
        queryKey=[redacted] id: task.conversationId }),
      });
    },
  });

  const handleComplete = useCallback(
    (e: React.MouseEvent) => {
      e.stopPropagation();
      if (isDone) return;
      // Ticking cleans up the task's Gmail draft, matching delete. Completion is one-way on this
      // row (`isDone` short-circuits), so there is no undo window to hold the cleanup behind.
      completeMutation.mutate({ taskId: task.id, cleanupDraft: true });
    },
    [isDone, completeMutation, task.id],
  );

  const handleRowClick = useCallback(() => {
    const actionData = task.taskActionData;
    if (actionData?.channel === 'email' && actionData.threadId) {
      selectThreadId(actionData.threadId);
      setIsThreadOpen(true);
    }
  }, [task.taskActionData, selectThreadId, setIsThreadOpen]);

  const hasThread =
    task.taskActionData?.channel === 'email' && !!task.taskActionData.threadId;

  return (
    <>
      {/* Divider */}
      <div className="border-border/75 mx-2 border-t" />

      <div
        className={cn(
          'group relative flex cursor-pointer flex-col items-start rounded-lg py-1.5 text-left text-sm md:py-2.5',
          compact ? 'mx-0' : 'mx-2',
          'hover:bg-action/12',
          isDone && 'opacity-50',
          className,
        )}
        onClick={hasThread ? handleRowClick : undefined}
      >
        <div className={cn('relative flex w-full items-center', compact ? 'gap-2 px-2' : 'gap-4 px-3')}>
          {/* Checkbox slot (mirrors avatar slot in Thread) */}
          <div
            className="relative flex h-6 w-6 shrink-0 items-center justify-center"
            onClick={handleComplete}
          >
            <div className={cn('hover:bg-primary/20 absolute left-1/2 top-1/2 z-10 -translate-x-1/2 -translate-y-1/2 rounded-full transition-colors', compact ? 'h-6 w-6' : 'h-8 w-8')} />
            <Tooltip>
              <TooltipTrigger asChild>
                <Checkbox
                  checked={isDone}
                  className="relative h-4 w-4"
                  onClick={(e) => e.stopPropagation()}
                  onCheckedChange={() => handleComplete({ stopPropagation: () => {} } as React.MouseEvent)}
                />
              </TooltipTrigger>
              <TooltipContent side="top" className="bg-raised mb-1 text-xs">
                {isDone ? 'Completed' : 'Mark complete'}
              </TooltipContent>
            </Tooltip>
          </div>

          <div className="flex min-w-0 flex-1 items-center justify-between gap-2">
            <div className="flex min-w-0 flex-1 items-center gap-2">
              {/* Task type badge (mirrors the sender name column) */}
              <span
                className={cn(
                  'text-foreground flex w-36 shrink-0 items-center gap-1 text-sm xl:w-44 2xl:w-52',
                  isDone ? 'font-normal' : 'font-medium',
                )}
              >
                <span className="min-w-0 truncate text-xs font-medium uppercase tracking-wide text-muted-foreground">
                  {task.taskType ?? 'Task'}
                </span>
              </span>

              {/* Unread dot placeholder — always invisible for tasks, keeps subject aligned */}
              <span className="mr-0.5 flex size-2 shrink-0 items-center rounded-full invisible" />

              {/* Description (mirrors subject) */}
              {task.description && (
                <TaskLine description={task.description} done={isDone} truncate />
              )}

              {/* Completed indicator */}
              {isDone && (
                <span className="flex shrink-0 items-center gap-0.5 text-xs text-green-600 dark:text-green-400">
                  <Check className="h-3 w-3" />
                  Done
                </span>
              )}
            </div>

            {/* Due date (mirrors thread date) */}
            {task.dueDate && (
              <p
                className={cn(
                  'w-16 shrink-0 text-right text-xs font-normal opacity-85',
                  'text-muted-foreground dark:text-[#8C8C8C]',
                  new Date(task.dueDate) < new Date() && !isDone
                    ? 'text-red-500 dark:text-red-400 opacity-100'
                    : '',
                )}
              >
                {format(new Date(task.dueDate as string | Date), 'MMM d')}
              </p>
            )}
          </div>
        </div>
      </div>
    </>
  );
});