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&apos; 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}
        />
      )}
    </>
  );
}