CreateConversationDialog.tsx19.0 KBView on GitHub
import {
  Dialog,
  DialogContent,
  DialogFooter,
  DialogHeader,
  DialogTitle,
} from '@/components/ui/dialog';
import { DEFAULT_PRIORITY_OPTIONS, DEFAULT_STATUS_OPTIONS } from '@/modules/crm/types';
import { getEnumColor, sortEnumOptions } from '@/modules/crm/field-enums';
import { getEnumDisplayText } from '@/modules/crm/utils';
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { useCanvasConfiguration } from '../../hooks/use-canvas-configuration';
import { useMutation } from '@tanstack/react-query';
import { useTRPC } from '@/providers/query-provider';
import { Button } from '@/components/ui/button';
import { useCedarStore } from '@/modules/store';
import { Building2, ChevronDown, Loader2, X } from 'lucide-react';
import { Input } from '@/components/ui/input';
import { Switch } from '@/components/ui/switch';
import { Textarea } from '@/components/ui/textarea';
import type { CrmFieldEnumOption } from '@/modules/crm/types';
import type { Aop } from '@/modules/aop/slice/aopSlice';
import { toast } from 'sonner';
import { cn } from '@/lib/utils';
import { z } from 'zod';

interface CreateConversationDialogProps {
  open: boolean;
  onOpenChange: (open: boolean) => void;
  canvasId?: string;
}

type CompanyCandidate = {
  companyId: number;
  companyName: string;
  domain: string | null;
  linkedinUrl: string | null;
  headcount: number | null;
  country: string | null;
  industries: string[];
  logoUrl: string | null;
  score: number;
};

function FieldRow({ label, children }: { label: string; children: React.ReactNode }) {
  return (
    <div className="flex min-h-8 items-start gap-4 py-1">
      <span className="w-24 shrink-0 pt-1 text-base text-muted-foreground">{label}</span>
      <div className="flex min-w-0 flex-1 flex-wrap items-center gap-1">{children}</div>
    </div>
  );
}

/** Inline pill-button option selector — avoids Radix dropdown z-index issues inside Dialog */
function PillSelect({
  value,
  options,
  onSelect,
  fieldKey,
  placeholder = 'Select…',
}: {
  value: string;
  options: CrmFieldEnumOption[];
  onSelect: (v: string) => void;
  fieldKey=[redacted] | 'priority';
  placeholder?: string;
}) {
  const [open, setOpen] = useState(false);
  const valid = options.filter((o): o is CrmFieldEnumOption & { value: string } => o.value != null);

  if (!open) {
    const selected = valid.find((o) => o.value === value);
    return (
      <button
        type="button"
        onClick={() => setOpen(true)}
        className={cn(
          'flex items-center gap-1 rounded-full px-2.5 py-0.5 text-xs font-medium transition-colors',
          selected
            ? getEnumColor(value, fieldKey)
            : 'bg-muted/50 text-muted-foreground hover:bg-muted',
        )}
      >
        {selected ? getEnumDisplayText(value, valid) : placeholder}
        <ChevronDown className="h-3 w-3 opacity-60" />
      </button>
    );
  }

  return (
    <div className="flex flex-wrap gap-1">
      {sortEnumOptions(valid).map((opt) => (
        <button
          key=[redacted]
          type="button"
          onClick={() => { onSelect(opt.value as string); setOpen(false); }}
          className={cn(
            'rounded-full px-2.5 py-0.5 text-xs font-medium transition-opacity',
            getEnumColor(opt.value, fieldKey),
            value === opt.value ? 'ring-2 ring-offset-1 ring-foreground/30' : 'opacity-70 hover:opacity-100',
          )}
        >
          {getEnumDisplayText(opt.value, valid)}
        </button>
      ))}
    </div>
  );
}

/** Inline AOP (type) pill selector */
function AopSelect({
  value,
  aops,
  onSelect,
}: {
  value: string | null;
  aops: Aop[];
  onSelect: (id: string | null) => void;
}) {
  const [open, setOpen] = useState(false);
  const selected = aops.find((a) => a.id === value);

  if (!open) {
    return (
      <button
        type="button"
        onClick={() => setOpen(true)}
        className="flex items-center gap-1 rounded-full bg-sunken px-2.5 py-0.5 text-xs font-normal text-muted-foreground transition-colors hover:bg-muted"
      >
        {selected?.name ?? 'No type'}
        <ChevronDown className="h-3 w-3 opacity-60" />
      </button>
    );
  }

  return (
    <div className="flex flex-wrap gap-1">
      <button
        type="button"
        onClick={() => { onSelect(null); setOpen(false); }}
        className={cn(
          'rounded-full px-2.5 py-0.5 text-xs font-normal transition-opacity',
          'bg-muted text-muted-foreground',
          value === null ? 'ring-2 ring-offset-1 ring-foreground/30' : 'opacity-70 hover:opacity-100',
        )}
      >
        No type
      </button>
      {aops.map((aop) => (
        <button
          key=[redacted]
          type="button"
          onClick={() => { onSelect(aop.id); setOpen(false); }}
          className={cn(
            'rounded-full bg-sunken px-2.5 py-0.5 text-xs font-normal transition-opacity',
            value === aop.id ? 'ring-2 ring-offset-1 ring-foreground/30' : 'opacity-70 hover:opacity-100',
          )}
        >
          {aop.name}
        </button>
      ))}
    </div>
  );
}

/** Two-step company search — mirrors the "Change company enrichment" flow */
function CompanySearchField({
  selected,
  onSelect,
  onClear,
}: {
  selected: CompanyCandidate | null;
  onSelect: (c: CompanyCandidate) => void;
  onClear: () => void;
}) {
  const trpc = useTRPC();
  const [query, setQuery] = useState('');
  const [candidates, setCandidates] = useState<CompanyCandidate[]>([]);
  const [searchedEmpty, setSearchedEmpty] = useState(false);
  const inputRef = useRef<HTMLInputElement>(null);

  const searchMutation = useMutation(trpc.enrichment.searchCompaniesForEnrichment.mutationOptions());

  const handleSearch = useCallback(() => {
    if (!query.trim()) return;
    setCandidates([]);
    setSearchedEmpty(false);
    searchMutation.mutate(
      { query: query.trim() },
      {
        onSuccess: (results) => {
          setCandidates(results);
          if (results.length === 0) setSearchedEmpty(true);
        },
        onError: (err) => toast.error(err instanceof Error ? err.message : 'Search failed'),
      },
    );
  }, [query, searchMutation]);

  // If a company is already selected, show it as a badge
  if (selected) {
    return (
      <div className="flex items-center gap-1.5">
        {selected.logoUrl ? (
          <img src={selected.logoUrl} alt={selected.companyName} className="h-4 w-4 rounded object-contain" />
        ) : (
          <Building2 className="h-3.5 w-3.5 text-muted-foreground" />
        )}
        <span className="text-base font-medium">{selected.companyName}</span>
        {selected.domain && (
          <span className="text-sm text-muted-foreground">{selected.domain}</span>
        )}
        <button
          type="button"
          onClick={onClear}
          className="ml-1 rounded-sm p-0.5 hover:bg-muted"
        >
          <X className="h-3 w-3 text-muted-foreground" />
        </button>
      </div>
    );
  }

  // Candidate list step
  if (candidates.length > 0) {
    return (
      <div className="w-full space-y-1.5">
        <div className="max-h-48 space-y-1 overflow-y-auto">
          {candidates.map((c) => (
            <button
              key=[redacted]
              type="button"
              onClick={() => onSelect(c)}
              className="flex w-full items-center gap-2.5 rounded-md border border-border px-3 py-2 text-left transition-colors hover:bg-muted/50"
            >
              {c.logoUrl ? (
                <img src={c.logoUrl} alt={c.companyName} className="h-7 w-7 shrink-0 rounded object-contain" />
              ) : (
                <div className="flex h-7 w-7 shrink-0 items-center justify-center rounded bg-muted">
                  <Building2 className="h-3.5 w-3.5 text-muted-foreground" />
                </div>
              )}
              <div className="min-w-0 flex-1">
                <div className="truncate text-sm font-medium">{c.companyName}</div>
                <div className="truncate text-xs text-muted-foreground">
                  {[c.domain, c.country, c.headcount != null ? `${c.headcount} employees` : null]
                    .filter(Boolean)
                    .join(' · ')}
                </div>
              </div>
            </button>
          ))}
        </div>
        <button
          type="button"
          className="text-xs text-muted-foreground underline underline-offset-2"
          onClick={() => { setCandidates([]); setSearchedEmpty(false); }}
        >
          Search again
        </button>
      </div>
    );
  }

  // Search input step
  return (
    <div className="flex w-full items-center gap-2">
      <Input
        ref={inputRef}
        placeholder="acme.com · Acme Inc · linkedin.com/company/…"
        value={query}
        onChange={(e) => setQuery(e.target.value)}
        onKeyDown={(e) => { if (e.key === 'Enter') handleSearch(); }}
        className="h-8 min-w-0 flex-1 border-0 bg-transparent px-0 text-base shadow-none focus-visible:ring-0"
        autoFocus
      />
      {searchedEmpty && (
        <span className="shrink-0 text-xs text-destructive">No results</span>
      )}
      <Button
        type="button"
        size="sm"
        variant="outline"
        className="h-7 shrink-0 px-2 text-xs"
        onClick={handleSearch}
        disabled={!query.trim() || searchMutation.isPending}
      >
        {searchMutation.isPending ? <Loader2 className="h-3 w-3 animate-spin" /> : 'Search'}
      </Button>
    </div>
  );
}

export function CreateConversationDialog({
  open,
  onOpenChange,
  canvasId,
}: CreateConversationDialogProps) {
  const trpc = useTRPC();
  const aopsById = useCedarStore((state) => state.aopsById);
  const setActiveConversationId = useCedarStore((state) => state.setActiveConversationId);
  const setIsConversationOpen = useCedarStore((state) => state.setIsConversationOpen);

  const { selectedAopIds: canvasSelectedAopIds } = useCanvasConfiguration(canvasId);

  const defaultAopId = useMemo(
    () => canvasSelectedAopIds?.find((id): id is string => id !== null) ?? null,
    [canvasSelectedAopIds],
  );

  const allAops = useMemo(() => Object.values(aopsById), [aopsById]);

  // Form state
  const [selectedCompany, setSelectedCompany] = useState<CompanyCandidate | null>(null);
  const [name, setName] = useState('');
  const [aopId, setAopId] = useState<string | null>(defaultAopId);
  const [status, setStatus] = useState('');
  const [priority, setPriority] = useState('');
  const [dealValue, setDealValue] = useState('');
  const [nextStep, setNextStep] = useState('');
  const [notes, setNotes] = useState('');
  const [searchEmails, setSearchEmails] = useState(false);
  /** Optional primary external contact — stored as a conversation participant for drafting */
  const [mainContactEmail, setMainContactEmail] = useState('');

  const nameWasAutoFilled = useRef(false);

  useEffect(() => {
    if (open) {
      setSelectedCompany(null);
      setName('');
      setAopId(defaultAopId);
      setStatus('');
      setPriority('');
      setDealValue('');
      setNextStep('');
      setNotes('');
      setSearchEmails(false);
      setMainContactEmail('');
      nameWasAutoFilled.current = false;
    }
  }, [open, defaultAopId]);

  const selectedAop = aopId ? aopsById[aopId] : null;

  const statusOptions = useMemo(() => {
    if (!selectedAop) return DEFAULT_STATUS_OPTIONS;
    const defs = selectedAop.conversationFieldDefinitions as
      | { status?: { options: typeof DEFAULT_STATUS_OPTIONS } }
      | null
      | undefined;
    return defs?.status?.options ?? DEFAULT_STATUS_OPTIONS;
  }, [selectedAop]);

  useEffect(() => { setStatus(statusOptions[0]?.value ?? ''); }, [statusOptions]);

  // Auto-fill name from company selection
  const handleCompanySelect = useCallback((c: CompanyCandidate) => {
    setSelectedCompany(c);
    if (!name || nameWasAutoFilled.current) {
      setName(c.companyName);
      nameWasAutoFilled.current = true;
    }
  }, [name]);

  const handleCompanyClear = useCallback(() => {
    setSelectedCompany(null);
    if (nameWasAutoFilled.current) {
      setName('');
      nameWasAutoFilled.current = false;
    }
  }, []);

  const createMutation = useMutation(trpc.crm.createConversation.mutationOptions());
  const changeCompanyMutation = useMutation(trpc.crm.changeConversationCompany.mutationOptions());

  const handleSubmit = useCallback(async () => {
    if (!selectedCompany) { toast.error('Select a company first'); return; }
    if (!name.trim()) { toast.error('Name is required'); return; }
    if (!status) { toast.error('Status is required'); return; }

    try {
      const trimmedContact = mainContactEmail.trim();
      let participantEmails: string[] | undefined;
      if (trimmedContact.length > 0) {
        const parsed = z.string().email().safeParse(trimmedContact);
        if (!parsed.success) {
          toast.error('Enter a valid email for their email, or leave it blank');
          return;
        }
        participantEmails = [parsed.data];
      }

      const conversation = await createMutation.mutateAsync({
        name: name.trim(),
        status,
        aopId: aopId ?? undefined,
        priority: priority || undefined,
        dealValue: dealValue ? parseFloat(dealValue) : undefined,
        nextStep: nextStep.trim() || undefined,
        // NOTE: `notes` is not part of `createConversation`'s input and never has been —
        // zod strips it, so the dialog's Notes box has never persisted anything. Left out
        // here rather than sent-and-dropped; wiring it up needs a column to write to.
        participantEmails,
        searchEmails,
        companyDomain: searchEmails
          ? selectedCompany.domain ?? selectedCompany.companyName
          : undefined,
      });

      // Link company with full enrichment data
      try {
        const linked = await changeCompanyMutation.mutateAsync({
          conversationId: conversation.id,
          domain: selectedCompany.domain ?? selectedCompany.companyName,
          linkedinUrl: selectedCompany.linkedinUrl ?? undefined,
          companyName: selectedCompany.companyName,
        });
        // A company backs only one conversation per owner — say so rather than leaving the
        // new conversation silently unlinked.
        if (linked.conflict) toast.warning(linked.message);
      } catch { /* non-fatal */ }

      onOpenChange(false);
      setActiveConversationId(conversation.id);
      setIsConversationOpen(true);
    } catch (error) {
      toast.error(error instanceof Error ? error.message : 'Failed to create conversation');
    }
  }, [
    selectedCompany, name, status, aopId, priority, dealValue, nextStep, notes, searchEmails,
    mainContactEmail,
    createMutation, changeCompanyMutation, onOpenChange,
    setActiveConversationId, setIsConversationOpen,
  ]);

  const isSubmitting = createMutation.isPending || changeCompanyMutation.isPending;

  return (
    <Dialog open={open} onOpenChange={onOpenChange}>
      <DialogContent className="max-w-md">
        <DialogHeader>
          <DialogTitle>Create conversation</DialogTitle>
        </DialogHeader>

        <div className="flex flex-col gap-0.5 py-2">
          <FieldRow label="Company">
            <CompanySearchField
              selected={selectedCompany}
              onSelect={handleCompanySelect}
              onClear={handleCompanyClear}
            />
          </FieldRow>

          <FieldRow label="Name">
            <Input
              placeholder="Conversation name"
              value={name}
              onChange={(e) => { setName(e.target.value); nameWasAutoFilled.current = false; }}
              className="h-8 border-0 bg-transparent px-0 text-base shadow-none focus-visible:ring-0"
            />
          </FieldRow>

          {allAops.length > 0 && (
            <FieldRow label="Type">
              <AopSelect value={aopId} aops={allAops} onSelect={setAopId} />
            </FieldRow>
          )}

          <FieldRow label="Stage">
            <PillSelect
              value={status}
              options={statusOptions}
              onSelect={setStatus}
              fieldKey=[redacted]
            />
          </FieldRow>

          <FieldRow label="Priority">
            <PillSelect
              value={priority}
              options={DEFAULT_PRIORITY_OPTIONS}
              onSelect={setPriority}
              fieldKey=[redacted]
              placeholder="No priority"
            />
          </FieldRow>

          <FieldRow label="Deal value">
            <div className="flex items-center gap-1">
              <span className="text-base text-muted-foreground">$</span>
              <Input
                type="number"
                placeholder="0"
                value={dealValue}
                onChange={(e) => setDealValue(e.target.value)}
                min={0}
                className="h-8 border-0 bg-transparent px-0 text-base shadow-none focus-visible:ring-0"
              />
            </div>
          </FieldRow>

          <FieldRow label="Next step">
            <Input
              placeholder="What's next?"
              value={nextStep}
              onChange={(e) => setNextStep(e.target.value)}
              className="h-8 border-0 bg-transparent px-0 text-base shadow-none focus-visible:ring-0"
            />
          </FieldRow>

          <FieldRow label="Their email">
            <Input
              type="email"
              inputMode="email"
              autoComplete="email"
              placeholder="Primary contact (optional)"
              value={mainContactEmail}
              onChange={(e) => setMainContactEmail(e.target.value)}
              className="h-8 border-0 bg-transparent px-0 text-base shadow-none focus-visible:ring-0"
            />
          </FieldRow>

          <FieldRow label="Notes">
            <Textarea
              placeholder="Any context on this deal — how you met, what you discussed, what's next…"
              value={notes}
              onChange={(e) => setNotes(e.target.value)}
              className="min-h-16 resize-y border-0 bg-transparent px-0 text-base shadow-none focus-visible:ring-0"
              rows={3}
            />
          </FieldRow>

          <div className="flex min-h-8 w-full items-center gap-3 py-1">
            <label
              htmlFor="create-conversation-search-emails"
              className="min-w-0 flex-1 cursor-pointer text-base text-muted-foreground"
            >
              Search past emails for this company
            </label>
            <Switch
              id="create-conversation-search-emails"
              className="shrink-0"
              checked={searchEmails}
              onCheckedChange={setSearchEmails}
            />
          </div>
        </div>

        <DialogFooter>
          <Button variant="outline" onClick={() => onOpenChange(false)} disabled={isSubmitting}>
            Cancel
          </Button>
          <Button
            onClick={handleSubmit}
            disabled={isSubmitting || !selectedCompany || !name.trim() || !status}
          >
            {isSubmitting && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
            Create
          </Button>
        </DialogFooter>
      </DialogContent>
    </Dialog>
  );
}