@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.
- package/README.md +1 -4
- package/dist/person/dto/create-followup.dto.d.ts +5 -0
- package/dist/person/dto/create-followup.dto.d.ts.map +1 -0
- package/dist/person/dto/create-followup.dto.js +31 -0
- package/dist/person/dto/create-followup.dto.js.map +1 -0
- package/dist/person/dto/create-interaction.dto.d.ts +12 -0
- package/dist/person/dto/create-interaction.dto.d.ts.map +1 -0
- package/dist/person/dto/create-interaction.dto.js +39 -0
- package/dist/person/dto/create-interaction.dto.js.map +1 -0
- package/dist/person/dto/create.dto.d.ts +24 -0
- package/dist/person/dto/create.dto.d.ts.map +1 -1
- package/dist/person/dto/create.dto.js +56 -1
- package/dist/person/dto/create.dto.js.map +1 -1
- package/dist/person/dto/duplicates-query.dto.d.ts +8 -0
- package/dist/person/dto/duplicates-query.dto.d.ts.map +1 -0
- package/dist/person/dto/duplicates-query.dto.js +45 -0
- package/dist/person/dto/duplicates-query.dto.js.map +1 -0
- package/dist/person/dto/merge.dto.d.ts +6 -0
- package/dist/person/dto/merge.dto.d.ts.map +1 -0
- package/dist/person/dto/merge.dto.js +35 -0
- package/dist/person/dto/merge.dto.js.map +1 -0
- package/dist/person/dto/update-lifecycle-stage.dto.d.ts +13 -0
- package/dist/person/dto/update-lifecycle-stage.dto.d.ts.map +1 -0
- package/dist/person/dto/update-lifecycle-stage.dto.js +34 -0
- package/dist/person/dto/update-lifecycle-stage.dto.js.map +1 -0
- package/dist/person/dto/update.dto.d.ts +8 -1
- package/dist/person/dto/update.dto.d.ts.map +1 -1
- package/dist/person/dto/update.dto.js +36 -0
- package/dist/person/dto/update.dto.js.map +1 -1
- package/dist/person/person.controller.d.ts +57 -1
- package/dist/person/person.controller.d.ts.map +1 -1
- package/dist/person/person.controller.js +85 -3
- package/dist/person/person.controller.js.map +1 -1
- package/dist/person/person.service.d.ts +79 -0
- package/dist/person/person.service.d.ts.map +1 -1
- package/dist/person/person.service.js +730 -9
- package/dist/person/person.service.js.map +1 -1
- package/hedhog/data/route.yaml +18 -0
- package/hedhog/frontend/app/_components/crm-coming-soon.tsx.ejs +110 -110
- package/hedhog/frontend/app/_components/crm-nav.tsx.ejs +73 -73
- package/hedhog/frontend/app/_lib/crm-mocks.ts.ejs +498 -256
- package/hedhog/frontend/app/_lib/crm-sections.tsx.ejs +81 -81
- package/hedhog/frontend/app/accounts/_components/account-form-sheet.tsx.ejs +477 -0
- package/hedhog/frontend/app/accounts/_components/account-types.ts.ejs +62 -0
- package/hedhog/frontend/app/accounts/page.tsx.ejs +886 -15
- package/hedhog/frontend/app/activities/page.tsx.ejs +15 -15
- package/hedhog/frontend/app/contact-type/page.tsx.ejs +105 -91
- package/hedhog/frontend/app/dashboard/page.tsx.ejs +506 -573
- package/hedhog/frontend/app/document-type/page.tsx.ejs +105 -91
- package/hedhog/frontend/app/follow-ups/page.tsx.ejs +15 -15
- package/hedhog/frontend/app/page.tsx.ejs +5 -5
- package/hedhog/frontend/app/person/_components/person-form-sheet.tsx.ejs +1440 -1103
- package/hedhog/frontend/app/person/_components/person-interaction-dialog.tsx.ejs +4 -3
- package/hedhog/frontend/app/person/_components/person-types.ts.ejs +14 -0
- package/hedhog/frontend/app/person/page.tsx.ejs +108 -190
- package/hedhog/frontend/app/pipeline/_components/lead-detail-sheet.tsx.ejs +599 -0
- package/hedhog/frontend/app/pipeline/page.tsx.ejs +1074 -299
- package/hedhog/frontend/app/reports/page.tsx.ejs +15 -15
- package/hedhog/frontend/messages/en.json +107 -0
- package/hedhog/frontend/messages/pt.json +106 -0
- package/package.json +6 -6
- package/src/person/dto/create-followup.dto.ts +15 -0
- package/src/person/dto/create-interaction.dto.ts +23 -0
- package/src/person/dto/create.dto.ts +50 -0
- package/src/person/dto/duplicates-query.dto.ts +34 -0
- package/src/person/dto/merge.dto.ts +15 -0
- package/src/person/dto/update-lifecycle-stage.dto.ts +19 -0
- package/src/person/dto/update.dto.ts +31 -1
- package/src/person/person.controller.ts +63 -2
- package/src/person/person.service.ts +1096 -7
|
@@ -1,299 +1,1074 @@
|
|
|
1
|
-
'use client';
|
|
2
|
-
|
|
3
|
-
import {
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
import {
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
}
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
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
|
+
}
|