CompanyMergeDropdown.tsx18.2 KBView on GitHub 'use client';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { ChangeCompanyEnrichmentDialog } from './ChangeCompanyEnrichmentDialog';
import { useMergeCompanies, useCompanies } from '../hooks/use-crm';
import { Loader2, Search, ArrowRight, Globe, AlertTriangle } from 'lucide-react';
import { useState, useRef, useEffect } from 'react';
import { useDebounce } from '@/hooks/use-debounce';
import { Checkbox } from '@/components/ui/checkbox';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { useTRPC } from '@/providers/query-provider';
import { toast } from 'sonner';
/** Every cached page of the pipeline list, whatever filter/sort variables produced it. */
const LIST_CONVERSATIONS_KEY = [['crm', 'listConversations']] as const;
type ConversationListPage = { conversations?: { id: string }[] };
type ConversationListData = { pages: ConversationListPage[]; pageParams: unknown[] };
interface CompanyMergeDropdownProps {
sourceCompanyId: string;
sourceCompanyName: string;
conversationId?: string; // Optional - needed for "Change company enrichment" feature
children: React.ReactNode; // The company name element that triggers the dropdown
}
export function CompanyMergeDropdown({
sourceCompanyId,
sourceCompanyName,
conversationId,
children,
}: CompanyMergeDropdownProps) {
const [isOpen, setIsOpen] = useState(false);
const [searchQuery, setSearchQuery] = useState('');
const [debouncedSearchQuery, setDebouncedSearchQuery] = useState('');
const [fromCompanyId, setFromCompanyId] = useState<string>(sourceCompanyId);
const [fromCompanyName, setFromCompanyName] = useState<string>(sourceCompanyName);
const [toCompanyId, setToCompanyId] = useState<string | null>(null);
const [toCompanyName, setToCompanyName] = useState<string | null>(null);
const [isConfirmDialogOpen, setIsConfirmDialogOpen] = useState(false);
const [permanentlyLinkDomains, setPermanentlyLinkDomains] = useState(true);
const searchInputRef = useRef<HTMLInputElement>(null);
// State for change company enrichment feature
const [isChangeDomainDialogOpen, setIsChangeDomainDialogOpen] = useState(false);
const trpc = useTRPC();
const queryClient = useQueryClient();
/**
* Take the merged-away row out of the pipeline immediately.
*
* `mergeCompanies` runs in the background — the dialog closes on a toast that
* invites you to navigate away — and only invalidates once the server call
* returns. Until then the source conversation, which the merge deletes, is still
* in every cached page of `crm.listConversations`, so the two companies you just
* merged go on showing as two rows. Drop it up front and let the invalidation in
* `useMergeCompanies` reconcile; the snapshot puts it back if the merge fails.
*/
const removeConversationFromPipeline = (removedId: string) => {
const snapshots = queryClient.getQueriesData<ConversationListData>({
queryKey=[redacted],
});
queryClient.setQueriesData<ConversationListData>(
{ queryKey=[redacted] },
(old) => {
if (!old?.pages) return old;
return {
...old,
pages: old.pages.map((page) => ({
...page,
conversations: (page.conversations ?? []).filter((c) => c.id !== removedId),
})),
};
},
);
return snapshots;
};
// Fetch org-scope warning when the confirmation dialog opens
const { data: orgScopeData } = useQuery(
trpc.crm.checkOrgScopeForMerge.queryOptions(
{ sourceCompanyId: fromCompanyId },
{ enabled: isConfirmDialogOpen, staleTime: 0 },
),
);
const mergeCompanies = useMergeCompanies();
const { data: orgConversations } = useQuery(
trpc.crm.getOrgConversationsForCompany.queryOptions(
{ globalCompanyId: sourceCompanyId, excludeConversationId: conversationId },
{ enabled: isOpen && !!conversationId, staleTime: 0 },
),
);
const mergeConversationForOrg = useMutation(
trpc.crm.mergeConversationForOrg.mutationOptions({
onError: (err) => toast.error(err.message ?? 'Merge failed'),
}),
);
// Debounce search query (300ms delay, same as typical search inputs)
const debouncedSetSearch = useDebounce((query: string) => {
setDebouncedSearchQuery(query);
}, 300);
// Update debounced search when searchQuery changes
useEffect(() => {
debouncedSetSearch(searchQuery);
}, [searchQuery, debouncedSetSearch]);
// Search for target companies using debounced query.
// Only fetch when the dropdown is open and the user has typed something.
const hasQuery = debouncedSearchQuery.trim().length > 0;
const { data: companiesData, isFetching: isLoadingCompanies } = useCompanies({
search: debouncedSearchQuery.trim() || undefined,
enabled: isOpen && hasQuery,
});
type CompanyRelationship = NonNullable<NonNullable<typeof companiesData>['companies']>[number];
const targetCompanies = companiesData?.companies?.filter(
(companyRel: CompanyRelationship) => companyRel.company?.id !== sourceCompanyId,
);
// Focus search input when dropdown opens, reset state when it closes
useEffect(() => {
if (isOpen && searchInputRef.current) {
// Small delay to ensure dropdown is rendered
setTimeout(() => {
searchInputRef.current?.focus();
}, 100);
} else if (!isOpen) {
// Reset search state when dropdown closes
setSearchQuery('');
setDebouncedSearchQuery('');
}
}, [isOpen]);
const handleCompanySelect = (companyId: string, companyName: string) => {
setFromCompanyId(sourceCompanyId);
setFromCompanyName(sourceCompanyName);
setToCompanyId(companyId);
setToCompanyName(companyName);
setIsOpen(false);
setIsConfirmDialogOpen(true);
};
const handleReverse = () => {
if (!toCompanyId || !toCompanyName) return;
const tempId = fromCompanyId;
const tempName = fromCompanyName;
setFromCompanyId(toCompanyId);
setFromCompanyName(toCompanyName);
setToCompanyId(tempId);
setToCompanyName(tempName);
};
const handleConfirmMerge = () => {
if (!toCompanyId) {
toast.error('Please select a company to merge into');
return;
}
// Close dialog immediately and show background toast
setIsConfirmDialogOpen(false);
toast.info('Merging companies in the background. Feel free to navigate around.');
// Capture values before resetting state
const fromId = fromCompanyId;
const toId = toCompanyId;
const skipAliasing = !permanentlyLinkDomains;
setSearchQuery('');
setToCompanyId(null);
setToCompanyName(null);
setFromCompanyId(sourceCompanyId);
setFromCompanyName(sourceCompanyName);
// The row that disappears is the SOURCE company's conversation. We only know which
// one that is when the source is still the company this dropdown was opened on —
// `handleReverse` can swap the direction, and then the surviving row is ours and the
// deleted one belongs to a conversation we have no id for. Leave the list to the
// invalidation in that case rather than guessing at a row.
const mergedAwayConversationId = conversationId && fromId === sourceCompanyId ? conversationId : null;
const snapshots = mergedAwayConversationId
? removeConversationFromPipeline(mergedAwayConversationId)
: null;
const restorePipeline = () => {
if (!snapshots) return;
for (const [key, data] of snapshots) {
queryClient.setQueryData(key, data);
}
};
// Trigger merge in background (don't await)
mergeCompanies.mutate(
{
sourceCompanyId: fromId,
targetCompanyId: toId,
skipDomainAliasing: skipAliasing,
},
{
onSuccess: (result) => {
if (!result.success) {
restorePipeline();
toast.error(result.error || 'Failed to merge companies');
}
},
onError: (error) => {
restorePipeline();
toast.error(error instanceof Error ? error.message : 'Failed to merge companies');
},
},
);
};
return (
<>
<DropdownMenu open={isOpen} onOpenChange={setIsOpen}>
<DropdownMenuTrigger asChild>{children}</DropdownMenuTrigger>
<DropdownMenuContent
className="w-[320px] p-0"
align="start"
onCloseAutoFocus={(e) => e.preventDefault()}
>
<div className="p-2 border-b">
<div className="relative">
<Search className="absolute left-2 top-2 h-3.5 w-3.5 text-muted-foreground" />
<Input
ref={searchInputRef}
placeholder="Merge into..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="h-8 pl-7 text-sm"
/>
</div>
</div>
{/* Results area with stable height */}
<div className="max-h-[280px] min-h-[80px] overflow-y-auto">
{(() => {
// Determine which state to show
const isDebouncing = searchQuery !== debouncedSearchQuery;
const hasTyped = searchQuery.trim().length > 0;
const showLoading = hasTyped && (isLoadingCompanies || isDebouncing);
const showResults = hasTyped && !isDebouncing && !isLoadingCompanies;
const showEmpty = !hasTyped;
if (showLoading) {
return (
<div className="flex items-center justify-center py-6">
<Loader2 className="h-4 w-4 animate-spin text-muted-foreground" />
</div>
);
}
if (showEmpty) {
return (
<div className="px-3 py-6 text-center text-xs text-muted-foreground">
Start typing to search...
</div>
);
}
if (showResults) {
if (targetCompanies && targetCompanies.length > 0) {
return targetCompanies.map((companyRel: CompanyRelationship) => {
const company = companyRel.company;
if (!company) return null;
return (
<DropdownMenuItem
key=[redacted]
onClick={() => handleCompanySelect(company.id, company.name)}
className="flex items-center justify-between gap-2 px-2 py-1.5"
>
<div className="flex-1 min-w-0">
<div className="text-sm font-medium truncate">{company.name}</div>
{company.domain && (
<div className="text-xs text-muted-foreground truncate">
{company.domain}
</div>
)}
</div>
</DropdownMenuItem>
);
});
}
return (
<div className="px-3 py-6 text-center text-xs text-muted-foreground">
No companies found
</div>
);
}
return null;
})()}
</div>
{/* Duplicate deals — other org members' conversations for the same company */}
{conversationId && orgConversations && orgConversations.length > 0 && (
<>
<DropdownMenuSeparator />
<div className="px-2 pt-1.5 pb-0.5">
<div className="flex items-center gap-1.5 mb-1">
<AlertTriangle className="h-3 w-3 text-amber-500 shrink-0" />
<span className="text-[10px] font-semibold uppercase tracking-wide text-amber-600 dark:text-amber-400">
Duplicate deals
</span>
</div>
{orgConversations.map((c) => (
<div key=[redacted] className="flex items-center gap-2 py-1">
<div className="flex-1 min-w-0">
<div className="text-xs font-medium truncate">{c.conversationName}</div>
<div className="text-[10px] text-muted-foreground truncate">
{c.userName ?? c.userEmail}
</div>
</div>
<Button
size="sm"
variant="outline"
className="h-5 shrink-0 px-2 text-[10px]"
disabled={mergeConversationForOrg.isPending}
onClick={(e) => {
e.preventDefault();
mergeConversationForOrg.mutate({
sourceConversationId: c.conversationId,
targetConversationId: conversationId,
});
}}
>
Merge into
</Button>
</div>
))}
</div>
</>
)}
{/* Change company enrichment option - only show if conversationId is provided */}
{conversationId && (
<>
<DropdownMenuSeparator />
<DropdownMenuItem
onClick={() => {
setIsOpen(false);
setIsChangeDomainDialogOpen(true);
}}
className="flex items-center gap-2 px-2 py-1.5"
>
<Globe className="h-4 w-4 text-muted-foreground" />
<span>Change company enrichment</span>
</DropdownMenuItem>
</>
)}
</DropdownMenuContent>
</DropdownMenu>
{/* Confirmation Dialog */}
<Dialog
open={isConfirmDialogOpen}
onOpenChange={(open) => {
setIsConfirmDialogOpen(open);
if (!open) {
// Reset to original state when dialog closes
setFromCompanyId(sourceCompanyId);
setFromCompanyName(sourceCompanyName);
setToCompanyId(null);
setToCompanyName(null);
setPermanentlyLinkDomains(true);
}
}}
>
<DialogContent className="sm:max-w-[500px]">
<DialogHeader>
<DialogTitle>Confirm Company Merge</DialogTitle>
<DialogDescription>
All events from <strong>{fromCompanyName}</strong> will be merged into{' '}
<strong>{toCompanyName}</strong> and <strong>{fromCompanyName}</strong> will be
deleted.
</DialogDescription>
</DialogHeader>
<div className="space-y-4 py-4">
{/* Company merge visualization */}
<div className="grid grid-cols-3 items-center gap-4 py-4">
<div className="flex flex-col items-center gap-2">
<div className="text-sm font-medium line-through text-muted-foreground">
{fromCompanyName}
</div>
<div className="text-xs text-muted-foreground">Will be deleted</div>
</div>
<div className="flex items-center justify-center">
<button
onClick={handleReverse}
className="flex items-center justify-center p-2 rounded-md hover:bg-muted transition-colors disabled:opacity-30 disabled:cursor-not-allowed"
title="Reverse merge direction"
disabled={!toCompanyId || !toCompanyName}
>
<ArrowRight className="h-5 w-5 text-primary" />
</button>
</div>
<div className="flex flex-col items-center gap-2">
<div className="text-sm font-medium">{toCompanyName}</div>
<div className="text-xs text-muted-foreground">Will be kept</div>
</div>
</div>
{/* Domain aliasing option */}
<div className="flex items-start gap-2 pt-2">
<Checkbox
id="permanently-link-domains"
checked={permanentlyLinkDomains}
onCheckedChange={(checked) => setPermanentlyLinkDomains(!!checked)}
/>
<div className="grid gap-0.5 leading-none">
<label
htmlFor="permanently-link-domains"
className="text-sm font-medium cursor-pointer"
>
Permanently link domains
</label>
<p className="text-xs text-muted-foreground">
Future emails from {fromCompanyName} will map to {toCompanyName}
</p>
</div>
</div>
{/* Org-scope warning */}
{orgScopeData?.willMergeOrgConversations && (
<div className="flex items-start gap-2 rounded-md border border-amber-200 bg-amber-50 px-3 py-2.5 dark:border-amber-800 dark:bg-amber-950/30">
<AlertTriangle className="h-4 w-4 shrink-0 text-amber-500 mt-0.5" />
<p className="text-xs text-amber-800 dark:text-amber-300">
This company has a{' '}
<strong>shared deal visible to all teammates</strong> (
{orgScopeData.orgConversationCount} deal
{orgScopeData.orgConversationCount !== 1 ? 's' : ''} across your team).
Merging will consolidate all teammates' deal history into one shared record.
</p>
</div>
)}
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setIsConfirmDialogOpen(false)}>
Cancel
</Button>
<Button onClick={handleConfirmMerge}>Confirm Merge</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{conversationId && (
<ChangeCompanyEnrichmentDialog
open={isChangeDomainDialogOpen}
onOpenChange={setIsChangeDomainDialogOpen}
conversationId={conversationId}
/>
)}
</>
);
}