@hed-hog/contact 0.0.298 → 0.0.300

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 (29) hide show
  1. package/dist/person/person.service.js +350 -350
  2. package/hedhog/data/dashboard.yaml +6 -0
  3. package/hedhog/data/dashboard_component.yaml +87 -0
  4. package/hedhog/data/dashboard_component_role.yaml +55 -0
  5. package/hedhog/data/dashboard_item.yaml +95 -0
  6. package/hedhog/data/dashboard_role.yaml +6 -0
  7. package/hedhog/data/route.yaml +68 -68
  8. package/hedhog/frontend/app/dashboard/_components/dashboard-widgets.tsx.ejs +508 -0
  9. package/hedhog/frontend/app/dashboard/_components/use-crm-dashboard-data.ts.ejs +104 -0
  10. package/hedhog/frontend/app/dashboard/page.tsx.ejs +37 -431
  11. package/hedhog/frontend/widgets/next-actions.tsx.ejs +40 -0
  12. package/hedhog/frontend/widgets/overview-kpis.tsx.ejs +47 -0
  13. package/hedhog/frontend/widgets/owner-performance.tsx.ejs +42 -0
  14. package/hedhog/frontend/widgets/quick-access.tsx.ejs +29 -0
  15. package/hedhog/frontend/widgets/source-breakdown.tsx.ejs +40 -0
  16. package/hedhog/frontend/widgets/stage-distribution.tsx.ejs +40 -0
  17. package/hedhog/frontend/widgets/top-owners.tsx.ejs +42 -0
  18. package/hedhog/frontend/widgets/unattended.tsx.ejs +40 -0
  19. package/hedhog/table/crm_activity.yaml +68 -68
  20. package/hedhog/table/crm_stage_history.yaml +34 -34
  21. package/hedhog/table/person_company.yaml +27 -27
  22. package/package.json +5 -5
  23. package/src/person/dto/account.dto.ts +100 -100
  24. package/src/person/dto/activity.dto.ts +54 -54
  25. package/src/person/dto/dashboard-query.dto.ts +25 -25
  26. package/src/person/dto/followup-query.dto.ts +25 -25
  27. package/src/person/dto/reports-query.dto.ts +25 -25
  28. package/src/person/person.controller.ts +176 -176
  29. package/src/person/person.service.ts +4825 -4825
@@ -0,0 +1,508 @@
1
+ 'use client';
2
+
3
+ import { Badge } from '@/components/ui/badge';
4
+ import {
5
+ Card,
6
+ CardContent,
7
+ CardDescription,
8
+ CardHeader,
9
+ CardTitle,
10
+ } from '@/components/ui/card';
11
+ import { KpiCardsGrid, type KpiCardItem } from '@/components/ui/kpi-cards-grid';
12
+ import { ScrollArea } from '@/components/ui/scroll-area';
13
+ import { formatDateTime } from '@/lib/format-date';
14
+ import { cn } from '@/lib/utils';
15
+ import { useApp } from '@hed-hog/next-app-provider';
16
+ import {
17
+ AlertTriangle,
18
+ ArrowUpRight,
19
+ BriefcaseBusiness,
20
+ CalendarClock,
21
+ ChartNoAxesCombined,
22
+ CircleDollarSign,
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 { crmImplementedSections } from '../../_lib/crm-sections';
42
+ import type {
43
+ DashboardListItem,
44
+ DashboardOwnerPerformanceItem,
45
+ DashboardResponse,
46
+ } from './dashboard-types';
47
+
48
+ const chartPalette = [
49
+ '#f97316',
50
+ '#14b8a6',
51
+ '#0ea5e9',
52
+ '#84cc16',
53
+ '#f59e0b',
54
+ '#ef4444',
55
+ '#8b5cf6',
56
+ ];
57
+
58
+ const chartTooltipStyle = {
59
+ backgroundColor: 'hsl(var(--card))',
60
+ border: '1px solid hsl(var(--border))',
61
+ borderRadius: '12px',
62
+ fontSize: '12px',
63
+ };
64
+
65
+ const fallbackKpiAccentClass =
66
+ 'from-orange-500/20 via-amber-500/10 to-transparent';
67
+
68
+ const kpiDefinitions = [
69
+ { key: 'totalLeads', metricKey: 'total_leads', icon: TrendingUp },
70
+ { key: 'qualified', metricKey: 'qualified', icon: Target },
71
+ { key: 'proposal', metricKey: 'proposal', icon: BriefcaseBusiness },
72
+ { key: 'customers', metricKey: 'customers', icon: CircleDollarSign },
73
+ { key: 'lost', metricKey: 'lost', icon: AlertTriangle },
74
+ { key: 'unassigned', metricKey: 'unassigned', icon: UserRoundX },
75
+ { key: 'overdue', metricKey: 'overdue', icon: CalendarClock },
76
+ { key: 'nextActions', metricKey: 'next_actions', icon: ChartNoAxesCombined },
77
+ ] as const;
78
+
79
+ type CrmDashboardCardProps = {
80
+ className?: string;
81
+ contentClassName?: string;
82
+ };
83
+
84
+ type CrmDashboardListCardProps = CrmDashboardCardProps & {
85
+ scrollAreaClassName?: string;
86
+ };
87
+
88
+ export function CrmDashboardKpiGrid({
89
+ dashboard,
90
+ className,
91
+ cardClassName,
92
+ }: {
93
+ dashboard: DashboardResponse;
94
+ className?: string;
95
+ cardClassName?: string;
96
+ }) {
97
+ const t = useTranslations('contact.CrmDashboard');
98
+
99
+ const items: KpiCardItem[] = kpiDefinitions.map((item, index) => {
100
+ const sectionStyle =
101
+ crmImplementedSections[index % crmImplementedSections.length] ??
102
+ crmImplementedSections[0];
103
+
104
+ return {
105
+ key: item.key,
106
+ title: t(`kpis.${item.key}.title`),
107
+ value: dashboard.kpis[item.metricKey],
108
+ description: t(`kpis.${item.key}.description`),
109
+ icon: item.icon,
110
+ accentClassName: sectionStyle?.colorClass ?? fallbackKpiAccentClass,
111
+ iconContainerClassName:
112
+ sectionStyle?.glowClass ?? 'bg-sky-500/10 text-sky-700',
113
+ className: cardClassName,
114
+ };
115
+ });
116
+
117
+ return <KpiCardsGrid items={items} className={className} />;
118
+ }
119
+
120
+ export function CrmStageChartCard({
121
+ items,
122
+ className,
123
+ contentClassName,
124
+ }: CrmDashboardCardProps & {
125
+ items: DashboardResponse['charts']['stage'];
126
+ }) {
127
+ const t = useTranslations('contact.CrmDashboard');
128
+
129
+ const stageChartData = items.map((item, index) => ({
130
+ name: t(`stageLabels.${item.key}`),
131
+ total: item.total,
132
+ fill: chartPalette[index % chartPalette.length],
133
+ }));
134
+
135
+ return (
136
+ <Card className={cn('min-w-0 overflow-hidden', className)}>
137
+ <CardHeader>
138
+ <CardTitle>{t('charts.stage.title')}</CardTitle>
139
+ <CardDescription>{t('charts.stage.description')}</CardDescription>
140
+ </CardHeader>
141
+ <CardContent className={cn('h-80', contentClassName)}>
142
+ <ResponsiveContainer width="100%" height="100%">
143
+ <BarChart data={stageChartData}>
144
+ <CartesianGrid
145
+ strokeDasharray="3 3"
146
+ stroke="hsl(var(--border))"
147
+ vertical={false}
148
+ />
149
+ <XAxis
150
+ dataKey="name"
151
+ tickLine={false}
152
+ axisLine={false}
153
+ fontSize={12}
154
+ />
155
+ <YAxis
156
+ tickLine={false}
157
+ axisLine={false}
158
+ fontSize={12}
159
+ allowDecimals={false}
160
+ />
161
+ <Tooltip contentStyle={chartTooltipStyle} />
162
+ <Bar dataKey="total" radius={[8, 8, 0, 0]}>
163
+ {stageChartData.map((entry) => (
164
+ <Cell key={entry.name} fill={entry.fill} />
165
+ ))}
166
+ </Bar>
167
+ </BarChart>
168
+ </ResponsiveContainer>
169
+ </CardContent>
170
+ </Card>
171
+ );
172
+ }
173
+
174
+ export function CrmSourceBreakdownCard({
175
+ items,
176
+ className,
177
+ contentClassName,
178
+ }: CrmDashboardCardProps & {
179
+ items: DashboardResponse['charts']['source'];
180
+ }) {
181
+ const t = useTranslations('contact.CrmDashboard');
182
+
183
+ const sourceChartData = items.map((item, index) => ({
184
+ name: t(`sourceLabels.${item.key}`),
185
+ total: item.total,
186
+ fill: chartPalette[index % chartPalette.length],
187
+ }));
188
+
189
+ return (
190
+ <Card className={cn('min-w-0 overflow-hidden', className)}>
191
+ <CardHeader>
192
+ <CardTitle>{t('charts.source.title')}</CardTitle>
193
+ <CardDescription>{t('charts.source.description')}</CardDescription>
194
+ </CardHeader>
195
+ <CardContent
196
+ className={cn(
197
+ 'grid min-w-0 gap-4 overflow-hidden lg:h-80 lg:grid-cols-[minmax(220px,0.9fr)_minmax(0,1fr)]',
198
+ contentClassName
199
+ )}
200
+ >
201
+ <div className="h-55 min-w-0 lg:h-full">
202
+ <ResponsiveContainer width="100%" height="100%">
203
+ <PieChart>
204
+ <Pie
205
+ data={sourceChartData}
206
+ dataKey="total"
207
+ nameKey="name"
208
+ innerRadius={58}
209
+ outerRadius={94}
210
+ paddingAngle={2}
211
+ >
212
+ {sourceChartData.map((entry) => (
213
+ <Cell key={entry.name} fill={entry.fill} />
214
+ ))}
215
+ </Pie>
216
+ <Tooltip contentStyle={chartTooltipStyle} />
217
+ </PieChart>
218
+ </ResponsiveContainer>
219
+ </div>
220
+ <ScrollArea className="min-w-0 lg:h-full">
221
+ <div className="space-y-3 lg:pr-3">
222
+ {sourceChartData.map((item) => (
223
+ <div
224
+ key={item.name}
225
+ className="flex min-w-0 items-center justify-between gap-3 rounded-2xl border border-border/70 px-4 py-3"
226
+ >
227
+ <div className="flex min-w-0 items-center gap-3">
228
+ <span
229
+ className="inline-block size-3 rounded-full"
230
+ style={{ backgroundColor: item.fill }}
231
+ />
232
+ <span className="truncate font-medium">{item.name}</span>
233
+ </div>
234
+ <span className="shrink-0 text-sm text-muted-foreground">
235
+ {item.total}
236
+ </span>
237
+ </div>
238
+ ))}
239
+ </div>
240
+ </ScrollArea>
241
+ </CardContent>
242
+ </Card>
243
+ );
244
+ }
245
+
246
+ export function CrmOwnerPerformanceCard({
247
+ items,
248
+ className,
249
+ contentClassName,
250
+ }: CrmDashboardCardProps & {
251
+ items: DashboardOwnerPerformanceItem[];
252
+ }) {
253
+ const t = useTranslations('contact.CrmDashboard');
254
+
255
+ return (
256
+ <Card className={cn('min-w-0 overflow-hidden', className)}>
257
+ <CardHeader>
258
+ <CardTitle>{t('charts.owner.title')}</CardTitle>
259
+ <CardDescription>{t('charts.owner.description')}</CardDescription>
260
+ </CardHeader>
261
+ <CardContent className={cn('h-80', contentClassName)}>
262
+ <ResponsiveContainer width="100%" height="100%">
263
+ <BarChart data={items}>
264
+ <CartesianGrid
265
+ strokeDasharray="3 3"
266
+ stroke="hsl(var(--border))"
267
+ vertical={false}
268
+ />
269
+ <XAxis
270
+ dataKey="owner_name"
271
+ tickLine={false}
272
+ axisLine={false}
273
+ fontSize={12}
274
+ />
275
+ <YAxis
276
+ tickLine={false}
277
+ axisLine={false}
278
+ fontSize={12}
279
+ allowDecimals={false}
280
+ />
281
+ <Tooltip contentStyle={chartTooltipStyle} />
282
+ <Bar dataKey="leads" fill="#f97316" radius={[8, 8, 0, 0]} />
283
+ <Bar dataKey="customers" fill="#0ea5e9" radius={[8, 8, 0, 0]} />
284
+ </BarChart>
285
+ </ResponsiveContainer>
286
+ </CardContent>
287
+ </Card>
288
+ );
289
+ }
290
+
291
+ export function CrmTopOwnersCard({
292
+ items,
293
+ className,
294
+ }: CrmDashboardCardProps & {
295
+ items: DashboardOwnerPerformanceItem[];
296
+ }) {
297
+ const t = useTranslations('contact.CrmDashboard');
298
+ const { currentLocaleCode } = useApp();
299
+
300
+ const topOwners = [...items]
301
+ .sort((left, right) => right.pipeline_value - left.pipeline_value)
302
+ .slice(0, 4);
303
+
304
+ const currencyFormatter = new Intl.NumberFormat(
305
+ currentLocaleCode === 'pt' ? 'pt-BR' : 'en-US',
306
+ {
307
+ style: 'currency',
308
+ currency: 'BRL',
309
+ maximumFractionDigits: 0,
310
+ }
311
+ );
312
+
313
+ return (
314
+ <Card className={cn('min-w-0 overflow-hidden', className)}>
315
+ <CardHeader>
316
+ <CardTitle>{t('bestOwners.title')}</CardTitle>
317
+ <CardDescription>{t('bestOwners.description')}</CardDescription>
318
+ </CardHeader>
319
+ <CardContent>
320
+ <div className="space-y-3">
321
+ {topOwners.length === 0 ? (
322
+ <p className="text-sm text-muted-foreground">
323
+ {t('common.noData')}
324
+ </p>
325
+ ) : (
326
+ topOwners.map((owner, index) => (
327
+ <div
328
+ key={`${owner.owner_user_id}-${owner.owner_name}`}
329
+ className="flex min-w-0 items-center justify-between gap-3 rounded-2xl border border-border/70 px-4 py-3"
330
+ >
331
+ <div className="min-w-0">
332
+ <p className="truncate font-medium">
333
+ {index + 1}. {owner.owner_name}
334
+ </p>
335
+ <p className="truncate text-xs text-muted-foreground">
336
+ {t('bestOwners.meta', {
337
+ leads: owner.leads,
338
+ customers: owner.customers,
339
+ })}
340
+ </p>
341
+ </div>
342
+ <span className="shrink-0 text-sm font-medium text-foreground">
343
+ {currencyFormatter.format(owner.pipeline_value)}
344
+ </span>
345
+ </div>
346
+ ))
347
+ )}
348
+ </div>
349
+ </CardContent>
350
+ </Card>
351
+ );
352
+ }
353
+
354
+ function CrmLeadBadges({ lead }: { lead: DashboardListItem }) {
355
+ const t = useTranslations('contact.CrmDashboard');
356
+
357
+ return (
358
+ <div className="mt-2 flex flex-wrap gap-2">
359
+ <Badge variant="outline">
360
+ {t(`stageLabels.${lead.lifecycle_stage}`)}
361
+ </Badge>
362
+ <Badge variant="secondary">{t(`sourceLabels.${lead.source}`)}</Badge>
363
+ </div>
364
+ );
365
+ }
366
+
367
+ export function CrmNextActionsCard({
368
+ items,
369
+ className,
370
+ scrollAreaClassName,
371
+ }: CrmDashboardListCardProps & {
372
+ items: DashboardListItem[];
373
+ }) {
374
+ const t = useTranslations('contact.CrmDashboard');
375
+ const { currentLocaleCode, getSettingValue } = useApp();
376
+
377
+ return (
378
+ <Card className={cn('min-w-0 overflow-hidden', className)}>
379
+ <CardHeader>
380
+ <CardTitle>{t('blocks.nextActions.title')}</CardTitle>
381
+ <CardDescription>{t('blocks.nextActions.description')}</CardDescription>
382
+ </CardHeader>
383
+ <CardContent>
384
+ <ScrollArea className={scrollAreaClassName ?? 'h-80 pr-3'}>
385
+ <div className="space-y-3">
386
+ {items.length === 0 ? (
387
+ <p className="text-sm text-muted-foreground">
388
+ {t('common.noData')}
389
+ </p>
390
+ ) : (
391
+ items.map((lead) => (
392
+ <div
393
+ key={lead.id}
394
+ className="rounded-2xl border border-border/70 px-4 py-3"
395
+ >
396
+ <p className="font-medium">{lead.name}</p>
397
+ <p className="text-xs text-muted-foreground">
398
+ {lead.owner_user?.name || t('common.unassigned')}
399
+ </p>
400
+ <CrmLeadBadges lead={lead} />
401
+ <p className="mt-2 text-sm text-muted-foreground">
402
+ {t('blocks.nextActions.when', {
403
+ date: formatDateTime(
404
+ lead.next_action_at ?? '',
405
+ getSettingValue,
406
+ currentLocaleCode
407
+ ),
408
+ })}
409
+ </p>
410
+ </div>
411
+ ))
412
+ )}
413
+ </div>
414
+ </ScrollArea>
415
+ </CardContent>
416
+ </Card>
417
+ );
418
+ }
419
+
420
+ export function CrmUnattendedCard({
421
+ items,
422
+ className,
423
+ scrollAreaClassName,
424
+ }: CrmDashboardListCardProps & {
425
+ items: DashboardListItem[];
426
+ }) {
427
+ const t = useTranslations('contact.CrmDashboard');
428
+
429
+ return (
430
+ <Card className={cn('min-w-0 overflow-hidden', className)}>
431
+ <CardHeader>
432
+ <CardTitle>{t('blocks.unattended.title')}</CardTitle>
433
+ <CardDescription>{t('blocks.unattended.description')}</CardDescription>
434
+ </CardHeader>
435
+ <CardContent>
436
+ <ScrollArea className={scrollAreaClassName ?? 'h-80 pr-3'}>
437
+ <div className="space-y-3">
438
+ {items.length === 0 ? (
439
+ <p className="text-sm text-muted-foreground">
440
+ {t('common.noData')}
441
+ </p>
442
+ ) : (
443
+ items.map((lead) => (
444
+ <div
445
+ key={lead.id}
446
+ className="rounded-2xl border border-border/70 px-4 py-3"
447
+ >
448
+ <p className="font-medium">{lead.name}</p>
449
+ <p className="text-xs text-muted-foreground">
450
+ {lead.owner_user?.name || t('common.unassigned')}
451
+ </p>
452
+ <p className="mt-2 text-sm text-muted-foreground">
453
+ {t('blocks.unattended.meta', {
454
+ stage: t(`stageLabels.${lead.lifecycle_stage}`),
455
+ source: t(`sourceLabels.${lead.source}`),
456
+ })}
457
+ </p>
458
+ </div>
459
+ ))
460
+ )}
461
+ </div>
462
+ </ScrollArea>
463
+ </CardContent>
464
+ </Card>
465
+ );
466
+ }
467
+
468
+ export function CrmQuickAccessCard({ className }: CrmDashboardCardProps) {
469
+ const menuT = useTranslations('contact.CrmMenu');
470
+ const t = useTranslations('contact.CrmDashboard');
471
+
472
+ return (
473
+ <Card className={cn('min-w-0 overflow-hidden', className)}>
474
+ <CardHeader>
475
+ <CardTitle>{t('blocks.quickAccess.title')}</CardTitle>
476
+ <CardDescription>{t('blocks.quickAccess.description')}</CardDescription>
477
+ </CardHeader>
478
+ <CardContent className="space-y-3">
479
+ {crmImplementedSections.map((section) => {
480
+ const Icon = section.icon;
481
+
482
+ return (
483
+ <Link
484
+ key={section.href}
485
+ href={section.href}
486
+ className="flex min-w-0 cursor-pointer items-center justify-between gap-3 rounded-2xl border border-border/70 px-4 py-3 transition-colors hover:bg-muted/40"
487
+ >
488
+ <div className="flex min-w-0 items-center gap-3">
489
+ <div className={`rounded-2xl p-3 ${section.glowClass}`}>
490
+ <Icon className="size-4" />
491
+ </div>
492
+ <div className="min-w-0">
493
+ <p className="truncate font-medium">
494
+ {menuT(`sections.${section.translationKey}.title`)}
495
+ </p>
496
+ <p className="truncate text-xs text-muted-foreground">
497
+ {menuT(`sections.${section.translationKey}.description`)}
498
+ </p>
499
+ </div>
500
+ </div>
501
+ <ArrowUpRight className="size-4 shrink-0 text-muted-foreground" />
502
+ </Link>
503
+ );
504
+ })}
505
+ </CardContent>
506
+ </Card>
507
+ );
508
+ }
@@ -0,0 +1,104 @@
1
+ 'use client';
2
+
3
+ import { useWidgetData } from '@/hooks/use-widget-data';
4
+ import type { DashboardPeriod, DashboardResponse } from './dashboard-types';
5
+
6
+ export const emptyDashboard: DashboardResponse = {
7
+ kpis: {
8
+ total_leads: 0,
9
+ qualified: 0,
10
+ proposal: 0,
11
+ customers: 0,
12
+ lost: 0,
13
+ unassigned: 0,
14
+ overdue: 0,
15
+ next_actions: 0,
16
+ },
17
+ charts: {
18
+ stage: [],
19
+ source: [],
20
+ owner_performance: [],
21
+ },
22
+ lists: {
23
+ next_actions: [],
24
+ unattended: [],
25
+ },
26
+ };
27
+
28
+ type ContactDashboardEndpointOptions = {
29
+ ownerUserId?: string | number;
30
+ period?: DashboardPeriod;
31
+ dateFrom?: string;
32
+ dateTo?: string;
33
+ };
34
+
35
+ type UseContactDashboardWidgetDataOptions<R> =
36
+ ContactDashboardEndpointOptions & {
37
+ queryKey: string;
38
+ enabled?: boolean;
39
+ select?: (data: DashboardResponse) => R;
40
+ };
41
+
42
+ type ContactDashboardWidgetDataResult<R> = {
43
+ data: R | undefined;
44
+ isLoading: boolean;
45
+ isError: boolean;
46
+ isAccessDenied: boolean;
47
+ error: any;
48
+ };
49
+
50
+ export function buildContactDashboardEndpoint({
51
+ ownerUserId = 'all',
52
+ period = '30d',
53
+ dateFrom,
54
+ dateTo,
55
+ }: ContactDashboardEndpointOptions = {}) {
56
+ const params = new URLSearchParams();
57
+
58
+ if (
59
+ ownerUserId !== 'all' &&
60
+ ownerUserId !== '' &&
61
+ ownerUserId !== null &&
62
+ ownerUserId !== undefined
63
+ ) {
64
+ params.set('owner_user_id', String(ownerUserId));
65
+ }
66
+
67
+ params.set('period', period);
68
+
69
+ if (period === 'custom') {
70
+ if (dateFrom) {
71
+ params.set('date_from', dateFrom);
72
+ }
73
+
74
+ if (dateTo) {
75
+ params.set('date_to', dateTo);
76
+ }
77
+ }
78
+
79
+ const query = params.toString();
80
+
81
+ return `/person/dashboard${query ? `?${query}` : ''}`;
82
+ }
83
+
84
+ export function useContactDashboardWidgetData<R = DashboardResponse>({
85
+ queryKey,
86
+ ownerUserId = 'all',
87
+ period = '30d',
88
+ dateFrom,
89
+ dateTo,
90
+ enabled = true,
91
+ select,
92
+ }: UseContactDashboardWidgetDataOptions<R>): ContactDashboardWidgetDataResult<R> {
93
+ return useWidgetData<DashboardResponse, R>({
94
+ endpoint: buildContactDashboardEndpoint({
95
+ ownerUserId,
96
+ period,
97
+ dateFrom,
98
+ dateTo,
99
+ }),
100
+ queryKey: `contact-dashboard-widget-${queryKey}-${period}-${String(ownerUserId)}`,
101
+ enabled,
102
+ select,
103
+ });
104
+ }