@hed-hog/contact 0.0.294 → 0.0.296

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 (57) hide show
  1. package/dist/person/dto/account.dto.d.ts +28 -0
  2. package/dist/person/dto/account.dto.d.ts.map +1 -0
  3. package/dist/person/dto/account.dto.js +123 -0
  4. package/dist/person/dto/account.dto.js.map +1 -0
  5. package/dist/person/dto/activity.dto.d.ts +15 -0
  6. package/dist/person/dto/activity.dto.d.ts.map +1 -0
  7. package/dist/person/dto/activity.dto.js +65 -0
  8. package/dist/person/dto/activity.dto.js.map +1 -0
  9. package/dist/person/dto/dashboard-query.dto.d.ts +9 -0
  10. package/dist/person/dto/dashboard-query.dto.d.ts.map +1 -0
  11. package/dist/person/dto/dashboard-query.dto.js +40 -0
  12. package/dist/person/dto/dashboard-query.dto.js.map +1 -0
  13. package/dist/person/dto/followup-query.dto.d.ts +10 -0
  14. package/dist/person/dto/followup-query.dto.d.ts.map +1 -0
  15. package/dist/person/dto/followup-query.dto.js +45 -0
  16. package/dist/person/dto/followup-query.dto.js.map +1 -0
  17. package/dist/person/dto/reports-query.dto.d.ts +8 -0
  18. package/dist/person/dto/reports-query.dto.d.ts.map +1 -0
  19. package/dist/person/dto/reports-query.dto.js +33 -0
  20. package/dist/person/dto/reports-query.dto.js.map +1 -0
  21. package/dist/person/person.controller.d.ts +266 -5
  22. package/dist/person/person.controller.d.ts.map +1 -1
  23. package/dist/person/person.controller.js +164 -6
  24. package/dist/person/person.controller.js.map +1 -1
  25. package/dist/person/person.service.d.ts +295 -5
  26. package/dist/person/person.service.d.ts.map +1 -1
  27. package/dist/person/person.service.js +1752 -27
  28. package/dist/person/person.service.js.map +1 -1
  29. package/dist/person-relation-type/person-relation-type.controller.d.ts +2 -2
  30. package/dist/person-relation-type/person-relation-type.service.d.ts +2 -2
  31. package/hedhog/data/route.yaml +68 -19
  32. package/hedhog/frontend/app/_lib/crm-sections.tsx.ejs +9 -9
  33. package/hedhog/frontend/app/accounts/_components/account-form-sheet.tsx.ejs +573 -477
  34. package/hedhog/frontend/app/accounts/_components/account-types.ts.ejs +9 -6
  35. package/hedhog/frontend/app/accounts/page.tsx.ejs +970 -892
  36. package/hedhog/frontend/app/activities/_components/activity-detail-sheet.tsx.ejs +240 -0
  37. package/hedhog/frontend/app/activities/_components/activity-types.ts.ejs +66 -0
  38. package/hedhog/frontend/app/activities/page.tsx.ejs +460 -812
  39. package/hedhog/frontend/app/dashboard/_components/dashboard-types.ts.ejs +70 -0
  40. package/hedhog/frontend/app/dashboard/page.tsx.ejs +639 -491
  41. package/hedhog/frontend/app/follow-ups/page.tsx.ejs +785 -696
  42. package/hedhog/frontend/app/person/_components/person-interaction-dialog.tsx.ejs +10 -12
  43. package/hedhog/frontend/app/reports/_components/report-types.ts.ejs +84 -0
  44. package/hedhog/frontend/app/reports/page.tsx.ejs +1196 -15
  45. package/hedhog/frontend/messages/en.json +242 -38
  46. package/hedhog/frontend/messages/pt.json +242 -38
  47. package/hedhog/table/crm_activity.yaml +68 -0
  48. package/hedhog/table/crm_stage_history.yaml +34 -0
  49. package/hedhog/table/person_company.yaml +27 -5
  50. package/package.json +9 -9
  51. package/src/person/dto/account.dto.ts +100 -0
  52. package/src/person/dto/activity.dto.ts +54 -0
  53. package/src/person/dto/dashboard-query.dto.ts +25 -0
  54. package/src/person/dto/followup-query.dto.ts +25 -0
  55. package/src/person/dto/reports-query.dto.ts +25 -0
  56. package/src/person/person.controller.ts +176 -43
  57. package/src/person/person.service.ts +4825 -2226
@@ -1,491 +1,639 @@
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
- }
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 { Input } from '@/components/ui/input';
13
+ import { KpiCardsGrid } from '@/components/ui/kpi-cards-grid';
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 { Skeleton } from '@/components/ui/skeleton';
23
+ import { formatDateTime } from '@/lib/format-date';
24
+ import { useApp, useQuery } from '@hed-hog/next-app-provider';
25
+ import {
26
+ AlertTriangle,
27
+ ArrowUpRight,
28
+ BriefcaseBusiness,
29
+ CalendarClock,
30
+ ChartNoAxesCombined,
31
+ CircleDollarSign,
32
+ Loader2,
33
+ RefreshCcw,
34
+ Target,
35
+ TrendingUp,
36
+ UserRoundX,
37
+ } from 'lucide-react';
38
+ import { useTranslations } from 'next-intl';
39
+ import Link from 'next/link';
40
+ import { useState } from 'react';
41
+ import {
42
+ Bar,
43
+ BarChart,
44
+ CartesianGrid,
45
+ Cell,
46
+ Pie,
47
+ PieChart,
48
+ ResponsiveContainer,
49
+ Tooltip,
50
+ XAxis,
51
+ YAxis,
52
+ } from 'recharts';
53
+ import { crmImplementedSections } from '../_lib/crm-sections';
54
+ import type { UserOption } from '../person/_components/person-types';
55
+ import {
56
+ CRM_DASHBOARD_SOURCE_ORDER,
57
+ CRM_DASHBOARD_STAGE_ORDER,
58
+ type DashboardPeriod,
59
+ type DashboardResponse,
60
+ } from './_components/dashboard-types';
61
+
62
+ const chartPalette = [
63
+ '#f97316',
64
+ '#14b8a6',
65
+ '#0ea5e9',
66
+ '#84cc16',
67
+ '#f59e0b',
68
+ '#ef4444',
69
+ '#8b5cf6',
70
+ ];
71
+
72
+ const chartTooltipStyle = {
73
+ backgroundColor: 'hsl(var(--card))',
74
+ border: '1px solid hsl(var(--border))',
75
+ borderRadius: '12px',
76
+ fontSize: '12px',
77
+ };
78
+
79
+ const fallbackKpiAccentClass =
80
+ 'from-orange-500/20 via-amber-500/10 to-transparent';
81
+
82
+ function formatDateInputValue(date: Date) {
83
+ const year = date.getFullYear();
84
+ const month = String(date.getMonth() + 1).padStart(2, '0');
85
+ const day = String(date.getDate()).padStart(2, '0');
86
+ return `${year}-${month}-${day}`;
87
+ }
88
+
89
+ function addDays(date: Date, amount: number) {
90
+ const next = new Date(date);
91
+ next.setDate(next.getDate() + amount);
92
+ return next;
93
+ }
94
+
95
+ function getDefaultCustomRange() {
96
+ const now = new Date();
97
+ return {
98
+ dateFrom: formatDateInputValue(addDays(now, -29)),
99
+ dateTo: formatDateInputValue(now),
100
+ };
101
+ }
102
+
103
+ const emptyDashboard: DashboardResponse = {
104
+ kpis: {
105
+ total_leads: 0,
106
+ qualified: 0,
107
+ proposal: 0,
108
+ customers: 0,
109
+ lost: 0,
110
+ unassigned: 0,
111
+ overdue: 0,
112
+ next_actions: 0,
113
+ },
114
+ charts: {
115
+ stage: CRM_DASHBOARD_STAGE_ORDER.map((key) => ({ key, total: 0 })),
116
+ source: CRM_DASHBOARD_SOURCE_ORDER.map((key) => ({ key, total: 0 })),
117
+ owner_performance: [],
118
+ },
119
+ lists: {
120
+ next_actions: [],
121
+ unattended: [],
122
+ },
123
+ };
124
+
125
+ export default function CrmDashboardPage() {
126
+ const t = useTranslations('contact.CrmDashboard');
127
+ const menuT = useTranslations('contact.CrmMenu');
128
+ const { request, currentLocaleCode, getSettingValue } = useApp();
129
+ const defaultCustomRange = getDefaultCustomRange();
130
+
131
+ const [ownerUserId, setOwnerUserId] = useState('all');
132
+ const [period, setPeriod] = useState<DashboardPeriod>('30d');
133
+ const [dateFrom, setDateFrom] = useState(defaultCustomRange.dateFrom);
134
+ const [dateTo, setDateTo] = useState(defaultCustomRange.dateTo);
135
+
136
+ const { data: owners = [] } = useQuery<UserOption[]>({
137
+ queryKey: ['contact-owner-options', currentLocaleCode],
138
+ queryFn: async () => {
139
+ const response = await request<UserOption[]>({
140
+ url: '/person/owner-options',
141
+ method: 'GET',
142
+ });
143
+
144
+ return response.data;
145
+ },
146
+ placeholderData: (previous) => previous ?? [],
147
+ });
148
+
149
+ const {
150
+ data: dashboard = emptyDashboard,
151
+ isLoading,
152
+ isFetching,
153
+ refetch,
154
+ } = useQuery<DashboardResponse>({
155
+ queryKey: [
156
+ 'contact-dashboard',
157
+ ownerUserId,
158
+ period,
159
+ dateFrom,
160
+ dateTo,
161
+ currentLocaleCode,
162
+ ],
163
+ queryFn: async () => {
164
+ const params = new URLSearchParams();
165
+
166
+ if (ownerUserId !== 'all') {
167
+ params.set('owner_user_id', ownerUserId);
168
+ }
169
+
170
+ params.set('period', period);
171
+
172
+ if (period === 'custom') {
173
+ params.set('date_from', dateFrom);
174
+ params.set('date_to', dateTo);
175
+ }
176
+
177
+ const response = await request<DashboardResponse>({
178
+ url: `/person/dashboard?${params.toString()}`,
179
+ method: 'GET',
180
+ });
181
+
182
+ return response.data;
183
+ },
184
+ placeholderData: (previous) => previous ?? emptyDashboard,
185
+ });
186
+
187
+ const stageChartData = dashboard.charts.stage.map((item, index) => ({
188
+ name: t(`stageLabels.${item.key}`),
189
+ total: item.total,
190
+ fill: chartPalette[index % chartPalette.length],
191
+ }));
192
+
193
+ const sourceChartData = dashboard.charts.source.map((item, index) => ({
194
+ name: t(`sourceLabels.${item.key}`),
195
+ total: item.total,
196
+ fill: chartPalette[index % chartPalette.length],
197
+ }));
198
+
199
+ const topOwners = [...dashboard.charts.owner_performance]
200
+ .sort((left, right) => right.pipeline_value - left.pipeline_value)
201
+ .slice(0, 4);
202
+
203
+ const kpiCards = [
204
+ { key: 'totalLeads', value: dashboard.kpis.total_leads, icon: TrendingUp },
205
+ { key: 'qualified', value: dashboard.kpis.qualified, icon: Target },
206
+ { key: 'proposal', value: dashboard.kpis.proposal, icon: BriefcaseBusiness },
207
+ { key: 'customers', value: dashboard.kpis.customers, icon: CircleDollarSign },
208
+ { key: 'lost', value: dashboard.kpis.lost, icon: AlertTriangle },
209
+ { key: 'unassigned', value: dashboard.kpis.unassigned, icon: UserRoundX },
210
+ { key: 'overdue', value: dashboard.kpis.overdue, icon: CalendarClock },
211
+ {
212
+ key: 'nextActions',
213
+ value: dashboard.kpis.next_actions,
214
+ icon: ChartNoAxesCombined,
215
+ },
216
+ ].map((item, index) => {
217
+ const sectionStyle =
218
+ crmImplementedSections[index % crmImplementedSections.length] ??
219
+ crmImplementedSections[0];
220
+
221
+ return {
222
+ key: item.key,
223
+ title: t(`kpis.${item.key}.title`),
224
+ value: item.value,
225
+ description: t(`kpis.${item.key}.description`),
226
+ icon: item.icon,
227
+ accentClassName: sectionStyle?.colorClass ?? fallbackKpiAccentClass,
228
+ iconContainerClassName:
229
+ sectionStyle?.glowClass ?? 'bg-sky-500/10 text-sky-700',
230
+ };
231
+ });
232
+
233
+ const currencyFormatter = new Intl.NumberFormat(
234
+ currentLocaleCode === 'pt' ? 'pt-BR' : 'en-US',
235
+ {
236
+ style: 'currency',
237
+ currency: 'BRL',
238
+ maximumFractionDigits: 0,
239
+ }
240
+ );
241
+
242
+ const filters = (
243
+ <div className="flex flex-wrap items-center justify-end gap-2">
244
+ <Select value={ownerUserId} onValueChange={setOwnerUserId}>
245
+ <SelectTrigger className="w-full sm:w-[180px]">
246
+ <SelectValue placeholder={t('filters.ownerPlaceholder')} />
247
+ </SelectTrigger>
248
+ <SelectContent>
249
+ <SelectItem value="all">{t('filters.ownerAll')}</SelectItem>
250
+ {owners.map((owner) => (
251
+ <SelectItem key={owner.id} value={String(owner.id)}>
252
+ {owner.name}
253
+ </SelectItem>
254
+ ))}
255
+ </SelectContent>
256
+ </Select>
257
+
258
+ <Select
259
+ value={period}
260
+ onValueChange={(value) => {
261
+ const nextPeriod = value as DashboardPeriod;
262
+ setPeriod(nextPeriod);
263
+ if (nextPeriod === 'custom' && (!dateFrom || !dateTo)) {
264
+ const nextRange = getDefaultCustomRange();
265
+ setDateFrom(nextRange.dateFrom);
266
+ setDateTo(nextRange.dateTo);
267
+ }
268
+ }}
269
+ >
270
+ <SelectTrigger className="w-full sm:w-[180px]">
271
+ <SelectValue placeholder={t('filters.periodPlaceholder')} />
272
+ </SelectTrigger>
273
+ <SelectContent>
274
+ <SelectItem value="7d">{t('filters.period7d')}</SelectItem>
275
+ <SelectItem value="30d">{t('filters.period30d')}</SelectItem>
276
+ <SelectItem value="90d">{t('filters.period90d')}</SelectItem>
277
+ <SelectItem value="custom">{t('filters.periodCustom')}</SelectItem>
278
+ </SelectContent>
279
+ </Select>
280
+
281
+ {period === 'custom' ? (
282
+ <>
283
+ <Input
284
+ type="date"
285
+ value={dateFrom}
286
+ onChange={(event) => setDateFrom(event.target.value)}
287
+ className="w-full sm:w-[170px]"
288
+ aria-label={t('filters.dateFrom')}
289
+ />
290
+ <Input
291
+ type="date"
292
+ value={dateTo}
293
+ onChange={(event) => setDateTo(event.target.value)}
294
+ className="w-full sm:w-[170px]"
295
+ aria-label={t('filters.dateTo')}
296
+ />
297
+ </>
298
+ ) : null}
299
+ </div>
300
+ );
301
+
302
+ return (
303
+ <Page>
304
+ <PageHeader
305
+ title={t('title')}
306
+ description={t('subtitle')}
307
+ breadcrumbs={[
308
+ { label: t('breadcrumbs.home'), href: '/' },
309
+ { label: t('breadcrumbs.crm') },
310
+ ]}
311
+ extraContent={filters}
312
+ actions={[
313
+ {
314
+ label: t('refresh'),
315
+ onClick: () => {
316
+ void refetch();
317
+ },
318
+ variant: 'outline',
319
+ icon: isFetching ? (
320
+ <Loader2 className="size-4 animate-spin" />
321
+ ) : (
322
+ <RefreshCcw className="size-4" />
323
+ ),
324
+ },
325
+ ]}
326
+ />
327
+
328
+ <div className="min-w-0 space-y-6 overflow-x-hidden">
329
+ <KpiCardsGrid items={kpiCards} />
330
+
331
+ {isLoading ? (
332
+ <div className="space-y-6">
333
+ <div className="grid gap-6 xl:grid-cols-2">
334
+ <Skeleton className="h-[320px] w-full" />
335
+ <Skeleton className="h-[320px] w-full" />
336
+ </div>
337
+ <div className="grid gap-6 xl:grid-cols-2">
338
+ <Skeleton className="h-[320px] w-full" />
339
+ <Skeleton className="h-[320px] w-full" />
340
+ </div>
341
+ <div className="grid gap-6 xl:grid-cols-3">
342
+ <Skeleton className="h-[320px] w-full" />
343
+ <Skeleton className="h-[320px] w-full" />
344
+ <Skeleton className="h-[320px] w-full" />
345
+ </div>
346
+ </div>
347
+ ) : (
348
+ <>
349
+ <div className="grid min-w-0 gap-6 xl:grid-cols-[minmax(0,1.2fr)_minmax(320px,0.8fr)]">
350
+ <Card className="min-w-0">
351
+ <CardHeader>
352
+ <CardTitle>{t('charts.stage.title')}</CardTitle>
353
+ <CardDescription>{t('charts.stage.description')}</CardDescription>
354
+ </CardHeader>
355
+ <CardContent className="h-[320px]">
356
+ <ResponsiveContainer width="100%" height="100%">
357
+ <BarChart data={stageChartData}>
358
+ <CartesianGrid
359
+ strokeDasharray="3 3"
360
+ stroke="hsl(var(--border))"
361
+ vertical={false}
362
+ />
363
+ <XAxis
364
+ dataKey="name"
365
+ tickLine={false}
366
+ axisLine={false}
367
+ fontSize={12}
368
+ />
369
+ <YAxis
370
+ tickLine={false}
371
+ axisLine={false}
372
+ fontSize={12}
373
+ allowDecimals={false}
374
+ />
375
+ <Tooltip contentStyle={chartTooltipStyle} />
376
+ <Bar dataKey="total" radius={[8, 8, 0, 0]}>
377
+ {stageChartData.map((entry) => (
378
+ <Cell key={entry.name} fill={entry.fill} />
379
+ ))}
380
+ </Bar>
381
+ </BarChart>
382
+ </ResponsiveContainer>
383
+ </CardContent>
384
+ </Card>
385
+
386
+ <Card className="min-w-0 overflow-hidden">
387
+ <CardHeader>
388
+ <CardTitle>{t('charts.source.title')}</CardTitle>
389
+ <CardDescription>{t('charts.source.description')}</CardDescription>
390
+ </CardHeader>
391
+ <CardContent className="grid min-w-0 gap-4 overflow-hidden lg:h-[320px] lg:grid-cols-[minmax(220px,0.9fr)_minmax(0,1fr)]">
392
+ <div className="h-[220px] min-w-0 lg:h-full">
393
+ <ResponsiveContainer width="100%" height="100%">
394
+ <PieChart>
395
+ <Pie
396
+ data={sourceChartData}
397
+ dataKey="total"
398
+ nameKey="name"
399
+ innerRadius={58}
400
+ outerRadius={94}
401
+ paddingAngle={2}
402
+ >
403
+ {sourceChartData.map((entry) => (
404
+ <Cell key={entry.name} fill={entry.fill} />
405
+ ))}
406
+ </Pie>
407
+ <Tooltip contentStyle={chartTooltipStyle} />
408
+ </PieChart>
409
+ </ResponsiveContainer>
410
+ </div>
411
+ <ScrollArea className="min-w-0 lg:h-full">
412
+ <div className="space-y-3 lg:pr-3">
413
+ {sourceChartData.map((item) => (
414
+ <div
415
+ key={item.name}
416
+ className="flex min-w-0 items-center justify-between gap-3 rounded-2xl border border-border/70 px-4 py-3"
417
+ >
418
+ <div className="flex min-w-0 items-center gap-3">
419
+ <span
420
+ className="inline-block size-3 rounded-full"
421
+ style={{ backgroundColor: item.fill }}
422
+ />
423
+ <span className="truncate font-medium">{item.name}</span>
424
+ </div>
425
+ <span className="shrink-0 text-sm text-muted-foreground">
426
+ {item.total}
427
+ </span>
428
+ </div>
429
+ ))}
430
+ </div>
431
+ </ScrollArea>
432
+ </CardContent>
433
+ </Card>
434
+ </div>
435
+
436
+ <div className="grid min-w-0 gap-6 xl:grid-cols-[minmax(0,1.1fr)_minmax(320px,0.9fr)]">
437
+ <Card className="min-w-0">
438
+ <CardHeader>
439
+ <CardTitle>{t('charts.owner.title')}</CardTitle>
440
+ <CardDescription>{t('charts.owner.description')}</CardDescription>
441
+ </CardHeader>
442
+ <CardContent className="h-[320px]">
443
+ <ResponsiveContainer width="100%" height="100%">
444
+ <BarChart data={dashboard.charts.owner_performance}>
445
+ <CartesianGrid
446
+ strokeDasharray="3 3"
447
+ stroke="hsl(var(--border))"
448
+ vertical={false}
449
+ />
450
+ <XAxis
451
+ dataKey="owner_name"
452
+ tickLine={false}
453
+ axisLine={false}
454
+ fontSize={12}
455
+ />
456
+ <YAxis
457
+ tickLine={false}
458
+ axisLine={false}
459
+ fontSize={12}
460
+ allowDecimals={false}
461
+ />
462
+ <Tooltip contentStyle={chartTooltipStyle} />
463
+ <Bar dataKey="leads" fill="#f97316" radius={[8, 8, 0, 0]} />
464
+ <Bar
465
+ dataKey="customers"
466
+ fill="#0ea5e9"
467
+ radius={[8, 8, 0, 0]}
468
+ />
469
+ </BarChart>
470
+ </ResponsiveContainer>
471
+ </CardContent>
472
+ </Card>
473
+
474
+ <Card className="min-w-0 overflow-hidden">
475
+ <CardHeader>
476
+ <CardTitle>{t('bestOwners.title')}</CardTitle>
477
+ <CardDescription>{t('bestOwners.description')}</CardDescription>
478
+ </CardHeader>
479
+ <CardContent>
480
+ <div className="space-y-3">
481
+ {topOwners.length === 0 ? (
482
+ <p className="text-sm text-muted-foreground">
483
+ {t('common.noData')}
484
+ </p>
485
+ ) : (
486
+ topOwners.map((owner, index) => (
487
+ <div
488
+ key={`${owner.owner_user_id}-${owner.owner_name}`}
489
+ className="flex min-w-0 items-center justify-between gap-3 rounded-2xl border border-border/70 px-4 py-3"
490
+ >
491
+ <div className="min-w-0">
492
+ <p className="truncate font-medium">
493
+ {index + 1}. {owner.owner_name}
494
+ </p>
495
+ <p className="truncate text-xs text-muted-foreground">
496
+ {t('bestOwners.meta', {
497
+ leads: owner.leads,
498
+ customers: owner.customers,
499
+ })}
500
+ </p>
501
+ </div>
502
+ <span className="shrink-0 text-sm font-medium text-foreground">
503
+ {currencyFormatter.format(owner.pipeline_value)}
504
+ </span>
505
+ </div>
506
+ ))
507
+ )}
508
+ </div>
509
+ </CardContent>
510
+ </Card>
511
+ </div>
512
+
513
+ <div className="grid gap-6 xl:grid-cols-3">
514
+ <Card className="min-w-0 overflow-hidden xl:col-span-1">
515
+ <CardHeader>
516
+ <CardTitle>{t('blocks.nextActions.title')}</CardTitle>
517
+ <CardDescription>{t('blocks.nextActions.description')}</CardDescription>
518
+ </CardHeader>
519
+ <CardContent>
520
+ <ScrollArea className="h-[320px] pr-3">
521
+ <div className="space-y-3">
522
+ {dashboard.lists.next_actions.length === 0 ? (
523
+ <p className="text-sm text-muted-foreground">
524
+ {t('common.noData')}
525
+ </p>
526
+ ) : (
527
+ dashboard.lists.next_actions.map((lead) => (
528
+ <div
529
+ key={lead.id}
530
+ className="rounded-2xl border border-border/70 px-4 py-3"
531
+ >
532
+ <p className="font-medium">{lead.name}</p>
533
+ <p className="text-xs text-muted-foreground">
534
+ {lead.owner_user?.name || t('common.unassigned')}
535
+ </p>
536
+ <div className="mt-2 flex flex-wrap gap-2">
537
+ <Badge variant="outline">
538
+ {t(`stageLabels.${lead.lifecycle_stage}`)}
539
+ </Badge>
540
+ <Badge variant="secondary">
541
+ {t(`sourceLabels.${lead.source}`)}
542
+ </Badge>
543
+ </div>
544
+ <p className="mt-2 text-sm text-muted-foreground">
545
+ {t('blocks.nextActions.when', {
546
+ date: formatDateTime(
547
+ lead.next_action_at ?? '',
548
+ getSettingValue,
549
+ currentLocaleCode
550
+ ),
551
+ })}
552
+ </p>
553
+ </div>
554
+ ))
555
+ )}
556
+ </div>
557
+ </ScrollArea>
558
+ </CardContent>
559
+ </Card>
560
+
561
+ <Card className="min-w-0 overflow-hidden xl:col-span-1">
562
+ <CardHeader>
563
+ <CardTitle>{t('blocks.unattended.title')}</CardTitle>
564
+ <CardDescription>{t('blocks.unattended.description')}</CardDescription>
565
+ </CardHeader>
566
+ <CardContent>
567
+ <ScrollArea className="h-[320px] pr-3">
568
+ <div className="space-y-3">
569
+ {dashboard.lists.unattended.length === 0 ? (
570
+ <p className="text-sm text-muted-foreground">
571
+ {t('common.noData')}
572
+ </p>
573
+ ) : (
574
+ dashboard.lists.unattended.map((lead) => (
575
+ <div
576
+ key={lead.id}
577
+ className="rounded-2xl border border-border/70 px-4 py-3"
578
+ >
579
+ <p className="font-medium">{lead.name}</p>
580
+ <p className="text-xs text-muted-foreground">
581
+ {lead.owner_user?.name || t('common.unassigned')}
582
+ </p>
583
+ <p className="mt-2 text-sm text-muted-foreground">
584
+ {t('blocks.unattended.meta', {
585
+ stage: t(`stageLabels.${lead.lifecycle_stage}`),
586
+ source: t(`sourceLabels.${lead.source}`),
587
+ })}
588
+ </p>
589
+ </div>
590
+ ))
591
+ )}
592
+ </div>
593
+ </ScrollArea>
594
+ </CardContent>
595
+ </Card>
596
+
597
+ <Card className="min-w-0 overflow-hidden xl:col-span-1">
598
+ <CardHeader>
599
+ <CardTitle>{t('blocks.quickAccess.title')}</CardTitle>
600
+ <CardDescription>{t('blocks.quickAccess.description')}</CardDescription>
601
+ </CardHeader>
602
+ <CardContent className="space-y-3">
603
+ {crmImplementedSections.map((section) => {
604
+ const Icon = section.icon;
605
+
606
+ return (
607
+ <Link
608
+ key={section.href}
609
+ href={section.href}
610
+ 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"
611
+ >
612
+ <div className="flex min-w-0 items-center gap-3">
613
+ <div className={`rounded-2xl p-3 ${section.glowClass}`}>
614
+ <Icon className="size-4" />
615
+ </div>
616
+ <div className="min-w-0">
617
+ <p className="truncate font-medium">
618
+ {menuT(`sections.${section.translationKey}.title`)}
619
+ </p>
620
+ <p className="truncate text-xs text-muted-foreground">
621
+ {menuT(
622
+ `sections.${section.translationKey}.description`
623
+ )}
624
+ </p>
625
+ </div>
626
+ </div>
627
+ <ArrowUpRight className="size-4 shrink-0 text-muted-foreground" />
628
+ </Link>
629
+ );
630
+ })}
631
+ </CardContent>
632
+ </Card>
633
+ </div>
634
+ </>
635
+ )}
636
+ </div>
637
+ </Page>
638
+ );
639
+ }