ConversationCard.tsx7.3 KBView on GitHub
/**
 * ConversationCard
 *
 * A single conversation rendered as a card (for the `cardList` canvas type).
 * All data comes off a HydratedConversation. The whole card is a click target
 * that opens the conversation (selectedArtifact { kind: 'conversation' }).
 *
 * Anatomy (see docs/conversation-index-and-card-lists.md §3.2 step 6):
 *   Header:   [company icon] conversation.name ★(if starred)   ←→  formatDealValue(dealValue)
 *   Top row:  labelled display fields — stage: · next step: · last contact:
 *             stage renders the SAME StatusBadgeEditor the CRM ConversationItem uses
 *             (with the canvas's status enumOptions); dates use RelativeDateBadge.
 *   Tasks:    each due (todo) task as an agenda-style row — [ ] task description
 */

import type { HydratedConversation, CrmFieldEnumOption } from '@/modules/crm/types';
import { formatDealValue } from '@/modules/crm/utils';
import { StatusBadgeEditor } from '@/modules/crm/components/ConversationCellComponents';
import { useOptimisticConversationActions } from '@/modules/crm/hooks/use-optimistic-conversation-actions';
import { TaskLine } from '@/modules/userTasks/components/TaskLine';
import { RelativeDateBadge } from '@/components/ui/relative-date-badge';
import { useCedarStore } from '@/modules/store';
import { Building2, Check, Star, X } from 'lucide-react';
import { cn } from '@/lib/utils';

interface ConversationCardProps {
  conversation: HydratedConversation;
  onClick: () => void;
  /** The canvas's status enumOptions (colours/icons) — same source ConversationItem uses. */
  statusOptions: CrmFieldEnumOption[];
  /**
   * When provided, a remove "×" is shown on the card (manual pinned-list curation).
   * Fires without triggering the card's open action.
   */
  onRemove?: () => void;
  className?: string;
}

export function ConversationCard({
  conversation,
  onClick,
  statusOptions,
  onRemove,
  className,
}: ConversationCardProps) {
  const conv = conversation.conversation;
  const { optimisticUpdateConversation } = useOptimisticConversationActions();
  // Demo mode blurs sensitive figures (deal size / ACV) for screen-sharing.
  const isDemoMode = useCedarStore((state) => state.isDemoMode);

  const status = conv.status;
  const logoUrl = conversation.company?.logoUrl;

  const todoTasks = (conversation.userTasks ?? []).filter((t) => t.status === 'todo');

  // Top-row display fields — hardcoded to stage / next step / last contact.
  const topFields = status || conv.nextStepDate || conv.lastContactedAt;

  return (
    <div className={cn('group relative', className)}>
      {onRemove && (
        <span
          role="button"
          tabIndex={0}
          aria-label="Remove from list"
          onClick={(e) => {
            e.stopPropagation();
            onRemove();
          }}
          onKeyDown={(e) => {
            if (e.key === 'Enter' || e.key === ' ') {
              e.preventDefault();
              e.stopPropagation();
              onRemove();
            }
          }}
          className={cn(
            'absolute right-2 top-2 z-10 flex h-5 w-5 cursor-pointer items-center justify-center rounded-full',
            'bg-muted/70 text-muted-foreground opacity-0 transition-opacity hover:bg-muted hover:text-foreground',
            'group-hover:opacity-100 focus-visible:opacity-100 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring',
          )}
        >
          <X className="h-3 w-3" />
        </span>
      )}
      <button
        type="button"
        onClick={onClick}
        className={cn(
          'flex w-full cursor-pointer flex-col gap-2 rounded-lg border border-border bg-card px-4 py-3 text-left',
          // hover:bg-accent, NOT a translucent bg-muted/40: the hover colour REPLACES the card's
          // own `bg-card`, so a translucent one makes the whole card go see-through wherever the
          // page behind it isn't flat — on the /agent hero it reads as the card fading out.
          'transition-colors hover:bg-accent focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring',
        )}
      >
        {/* Header: company icon + name (+ star)  ←→  ACV */}
        <div className="flex items-start justify-between gap-3">
          <div className="flex min-w-0 items-center gap-2">
            {logoUrl ? (
              // eslint-disable-next-line @next/next/no-img-element
              <img src={logoUrl} alt="" className="h-4 w-4 shrink-0 rounded-sm object-contain" />
            ) : (
              <Building2 className="text-muted-foreground h-4 w-4 shrink-0" />
            )}
            <span className="truncate text-sm font-semibold text-foreground">
              {conv.name || 'Untitled'}
            </span>
            {conv.important && (
              <Star className="h-3.5 w-3.5 shrink-0 fill-amber-400 text-amber-400" />
            )}
          </div>
          <span
            className={cn(
              'shrink-0 text-sm font-semibold text-foreground transition-all',
              isDemoMode && 'select-none blur-[2px]',
            )}
            title={isDemoMode ? 'Hidden in demo mode' : undefined}
          >
            {formatDealValue(conv.dealValue)}
          </span>
        </div>

        {/* Top row — labelled display fields (SAME badge components as the CRM canvas) */}
        {topFields && (
          <div className="flex flex-wrap items-center gap-x-3 gap-y-1 text-xs text-muted-foreground">
            {status && (
              <span
                className="inline-flex items-center gap-1"
                onClick={(e) => e.stopPropagation()}
              >
                stage:
                <StatusBadgeEditor
                  value={status}
                  options={statusOptions}
                  onSelect={(v) => optimisticUpdateConversation(conv.id, { status: v || null })}
                  columnId="status"
                  placeholder="Status..."
                />
              </span>
            )}
            {conv.nextStepDate && (
              <span className="inline-flex items-center gap-1">
                next step:
                <RelativeDateBadge date={conv.nextStepDate} colorType="scheduled" />
              </span>
            )}
            {conv.lastContactedAt && (
              <span className="inline-flex items-center gap-1">
                last contact:
                <RelativeDateBadge date={conv.lastContactedAt} colorType="history" />
              </span>
            )}
          </div>
        )}

        {/* Due tasks — agenda-style [ ] checkbox + description (display only); or a
            caught-up state when this deal has nothing pending. */}
        {todoTasks.length > 0 ? (
          <div className="flex flex-col gap-1.5 pt-0.5">
            {todoTasks.map((task) => (
              <div key=[redacted] className="flex items-start gap-2">
                <span className="mt-0.5 h-3.5 w-3.5 shrink-0 rounded-sm border border-muted-foreground/50" />
                <TaskLine description={task.description} clamp className="text-foreground/90" />
              </div>
            ))}
          </div>
        ) : (
          <div className="flex items-center gap-1.5 pt-0.5 text-sm text-emerald-600 dark:text-emerald-400">
            <Check className="h-3.5 w-3.5 shrink-0" />
            <span>No due tasks</span>
          </div>
        )}
      </button>
    </div>
  );
}