@hed-hog/contact 0.0.279 → 0.0.286

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 (73) hide show
  1. package/README.md +2 -0
  2. package/dist/contact.service.d.ts +2 -148
  3. package/dist/contact.service.d.ts.map +1 -1
  4. package/dist/person/dto/create-followup.dto.d.ts +5 -0
  5. package/dist/person/dto/create-followup.dto.d.ts.map +1 -0
  6. package/dist/person/dto/create-followup.dto.js +31 -0
  7. package/dist/person/dto/create-followup.dto.js.map +1 -0
  8. package/dist/person/dto/create-interaction.dto.d.ts +12 -0
  9. package/dist/person/dto/create-interaction.dto.d.ts.map +1 -0
  10. package/dist/person/dto/create-interaction.dto.js +39 -0
  11. package/dist/person/dto/create-interaction.dto.js.map +1 -0
  12. package/dist/person/dto/create.dto.d.ts +24 -0
  13. package/dist/person/dto/create.dto.d.ts.map +1 -1
  14. package/dist/person/dto/create.dto.js +56 -1
  15. package/dist/person/dto/create.dto.js.map +1 -1
  16. package/dist/person/dto/duplicates-query.dto.d.ts +8 -0
  17. package/dist/person/dto/duplicates-query.dto.d.ts.map +1 -0
  18. package/dist/person/dto/duplicates-query.dto.js +45 -0
  19. package/dist/person/dto/duplicates-query.dto.js.map +1 -0
  20. package/dist/person/dto/merge.dto.d.ts +6 -0
  21. package/dist/person/dto/merge.dto.d.ts.map +1 -0
  22. package/dist/person/dto/merge.dto.js +35 -0
  23. package/dist/person/dto/merge.dto.js.map +1 -0
  24. package/dist/person/dto/update-lifecycle-stage.dto.d.ts +13 -0
  25. package/dist/person/dto/update-lifecycle-stage.dto.d.ts.map +1 -0
  26. package/dist/person/dto/update-lifecycle-stage.dto.js +34 -0
  27. package/dist/person/dto/update-lifecycle-stage.dto.js.map +1 -0
  28. package/dist/person/dto/update.dto.d.ts +8 -1
  29. package/dist/person/dto/update.dto.d.ts.map +1 -1
  30. package/dist/person/dto/update.dto.js +36 -0
  31. package/dist/person/dto/update.dto.js.map +1 -1
  32. package/dist/person/person.controller.d.ts +57 -1
  33. package/dist/person/person.controller.d.ts.map +1 -1
  34. package/dist/person/person.controller.js +85 -3
  35. package/dist/person/person.controller.js.map +1 -1
  36. package/dist/person/person.service.d.ts +79 -0
  37. package/dist/person/person.service.d.ts.map +1 -1
  38. package/dist/person/person.service.js +730 -9
  39. package/dist/person/person.service.js.map +1 -1
  40. package/hedhog/data/route.yaml +18 -0
  41. package/hedhog/frontend/app/_components/crm-coming-soon.tsx.ejs +110 -110
  42. package/hedhog/frontend/app/_components/crm-nav.tsx.ejs +73 -73
  43. package/hedhog/frontend/app/_lib/crm-mocks.ts.ejs +498 -256
  44. package/hedhog/frontend/app/_lib/crm-sections.tsx.ejs +81 -81
  45. package/hedhog/frontend/app/accounts/_components/account-form-sheet.tsx.ejs +477 -0
  46. package/hedhog/frontend/app/accounts/_components/account-types.ts.ejs +62 -0
  47. package/hedhog/frontend/app/accounts/page.tsx.ejs +892 -15
  48. package/hedhog/frontend/app/activities/page.tsx.ejs +812 -15
  49. package/hedhog/frontend/app/contact-type/page.tsx.ejs +105 -91
  50. package/hedhog/frontend/app/dashboard/page.tsx.ejs +491 -573
  51. package/hedhog/frontend/app/document-type/page.tsx.ejs +105 -91
  52. package/hedhog/frontend/app/follow-ups/page.tsx.ejs +696 -15
  53. package/hedhog/frontend/app/page.tsx.ejs +5 -5
  54. package/hedhog/frontend/app/person/_components/person-form-sheet.tsx.ejs +1440 -1103
  55. package/hedhog/frontend/app/person/_components/person-interaction-dialog.tsx.ejs +4 -3
  56. package/hedhog/frontend/app/person/_components/person-types.ts.ejs +14 -0
  57. package/hedhog/frontend/app/person/page.tsx.ejs +112 -190
  58. package/hedhog/frontend/app/pipeline/_components/lead-detail-sheet.tsx.ejs +599 -0
  59. package/hedhog/frontend/app/pipeline/page.tsx.ejs +1048 -299
  60. package/hedhog/frontend/app/reports/page.tsx.ejs +15 -15
  61. package/hedhog/frontend/messages/en.json +268 -0
  62. package/hedhog/frontend/messages/pt.json +233 -0
  63. package/package.json +6 -6
  64. package/src/contact.service.ts +2 -2
  65. package/src/person/dto/create-followup.dto.ts +15 -0
  66. package/src/person/dto/create-interaction.dto.ts +23 -0
  67. package/src/person/dto/create.dto.ts +50 -0
  68. package/src/person/dto/duplicates-query.dto.ts +34 -0
  69. package/src/person/dto/merge.dto.ts +15 -0
  70. package/src/person/dto/update-lifecycle-stage.dto.ts +19 -0
  71. package/src/person/dto/update.dto.ts +31 -1
  72. package/src/person/person.controller.ts +63 -2
  73. package/src/person/person.service.ts +1096 -7
@@ -1,573 +1,491 @@
1
- 'use client';
2
-
3
- import { CrmNav } from '../_components/crm-nav';
4
- import {
5
- crmMockLeads,
6
- crmOwners,
7
- crmSourceOrder,
8
- crmStageOrder,
9
- } from '../_lib/crm-mocks';
10
- import { crmImplementedSections } from '../_lib/crm-sections';
11
- import { Page, PageHeader } from '@/components/entity-list';
12
- import { Badge } from '@/components/ui/badge';
13
- import { Button } from '@/components/ui/button';
14
- import {
15
- Card,
16
- CardContent,
17
- CardDescription,
18
- CardHeader,
19
- CardTitle,
20
- } from '@/components/ui/card';
21
- import { ScrollArea } from '@/components/ui/scroll-area';
22
- import { useApp } from '@hed-hog/next-app-provider';
23
- import {
24
- AlertTriangle,
25
- ArrowUpRight,
26
- BriefcaseBusiness,
27
- CalendarClock,
28
- ChartNoAxesCombined,
29
- CircleDollarSign,
30
- RefreshCcw,
31
- Sparkles,
32
- Target,
33
- TrendingUp,
34
- UserRoundX,
35
- } from 'lucide-react';
36
- import Link from 'next/link';
37
- import { useTranslations } from 'next-intl';
38
- import {
39
- Bar,
40
- BarChart,
41
- CartesianGrid,
42
- Cell,
43
- Pie,
44
- PieChart,
45
- ResponsiveContainer,
46
- Tooltip,
47
- XAxis,
48
- YAxis,
49
- } from 'recharts';
50
-
51
- const chartPalette = [
52
- '#f97316',
53
- '#14b8a6',
54
- '#0ea5e9',
55
- '#84cc16',
56
- '#f59e0b',
57
- '#ef4444',
58
- '#8b5cf6',
59
- ];
60
-
61
- const chartTooltipStyle = {
62
- backgroundColor: 'hsl(var(--card))',
63
- border: '1px solid hsl(var(--border))',
64
- borderRadius: '12px',
65
- fontSize: '12px',
66
- };
67
-
68
- const fallbackKpiAccentClass =
69
- 'from-orange-500/20 via-amber-500/10 to-transparent';
70
-
71
- export default function CrmDashboardPage() {
72
- const t = useTranslations('contact.CrmDashboard');
73
- const menuT = useTranslations('contact.CrmMenu');
74
- const { currentLocaleCode } = useApp();
75
-
76
- const qualifiedCount = crmMockLeads.filter((lead) =>
77
- ['qualified', 'proposal', 'negotiation', 'customer'].includes(
78
- lead.lifecycle_stage ?? ''
79
- )
80
- ).length;
81
- const proposalCount = crmMockLeads.filter(
82
- (lead) => lead.lifecycle_stage === 'proposal'
83
- ).length;
84
- const customerCount = crmMockLeads.filter(
85
- (lead) => lead.lifecycle_stage === 'customer'
86
- ).length;
87
- const lostCount = crmMockLeads.filter(
88
- (lead) => lead.lifecycle_stage === 'lost'
89
- ).length;
90
- const unassignedCount = crmMockLeads.filter(
91
- (lead) => !lead.owner_user_id
92
- ).length;
93
- const overdueFollowups = crmMockLeads.filter(
94
- (lead) =>
95
- lead.next_action_at &&
96
- new Date(lead.next_action_at) < new Date('2026-03-16T00:00:00.000Z')
97
- ).length;
98
-
99
- const totalDealValue = crmMockLeads.reduce(
100
- (sum, lead) => sum + lead.dealValue,
101
- 0
102
- );
103
- const conversionRate = Math.round(
104
- (customerCount / Math.max(crmMockLeads.length, 1)) * 100
105
- );
106
-
107
- const stageChartData = crmStageOrder.map((stage, index) => ({
108
- name: t(`stageLabels.${stage}`),
109
- total: crmMockLeads.filter((lead) => lead.lifecycle_stage === stage).length,
110
- fill: chartPalette[index % chartPalette.length],
111
- }));
112
-
113
- const sourceChartData = crmSourceOrder.map((source, index) => ({
114
- name: t(`sourceLabels.${source}`),
115
- total: crmMockLeads.filter((lead) => lead.source === source).length,
116
- fill: chartPalette[index % chartPalette.length],
117
- }));
118
-
119
- const ownerPerformanceData = crmOwners.map((owner) => {
120
- const leads = crmMockLeads.filter(
121
- (lead) => lead.owner_user_id === owner.id
122
- );
123
-
124
- return {
125
- name: owner.name,
126
- leads: leads.length,
127
- customers: leads.filter((lead) => lead.lifecycle_stage === 'customer')
128
- .length,
129
- pipeline: leads
130
- .filter((lead) => lead.lifecycle_stage !== 'lost')
131
- .reduce((sum, lead) => sum + lead.dealValue, 0),
132
- };
133
- });
134
-
135
- const nextActions = [...crmMockLeads]
136
- .filter((lead) => lead.next_action_at)
137
- .sort(
138
- (left, right) =>
139
- new Date(left.next_action_at ?? 0).getTime() -
140
- new Date(right.next_action_at ?? 0).getTime()
141
- )
142
- .slice(0, 5);
143
-
144
- const unattendedLeads = crmMockLeads
145
- .filter((lead) => !lead.owner_user_id || lead.lifecycle_stage === 'new')
146
- .slice(0, 5);
147
-
148
- const topOwners = [...ownerPerformanceData]
149
- .sort((left, right) => right.pipeline - left.pipeline)
150
- .slice(0, 4);
151
-
152
- const currencyFormatter = new Intl.NumberFormat(
153
- currentLocaleCode === 'pt' ? 'pt-BR' : 'en-US',
154
- {
155
- style: 'currency',
156
- currency: 'BRL',
157
- maximumFractionDigits: 0,
158
- }
159
- );
160
-
161
- return (
162
- <Page>
163
- <PageHeader
164
- title={t('title')}
165
- description={t('subtitle')}
166
- breadcrumbs={[
167
- { label: t('breadcrumbs.home'), href: '/' },
168
- { label: t('breadcrumbs.crm') },
169
- ]}
170
- actions={[
171
- {
172
- label: t('refresh'),
173
- onClick: () => window.location.reload(),
174
- variant: 'outline',
175
- icon: <RefreshCcw className="size-4" />,
176
- },
177
- ]}
178
- />
179
-
180
- <div className="min-w-0 space-y-6 overflow-x-hidden">
181
- <Card className="overflow-hidden border-orange-200/70 bg-gradient-to-br from-orange-50 via-background to-amber-50 py-0">
182
- <CardContent className="grid min-w-0 gap-6 px-6 py-6 lg:grid-cols-[minmax(0,1.3fr)_minmax(280px,0.9fr)]">
183
- <div className="min-w-0 space-y-4">
184
- <Badge className="w-fit rounded-full bg-orange-500/10 px-3 py-1 text-orange-700 hover:bg-orange-500/10">
185
- <Sparkles className="mr-2 size-3.5" />
186
- {t('heroBadge')}
187
- </Badge>
188
- <div className="space-y-2">
189
- <h2 className="text-3xl font-semibold tracking-tight text-balance">
190
- {t('heroTitle')}
191
- </h2>
192
- <p className="max-w-2xl text-sm leading-6 text-muted-foreground">
193
- {t('heroDescription')}
194
- </p>
195
- </div>
196
- <div className="flex flex-wrap gap-3">
197
- <Button asChild>
198
- <Link href="/contact/pipeline">{t('primaryAction')}</Link>
199
- </Button>
200
- <Button asChild variant="outline">
201
- <Link href="/contact/person">{t('secondaryAction')}</Link>
202
- </Button>
203
- </div>
204
- </div>
205
-
206
- <div className="grid gap-3 sm:grid-cols-3 lg:grid-cols-1">
207
- {[
208
- {
209
- icon: CircleDollarSign,
210
- label: t('summary.pipelineValue'),
211
- value: currencyFormatter.format(totalDealValue),
212
- },
213
- {
214
- icon: Target,
215
- label: t('summary.conversionRate'),
216
- value: `${conversionRate}%`,
217
- },
218
- {
219
- icon: BriefcaseBusiness,
220
- label: t('summary.activeOwners'),
221
- value: crmOwners.length,
222
- },
223
- ].map((item) => (
224
- <div
225
- key={item.label}
226
- className="rounded-2xl border border-white/70 bg-white/80 p-4 shadow-sm backdrop-blur"
227
- >
228
- <div className="mb-3 flex items-center gap-2 text-muted-foreground">
229
- <item.icon className="size-4" />
230
- <span className="text-xs uppercase tracking-[0.2em]">
231
- {item.label}
232
- </span>
233
- </div>
234
- <div className="text-2xl font-semibold tracking-tight">
235
- {item.value}
236
- </div>
237
- </div>
238
- ))}
239
- </div>
240
- </CardContent>
241
- </Card>
242
-
243
- <CrmNav currentHref="/contact/dashboard" />
244
-
245
- <div className="grid gap-4 md:grid-cols-2 xl:grid-cols-4">
246
- {[
247
- { key: 'totalLeads', value: crmMockLeads.length, icon: TrendingUp },
248
- { key: 'qualified', value: qualifiedCount, icon: Target },
249
- { key: 'proposal', value: proposalCount, icon: BriefcaseBusiness },
250
- { key: 'customers', value: customerCount, icon: CircleDollarSign },
251
- { key: 'lost', value: lostCount, icon: AlertTriangle },
252
- { key: 'unassigned', value: unassignedCount, icon: UserRoundX },
253
- { key: 'overdue', value: overdueFollowups, icon: CalendarClock },
254
- {
255
- key: 'nextActions',
256
- value: nextActions.length,
257
- icon: ChartNoAxesCombined,
258
- },
259
- ].map((item, index) => {
260
- const sectionStyle =
261
- crmImplementedSections[index % crmImplementedSections.length] ??
262
- crmImplementedSections[0];
263
-
264
- return (
265
- <Card
266
- key={item.key}
267
- className="min-w-0 overflow-hidden border-border/70 py-0"
268
- >
269
- <div
270
- className={`h-1 w-full bg-gradient-to-r ${sectionStyle?.colorClass ?? fallbackKpiAccentClass}`}
271
- />
272
- <CardContent className="flex items-start justify-between gap-3 px-6 py-5">
273
- <div className="min-w-0">
274
- <p className="text-xs uppercase tracking-[0.2em] text-muted-foreground">
275
- {t(`kpis.${item.key}.title`)}
276
- </p>
277
- <p className="mt-2 text-3xl font-semibold tracking-tight">
278
- {item.value}
279
- </p>
280
- <p className="mt-1 text-sm text-muted-foreground">
281
- {t(`kpis.${item.key}.description`)}
282
- </p>
283
- </div>
284
- <div className="rounded-2xl bg-muted p-3 text-muted-foreground">
285
- <item.icon className="size-5" />
286
- </div>
287
- </CardContent>
288
- </Card>
289
- );
290
- })}
291
- </div>
292
-
293
- <div className="grid min-w-0 gap-6 xl:grid-cols-[minmax(0,1.2fr)_minmax(320px,0.8fr)]">
294
- <Card className="min-w-0">
295
- <CardHeader>
296
- <CardTitle>{t('charts.stage.title')}</CardTitle>
297
- <CardDescription>{t('charts.stage.description')}</CardDescription>
298
- </CardHeader>
299
- <CardContent className="h-[320px]">
300
- <ResponsiveContainer width="100%" height="100%">
301
- <BarChart data={stageChartData}>
302
- <CartesianGrid
303
- strokeDasharray="3 3"
304
- stroke="hsl(var(--border))"
305
- vertical={false}
306
- />
307
- <XAxis
308
- dataKey="name"
309
- tickLine={false}
310
- axisLine={false}
311
- fontSize={12}
312
- />
313
- <YAxis
314
- tickLine={false}
315
- axisLine={false}
316
- fontSize={12}
317
- allowDecimals={false}
318
- />
319
- <Tooltip contentStyle={chartTooltipStyle} />
320
- <Bar dataKey="total" radius={[8, 8, 0, 0]}>
321
- {stageChartData.map((entry) => (
322
- <Cell key={entry.name} fill={entry.fill} />
323
- ))}
324
- </Bar>
325
- </BarChart>
326
- </ResponsiveContainer>
327
- </CardContent>
328
- </Card>
329
-
330
- <Card className="min-w-0 overflow-hidden">
331
- <CardHeader>
332
- <CardTitle>{t('charts.source.title')}</CardTitle>
333
- <CardDescription>
334
- {t('charts.source.description')}
335
- </CardDescription>
336
- </CardHeader>
337
- <CardContent className="grid min-w-0 gap-4 overflow-hidden lg:h-[320px] lg:grid-cols-[minmax(220px,0.9fr)_minmax(0,1fr)]">
338
- <div className="h-[220px] min-w-0 lg:h-full">
339
- <ResponsiveContainer width="100%" height="100%">
340
- <PieChart>
341
- <Pie
342
- data={sourceChartData}
343
- dataKey="total"
344
- nameKey="name"
345
- innerRadius={58}
346
- outerRadius={94}
347
- paddingAngle={2}
348
- >
349
- {sourceChartData.map((entry) => (
350
- <Cell key={entry.name} fill={entry.fill} />
351
- ))}
352
- </Pie>
353
- <Tooltip contentStyle={chartTooltipStyle} />
354
- </PieChart>
355
- </ResponsiveContainer>
356
- </div>
357
- <ScrollArea className="min-w-0 lg:h-full">
358
- <div className="space-y-3 lg:pr-3">
359
- {sourceChartData.map((item) => (
360
- <div
361
- key={item.name}
362
- className="flex min-w-0 items-center justify-between gap-3 rounded-2xl border border-border/70 px-4 py-3"
363
- >
364
- <div className="flex min-w-0 items-center gap-3">
365
- <span
366
- className="inline-block size-3 rounded-full"
367
- style={{ backgroundColor: item.fill }}
368
- />
369
- <span className="truncate font-medium">
370
- {item.name}
371
- </span>
372
- </div>
373
- <span className="shrink-0 text-sm text-muted-foreground">
374
- {item.total}
375
- </span>
376
- </div>
377
- ))}
378
- </div>
379
- </ScrollArea>
380
- </CardContent>
381
- </Card>
382
- </div>
383
-
384
- <div className="grid min-w-0 gap-6 xl:grid-cols-[minmax(0,1.1fr)_minmax(320px,0.9fr)]">
385
- <Card className="min-w-0">
386
- <CardHeader>
387
- <CardTitle>{t('charts.owner.title')}</CardTitle>
388
- <CardDescription>{t('charts.owner.description')}</CardDescription>
389
- </CardHeader>
390
- <CardContent className="h-[320px]">
391
- <ResponsiveContainer width="100%" height="100%">
392
- <BarChart data={ownerPerformanceData}>
393
- <CartesianGrid
394
- strokeDasharray="3 3"
395
- stroke="hsl(var(--border))"
396
- vertical={false}
397
- />
398
- <XAxis
399
- dataKey="name"
400
- tickLine={false}
401
- axisLine={false}
402
- fontSize={12}
403
- />
404
- <YAxis
405
- tickLine={false}
406
- axisLine={false}
407
- fontSize={12}
408
- allowDecimals={false}
409
- />
410
- <Tooltip contentStyle={chartTooltipStyle} />
411
- <Bar dataKey="leads" fill="#f97316" radius={[8, 8, 0, 0]} />
412
- <Bar
413
- dataKey="customers"
414
- fill="#0ea5e9"
415
- radius={[8, 8, 0, 0]}
416
- />
417
- </BarChart>
418
- </ResponsiveContainer>
419
- </CardContent>
420
- </Card>
421
-
422
- <Card className="min-w-0 overflow-hidden">
423
- <CardHeader>
424
- <CardTitle>{t('bestOwners.title')}</CardTitle>
425
- <CardDescription>{t('bestOwners.description')}</CardDescription>
426
- </CardHeader>
427
- <CardContent>
428
- <div className="space-y-3">
429
- {topOwners.map((owner, index) => (
430
- <div
431
- key={owner.name}
432
- className="flex min-w-0 items-center justify-between gap-3 rounded-2xl border border-border/70 px-4 py-3"
433
- >
434
- <div className="min-w-0">
435
- <p className="truncate font-medium">
436
- {index + 1}. {owner.name}
437
- </p>
438
- <p className="truncate text-xs text-muted-foreground">
439
- {t('bestOwners.meta', {
440
- leads: owner.leads,
441
- customers: owner.customers,
442
- })}
443
- </p>
444
- </div>
445
- <span className="shrink-0 text-sm font-medium text-foreground">
446
- {currencyFormatter.format(owner.pipeline)}
447
- </span>
448
- </div>
449
- ))}
450
- </div>
451
- </CardContent>
452
- </Card>
453
- </div>
454
-
455
- <div className="grid gap-6 xl:grid-cols-3">
456
- <Card className="min-w-0 overflow-hidden xl:col-span-1">
457
- <CardHeader>
458
- <CardTitle>{t('blocks.nextActions.title')}</CardTitle>
459
- <CardDescription>
460
- {t('blocks.nextActions.description')}
461
- </CardDescription>
462
- </CardHeader>
463
- <CardContent>
464
- <ScrollArea className="h-[320px] pr-3">
465
- <div className="space-y-3">
466
- {nextActions.map((lead) => (
467
- <div
468
- key={lead.id}
469
- className="rounded-2xl border border-border/70 px-4 py-3"
470
- >
471
- <p className="font-medium">{lead.name}</p>
472
- <p className="text-xs text-muted-foreground">
473
- {lead.owner_user?.name || t('common.unassigned')}
474
- </p>
475
- <div className="mt-2 flex flex-wrap gap-2">
476
- <Badge variant="outline">
477
- {t(`stageLabels.${lead.lifecycle_stage ?? 'new'}`)}
478
- </Badge>
479
- <Badge variant="secondary">
480
- {t(`sourceLabels.${lead.source ?? 'other'}`)}
481
- </Badge>
482
- </div>
483
- <p className="mt-2 text-sm text-muted-foreground">
484
- {t('blocks.nextActions.when', {
485
- date: new Date(
486
- lead.next_action_at ?? ''
487
- ).toLocaleString(),
488
- })}
489
- </p>
490
- </div>
491
- ))}
492
- </div>
493
- </ScrollArea>
494
- </CardContent>
495
- </Card>
496
-
497
- <Card className="min-w-0 overflow-hidden xl:col-span-1">
498
- <CardHeader>
499
- <CardTitle>{t('blocks.unattended.title')}</CardTitle>
500
- <CardDescription>
501
- {t('blocks.unattended.description')}
502
- </CardDescription>
503
- </CardHeader>
504
- <CardContent>
505
- <ScrollArea className="h-[320px] pr-3">
506
- <div className="space-y-3">
507
- {unattendedLeads.map((lead) => (
508
- <div
509
- key={lead.id}
510
- className="rounded-2xl border border-border/70 px-4 py-3"
511
- >
512
- <p className="font-medium">{lead.name}</p>
513
- <p className="text-xs text-muted-foreground">
514
- {lead.owner_user?.name || t('common.unassigned')}
515
- </p>
516
- <p className="mt-2 text-sm text-muted-foreground">
517
- {t('blocks.unattended.meta', {
518
- stage: t(
519
- `stageLabels.${lead.lifecycle_stage ?? 'new'}`
520
- ),
521
- source: t(`sourceLabels.${lead.source ?? 'other'}`),
522
- })}
523
- </p>
524
- </div>
525
- ))}
526
- </div>
527
- </ScrollArea>
528
- </CardContent>
529
- </Card>
530
-
531
- <Card className="min-w-0 overflow-hidden xl:col-span-1">
532
- <CardHeader>
533
- <CardTitle>{t('blocks.quickAccess.title')}</CardTitle>
534
- <CardDescription>
535
- {t('blocks.quickAccess.description')}
536
- </CardDescription>
537
- </CardHeader>
538
- <CardContent className="space-y-3">
539
- {crmImplementedSections.map((section) => {
540
- const Icon = section.icon;
541
-
542
- return (
543
- <Link
544
- key={section.href}
545
- href={section.href}
546
- className="flex min-w-0 items-center justify-between gap-3 rounded-2xl border border-border/70 px-4 py-3 transition-colors hover:bg-muted/40"
547
- >
548
- <div className="flex min-w-0 items-center gap-3">
549
- <div className={`rounded-2xl p-3 ${section.glowClass}`}>
550
- <Icon className="size-4" />
551
- </div>
552
- <div className="min-w-0">
553
- <p className="truncate font-medium">
554
- {menuT(`sections.${section.translationKey}.title`)}
555
- </p>
556
- <p className="truncate text-xs text-muted-foreground">
557
- {menuT(
558
- `sections.${section.translationKey}.description`
559
- )}
560
- </p>
561
- </div>
562
- </div>
563
- <ArrowUpRight className="size-4 shrink-0 text-muted-foreground" />
564
- </Link>
565
- );
566
- })}
567
- </CardContent>
568
- </Card>
569
- </div>
570
- </div>
571
- </Page>
572
- );
573
- }
1
+ 'use client';
2
+
3
+ import { Page, PageHeader } from '@/components/entity-list';
4
+ import { Badge } from '@/components/ui/badge';
5
+ import {
6
+ Card,
7
+ CardContent,
8
+ CardDescription,
9
+ CardHeader,
10
+ CardTitle,
11
+ } from '@/components/ui/card';
12
+ import { KpiCardsGrid } from '@/components/ui/kpi-cards-grid';
13
+ import { ScrollArea } from '@/components/ui/scroll-area';
14
+ import { useApp } from '@hed-hog/next-app-provider';
15
+ import {
16
+ AlertTriangle,
17
+ ArrowUpRight,
18
+ BriefcaseBusiness,
19
+ CalendarClock,
20
+ ChartNoAxesCombined,
21
+ CircleDollarSign,
22
+ RefreshCcw,
23
+ Target,
24
+ TrendingUp,
25
+ UserRoundX,
26
+ } from 'lucide-react';
27
+ import { useTranslations } from 'next-intl';
28
+ import Link from 'next/link';
29
+ import {
30
+ Bar,
31
+ BarChart,
32
+ CartesianGrid,
33
+ Cell,
34
+ Pie,
35
+ PieChart,
36
+ ResponsiveContainer,
37
+ Tooltip,
38
+ XAxis,
39
+ YAxis,
40
+ } from 'recharts';
41
+ import {
42
+ crmMockLeads,
43
+ crmOwners,
44
+ crmSourceOrder,
45
+ crmStageOrder,
46
+ } from '../_lib/crm-mocks';
47
+ import { crmImplementedSections } from '../_lib/crm-sections';
48
+
49
+ const chartPalette = [
50
+ '#f97316',
51
+ '#14b8a6',
52
+ '#0ea5e9',
53
+ '#84cc16',
54
+ '#f59e0b',
55
+ '#ef4444',
56
+ '#8b5cf6',
57
+ ];
58
+
59
+ const chartTooltipStyle = {
60
+ backgroundColor: 'hsl(var(--card))',
61
+ border: '1px solid hsl(var(--border))',
62
+ borderRadius: '12px',
63
+ fontSize: '12px',
64
+ };
65
+
66
+ const fallbackKpiAccentClass =
67
+ 'from-orange-500/20 via-amber-500/10 to-transparent';
68
+
69
+ export default function CrmDashboardPage() {
70
+ const t = useTranslations('contact.CrmDashboard');
71
+ const menuT = useTranslations('contact.CrmMenu');
72
+ const { currentLocaleCode } = useApp();
73
+
74
+ const qualifiedCount = crmMockLeads.filter((lead) =>
75
+ ['qualified', 'proposal', 'negotiation', 'customer'].includes(
76
+ lead.lifecycle_stage ?? ''
77
+ )
78
+ ).length;
79
+ const proposalCount = crmMockLeads.filter(
80
+ (lead) => lead.lifecycle_stage === 'proposal'
81
+ ).length;
82
+ const customerCount = crmMockLeads.filter(
83
+ (lead) => lead.lifecycle_stage === 'customer'
84
+ ).length;
85
+ const lostCount = crmMockLeads.filter(
86
+ (lead) => lead.lifecycle_stage === 'lost'
87
+ ).length;
88
+ const unassignedCount = crmMockLeads.filter(
89
+ (lead) => !lead.owner_user_id
90
+ ).length;
91
+ const overdueFollowups = crmMockLeads.filter(
92
+ (lead) =>
93
+ lead.next_action_at &&
94
+ new Date(lead.next_action_at) < new Date('2026-03-16T00:00:00.000Z')
95
+ ).length;
96
+
97
+ const totalDealValue = crmMockLeads.reduce(
98
+ (sum, lead) => sum + lead.dealValue,
99
+ 0
100
+ );
101
+ const conversionRate = Math.round(
102
+ (customerCount / Math.max(crmMockLeads.length, 1)) * 100
103
+ );
104
+
105
+ const stageChartData = crmStageOrder.map((stage, index) => ({
106
+ name: t(`stageLabels.${stage}`),
107
+ total: crmMockLeads.filter((lead) => lead.lifecycle_stage === stage).length,
108
+ fill: chartPalette[index % chartPalette.length],
109
+ }));
110
+
111
+ const sourceChartData = crmSourceOrder.map((source, index) => ({
112
+ name: t(`sourceLabels.${source}`),
113
+ total: crmMockLeads.filter((lead) => lead.source === source).length,
114
+ fill: chartPalette[index % chartPalette.length],
115
+ }));
116
+
117
+ const ownerPerformanceData = crmOwners.map((owner) => {
118
+ const leads = crmMockLeads.filter(
119
+ (lead) => lead.owner_user_id === owner.id
120
+ );
121
+
122
+ return {
123
+ name: owner.name,
124
+ leads: leads.length,
125
+ customers: leads.filter((lead) => lead.lifecycle_stage === 'customer')
126
+ .length,
127
+ pipeline: leads
128
+ .filter((lead) => lead.lifecycle_stage !== 'lost')
129
+ .reduce((sum, lead) => sum + lead.dealValue, 0),
130
+ };
131
+ });
132
+
133
+ const nextActions = [...crmMockLeads]
134
+ .filter((lead) => lead.next_action_at)
135
+ .sort(
136
+ (left, right) =>
137
+ new Date(left.next_action_at ?? 0).getTime() -
138
+ new Date(right.next_action_at ?? 0).getTime()
139
+ )
140
+ .slice(0, 5);
141
+
142
+ const unattendedLeads = crmMockLeads
143
+ .filter((lead) => !lead.owner_user_id || lead.lifecycle_stage === 'new')
144
+ .slice(0, 5);
145
+
146
+ const topOwners = [...ownerPerformanceData]
147
+ .sort((left, right) => right.pipeline - left.pipeline)
148
+ .slice(0, 4);
149
+
150
+ const kpiCards = [
151
+ { key: 'totalLeads', value: crmMockLeads.length, icon: TrendingUp },
152
+ { key: 'qualified', value: qualifiedCount, icon: Target },
153
+ { key: 'proposal', value: proposalCount, icon: BriefcaseBusiness },
154
+ { key: 'customers', value: customerCount, icon: CircleDollarSign },
155
+ { key: 'lost', value: lostCount, icon: AlertTriangle },
156
+ { key: 'unassigned', value: unassignedCount, icon: UserRoundX },
157
+ { key: 'overdue', value: overdueFollowups, icon: CalendarClock },
158
+ {
159
+ key: 'nextActions',
160
+ value: nextActions.length,
161
+ icon: ChartNoAxesCombined,
162
+ },
163
+ ].map((item, index) => {
164
+ const sectionStyle =
165
+ crmImplementedSections[index % crmImplementedSections.length] ??
166
+ crmImplementedSections[0];
167
+
168
+ return {
169
+ key: item.key,
170
+ title: t(`kpis.${item.key}.title`),
171
+ value: item.value,
172
+ description: t(`kpis.${item.key}.description`),
173
+ icon: item.icon,
174
+ accentClassName: sectionStyle?.colorClass ?? fallbackKpiAccentClass,
175
+ iconContainerClassName:
176
+ sectionStyle?.glowClass ?? 'bg-sky-500/10 text-sky-700',
177
+ };
178
+ });
179
+
180
+ const currencyFormatter = new Intl.NumberFormat(
181
+ currentLocaleCode === 'pt' ? 'pt-BR' : 'en-US',
182
+ {
183
+ style: 'currency',
184
+ currency: 'BRL',
185
+ maximumFractionDigits: 0,
186
+ }
187
+ );
188
+
189
+ return (
190
+ <Page>
191
+ <PageHeader
192
+ title={t('title')}
193
+ description={t('subtitle')}
194
+ breadcrumbs={[
195
+ { label: t('breadcrumbs.home'), href: '/' },
196
+ { label: t('breadcrumbs.crm') },
197
+ ]}
198
+ actions={[
199
+ {
200
+ label: t('refresh'),
201
+ onClick: () => window.location.reload(),
202
+ variant: 'outline',
203
+ icon: <RefreshCcw className="size-4" />,
204
+ },
205
+ ]}
206
+ />
207
+
208
+ <div className="min-w-0 space-y-6 overflow-x-hidden">
209
+ <KpiCardsGrid items={kpiCards} />
210
+
211
+ <div className="grid min-w-0 gap-6 xl:grid-cols-[minmax(0,1.2fr)_minmax(320px,0.8fr)]">
212
+ <Card className="min-w-0">
213
+ <CardHeader>
214
+ <CardTitle>{t('charts.stage.title')}</CardTitle>
215
+ <CardDescription>{t('charts.stage.description')}</CardDescription>
216
+ </CardHeader>
217
+ <CardContent className="h-[320px]">
218
+ <ResponsiveContainer width="100%" height="100%">
219
+ <BarChart data={stageChartData}>
220
+ <CartesianGrid
221
+ strokeDasharray="3 3"
222
+ stroke="hsl(var(--border))"
223
+ vertical={false}
224
+ />
225
+ <XAxis
226
+ dataKey="name"
227
+ tickLine={false}
228
+ axisLine={false}
229
+ fontSize={12}
230
+ />
231
+ <YAxis
232
+ tickLine={false}
233
+ axisLine={false}
234
+ fontSize={12}
235
+ allowDecimals={false}
236
+ />
237
+ <Tooltip contentStyle={chartTooltipStyle} />
238
+ <Bar dataKey="total" radius={[8, 8, 0, 0]}>
239
+ {stageChartData.map((entry) => (
240
+ <Cell key={entry.name} fill={entry.fill} />
241
+ ))}
242
+ </Bar>
243
+ </BarChart>
244
+ </ResponsiveContainer>
245
+ </CardContent>
246
+ </Card>
247
+
248
+ <Card className="min-w-0 overflow-hidden">
249
+ <CardHeader>
250
+ <CardTitle>{t('charts.source.title')}</CardTitle>
251
+ <CardDescription>
252
+ {t('charts.source.description')}
253
+ </CardDescription>
254
+ </CardHeader>
255
+ <CardContent className="grid min-w-0 gap-4 overflow-hidden lg:h-[320px] lg:grid-cols-[minmax(220px,0.9fr)_minmax(0,1fr)]">
256
+ <div className="h-[220px] min-w-0 lg:h-full">
257
+ <ResponsiveContainer width="100%" height="100%">
258
+ <PieChart>
259
+ <Pie
260
+ data={sourceChartData}
261
+ dataKey="total"
262
+ nameKey="name"
263
+ innerRadius={58}
264
+ outerRadius={94}
265
+ paddingAngle={2}
266
+ >
267
+ {sourceChartData.map((entry) => (
268
+ <Cell key={entry.name} fill={entry.fill} />
269
+ ))}
270
+ </Pie>
271
+ <Tooltip contentStyle={chartTooltipStyle} />
272
+ </PieChart>
273
+ </ResponsiveContainer>
274
+ </div>
275
+ <ScrollArea className="min-w-0 lg:h-full">
276
+ <div className="space-y-3 lg:pr-3">
277
+ {sourceChartData.map((item) => (
278
+ <div
279
+ key={item.name}
280
+ className="flex min-w-0 items-center justify-between gap-3 rounded-2xl border border-border/70 px-4 py-3"
281
+ >
282
+ <div className="flex min-w-0 items-center gap-3">
283
+ <span
284
+ className="inline-block size-3 rounded-full"
285
+ style={{ backgroundColor: item.fill }}
286
+ />
287
+ <span className="truncate font-medium">
288
+ {item.name}
289
+ </span>
290
+ </div>
291
+ <span className="shrink-0 text-sm text-muted-foreground">
292
+ {item.total}
293
+ </span>
294
+ </div>
295
+ ))}
296
+ </div>
297
+ </ScrollArea>
298
+ </CardContent>
299
+ </Card>
300
+ </div>
301
+
302
+ <div className="grid min-w-0 gap-6 xl:grid-cols-[minmax(0,1.1fr)_minmax(320px,0.9fr)]">
303
+ <Card className="min-w-0">
304
+ <CardHeader>
305
+ <CardTitle>{t('charts.owner.title')}</CardTitle>
306
+ <CardDescription>{t('charts.owner.description')}</CardDescription>
307
+ </CardHeader>
308
+ <CardContent className="h-[320px]">
309
+ <ResponsiveContainer width="100%" height="100%">
310
+ <BarChart data={ownerPerformanceData}>
311
+ <CartesianGrid
312
+ strokeDasharray="3 3"
313
+ stroke="hsl(var(--border))"
314
+ vertical={false}
315
+ />
316
+ <XAxis
317
+ dataKey="name"
318
+ tickLine={false}
319
+ axisLine={false}
320
+ fontSize={12}
321
+ />
322
+ <YAxis
323
+ tickLine={false}
324
+ axisLine={false}
325
+ fontSize={12}
326
+ allowDecimals={false}
327
+ />
328
+ <Tooltip contentStyle={chartTooltipStyle} />
329
+ <Bar dataKey="leads" fill="#f97316" radius={[8, 8, 0, 0]} />
330
+ <Bar
331
+ dataKey="customers"
332
+ fill="#0ea5e9"
333
+ radius={[8, 8, 0, 0]}
334
+ />
335
+ </BarChart>
336
+ </ResponsiveContainer>
337
+ </CardContent>
338
+ </Card>
339
+
340
+ <Card className="min-w-0 overflow-hidden">
341
+ <CardHeader>
342
+ <CardTitle>{t('bestOwners.title')}</CardTitle>
343
+ <CardDescription>{t('bestOwners.description')}</CardDescription>
344
+ </CardHeader>
345
+ <CardContent>
346
+ <div className="space-y-3">
347
+ {topOwners.map((owner, index) => (
348
+ <div
349
+ key={owner.name}
350
+ className="flex min-w-0 items-center justify-between gap-3 rounded-2xl border border-border/70 px-4 py-3"
351
+ >
352
+ <div className="min-w-0">
353
+ <p className="truncate font-medium">
354
+ {index + 1}. {owner.name}
355
+ </p>
356
+ <p className="truncate text-xs text-muted-foreground">
357
+ {t('bestOwners.meta', {
358
+ leads: owner.leads,
359
+ customers: owner.customers,
360
+ })}
361
+ </p>
362
+ </div>
363
+ <span className="shrink-0 text-sm font-medium text-foreground">
364
+ {currencyFormatter.format(owner.pipeline)}
365
+ </span>
366
+ </div>
367
+ ))}
368
+ </div>
369
+ </CardContent>
370
+ </Card>
371
+ </div>
372
+
373
+ <div className="grid gap-6 xl:grid-cols-3">
374
+ <Card className="min-w-0 overflow-hidden xl:col-span-1">
375
+ <CardHeader>
376
+ <CardTitle>{t('blocks.nextActions.title')}</CardTitle>
377
+ <CardDescription>
378
+ {t('blocks.nextActions.description')}
379
+ </CardDescription>
380
+ </CardHeader>
381
+ <CardContent>
382
+ <ScrollArea className="h-[320px] pr-3">
383
+ <div className="space-y-3">
384
+ {nextActions.map((lead) => (
385
+ <div
386
+ key={lead.id}
387
+ className="rounded-2xl border border-border/70 px-4 py-3"
388
+ >
389
+ <p className="font-medium">{lead.name}</p>
390
+ <p className="text-xs text-muted-foreground">
391
+ {lead.owner_user?.name || t('common.unassigned')}
392
+ </p>
393
+ <div className="mt-2 flex flex-wrap gap-2">
394
+ <Badge variant="outline">
395
+ {t(`stageLabels.${lead.lifecycle_stage ?? 'new'}`)}
396
+ </Badge>
397
+ <Badge variant="secondary">
398
+ {t(`sourceLabels.${lead.source ?? 'other'}`)}
399
+ </Badge>
400
+ </div>
401
+ <p className="mt-2 text-sm text-muted-foreground">
402
+ {t('blocks.nextActions.when', {
403
+ date: new Date(
404
+ lead.next_action_at ?? ''
405
+ ).toLocaleString(),
406
+ })}
407
+ </p>
408
+ </div>
409
+ ))}
410
+ </div>
411
+ </ScrollArea>
412
+ </CardContent>
413
+ </Card>
414
+
415
+ <Card className="min-w-0 overflow-hidden xl:col-span-1">
416
+ <CardHeader>
417
+ <CardTitle>{t('blocks.unattended.title')}</CardTitle>
418
+ <CardDescription>
419
+ {t('blocks.unattended.description')}
420
+ </CardDescription>
421
+ </CardHeader>
422
+ <CardContent>
423
+ <ScrollArea className="h-[320px] pr-3">
424
+ <div className="space-y-3">
425
+ {unattendedLeads.map((lead) => (
426
+ <div
427
+ key={lead.id}
428
+ className="rounded-2xl border border-border/70 px-4 py-3"
429
+ >
430
+ <p className="font-medium">{lead.name}</p>
431
+ <p className="text-xs text-muted-foreground">
432
+ {lead.owner_user?.name || t('common.unassigned')}
433
+ </p>
434
+ <p className="mt-2 text-sm text-muted-foreground">
435
+ {t('blocks.unattended.meta', {
436
+ stage: t(
437
+ `stageLabels.${lead.lifecycle_stage ?? 'new'}`
438
+ ),
439
+ source: t(`sourceLabels.${lead.source ?? 'other'}`),
440
+ })}
441
+ </p>
442
+ </div>
443
+ ))}
444
+ </div>
445
+ </ScrollArea>
446
+ </CardContent>
447
+ </Card>
448
+
449
+ <Card className="min-w-0 overflow-hidden xl:col-span-1">
450
+ <CardHeader>
451
+ <CardTitle>{t('blocks.quickAccess.title')}</CardTitle>
452
+ <CardDescription>
453
+ {t('blocks.quickAccess.description')}
454
+ </CardDescription>
455
+ </CardHeader>
456
+ <CardContent className="space-y-3">
457
+ {crmImplementedSections.map((section) => {
458
+ const Icon = section.icon;
459
+
460
+ return (
461
+ <Link
462
+ key={section.href}
463
+ href={section.href}
464
+ className="flex min-w-0 items-center justify-between gap-3 rounded-2xl border border-border/70 px-4 py-3 transition-colors hover:bg-muted/40"
465
+ >
466
+ <div className="flex min-w-0 items-center gap-3">
467
+ <div className={`rounded-2xl p-3 ${section.glowClass}`}>
468
+ <Icon className="size-4" />
469
+ </div>
470
+ <div className="min-w-0">
471
+ <p className="truncate font-medium">
472
+ {menuT(`sections.${section.translationKey}.title`)}
473
+ </p>
474
+ <p className="truncate text-xs text-muted-foreground">
475
+ {menuT(
476
+ `sections.${section.translationKey}.description`
477
+ )}
478
+ </p>
479
+ </div>
480
+ </div>
481
+ <ArrowUpRight className="size-4 shrink-0 text-muted-foreground" />
482
+ </Link>
483
+ );
484
+ })}
485
+ </CardContent>
486
+ </Card>
487
+ </div>
488
+ </div>
489
+ </Page>
490
+ );
491
+ }