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';