crm-deal-manager.tsx21.1 KBView on GitHub
'use client';

import { useState, useEffect, useMemo, useImperativeHandle, forwardRef, useRef } from 'react';
import type { ExternalCrmEvent } from '../../../server/src/services/integrations/crm';
import { Loader2, RefreshCw, ChevronDown, ChevronUp, X, Copy } from 'lucide-react';
import { ScrollArea } from '@/components/ui/scroll-area';
import { useTRPC } from '@/providers/query-provider';
import { Checkbox } from '@/components/ui/checkbox';
import { useQuery } from '@tanstack/react-query';
import { Button } from '@/components/ui/button';
import { Label } from '@/components/ui/label';
import { Input } from '@/components/ui/input';
import { Badge } from '@/components/ui/badge';
import { DatePicker } from '@/components/ui/date-picker';
import { toast } from 'sonner';

/**
 * Cedar CRM Deal type - matches server-side CrmDeal
 * Used by all CRM provider integrations
 */

// Helper to format currency
const formatCurrency = (amount: number, currency: string) => {
  return new Intl.NumberFormat('en-US', {
    style: 'currency',
    currency: currency,
  }).format(amount);
};

// Format deal for clipboard: Company Name | Domain | Stage (one row per deal)
const formatDealForCopy = (deal: ExternalCrmEvent): string => {
  const name = deal.company?.name?.trim() || deal.name?.trim() || '—';
  const domain = deal.company?.domain?.trim() || '—';
  const stage = deal.stage?.trim() || 'Unknown';
  return `${name} | ${domain} | ${stage}`;
};

interface CrmDealManagerProps {
  providerId: 'hubspot' | 'attio' | 'salesforce' | 'copper';
  userId?: string;
  onSelectionChange?: (selectedCount: number) => void;
}

export interface CrmDealManagerRef {
  getSelectedDeals: () => Set<string>;
  getSelectedDealData: () => ExternalCrmEvent[];
}

export const CrmDealManager = forwardRef<CrmDealManagerRef, CrmDealManagerProps>(
  ({ providerId, userId, onSelectionChange }, ref) => {
    const trpc = useTRPC();
    const [selectedDealIds, setSelectedDealIds] = useState<Set<string>>(new Set());
    const selectedDealIdsRef = useRef<Set<string>>(new Set());
    const [hasInitializedSelection, setHasInitializedSelection] = useState(false);
    const [expandedDealIds, setExpandedDealIds] = useState<Set<string>>(new Set());
    const [rangeCount, setRangeCount] = useState<string>('');
    const [rangeOffset, setRangeOffset] = useState<string>('');
    const [lastModified, setLastModified] = useState<string | undefined>(undefined);

    // Keep ref in sync with state
    useEffect(() => {
      selectedDealIdsRef.current = selectedDealIds;
    }, [selectedDealIds]);

    // Fetch deals (no stage filter - fetch all)
    // Disabled by default - only fetches when user clicks refresh
    const {
      data: dealsData,
      isLoading: isLoadingDeals,
      refetch: refetchDeals,
      isRefetching: isRefetchingDeals,
    } = useQuery({
      ...trpc.integrations.crm.getDeals.queryOptions({
        providerId,
        stage: '',
        userId,
        // Convert yyyy-MM-dd to ISO string (start of day UTC) for backend filtering
        lastModified: lastModified
          ? new Date(lastModified + 'T00:00:00.000Z').toISOString()
          : undefined,
      }),
      enabled: false, // Don't fetch automatically - wait for user to click refresh
    });

    // Type the deals array as CedarCrmDeal[]
    const deals: ExternalCrmEvent[] = useMemo(
      () =>
        dealsData?.deals && Array.isArray(dealsData.deals)
          ? (dealsData.deals as unknown as ExternalCrmEvent[])
          : [],
      [dealsData?.deals],
    );

    // Bucket deals by stage for display
    const dealsByStage = useMemo(() => {
      const groups = new Map<string, ExternalCrmEvent[]>();
      for (const deal of deals) {
        const stage = (deal.stage?.trim() || 'Unknown') as string;
        const arr = groups.get(stage) ?? [];
        arr.push(deal);
        groups.set(stage, arr);
      }
      const sortedStages = Array.from(groups.keys()).sort((a, b) => {
        if (a === 'Unknown') return 1;
        if (b === 'Unknown') return -1;
        return a.localeCompare(b);
      });
      return { groups, sortedStages };
    }, [deals]);

    useEffect(() => {
      if (dealsData && deals.length > 0 && !hasInitializedSelection) {
        // Mark as initialized but don't change selection - preserve user's choices
        setHasInitializedSelection(true);
        // Update parent with current selection count (preserve existing selections)
        setSelectedDealIds((current) => {
          if (onSelectionChange) {
            onSelectionChange(current.size);
          }
          return current; // Don't modify, just read
        });
      }
    }, [deals, dealsData, hasInitializedSelection, onSelectionChange]);

    const toggleDeal = (dealId: string, checked: boolean) => {
      setSelectedDealIds((prev) => {
        const newSet = new Set(prev);
        if (checked) {
          newSet.add(dealId);
        } else {
          newSet.delete(dealId);
        }
        // Notify parent of selection change
        if (onSelectionChange) {
          onSelectionChange(newSet.size);
        }
        return newSet;
      });
    };

    const toggleDealJson = (dealId: string) => {
      setExpandedDealIds((prev) => {
        const newSet = new Set(prev);
        if (newSet.has(dealId)) {
          newSet.delete(dealId);
        } else {
          newSet.add(dealId);
        }
        return newSet;
      });
    };

    useImperativeHandle(
      ref,
      () => ({
        getSelectedDeals: () => new Set(selectedDealIdsRef.current),
        getSelectedDealData: () => {
          // Always use current deals and selectedDealIds from ref (not closure)
          const currentDeals =
            dealsData?.deals && Array.isArray(dealsData.deals)
              ? (dealsData.deals as unknown as ExternalCrmEvent[])
              : [];
          const currentSelectedIds = selectedDealIdsRef.current;
          return currentDeals.filter((deal) => currentSelectedIds.has(deal.id));
        },
      }),
      [dealsData?.deals],
    );

    return (
      <div className="flex flex-col gap-6 p-4">
        <div>
          <div className="mb-4 flex flex-col gap-2">
            <div>
              <h4 className="text-muted-foreground text-sm font-medium">All Deals</h4>
              {deals.length > 0 && (
                <p className="text-muted-foreground text-xs">
                  {selectedDealIds.size} of {deals.length} selected
                </p>
              )}
            </div>
            <div className="flex items-center gap-2">
              <Label
                htmlFor={`last-modified-${providerId}`}
                className="shrink-0 whitespace-nowrap text-xs"
              >
                Last Modified:
              </Label>
              <div className="min-w-0 flex-shrink">
                <DatePicker
                  value={lastModified}
                  onChange={(date) => setLastModified(date)}
                  placeholder="Any date"
                />
              </div>
              {lastModified && (
                <Button
                  variant="ghost"
                  size="sm"
                  className="h-8 w-8 shrink-0 p-0"
                  onClick={() => setLastModified(undefined)}
                >
                  <X className="h-4 w-4" />
                </Button>
              )}
              <Button
                variant="ghost"
                size="sm"
                className="shrink-0"
                onClick={() => refetchDeals()}
                disabled={isLoadingDeals || isRefetchingDeals}
              >
                <RefreshCw
                  className={`h-4 w-4 ${isLoadingDeals || isRefetchingDeals ? 'animate-spin' : ''}`}
                />
              </Button>
            </div>
            {deals.length > 0 && dealsByStage.sortedStages.length > 1 && (
              <div className="flex flex-wrap gap-1.5">
                <span className="text-muted-foreground self-center text-xs">Stages:</span>
                {dealsByStage.sortedStages.map((stage) => {
                  const stageDeals = dealsByStage.groups.get(stage)!;
                  const stageIds = stageDeals.map((d) => d.id);
                  const allSelected = stageIds.every((id) => selectedDealIds.has(id));
                  const someSelected = stageIds.some((id) => selectedDealIds.has(id));
                  return (
                    <button
                      key=[redacted]
                      type="button"
                      onClick={() => {
                        setSelectedDealIds((prev) => {
                          const newSet = new Set(prev);
                          if (allSelected) {
                            stageIds.forEach((id) => newSet.delete(id));
                          } else {
                            stageIds.forEach((id) => newSet.add(id));
                          }
                          if (onSelectionChange) onSelectionChange(newSet.size);
                          return newSet;
                        });
                      }}
                      className={`rounded-full border px-2 py-0.5 text-[11px] transition-colors ${
                        allSelected
                          ? 'border-primary bg-primary text-primary-foreground'
                          : someSelected
                            ? 'border-primary/50 bg-primary/10 text-primary'
                            : 'border-border text-muted-foreground hover:border-primary/50 hover:text-foreground'
                      }`}
                    >
                      {stage} ({stageDeals.length})
                    </button>
                  );
                })}
              </div>
            )}
            {deals.length > 0 && (
              <div className="flex items-center gap-1.5">
                <div className="flex items-center gap-1">
                  <Label
                    htmlFor={`range-count-${providerId}`}
                    className="whitespace-nowrap text-xs"
                  >
                    #
                  </Label>
                  <Input
                    id={`range-count-${providerId}`}
                    type="number"
                    placeholder="Count"
                    value={rangeCount}
                    onChange={(e) => setRangeCount(e.target.value)}
                    className="h-7 w-12 text-xs"
                    min="1"
                  />
                </div>
                <div className="flex items-center gap-1">
                  <Label
                    htmlFor={`range-offset-${providerId}`}
                    className="whitespace-nowrap text-xs"
                  >
                    Offset
                  </Label>
                  <Input
                    id={`range-offset-${providerId}`}
                    type="number"
                    placeholder="Offset"
                    value={rangeOffset}
                    onChange={(e) => setRangeOffset(e.target.value)}
                    className="h-7 w-12 text-xs"
                    min="0"
                  />
                </div>
                <Button
                  variant="ghost"
                  size="sm"
                  className="h-7 text-xs"
                  onClick={() => {
                    const count = parseInt(rangeCount, 10);
                    const offset = parseInt(rangeOffset, 10);

                    if (isNaN(count) || isNaN(offset) || count <= 0 || offset < 0) {
                      return;
                    }

                    const endIndex = Math.min(offset + count, deals.length);
                    const rangeDeals = deals.slice(offset, endIndex);
                    const rangeIds = rangeDeals.map((deal) => deal.id);

                    setSelectedDealIds((prev) => {
                      const newSet = new Set(prev);
                      rangeIds.forEach((id) => newSet.add(id));
                      if (onSelectionChange) {
                        onSelectionChange(newSet.size);
                      }
                      return newSet;
                    });
                  }}
                  disabled={!rangeCount || !rangeOffset || deals.length === 0}
                >
                  Select Range
                </Button>
                <Button
                  variant="ghost"
                  size="sm"
                  className="h-7 text-xs"
                  onClick={async () => {
                    const toCopy =
                      selectedDealIds.size > 0
                        ? deals.filter((d) => selectedDealIds.has(d.id))
                        : deals;
                    const lines = toCopy.map(formatDealForCopy);
                    const text = lines.length > 0 ? lines.join('\n') : '';
                    if (text) {
                      await navigator.clipboard.writeText(text);
                    } else {
                      toast.info('No deals to copy');
                    }
                  }}
                  disabled={deals.length === 0}
                  title="Copy company name, domain, and stage (one per row)"
                >
                  <Copy className="mr-1 h-3 w-3" />
                  Copy
                </Button>
                <Button
                  variant="ghost"
                  size="sm"
                  className="h-7 text-xs"
                  onClick={async () => {
                    const toCopy =
                      selectedDealIds.size > 0
                        ? deals.filter((d) => selectedDealIds.has(d.id))
                        : deals;
                    const ids = toCopy.map((d) => d.id);
                    const text = ids.join('\n');
                    if (text) {
                      await navigator.clipboard.writeText(text);
                    } else {
                      toast.info('No deals to copy');
                    }
                  }}
                  disabled={deals.length === 0}
                  title="Copy deal IDs (one per row)"
                >
                  <Copy className="mr-1 h-3 w-3" />
                  Copy IDs
                </Button>
                <div className="flex items-center gap-1.5">
                  <Checkbox
                    id={`select-all-${providerId}`}
                    checked={deals.length > 0 && selectedDealIds.size === deals.length}
                    onCheckedChange={(checked) => {
                      if (checked) {
                        const allIds = deals.map((deal) => deal.id);
                        const newSet = new Set(allIds);
                        setSelectedDealIds(newSet);
                        if (onSelectionChange) {
                          onSelectionChange(newSet.size);
                        }
                      } else {
                        setSelectedDealIds(new Set());
                        if (onSelectionChange) {
                          onSelectionChange(0);
                        }
                      }
                    }}
                  />
                  <Label
                    htmlFor={`select-all-${providerId}`}
                    className="text-xs cursor-pointer"
                  >
                    Select All
                  </Label>
                </div>
              </div>
            )}
          </div>

          <ScrollArea className="h-[400px] rounded-md border p-4">
            {isLoadingDeals ? (
              <div className="flex h-full items-center justify-center">
                <Loader2 className="text-muted-foreground h-6 w-6 animate-spin" />
              </div>
            ) : dealsData && deals.length > 0 ? (
              <div className="space-y-6">
                {dealsByStage.sortedStages.map((stage) => {
                  const stageDeals = dealsByStage.groups.get(stage)!;
                  return (
                    <div key=[redacted] className="space-y-2">
                      <h5 className="text-muted-foreground sticky top-0 z-10 bg-background/95 py-1.5 text-xs font-medium backdrop-blur supports-[backdrop-filter]:bg-background/60">
                        {stage} ({stageDeals.length})
                      </h5>
                      <div className="space-y-3">
                        {stageDeals.map((deal) => {
                          const createdAt =
                            deal.createdAt instanceof Date
                              ? deal.createdAt
                              : deal.createdAt
                                ? new Date(deal.createdAt)
                                : new Date();

                          return (
                            <div
                              key=[redacted]
                              className="bg-card flex flex-col gap-2 rounded-md border p-3 text-sm transition-all"
                            >
                              <div className="flex items-start justify-between gap-2">
                                <div className="flex items-center gap-2">
                                  <Checkbox
                                    id={`deal-${deal.id}`}
                                    checked={selectedDealIds.has(deal.id)}
                                    onCheckedChange={(checked) =>
                                      toggleDeal(deal.id, checked === true)
                                    }
                                  />
                                  <div className="font-medium">{deal.name}</div>
                                </div>
                                {deal.amount !== undefined && (
                                  <div className="font-mono text-green-600 dark:text-green-400">
                                    {formatCurrency(deal.amount, 'USD')}
                                  </div>
                                )}
                              </div>

                              <div className="flex flex-col gap-2">
                                <div className="text-muted-foreground flex items-center justify-between text-xs">
                                  <div className="flex items-center gap-2">
                                    <Badge variant="outline" className="text-[10px] font-normal">
                                      {deal.stage}
                                    </Badge>
                                    <span>Created {createdAt.toLocaleDateString()}</span>
                                  </div>
                                  <Button
                                    variant="ghost"
                                    size="sm"
                                    className="h-6 w-6 shrink-0 p-0"
                                    onClick={async () => {
                                      const text = formatDealForCopy(deal);
                                      await navigator.clipboard.writeText(text);
                                    }}
                                    title="Copy company name, domain, and stage"
                                  >
                                    <Copy className="h-3 w-3" />
                                  </Button>
                                </div>
                                <Button
                                  variant="ghost"
                                  size="sm"
                                  className="h-6 w-full justify-start text-xs"
                                  onClick={() => toggleDealJson(deal.id)}
                                >
                                  {expandedDealIds.has(deal.id) ? (
                                    <>
                                      <ChevronUp className="mr-1 h-3 w-3" />
                                      Hide JSON
                                    </>
                                  ) : (
                                    <>
                                      <ChevronDown className="mr-1 h-3 w-3" />
                                      Show JSON
                                    </>
                                  )}
                                </Button>
                                {expandedDealIds.has(deal.id) && (
                                  <pre className="bg-muted max-h-96 overflow-auto rounded-md p-2 text-xs">
                                    <code>
                                      {JSON.stringify(
                                        deal,
                                        (key, value) => {
                                          if (value === undefined) return null;
                                          return value;
                                        },
                                        2,
                                      )}
                                    </code>
                                  </pre>
                                )}
                              </div>
                            </div>
                          );
                        })}
                      </div>
                    </div>
                  );
                })}
              </div>
            ) : dealsData ? (
              <div className="text-muted-foreground py-8 text-center text-sm">No deals found.</div>
            ) : (
              <div className="text-muted-foreground py-8 text-center text-sm">
                Click the refresh button to load deals.
              </div>
            )}
          </ScrollArea>
        </div>
      </div>
    );
  },
);

CrmDealManager.displayName = 'CrmDealManager';