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>
);
}