@hed-hog/contact 0.0.278 → 0.0.285

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (70) hide show
  1. package/README.md +1 -4
  2. package/dist/person/dto/create-followup.dto.d.ts +5 -0
  3. package/dist/person/dto/create-followup.dto.d.ts.map +1 -0
  4. package/dist/person/dto/create-followup.dto.js +31 -0
  5. package/dist/person/dto/create-followup.dto.js.map +1 -0
  6. package/dist/person/dto/create-interaction.dto.d.ts +12 -0
  7. package/dist/person/dto/create-interaction.dto.d.ts.map +1 -0
  8. package/dist/person/dto/create-interaction.dto.js +39 -0
  9. package/dist/person/dto/create-interaction.dto.js.map +1 -0
  10. package/dist/person/dto/create.dto.d.ts +24 -0
  11. package/dist/person/dto/create.dto.d.ts.map +1 -1
  12. package/dist/person/dto/create.dto.js +56 -1
  13. package/dist/person/dto/create.dto.js.map +1 -1
  14. package/dist/person/dto/duplicates-query.dto.d.ts +8 -0
  15. package/dist/person/dto/duplicates-query.dto.d.ts.map +1 -0
  16. package/dist/person/dto/duplicates-query.dto.js +45 -0
  17. package/dist/person/dto/duplicates-query.dto.js.map +1 -0
  18. package/dist/person/dto/merge.dto.d.ts +6 -0
  19. package/dist/person/dto/merge.dto.d.ts.map +1 -0
  20. package/dist/person/dto/merge.dto.js +35 -0
  21. package/dist/person/dto/merge.dto.js.map +1 -0
  22. package/dist/person/dto/update-lifecycle-stage.dto.d.ts +13 -0
  23. package/dist/person/dto/update-lifecycle-stage.dto.d.ts.map +1 -0
  24. package/dist/person/dto/update-lifecycle-stage.dto.js +34 -0
  25. package/dist/person/dto/update-lifecycle-stage.dto.js.map +1 -0
  26. package/dist/person/dto/update.dto.d.ts +8 -1
  27. package/dist/person/dto/update.dto.d.ts.map +1 -1
  28. package/dist/person/dto/update.dto.js +36 -0
  29. package/dist/person/dto/update.dto.js.map +1 -1
  30. package/dist/person/person.controller.d.ts +57 -1
  31. package/dist/person/person.controller.d.ts.map +1 -1
  32. package/dist/person/person.controller.js +85 -3
  33. package/dist/person/person.controller.js.map +1 -1
  34. package/dist/person/person.service.d.ts +79 -0
  35. package/dist/person/person.service.d.ts.map +1 -1
  36. package/dist/person/person.service.js +730 -9
  37. package/dist/person/person.service.js.map +1 -1
  38. package/hedhog/data/route.yaml +18 -0
  39. package/hedhog/frontend/app/_components/crm-coming-soon.tsx.ejs +110 -110
  40. package/hedhog/frontend/app/_components/crm-nav.tsx.ejs +73 -73
  41. package/hedhog/frontend/app/_lib/crm-mocks.ts.ejs +498 -256
  42. package/hedhog/frontend/app/_lib/crm-sections.tsx.ejs +81 -81
  43. package/hedhog/frontend/app/accounts/_components/account-form-sheet.tsx.ejs +477 -0
  44. package/hedhog/frontend/app/accounts/_components/account-types.ts.ejs +62 -0
  45. package/hedhog/frontend/app/accounts/page.tsx.ejs +886 -15
  46. package/hedhog/frontend/app/activities/page.tsx.ejs +15 -15
  47. package/hedhog/frontend/app/contact-type/page.tsx.ejs +105 -91
  48. package/hedhog/frontend/app/dashboard/page.tsx.ejs +506 -573
  49. package/hedhog/frontend/app/document-type/page.tsx.ejs +105 -91
  50. package/hedhog/frontend/app/follow-ups/page.tsx.ejs +15 -15
  51. package/hedhog/frontend/app/page.tsx.ejs +5 -5
  52. package/hedhog/frontend/app/person/_components/person-form-sheet.tsx.ejs +1440 -1103
  53. package/hedhog/frontend/app/person/_components/person-interaction-dialog.tsx.ejs +4 -3
  54. package/hedhog/frontend/app/person/_components/person-types.ts.ejs +14 -0
  55. package/hedhog/frontend/app/person/page.tsx.ejs +108 -190
  56. package/hedhog/frontend/app/pipeline/_components/lead-detail-sheet.tsx.ejs +599 -0
  57. package/hedhog/frontend/app/pipeline/page.tsx.ejs +1074 -299
  58. package/hedhog/frontend/app/reports/page.tsx.ejs +15 -15
  59. package/hedhog/frontend/messages/en.json +107 -0
  60. package/hedhog/frontend/messages/pt.json +106 -0
  61. package/package.json +6 -6
  62. package/src/person/dto/create-followup.dto.ts +15 -0
  63. package/src/person/dto/create-interaction.dto.ts +23 -0
  64. package/src/person/dto/create.dto.ts +50 -0
  65. package/src/person/dto/duplicates-query.dto.ts +34 -0
  66. package/src/person/dto/merge.dto.ts +15 -0
  67. package/src/person/dto/update-lifecycle-stage.dto.ts +19 -0
  68. package/src/person/dto/update.dto.ts +31 -1
  69. package/src/person/person.controller.ts +63 -2
  70. package/src/person/person.service.ts +1096 -7
@@ -1,299 +1,1074 @@
1
- 'use client';
2
-
3
- import { CrmNav } from '../_components/crm-nav';
4
- import { crmMockLeads, crmOwners, crmStageOrder } from '../_lib/crm-mocks';
5
- import { Page, PageHeader } from '@/components/entity-list';
6
- import { Badge } from '@/components/ui/badge';
7
- import {
8
- Card,
9
- CardContent,
10
- CardDescription,
11
- CardHeader,
12
- CardTitle,
13
- } from '@/components/ui/card';
14
- import { ScrollArea } from '@/components/ui/scroll-area';
15
- import {
16
- Select,
17
- SelectContent,
18
- SelectItem,
19
- SelectTrigger,
20
- SelectValue,
21
- } from '@/components/ui/select';
22
- import { cn } from '@/lib/utils';
23
- import {
24
- CalendarClock,
25
- CircleDollarSign,
26
- Filter,
27
- Target,
28
- Users,
29
- } from 'lucide-react';
30
- import { useTranslations } from 'next-intl';
31
- import { useMemo, useState } from 'react';
32
-
33
- function daysSince(date: string) {
34
- const diff =
35
- new Date('2026-03-16T00:00:00.000Z').getTime() - new Date(date).getTime();
36
- return Math.max(0, Math.floor(diff / (1000 * 60 * 60 * 24)));
37
- }
38
-
39
- export default function CrmPipelinePage() {
40
- const t = useTranslations('contact.CrmPipeline');
41
- const dashboardT = useTranslations('contact.CrmDashboard');
42
- const [ownerFilter, setOwnerFilter] = useState('all');
43
-
44
- const filteredLeads = useMemo(
45
- () =>
46
- ownerFilter === 'all'
47
- ? crmMockLeads
48
- : crmMockLeads.filter(
49
- (lead) => String(lead.owner_user_id ?? 'unassigned') === ownerFilter
50
- ),
51
- [ownerFilter]
52
- );
53
-
54
- const conversionRate = Math.round(
55
- (filteredLeads.filter((lead) => lead.lifecycle_stage === 'customer')
56
- .length /
57
- Math.max(filteredLeads.length, 1)) *
58
- 100
59
- );
60
- const overdue = filteredLeads.filter(
61
- (lead) =>
62
- lead.next_action_at &&
63
- new Date(lead.next_action_at) < new Date('2026-03-16T00:00:00.000Z')
64
- ).length;
65
- const nextActions = filteredLeads.filter(
66
- (lead) => lead.next_action_at
67
- ).length;
68
-
69
- return (
70
- <Page>
71
- <PageHeader
72
- breadcrumbs={[
73
- { label: t('breadcrumbs.home'), href: '/' },
74
- { label: t('breadcrumbs.crm'), href: '/contact/dashboard' },
75
- { label: t('breadcrumbs.pipeline') },
76
- ]}
77
- title={t('title')}
78
- description={t('subtitle')}
79
- />
80
-
81
- <div className="min-w-0 space-y-6 overflow-x-hidden">
82
- <CrmNav currentHref="/contact/pipeline" />
83
-
84
- <Card className="overflow-hidden border-sky-200/70 bg-gradient-to-br from-sky-50 via-background to-cyan-50 py-0">
85
- <CardContent className="grid gap-4 px-6 py-6 lg:grid-cols-[minmax(0,1fr)_auto] lg:items-end">
86
- <div className="space-y-2">
87
- <Badge className="w-fit rounded-full bg-sky-500/10 px-3 py-1 text-sky-700 hover:bg-sky-500/10">
88
- <Filter className="mr-2 size-3.5" />
89
- {t('filterBadge')}
90
- </Badge>
91
- <h2 className="text-3xl font-semibold tracking-tight">
92
- {t('heroTitle')}
93
- </h2>
94
- <p className="max-w-2xl text-sm leading-6 text-muted-foreground">
95
- {t('heroDescription')}
96
- </p>
97
- </div>
98
-
99
- <div className="w-full max-w-xs space-y-2">
100
- <label className="text-sm font-medium">
101
- {t('ownerFilterLabel')}
102
- </label>
103
- <Select value={ownerFilter} onValueChange={setOwnerFilter}>
104
- <SelectTrigger className="w-full bg-background">
105
- <SelectValue />
106
- </SelectTrigger>
107
- <SelectContent>
108
- <SelectItem value="all">{t('ownerFilterAll')}</SelectItem>
109
- <SelectItem value="unassigned">
110
- {dashboardT('common.unassigned')}
111
- </SelectItem>
112
- {crmOwners.map((owner) => (
113
- <SelectItem key={owner.id} value={String(owner.id)}>
114
- {owner.name}
115
- </SelectItem>
116
- ))}
117
- </SelectContent>
118
- </Select>
119
- </div>
120
- </CardContent>
121
- </Card>
122
-
123
- <div className="grid gap-4 md:grid-cols-2 xl:grid-cols-4">
124
- {[
125
- { key: 'total', value: filteredLeads.length, icon: Users },
126
- { key: 'conversion', value: `${conversionRate}%`, icon: Target },
127
- { key: 'overdue', value: overdue, icon: CalendarClock },
128
- {
129
- key: 'pipelineValue',
130
- value: new Intl.NumberFormat('pt-BR', {
131
- style: 'currency',
132
- currency: 'BRL',
133
- maximumFractionDigits: 0,
134
- }).format(
135
- filteredLeads.reduce((sum, lead) => sum + lead.dealValue, 0)
136
- ),
137
- icon: CircleDollarSign,
138
- },
139
- ].map((item) => (
140
- <Card key={item.key} className="py-0">
141
- <CardContent className="flex items-center justify-between gap-3 p-4">
142
- <div className="min-w-0">
143
- <p className="text-xs uppercase tracking-[0.16em] text-muted-foreground">
144
- {t(`summary.${item.key}.title`)}
145
- </p>
146
- <p className="mt-2 text-2xl font-semibold">{item.value}</p>
147
- <p className="text-sm text-muted-foreground">
148
- {t(`summary.${item.key}.description`, {
149
- count: nextActions,
150
- })}
151
- </p>
152
- </div>
153
- <div className="rounded-2xl bg-muted p-3 text-muted-foreground">
154
- <item.icon className="size-5" />
155
- </div>
156
- </CardContent>
157
- </Card>
158
- ))}
159
- </div>
160
-
161
- <div className="grid min-w-0 gap-4 xl:grid-cols-7">
162
- {crmStageOrder.map((stage) => {
163
- const stageLeads = filteredLeads.filter(
164
- (lead) => lead.lifecycle_stage === stage
165
- );
166
-
167
- return (
168
- <Card
169
- key={stage}
170
- className="min-w-0 overflow-hidden py-0 xl:col-span-1"
171
- >
172
- <CardHeader className="border-b bg-muted/30">
173
- <div className="flex items-start justify-between gap-3">
174
- <div className="min-w-0">
175
- <CardTitle className="truncate text-base">
176
- {dashboardT(`stageLabels.${stage}`)}
177
- </CardTitle>
178
- <CardDescription>
179
- {t('stageCount', { count: stageLeads.length })}
180
- </CardDescription>
181
- </div>
182
- <Badge variant="secondary">{stageLeads.length}</Badge>
183
- </div>
184
- </CardHeader>
185
- <CardContent className="p-0">
186
- <ScrollArea className="h-[520px]">
187
- <div className="space-y-3 p-4">
188
- {stageLeads.length === 0 ? (
189
- <div className="rounded-2xl border border-dashed p-4 text-center text-sm text-muted-foreground">
190
- {t('emptyStage')}
191
- </div>
192
- ) : (
193
- stageLeads.map((lead) => (
194
- <div
195
- key={lead.id}
196
- className="rounded-2xl border border-border/70 bg-card p-4 shadow-sm"
197
- >
198
- <div className="flex items-start justify-between gap-3">
199
- <div className="min-w-0">
200
- <p className="line-clamp-2 font-medium">
201
- {lead.name}
202
- </p>
203
- <p className="truncate text-xs text-muted-foreground">
204
- {lead.trade_name ||
205
- lead.companyLabel ||
206
- dashboardT(
207
- `sourceLabels.${lead.source ?? 'other'}`
208
- )}
209
- </p>
210
- </div>
211
- <Badge
212
- variant="outline"
213
- className={cn(
214
- 'shrink-0',
215
- lead.status === 'active'
216
- ? 'border-green-500/20 bg-green-500/10 text-green-600'
217
- : 'border-red-500/20 bg-red-500/10 text-red-600'
218
- )}
219
- >
220
- {lead.status === 'active'
221
- ? t('active')
222
- : t('inactive')}
223
- </Badge>
224
- </div>
225
-
226
- <div className="mt-3 flex flex-wrap gap-2">
227
- <Badge variant="secondary">
228
- {lead.owner_user?.name ||
229
- dashboardT('common.unassigned')}
230
- </Badge>
231
- <Badge variant="outline">
232
- {dashboardT(
233
- `sourceLabels.${lead.source ?? 'other'}`
234
- )}
235
- </Badge>
236
- </div>
237
-
238
- <div className="mt-4 space-y-2 text-sm text-muted-foreground">
239
- <div className="flex items-center justify-between gap-3">
240
- <span>{t('card.dealValue')}</span>
241
- <span className="font-medium text-foreground">
242
- {new Intl.NumberFormat('pt-BR', {
243
- style: 'currency',
244
- currency: 'BRL',
245
- maximumFractionDigits: 0,
246
- }).format(lead.dealValue)}
247
- </span>
248
- </div>
249
- <div className="flex items-center justify-between gap-3">
250
- <span>{t('card.score')}</span>
251
- <span className="font-medium text-foreground">
252
- {lead.score}
253
- </span>
254
- </div>
255
- <div className="flex items-center justify-between gap-3">
256
- <span>{t('card.age')}</span>
257
- <span className="font-medium text-foreground">
258
- {t('card.days', {
259
- count: daysSince(lead.created_at),
260
- })}
261
- </span>
262
- </div>
263
- <div className="flex items-center justify-between gap-3">
264
- <span>{t('card.nextAction')}</span>
265
- <span className="truncate text-right font-medium text-foreground">
266
- {lead.next_action_at
267
- ? new Date(
268
- lead.next_action_at
269
- ).toLocaleDateString('pt-BR')
270
- : t('card.noFollowup')}
271
- </span>
272
- </div>
273
- </div>
274
-
275
- <div className="mt-4 flex flex-wrap gap-2">
276
- {lead.tags.map((tag) => (
277
- <Badge
278
- key={tag}
279
- variant="outline"
280
- className="text-[11px]"
281
- >
282
- {tag}
283
- </Badge>
284
- ))}
285
- </div>
286
- </div>
287
- ))
288
- )}
289
- </div>
290
- </ScrollArea>
291
- </CardContent>
292
- </Card>
293
- );
294
- })}
295
- </div>
296
- </div>
297
- </Page>
298
- );
299
- }
1
+ 'use client';
2
+
3
+ import {
4
+ closestCenter,
5
+ DndContext,
6
+ DragOverlay,
7
+ PointerSensor,
8
+ useDraggable,
9
+ useDroppable,
10
+ useSensor,
11
+ useSensors,
12
+ type DragEndEvent,
13
+ type DragStartEvent,
14
+ type UniqueIdentifier,
15
+ } from '@dnd-kit/core';
16
+ import { CSS } from '@dnd-kit/utilities';
17
+ import {
18
+ Page,
19
+ PageHeader,
20
+ SearchBar,
21
+ type SearchBarControl,
22
+ } from '@/components/entity-list';
23
+ import { Badge } from '@/components/ui/badge';
24
+ import { Button } from '@/components/ui/button';
25
+ import {
26
+ Card,
27
+ CardContent,
28
+ CardDescription,
29
+ CardHeader,
30
+ CardTitle,
31
+ } from '@/components/ui/card';
32
+ import {
33
+ Collapsible,
34
+ CollapsibleContent,
35
+ CollapsibleTrigger,
36
+ } from '@/components/ui/collapsible';
37
+ import { ScrollArea } from '@/components/ui/scroll-area';
38
+ import {
39
+ Tooltip,
40
+ TooltipContent,
41
+ TooltipTrigger,
42
+ } from '@/components/ui/tooltip';
43
+ import { useDebounce } from '@/hooks/use-debounce';
44
+ import { cn } from '@/lib/utils';
45
+ import { useApp, useQuery } from '@hed-hog/next-app-provider';
46
+ import {
47
+ CalendarCheck,
48
+ CalendarClock,
49
+ CalendarX,
50
+ ChevronDown,
51
+ ChevronsDownUp,
52
+ ChevronsUpDown,
53
+ ChevronUp,
54
+ CircleDollarSign,
55
+ CircleUser,
56
+ Plus,
57
+ Star,
58
+ Tag,
59
+ Target,
60
+ TrendingUp,
61
+ Users,
62
+ } from 'lucide-react';
63
+ import { useTranslations } from 'next-intl';
64
+ import {
65
+ useCallback,
66
+ useEffect,
67
+ useMemo,
68
+ useRef,
69
+ useState,
70
+ type KeyboardEvent,
71
+ type ReactNode,
72
+ } from 'react';
73
+ import { toast } from 'sonner';
74
+ import { crmImplementedSections } from '../_lib/crm-sections';
75
+ import { crmStageOrder, type CrmLead } from '../_lib/crm-mocks';
76
+ import type {
77
+ ContactTypeOption,
78
+ DocumentTypeOption,
79
+ PaginatedResult,
80
+ Person,
81
+ PersonLifecycleStage,
82
+ UserOption,
83
+ } from '../person/_components/person-types';
84
+ import { PersonFormSheet } from '../person/_components/person-form-sheet';
85
+ import { LeadDetailSheet } from './_components/lead-detail-sheet';
86
+
87
+ const PIPELINE_EXPANDED_KEY = 'contact:crm-pipeline:expanded-leads';
88
+ const LEAD_DND_PREFIX = 'lead-';
89
+ const STAGE_DND_PREFIX = 'stage-';
90
+
91
+ function stageDropId(stage: PersonLifecycleStage) {
92
+ return `${STAGE_DND_PREFIX}${stage}`;
93
+ }
94
+
95
+ function leadDragId(leadId: number) {
96
+ return `${LEAD_DND_PREFIX}${leadId}`;
97
+ }
98
+
99
+ function parseLeadId(dndId: UniqueIdentifier | null | undefined) {
100
+ if (!dndId) return null;
101
+ const value = String(dndId);
102
+ if (!value.startsWith(LEAD_DND_PREFIX)) return null;
103
+ const parsed = Number(value.slice(LEAD_DND_PREFIX.length));
104
+ return Number.isFinite(parsed) ? parsed : null;
105
+ }
106
+
107
+ function parseStage(dndId: UniqueIdentifier | null | undefined) {
108
+ if (!dndId) return null;
109
+ const value = String(dndId);
110
+ if (!value.startsWith(STAGE_DND_PREFIX)) return null;
111
+ const candidate = value.slice(STAGE_DND_PREFIX.length);
112
+ return crmStageOrder.includes(candidate as PersonLifecycleStage)
113
+ ? (candidate as PersonLifecycleStage)
114
+ : null;
115
+ }
116
+
117
+ type StageDropZoneProps = {
118
+ stage: PersonLifecycleStage;
119
+ children: (isOver: boolean) => ReactNode;
120
+ };
121
+
122
+ function StageDropZone({ stage, children }: StageDropZoneProps) {
123
+ const { isOver, setNodeRef } = useDroppable({ id: stageDropId(stage) });
124
+
125
+ return <div ref={setNodeRef}>{children(isOver)}</div>;
126
+ }
127
+
128
+ type DraggableLeadCardProps = {
129
+ leadId: number;
130
+ className?: string;
131
+ onClick: () => void;
132
+ onKeyDown: (event: KeyboardEvent<HTMLDivElement>) => void;
133
+ children: ReactNode;
134
+ };
135
+
136
+ function DraggableLeadCard({
137
+ leadId,
138
+ className,
139
+ onClick,
140
+ onKeyDown,
141
+ children,
142
+ }: DraggableLeadCardProps) {
143
+ const { attributes, isDragging, listeners, setNodeRef, transform } =
144
+ useDraggable({
145
+ id: leadDragId(leadId),
146
+ });
147
+
148
+ return (
149
+ <div
150
+ ref={setNodeRef}
151
+ style={{ transform: CSS.Translate.toString(transform) }}
152
+ className={cn(
153
+ className,
154
+ 'touch-none',
155
+ isDragging && 'z-30 opacity-60 shadow-lg ring-1 ring-primary/30'
156
+ )}
157
+ role="button"
158
+ tabIndex={0}
159
+ onClick={onClick}
160
+ onKeyDown={onKeyDown}
161
+ {...listeners}
162
+ {...attributes}
163
+ >
164
+ {children}
165
+ </div>
166
+ );
167
+ }
168
+
169
+ function daysSince(date: string) {
170
+ const diff =
171
+ new Date('2026-03-16T00:00:00.000Z').getTime() - new Date(date).getTime();
172
+ return Math.max(0, Math.floor(diff / (1000 * 60 * 60 * 24)));
173
+ }
174
+
175
+ function nextActionUrgency(nextActionAt: string | null | undefined) {
176
+ if (!nextActionAt) return 'none';
177
+ const now = new Date('2026-03-16T00:00:00.000Z');
178
+ const date = new Date(nextActionAt);
179
+ const diffDays = Math.floor(
180
+ (date.getTime() - now.getTime()) / (1000 * 60 * 60 * 24)
181
+ );
182
+ if (diffDays < 0) return 'overdue';
183
+ if (diffDays <= 3) return 'soon';
184
+ return 'ok';
185
+ }
186
+
187
+ function formatCurrency(value: number) {
188
+ return new Intl.NumberFormat('pt-BR', {
189
+ style: 'currency',
190
+ currency: 'BRL',
191
+ maximumFractionDigits: 0,
192
+ }).format(value);
193
+ }
194
+
195
+ function loadExpandedFromStorage(): Set<number> {
196
+ try {
197
+ const raw = localStorage.getItem(PIPELINE_EXPANDED_KEY);
198
+ if (!raw) return new Set();
199
+ const parsed = JSON.parse(raw);
200
+ if (Array.isArray(parsed)) return new Set<number>(parsed);
201
+ } catch {
202
+ // ignore
203
+ }
204
+ return new Set();
205
+ }
206
+
207
+ function saveExpandedToStorage(ids: Set<number>) {
208
+ try {
209
+ localStorage.setItem(PIPELINE_EXPANDED_KEY, JSON.stringify([...ids]));
210
+ } catch {
211
+ // ignore
212
+ }
213
+ }
214
+
215
+ function mapPersonToCrmLead(person: Person): CrmLead {
216
+ return {
217
+ id: person.id,
218
+ name: person.name,
219
+ type: person.type,
220
+ status: person.status,
221
+ trade_name: person.trade_name ?? null,
222
+ owner_user_id: person.owner_user_id ?? null,
223
+ owner_user: person.owner_user ?? null,
224
+ source: person.source ?? 'other',
225
+ lifecycle_stage: person.lifecycle_stage ?? 'new',
226
+ next_action_at: person.next_action_at ?? null,
227
+ created_at: person.created_at,
228
+ score: Math.max(0, Math.min(100, Number(person.score) || 0)),
229
+ dealValue: Number(person.deal_value) || 0,
230
+ lastInteractionAt: person.last_interaction_at || person.created_at,
231
+ companyLabel:
232
+ person.type === 'company'
233
+ ? person.trade_name || null
234
+ : person.employer_company?.name || null,
235
+ tags: Array.isArray(person.tags) ? person.tags.filter(Boolean) : [],
236
+ };
237
+ }
238
+
239
+ export default function CrmPipelinePage() {
240
+ const t = useTranslations('contact.CrmPipeline');
241
+ const dashboardT = useTranslations('contact.CrmDashboard');
242
+ const { request, currentLocaleCode } = useApp();
243
+ const [ownerFilter, setOwnerFilter] = useState('all');
244
+ const [searchTerm, setSearchTerm] = useState('');
245
+ const debouncedSearch = useDebounce(searchTerm);
246
+ const [formSheetOpen, setFormSheetOpen] = useState(false);
247
+ const [selectedLead, setSelectedLead] = useState<CrmLead | null>(null);
248
+ const [detailOpen, setDetailOpen] = useState(false);
249
+ const [activeDragLeadId, setActiveDragLeadId] = useState<number | null>(null);
250
+ const [stageOverrides, setStageOverrides] = useState<
251
+ Record<number, PersonLifecycleStage>
252
+ >({});
253
+ const preventCardClickRef = useRef(false);
254
+
255
+ const sensors = useSensors(
256
+ useSensor(PointerSensor, {
257
+ activationConstraint: { distance: 6 },
258
+ })
259
+ );
260
+
261
+ const [expandedIds, setExpandedIds] = useState<Set<number>>(() =>
262
+ loadExpandedFromStorage()
263
+ );
264
+
265
+ const { data: owners = [] } = useQuery<UserOption[]>({
266
+ queryKey: ['contact-person-owner-options', currentLocaleCode],
267
+ queryFn: async () => {
268
+ const response = await request<UserOption[]>({
269
+ url: '/person/owner-options',
270
+ method: 'GET',
271
+ });
272
+
273
+ return response.data;
274
+ },
275
+ placeholderData: (previous) => previous ?? [],
276
+ });
277
+
278
+ const { data: contactTypes = [] } = useQuery<ContactTypeOption[]>({
279
+ queryKey: ['contact-person-contact-types', currentLocaleCode],
280
+ queryFn: async () => {
281
+ const response = await request<{ data: ContactTypeOption[] }>({
282
+ url: '/person-contact-type?pageSize=100',
283
+ method: 'GET',
284
+ });
285
+
286
+ return response.data.data || [];
287
+ },
288
+ placeholderData: (previous) => previous ?? [],
289
+ });
290
+
291
+ const { data: documentTypes = [] } = useQuery<DocumentTypeOption[]>({
292
+ queryKey: ['contact-person-document-types', currentLocaleCode],
293
+ queryFn: async () => {
294
+ const response = await request<{ data: DocumentTypeOption[] }>({
295
+ url: '/person-document-type?pageSize=100',
296
+ method: 'GET',
297
+ });
298
+
299
+ return response.data.data || [];
300
+ },
301
+ placeholderData: (previous) => previous ?? [],
302
+ });
303
+
304
+ const {
305
+ data: paginate = { data: [], total: 0, page: 1, pageSize: 500 },
306
+ refetch,
307
+ } = useQuery<PaginatedResult<Person>>({
308
+ queryKey: ['contact-crm-pipeline', debouncedSearch, currentLocaleCode],
309
+ queryFn: async () => {
310
+ const params = new URLSearchParams();
311
+ params.set('page', '1');
312
+ params.set('pageSize', '500');
313
+ if (debouncedSearch) params.set('search', debouncedSearch);
314
+
315
+ const response = await request<PaginatedResult<Person>>({
316
+ url: `/person?${params.toString()}`,
317
+ method: 'GET',
318
+ });
319
+
320
+ return response.data;
321
+ },
322
+ placeholderData: (previous) =>
323
+ previous ?? { data: [], total: 0, page: 1, pageSize: 500 },
324
+ });
325
+
326
+ const filteredLeads = useMemo(() => {
327
+ return paginate.data.map(mapPersonToCrmLead).filter((lead) => {
328
+ if (ownerFilter === 'all') return true;
329
+ if (ownerFilter === 'unassigned') return !lead.owner_user_id;
330
+ return String(lead.owner_user_id ?? '') === ownerFilter;
331
+ });
332
+ }, [ownerFilter, paginate.data]);
333
+
334
+ const visibleLeads = useMemo(
335
+ () =>
336
+ filteredLeads.map((lead) => ({
337
+ ...lead,
338
+ lifecycle_stage: stageOverrides[lead.id] ?? lead.lifecycle_stage,
339
+ })),
340
+ [filteredLeads, stageOverrides]
341
+ );
342
+
343
+ const visibleLeadsById = useMemo(
344
+ () => new Map(visibleLeads.map((lead) => [lead.id, lead])),
345
+ [visibleLeads]
346
+ );
347
+
348
+ const activeDragLead = useMemo(() => {
349
+ if (!activeDragLeadId) return null;
350
+ return visibleLeadsById.get(activeDragLeadId) ?? null;
351
+ }, [activeDragLeadId, visibleLeadsById]);
352
+
353
+ // Normalise persisted IDs to only those present in current filtered set.
354
+ useEffect(() => {
355
+ const filteredIds = new Set(visibleLeads.map((l) => l.id));
356
+ setExpandedIds((prev) => {
357
+ const next = new Set<number>(
358
+ [...prev].filter((id) => filteredIds.has(id))
359
+ );
360
+ if (next.size !== prev.size) saveExpandedToStorage(next);
361
+ return next;
362
+ });
363
+ }, [visibleLeads]);
364
+
365
+ useEffect(() => {
366
+ setStageOverrides((prev) => {
367
+ const baseById = new Map(
368
+ filteredLeads.map((lead) => [lead.id, lead.lifecycle_stage])
369
+ );
370
+
371
+ let changed = false;
372
+ const next: Record<number, PersonLifecycleStage> = {};
373
+
374
+ for (const [idRaw, stage] of Object.entries(prev)) {
375
+ const id = Number(idRaw);
376
+ const baseStage = baseById.get(id);
377
+
378
+ if (!baseStage) {
379
+ changed = true;
380
+ continue;
381
+ }
382
+
383
+ if (baseStage !== stage) {
384
+ next[id] = stage;
385
+ continue;
386
+ }
387
+
388
+ changed = true;
389
+ }
390
+
391
+ return changed ? next : prev;
392
+ });
393
+ }, [filteredLeads]);
394
+
395
+ const toggleExpanded = useCallback((id: number) => {
396
+ setExpandedIds((prev) => {
397
+ const next = new Set(prev);
398
+ if (next.has(id)) next.delete(id);
399
+ else next.add(id);
400
+ saveExpandedToStorage(next);
401
+ return next;
402
+ });
403
+ }, []);
404
+
405
+ const expandAll = useCallback(() => {
406
+ const next = new Set<number>(visibleLeads.map((l) => l.id));
407
+ setExpandedIds(next);
408
+ saveExpandedToStorage(next);
409
+ }, [visibleLeads]);
410
+
411
+ const collapseAll = useCallback(() => {
412
+ const next = new Set<number>();
413
+ setExpandedIds(next);
414
+ saveExpandedToStorage(next);
415
+ }, []);
416
+
417
+ const searchControls: SearchBarControl[] = [
418
+ {
419
+ id: 'owner',
420
+ type: 'select',
421
+ value: ownerFilter,
422
+ onChange: setOwnerFilter,
423
+ placeholder: t('ownerFilterLabel'),
424
+ options: [
425
+ { value: 'all', label: t('ownerFilterAll') },
426
+ { value: 'unassigned', label: dashboardT('common.unassigned') },
427
+ ...owners.map((owner) => ({
428
+ value: String(owner.id),
429
+ label: owner.name,
430
+ })),
431
+ ],
432
+ className: 'sm:w-56',
433
+ },
434
+ ];
435
+
436
+ const conversionRate = Math.round(
437
+ (visibleLeads.filter((lead) => lead.lifecycle_stage === 'customer').length /
438
+ Math.max(visibleLeads.length, 1)) *
439
+ 100
440
+ );
441
+ const overdue = visibleLeads.filter(
442
+ (lead) =>
443
+ lead.next_action_at &&
444
+ new Date(lead.next_action_at) < new Date('2026-03-16T00:00:00.000Z')
445
+ ).length;
446
+ const nextActions = visibleLeads.filter((lead) => lead.next_action_at).length;
447
+ const fallbackKpiAccentClass =
448
+ 'from-sky-500/20 via-cyan-500/10 to-transparent';
449
+
450
+ const kpiCards = [
451
+ { key: 'total', value: filteredLeads.length, icon: Users },
452
+ { key: 'conversion', value: `${conversionRate}%`, icon: Target },
453
+ { key: 'overdue', value: overdue, icon: CalendarClock },
454
+ {
455
+ key: 'pipelineValue',
456
+ value: formatCurrency(
457
+ visibleLeads.reduce((sum, lead) => sum + lead.dealValue, 0)
458
+ ),
459
+ icon: CircleDollarSign,
460
+ },
461
+ ] as const;
462
+
463
+ const allExpanded =
464
+ visibleLeads.length > 0 && visibleLeads.every((l) => expandedIds.has(l.id));
465
+
466
+ const openLeadDetail = useCallback((lead: CrmLead) => {
467
+ setSelectedLead(lead);
468
+ setDetailOpen(true);
469
+ }, []);
470
+
471
+ const refreshLead = useCallback(
472
+ async (leadId: number) => {
473
+ const response = await request<Person>({
474
+ url: `/person/${leadId}`,
475
+ method: 'GET',
476
+ });
477
+
478
+ const nextLead = mapPersonToCrmLead(response.data);
479
+ setSelectedLead(nextLead);
480
+ return nextLead;
481
+ },
482
+ [request]
483
+ );
484
+
485
+ const handleLeadUpdated = useCallback(
486
+ async (lead: CrmLead) => {
487
+ await refetch();
488
+ if (detailOpen) {
489
+ await refreshLead(lead.id);
490
+ }
491
+ },
492
+ [detailOpen, refetch, refreshLead]
493
+ );
494
+
495
+ const handleMoveStage = useCallback(
496
+ async (lead: CrmLead, stage: PersonLifecycleStage) => {
497
+ try {
498
+ await request({
499
+ url: `/person/${lead.id}/lifecycle-stage`,
500
+ method: 'POST',
501
+ data: { lifecycle_stage: stage },
502
+ });
503
+
504
+ toast.success(
505
+ t('detail.stageMoveSuccess', {
506
+ stage: dashboardT(`stageLabels.${stage}`),
507
+ })
508
+ );
509
+
510
+ await handleLeadUpdated(lead);
511
+ } catch {
512
+ toast.error(t('detail.stageMoveError'));
513
+ }
514
+ },
515
+ [dashboardT, handleLeadUpdated, request, t]
516
+ );
517
+
518
+ const resolveStageFromDropTarget = useCallback(
519
+ (dndId: UniqueIdentifier | null | undefined) => {
520
+ const dropStage = parseStage(dndId);
521
+ if (dropStage) return dropStage;
522
+
523
+ const dropLeadId = parseLeadId(dndId);
524
+ if (!dropLeadId) return null;
525
+
526
+ return visibleLeadsById.get(dropLeadId)?.lifecycle_stage ?? null;
527
+ },
528
+ [visibleLeadsById]
529
+ );
530
+
531
+ const onDragStart = useCallback((event: DragStartEvent) => {
532
+ const leadId = parseLeadId(event.active.id);
533
+ setActiveDragLeadId(leadId);
534
+ preventCardClickRef.current = true;
535
+ }, []);
536
+
537
+ const onDragEnd = useCallback(
538
+ async (event: DragEndEvent) => {
539
+ const sourceLeadId = parseLeadId(event.active.id);
540
+ const targetStage = resolveStageFromDropTarget(event.over?.id);
541
+
542
+ setActiveDragLeadId(null);
543
+ window.setTimeout(() => {
544
+ preventCardClickRef.current = false;
545
+ }, 0);
546
+
547
+ if (!sourceLeadId || !targetStage) return;
548
+
549
+ const sourceLead = visibleLeadsById.get(sourceLeadId);
550
+ if (!sourceLead || sourceLead.lifecycle_stage === targetStage) return;
551
+
552
+ const previousStage = sourceLead.lifecycle_stage;
553
+
554
+ setStageOverrides((prev) => ({ ...prev, [sourceLeadId]: targetStage }));
555
+ setSelectedLead((prev) =>
556
+ prev && prev.id === sourceLeadId
557
+ ? { ...prev, lifecycle_stage: targetStage }
558
+ : prev
559
+ );
560
+
561
+ try {
562
+ await request({
563
+ url: `/person/${sourceLead.id}/lifecycle-stage`,
564
+ method: 'POST',
565
+ data: { lifecycle_stage: targetStage },
566
+ });
567
+
568
+ toast.success(
569
+ t('detail.stageMoveSuccess', {
570
+ stage: dashboardT(`stageLabels.${targetStage}`),
571
+ })
572
+ );
573
+
574
+ await handleLeadUpdated(sourceLead);
575
+ } catch {
576
+ setStageOverrides((prev) => ({
577
+ ...prev,
578
+ [sourceLeadId]: previousStage,
579
+ }));
580
+ setSelectedLead((prev) =>
581
+ prev && prev.id === sourceLeadId
582
+ ? { ...prev, lifecycle_stage: previousStage }
583
+ : prev
584
+ );
585
+ toast.error(t('detail.stageMoveError'));
586
+ }
587
+ },
588
+ [
589
+ dashboardT,
590
+ handleLeadUpdated,
591
+ request,
592
+ resolveStageFromDropTarget,
593
+ t,
594
+ visibleLeadsById,
595
+ ]
596
+ );
597
+
598
+ const closeLeadDetail = useCallback((open: boolean) => {
599
+ setDetailOpen(open);
600
+ if (!open) {
601
+ setSelectedLead(null);
602
+ }
603
+ }, []);
604
+
605
+ return (
606
+ <Page>
607
+ <PageHeader
608
+ breadcrumbs={[
609
+ { label: t('breadcrumbs.home'), href: '/' },
610
+ { label: t('breadcrumbs.crm'), href: '/contact/dashboard' },
611
+ { label: t('breadcrumbs.pipeline') },
612
+ ]}
613
+ title={t('title')}
614
+ description={t('subtitle')}
615
+ actions={[
616
+ {
617
+ label: t('controls.expandAll'),
618
+ onClick: expandAll,
619
+ icon: <ChevronsUpDown className="h-4 w-4" />,
620
+ variant: 'secondary',
621
+ },
622
+ {
623
+ label: t('controls.collapseAll'),
624
+ onClick: collapseAll,
625
+ icon: <ChevronsDownUp className="h-4 w-4" />,
626
+ variant: 'secondary',
627
+ },
628
+ {
629
+ label: t('newPerson'),
630
+ onClick: () => setFormSheetOpen(true),
631
+ icon: <Plus className="h-4 w-4" />,
632
+ },
633
+ ]}
634
+ />
635
+
636
+ <div className="min-w-0 space-y-6 overflow-x-hidden">
637
+ <div className="grid gap-4 md:grid-cols-2 xl:grid-cols-4">
638
+ {kpiCards.map((item, index) => {
639
+ const sectionStyle =
640
+ crmImplementedSections[index % crmImplementedSections.length] ??
641
+ crmImplementedSections[0];
642
+
643
+ return (
644
+ <Card
645
+ key={item.key}
646
+ className="min-w-0 overflow-hidden border-border/70 py-0"
647
+ >
648
+ <div
649
+ className={cn(
650
+ 'h-1 w-full bg-linear-to-r',
651
+ sectionStyle?.colorClass ?? fallbackKpiAccentClass
652
+ )}
653
+ />
654
+ <CardContent className="flex items-start justify-between gap-3 px-6 py-5">
655
+ <div className="min-w-0">
656
+ <p className="text-xs uppercase tracking-[0.2em] text-muted-foreground">
657
+ {t(`summary.${item.key}.title`)}
658
+ </p>
659
+ <p className="mt-2 text-3xl font-semibold tracking-tight">
660
+ {item.value}
661
+ </p>
662
+ <p className="mt-1 text-sm text-muted-foreground">
663
+ {t(`summary.${item.key}.description`, {
664
+ count: nextActions,
665
+ })}
666
+ </p>
667
+ </div>
668
+ <div
669
+ className={cn(
670
+ 'rounded-2xl p-3',
671
+ sectionStyle?.glowClass ?? 'bg-sky-500/10 text-sky-700'
672
+ )}
673
+ >
674
+ <item.icon className="size-5" />
675
+ </div>
676
+ </CardContent>
677
+ </Card>
678
+ );
679
+ })}
680
+ </div>
681
+
682
+ <SearchBar
683
+ searchQuery={searchTerm}
684
+ onSearchChange={setSearchTerm}
685
+ onSearch={() => undefined}
686
+ placeholder={t('searchPlaceholder')}
687
+ controls={searchControls}
688
+ />
689
+
690
+ <DndContext
691
+ sensors={sensors}
692
+ collisionDetection={closestCenter}
693
+ onDragStart={onDragStart}
694
+ onDragEnd={onDragEnd}
695
+ >
696
+ <div className="overflow-x-auto pb-2">
697
+ <div className="grid min-w-[1540px] grid-cols-7 gap-2">
698
+ {crmStageOrder.map((stage) => {
699
+ const stageLeads = visibleLeads.filter(
700
+ (lead) => lead.lifecycle_stage === stage
701
+ );
702
+
703
+ return (
704
+ <StageDropZone key={stage} stage={stage}>
705
+ {(isOver) => (
706
+ <Card
707
+ className={cn(
708
+ 'min-w-[210px] snap-start overflow-hidden py-0 transition-colors',
709
+ isOver && 'border-primary/40 bg-primary/5'
710
+ )}
711
+ >
712
+ <CardHeader className="border-b bg-muted/35 px-4 py-4">
713
+ <div className="flex items-start justify-between gap-2">
714
+ <div className="min-w-0">
715
+ <CardTitle className="truncate text-sm font-semibold">
716
+ {dashboardT(`stageLabels.${stage}`)}
717
+ </CardTitle>
718
+ <CardDescription className="text-xs">
719
+ {t('stageCount', { count: stageLeads.length })}
720
+ </CardDescription>
721
+ </div>
722
+ <Badge variant="secondary">
723
+ {stageLeads.length}
724
+ </Badge>
725
+ </div>
726
+ </CardHeader>
727
+ <CardContent className="p-0">
728
+ <ScrollArea className="h-[400px] lg:h-[350px] xl:h-[400px]">
729
+ <div className="space-y-2 p-2">
730
+ {stageLeads.length === 0 ? (
731
+ <div className="rounded-xl border border-dashed p-4 text-center text-sm text-muted-foreground">
732
+ {t('emptyStage')}
733
+ </div>
734
+ ) : (
735
+ stageLeads.map((lead) => {
736
+ const isOpen = expandedIds.has(lead.id);
737
+ const urgency = nextActionUrgency(
738
+ lead.next_action_at
739
+ );
740
+ const visibleTags = lead.tags.slice(0, 2);
741
+ const hiddenTags = lead.tags.slice(2);
742
+ const age = daysSince(lead.created_at);
743
+
744
+ return (
745
+ <Collapsible
746
+ key={lead.id}
747
+ open={isOpen}
748
+ onOpenChange={() =>
749
+ toggleExpanded(lead.id)
750
+ }
751
+ >
752
+ <DraggableLeadCard
753
+ leadId={lead.id}
754
+ className="cursor-pointer rounded-xl border border-border/60 bg-card shadow-xs transition-colors hover:border-primary/30 hover:bg-muted/10"
755
+ onClick={() => {
756
+ if (preventCardClickRef.current)
757
+ return;
758
+ openLeadDetail(lead);
759
+ }}
760
+ onKeyDown={(event) => {
761
+ if (
762
+ event.key === 'Enter' ||
763
+ event.key === ' '
764
+ ) {
765
+ event.preventDefault();
766
+ openLeadDetail(lead);
767
+ }
768
+ }}
769
+ >
770
+ {/* ── Always-visible layer ── */}
771
+ <div className="p-3">
772
+ {/* Row 1: name + status badge */}
773
+ <div className="flex items-start justify-between gap-2">
774
+ <p className="line-clamp-2 min-w-0 flex-1 text-xs font-semibold leading-4">
775
+ {lead.name}
776
+ </p>
777
+ <Tooltip>
778
+ <TooltipTrigger asChild>
779
+ <Badge
780
+ variant="outline"
781
+ className={cn(
782
+ 'shrink-0 cursor-default whitespace-nowrap text-[10px] leading-3 px-1.5 py-0.5',
783
+ lead.status === 'active'
784
+ ? 'border-green-500/30 bg-green-500/10 text-green-600'
785
+ : 'border-red-500/30 bg-red-500/10 text-red-600'
786
+ )}
787
+ >
788
+ {lead.status === 'active'
789
+ ? t('active')
790
+ : t('inactive')}
791
+ </Badge>
792
+ </TooltipTrigger>
793
+ <TooltipContent side="top">
794
+ {lead.status === 'active'
795
+ ? t('tooltips.statusActive')
796
+ : t(
797
+ 'tooltips.statusInactive'
798
+ )}
799
+ </TooltipContent>
800
+ </Tooltip>
801
+ </div>
802
+
803
+ {/* Row 2: company/source subtitle */}
804
+ <p className="mt-0.5 truncate text-[11px] text-muted-foreground">
805
+ {lead.trade_name ||
806
+ lead.companyLabel ||
807
+ dashboardT(
808
+ `sourceLabels.${lead.source ?? 'other'}`
809
+ )}
810
+ </p>
811
+
812
+ {/* Row 3: owner chip */}
813
+ <div className="mt-2 flex items-center gap-1.5">
814
+ <Tooltip>
815
+ <TooltipTrigger asChild>
816
+ <div className="flex min-w-0 cursor-default items-center gap-1 rounded-md bg-muted/60 px-1.5 py-0.5">
817
+ <CircleUser className="size-3 shrink-0 text-muted-foreground" />
818
+ <span className="truncate text-[11px] text-muted-foreground">
819
+ {lead.owner_user?.name ||
820
+ dashboardT(
821
+ 'common.unassigned'
822
+ )}
823
+ </span>
824
+ </div>
825
+ </TooltipTrigger>
826
+ <TooltipContent side="top">
827
+ {t('tooltips.owner')}
828
+ </TooltipContent>
829
+ </Tooltip>
830
+ </div>
831
+
832
+ {/* Row 4: value + next action */}
833
+ <div className="mt-2.5 flex items-center justify-between gap-2">
834
+ <Tooltip>
835
+ <TooltipTrigger asChild>
836
+ <div className="flex cursor-default items-center gap-1 text-[11px] text-muted-foreground">
837
+ <CircleDollarSign className="size-3 shrink-0 text-emerald-500" />
838
+ <span className="font-medium text-foreground">
839
+ {formatCurrency(
840
+ lead.dealValue
841
+ )}
842
+ </span>
843
+ </div>
844
+ </TooltipTrigger>
845
+ <TooltipContent side="top">
846
+ {t('tooltips.dealValue')}
847
+ </TooltipContent>
848
+ </Tooltip>
849
+
850
+ <Tooltip>
851
+ <TooltipTrigger asChild>
852
+ <div
853
+ className={cn(
854
+ 'flex cursor-default items-center gap-1 text-[11px]',
855
+ urgency === 'overdue' &&
856
+ 'text-red-500',
857
+ urgency === 'soon' &&
858
+ 'text-amber-500',
859
+ urgency === 'ok' &&
860
+ 'text-blue-500',
861
+ urgency === 'none' &&
862
+ 'text-muted-foreground'
863
+ )}
864
+ >
865
+ {urgency === 'overdue' ? (
866
+ <CalendarX className="size-3 shrink-0" />
867
+ ) : urgency === 'soon' ? (
868
+ <CalendarClock className="size-3 shrink-0" />
869
+ ) : urgency === 'ok' ? (
870
+ <CalendarCheck className="size-3 shrink-0" />
871
+ ) : (
872
+ <CalendarClock className="size-3 shrink-0" />
873
+ )}
874
+ <span className="font-medium">
875
+ {lead.next_action_at
876
+ ? new Date(
877
+ lead.next_action_at
878
+ ).toLocaleDateString(
879
+ 'pt-BR'
880
+ )
881
+ : t('card.noFollowup')}
882
+ </span>
883
+ </div>
884
+ </TooltipTrigger>
885
+ <TooltipContent side="top">
886
+ {urgency === 'overdue'
887
+ ? t(
888
+ 'tooltips.nextActionOverdue'
889
+ )
890
+ : urgency === 'soon'
891
+ ? t(
892
+ 'tooltips.nextActionSoon'
893
+ )
894
+ : urgency === 'ok'
895
+ ? t(
896
+ 'tooltips.nextActionOk'
897
+ )
898
+ : t(
899
+ 'tooltips.nextActionNone'
900
+ )}
901
+ </TooltipContent>
902
+ </Tooltip>
903
+ </div>
904
+
905
+ {/* Row 5: up to 2 tags */}
906
+ {visibleTags.length > 0 && (
907
+ <div className="mt-2 flex flex-wrap gap-1">
908
+ {visibleTags.map((tag) => (
909
+ <Badge
910
+ key={tag}
911
+ variant="outline"
912
+ className="px-1.5 py-0 text-[10px]"
913
+ >
914
+ {tag}
915
+ </Badge>
916
+ ))}
917
+ {hiddenTags.length > 0 &&
918
+ !isOpen && (
919
+ <Badge
920
+ variant="secondary"
921
+ className="px-1.5 py-0 text-[10px]"
922
+ >
923
+ +{hiddenTags.length}
924
+ </Badge>
925
+ )}
926
+ </div>
927
+ )}
928
+ </div>
929
+
930
+ {/* ── Expand/collapse trigger ── */}
931
+ <CollapsibleTrigger asChild>
932
+ <button
933
+ className="flex w-full items-center justify-center gap-1 border-t border-border/40 py-1.5 text-[10px] text-muted-foreground transition-colors hover:bg-muted/40 hover:text-foreground"
934
+ aria-label={
935
+ isOpen
936
+ ? t('controls.hideDetails')
937
+ : t('controls.showDetails')
938
+ }
939
+ onClick={(event) =>
940
+ event.stopPropagation()
941
+ }
942
+ >
943
+ {isOpen ? (
944
+ <>
945
+ {t('controls.hideDetails')}
946
+ <ChevronUp className="size-3" />
947
+ </>
948
+ ) : (
949
+ <>
950
+ {t('controls.showDetails')}
951
+ <ChevronDown className="size-3" />
952
+ </>
953
+ )}
954
+ </button>
955
+ </CollapsibleTrigger>
956
+
957
+ {/* ── Collapsible details ── */}
958
+ <CollapsibleContent>
959
+ <div className="space-y-2 border-t border-border/30 px-3 py-2.5 text-[11px] text-muted-foreground">
960
+ <div className="flex items-center justify-between gap-2">
961
+ <Tooltip>
962
+ <TooltipTrigger asChild>
963
+ <div className="flex cursor-default items-center gap-1">
964
+ <Star className="size-3 shrink-0 text-amber-400" />
965
+ <span>
966
+ {t('card.score')}
967
+ </span>
968
+ </div>
969
+ </TooltipTrigger>
970
+ <TooltipContent side="left">
971
+ {t('tooltips.score')}
972
+ </TooltipContent>
973
+ </Tooltip>
974
+ <span className="font-semibold text-foreground">
975
+ {lead.score}
976
+ </span>
977
+ </div>
978
+
979
+ <div className="flex items-center justify-between gap-2">
980
+ <Tooltip>
981
+ <TooltipTrigger asChild>
982
+ <div className="flex cursor-default items-center gap-1">
983
+ <TrendingUp className="size-3 shrink-0 text-violet-400" />
984
+ <span>{t('card.age')}</span>
985
+ </div>
986
+ </TooltipTrigger>
987
+ <TooltipContent side="left">
988
+ {t('tooltips.age')}
989
+ </TooltipContent>
990
+ </Tooltip>
991
+ <span className="font-semibold text-foreground">
992
+ {t('card.days', { count: age })}
993
+ </span>
994
+ </div>
995
+
996
+ <div className="flex items-center justify-between gap-2">
997
+ <div className="flex items-center gap-1">
998
+ <Tag className="size-3 shrink-0 text-sky-400" />
999
+ <span>
1000
+ {dashboardT(
1001
+ `sourceLabels.${lead.source ?? 'other'}`
1002
+ )}
1003
+ </span>
1004
+ </div>
1005
+ </div>
1006
+
1007
+ {hiddenTags.length > 0 && (
1008
+ <div className="flex flex-wrap gap-1 pt-0.5">
1009
+ {hiddenTags.map((tag) => (
1010
+ <Badge
1011
+ key={tag}
1012
+ variant="outline"
1013
+ className="px-1.5 py-0 text-[10px]"
1014
+ >
1015
+ {tag}
1016
+ </Badge>
1017
+ ))}
1018
+ </div>
1019
+ )}
1020
+ </div>
1021
+ </CollapsibleContent>
1022
+ </DraggableLeadCard>
1023
+ </Collapsible>
1024
+ );
1025
+ })
1026
+ )}
1027
+ </div>
1028
+ </ScrollArea>
1029
+ </CardContent>
1030
+ </Card>
1031
+ )}
1032
+ </StageDropZone>
1033
+ );
1034
+ })}
1035
+ </div>
1036
+ </div>
1037
+
1038
+ <DragOverlay>
1039
+ {activeDragLead ? (
1040
+ <div className="w-[210px] rounded-xl border border-primary/30 bg-card p-3 shadow-lg">
1041
+ <p className="line-clamp-2 text-xs font-semibold leading-4">
1042
+ {activeDragLead.name}
1043
+ </p>
1044
+ <p className="mt-1 text-[11px] text-muted-foreground">
1045
+ {dashboardT(`stageLabels.${activeDragLead.lifecycle_stage}`)}
1046
+ </p>
1047
+ </div>
1048
+ ) : null}
1049
+ </DragOverlay>
1050
+ </DndContext>
1051
+
1052
+ <LeadDetailSheet
1053
+ lead={selectedLead}
1054
+ open={detailOpen}
1055
+ onOpenChange={closeLeadDetail}
1056
+ onEditCadastro={() => closeLeadDetail(false)}
1057
+ onMoveStage={handleMoveStage}
1058
+ onLeadUpdated={handleLeadUpdated}
1059
+ />
1060
+
1061
+ <PersonFormSheet
1062
+ open={formSheetOpen}
1063
+ person={null}
1064
+ contactTypes={contactTypes}
1065
+ documentTypes={documentTypes}
1066
+ onOpenChange={setFormSheetOpen}
1067
+ onSuccess={() => {
1068
+ void refetch();
1069
+ }}
1070
+ />
1071
+ </div>
1072
+ </Page>
1073
+ );
1074
+ }