@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,15 +1,1196 @@
1
- 'use client';
2
-
3
- import { CrmComingSoon } from '../_components/crm-coming-soon';
4
- import { BarChart3 } from 'lucide-react';
5
-
6
- export default function CrmReportsPage() {
7
- return (
8
- <CrmComingSoon
9
- currentHref="/contact/reports"
10
- titleKey="reports"
11
- descriptionKey="reports"
12
- icon={BarChart3}
13
- />
14
- );
15
- }
1
+ 'use client';
2
+
3
+ import { Page, PageHeader } from '@/components/entity-list';
4
+ import { Button } from '@/components/ui/button';
5
+ import {
6
+ Card,
7
+ CardContent,
8
+ CardDescription,
9
+ CardHeader,
10
+ CardTitle,
11
+ } from '@/components/ui/card';
12
+ import {
13
+ Dialog,
14
+ DialogContent,
15
+ DialogDescription,
16
+ DialogFooter,
17
+ DialogHeader,
18
+ DialogTitle,
19
+ } from '@/components/ui/dialog';
20
+ import { Input } from '@/components/ui/input';
21
+ import { KpiCardsGrid } from '@/components/ui/kpi-cards-grid';
22
+ import {
23
+ Select,
24
+ SelectContent,
25
+ SelectItem,
26
+ SelectTrigger,
27
+ SelectValue,
28
+ } from '@/components/ui/select';
29
+ import { Skeleton } from '@/components/ui/skeleton';
30
+ import {
31
+ Table,
32
+ TableBody,
33
+ TableCell,
34
+ TableHead,
35
+ TableHeader,
36
+ TableRow,
37
+ } from '@/components/ui/table';
38
+ import { cn } from '@/lib/utils';
39
+ import { useApp, useQuery } from '@hed-hog/next-app-provider';
40
+ import {
41
+ Activity,
42
+ AlertTriangle,
43
+ ChartNoAxesCombined,
44
+ CircleDollarSign,
45
+ Loader2,
46
+ Printer,
47
+ RefreshCcw,
48
+ Target,
49
+ TrendingUp,
50
+ } from 'lucide-react';
51
+ import { useTranslations } from 'next-intl';
52
+ import { useState } from 'react';
53
+ import { flushSync } from 'react-dom';
54
+ import {
55
+ Bar,
56
+ BarChart,
57
+ CartesianGrid,
58
+ Cell,
59
+ Legend,
60
+ Line,
61
+ LineChart,
62
+ Pie,
63
+ PieChart,
64
+ ResponsiveContainer,
65
+ Tooltip,
66
+ XAxis,
67
+ YAxis,
68
+ } from 'recharts';
69
+ import { crmImplementedSections } from '../_lib/crm-sections';
70
+ import {
71
+ REPORT_ACTIVITY_ORDER,
72
+ REPORT_SOURCE_ORDER,
73
+ REPORT_STAGE_ORDER,
74
+ type ReportGroupBy,
75
+ type ReportResponse,
76
+ } from './_components/report-types';
77
+
78
+ const chartPalette = [
79
+ '#f97316',
80
+ '#0ea5e9',
81
+ '#14b8a6',
82
+ '#84cc16',
83
+ '#f59e0b',
84
+ '#ef4444',
85
+ '#8b5cf6',
86
+ ];
87
+
88
+ const chartTooltipStyle = {
89
+ backgroundColor: 'hsl(var(--card))',
90
+ border: '1px solid hsl(var(--border))',
91
+ borderRadius: '12px',
92
+ fontSize: '12px',
93
+ };
94
+
95
+ function formatDateInputValue(date: Date) {
96
+ const year = date.getFullYear();
97
+ const month = String(date.getMonth() + 1).padStart(2, '0');
98
+ const day = String(date.getDate()).padStart(2, '0');
99
+ return `${year}-${month}-${day}`;
100
+ }
101
+
102
+ function addDays(date: Date, amount: number) {
103
+ const next = new Date(date);
104
+ next.setDate(next.getDate() + amount);
105
+ return next;
106
+ }
107
+
108
+ function getDefaultDateRange() {
109
+ const now = new Date();
110
+ return {
111
+ dateFrom: formatDateInputValue(addDays(now, -29)),
112
+ dateTo: formatDateInputValue(now),
113
+ };
114
+ }
115
+
116
+ function getIntlLocale(localeCode?: string) {
117
+ return localeCode?.startsWith('pt') ? 'pt-BR' : 'en-US';
118
+ }
119
+
120
+ function formatDisplayDate(value: string, locale: string) {
121
+ const [year, month, day] = value.split('-').map(Number);
122
+
123
+ if (!year || !month || !day) {
124
+ return value;
125
+ }
126
+
127
+ return new Intl.DateTimeFormat(locale, {
128
+ dateStyle: 'medium',
129
+ }).format(new Date(year, month - 1, day));
130
+ }
131
+
132
+ const emptyReports: ReportResponse = {
133
+ summary: {
134
+ new_leads: 0,
135
+ qualified_moves: 0,
136
+ customer_moves: 0,
137
+ lost_moves: 0,
138
+ interactions: 0,
139
+ followups_completed: 0,
140
+ conversion_rate: 0,
141
+ },
142
+ timeline: [],
143
+ breakdowns: {
144
+ source: REPORT_SOURCE_ORDER.map((key) => ({ key, total: 0 })),
145
+ current_stage: REPORT_STAGE_ORDER.map((key) => ({ key, total: 0 })),
146
+ activity_type: REPORT_ACTIVITY_ORDER.map((key) => ({ key, total: 0 })),
147
+ },
148
+ owners: [],
149
+ table: [],
150
+ };
151
+
152
+ type PrintLayoutMode = 'portrait' | 'landscape';
153
+
154
+ export default function CrmReportsPage() {
155
+ const t = useTranslations('contact.CrmReports');
156
+ const dashboardT = useTranslations('contact.CrmDashboard');
157
+ const activitiesT = useTranslations('contact.CrmActivities');
158
+ const { request, currentLocaleCode } = useApp();
159
+ const defaultRange = getDefaultDateRange();
160
+ const locale = getIntlLocale(currentLocaleCode);
161
+
162
+ const [dateFrom, setDateFrom] = useState(defaultRange.dateFrom);
163
+ const [dateTo, setDateTo] = useState(defaultRange.dateTo);
164
+ const [groupBy, setGroupBy] = useState<ReportGroupBy>('day');
165
+ const [isPrintDialogOpen, setIsPrintDialogOpen] = useState(false);
166
+ const [printLayoutMode, setPrintLayoutMode] =
167
+ useState<PrintLayoutMode>('portrait');
168
+ const [printedAt, setPrintedAt] = useState<Date | null>(null);
169
+
170
+ const hasInvalidRange = Boolean(dateFrom && dateTo && dateFrom > dateTo);
171
+ const canPrint = !hasInvalidRange;
172
+
173
+ const {
174
+ data: reports = emptyReports,
175
+ isLoading,
176
+ isFetching,
177
+ refetch,
178
+ } = useQuery<ReportResponse>({
179
+ queryKey: ['contact-reports', dateFrom, dateTo, groupBy, currentLocaleCode],
180
+ enabled: !hasInvalidRange,
181
+ queryFn: async () => {
182
+ const params = new URLSearchParams({
183
+ date_from: dateFrom,
184
+ date_to: dateTo,
185
+ group_by: groupBy,
186
+ });
187
+
188
+ const response = await request<ReportResponse>({
189
+ url: `/person/reports?${params.toString()}`,
190
+ method: 'GET',
191
+ });
192
+
193
+ return response.data;
194
+ },
195
+ placeholderData: (previous) => previous ?? emptyReports,
196
+ });
197
+
198
+ const percentFormatter = new Intl.NumberFormat(locale, {
199
+ style: 'percent',
200
+ maximumFractionDigits: 1,
201
+ });
202
+ const dateTimeFormatter = new Intl.DateTimeFormat(locale, {
203
+ dateStyle: 'medium',
204
+ timeStyle: 'short',
205
+ });
206
+
207
+ const timelineData = reports.timeline.map((item) => ({
208
+ ...item,
209
+ conversion_rate_percent: Number((item.conversion_rate * 100).toFixed(1)),
210
+ }));
211
+
212
+ const sourceChartData = reports.breakdowns.source.map((item, index) => ({
213
+ name: dashboardT(`sourceLabels.${item.key}`),
214
+ total: item.total,
215
+ fill: chartPalette[index % chartPalette.length],
216
+ }));
217
+
218
+ const stageChartData = reports.breakdowns.current_stage.map((item, index) => ({
219
+ name: dashboardT(`stageLabels.${item.key}`),
220
+ total: item.total,
221
+ fill: chartPalette[index % chartPalette.length],
222
+ }));
223
+
224
+ const activityTypeChartData = reports.breakdowns.activity_type.map(
225
+ (item, index) => ({
226
+ name: activitiesT(`type.${item.key}`),
227
+ total: item.total,
228
+ fill: chartPalette[index % chartPalette.length],
229
+ })
230
+ );
231
+
232
+ const ownersChartData = reports.owners.slice(0, 8);
233
+ const hasTimelineData = timelineData.some(
234
+ (item) =>
235
+ item.new_leads > 0 ||
236
+ item.qualified_moves > 0 ||
237
+ item.customer_moves > 0 ||
238
+ item.lost_moves > 0 ||
239
+ item.interactions > 0 ||
240
+ item.followups_completed > 0
241
+ );
242
+ const hasSourceData = sourceChartData.some((item) => item.total > 0);
243
+ const hasStageData = stageChartData.some((item) => item.total > 0);
244
+ const hasOwnersData = ownersChartData.some(
245
+ (item) =>
246
+ item.interactions > 0 ||
247
+ item.followups_completed > 0 ||
248
+ item.customer_moves > 0
249
+ );
250
+ const hasActivityTypeData = activityTypeChartData.some(
251
+ (item) => item.total > 0
252
+ );
253
+ const printedAtLabel = dateTimeFormatter.format(printedAt ?? new Date());
254
+ const printFilterItems = [
255
+ {
256
+ label: t('printedFilters.dateFrom'),
257
+ value: formatDisplayDate(dateFrom, locale),
258
+ },
259
+ {
260
+ label: t('printedFilters.dateTo'),
261
+ value: formatDisplayDate(dateTo, locale),
262
+ },
263
+ {
264
+ label: t('printedFilters.groupBy'),
265
+ value: t(`groupBy.${groupBy}`),
266
+ },
267
+ {
268
+ label: t('printedAt'),
269
+ value: printedAtLabel,
270
+ },
271
+ ];
272
+ const emptyChartState = (
273
+ <div className="crm-report-empty-chart flex h-full min-h-[180px] items-center justify-center rounded-2xl border border-dashed border-border/60 text-sm text-muted-foreground">
274
+ {t('common.noData')}
275
+ </div>
276
+ );
277
+
278
+ const kpiCards = [
279
+ {
280
+ key: 'newLeads',
281
+ title: t('kpis.newLeads.title'),
282
+ value: reports.summary.new_leads,
283
+ description: t('kpis.newLeads.description'),
284
+ icon: TrendingUp,
285
+ accentClassName: crmImplementedSections[0]?.colorClass,
286
+ iconContainerClassName: crmImplementedSections[0]?.glowClass,
287
+ className: 'crm-report-kpi-card',
288
+ contentClassName: 'crm-report-kpi-card__content',
289
+ valueClassName: 'crm-report-kpi-card__value',
290
+ descriptionClassName: 'crm-report-kpi-card__description',
291
+ },
292
+ {
293
+ key: 'qualifiedMoves',
294
+ title: t('kpis.qualifiedMoves.title'),
295
+ value: reports.summary.qualified_moves,
296
+ description: t('kpis.qualifiedMoves.description'),
297
+ icon: Target,
298
+ accentClassName: crmImplementedSections[1]?.colorClass,
299
+ iconContainerClassName: crmImplementedSections[1]?.glowClass,
300
+ className: 'crm-report-kpi-card',
301
+ contentClassName: 'crm-report-kpi-card__content',
302
+ valueClassName: 'crm-report-kpi-card__value',
303
+ descriptionClassName: 'crm-report-kpi-card__description',
304
+ },
305
+ {
306
+ key: 'customerMoves',
307
+ title: t('kpis.customerMoves.title'),
308
+ value: reports.summary.customer_moves,
309
+ description: t('kpis.customerMoves.description'),
310
+ icon: CircleDollarSign,
311
+ accentClassName: crmImplementedSections[2]?.colorClass,
312
+ iconContainerClassName: crmImplementedSections[2]?.glowClass,
313
+ className: 'crm-report-kpi-card',
314
+ contentClassName: 'crm-report-kpi-card__content',
315
+ valueClassName: 'crm-report-kpi-card__value',
316
+ descriptionClassName: 'crm-report-kpi-card__description',
317
+ },
318
+ {
319
+ key: 'lostMoves',
320
+ title: t('kpis.lostMoves.title'),
321
+ value: reports.summary.lost_moves,
322
+ description: t('kpis.lostMoves.description'),
323
+ icon: AlertTriangle,
324
+ accentClassName: crmImplementedSections[3]?.colorClass,
325
+ iconContainerClassName: crmImplementedSections[3]?.glowClass,
326
+ className: 'crm-report-kpi-card',
327
+ contentClassName: 'crm-report-kpi-card__content',
328
+ valueClassName: 'crm-report-kpi-card__value',
329
+ descriptionClassName: 'crm-report-kpi-card__description',
330
+ },
331
+ {
332
+ key: 'interactions',
333
+ title: t('kpis.interactions.title'),
334
+ value: reports.summary.interactions,
335
+ description: t('kpis.interactions.description'),
336
+ icon: Activity,
337
+ accentClassName: crmImplementedSections[4]?.colorClass,
338
+ iconContainerClassName: crmImplementedSections[4]?.glowClass,
339
+ className: 'crm-report-kpi-card',
340
+ contentClassName: 'crm-report-kpi-card__content',
341
+ valueClassName: 'crm-report-kpi-card__value',
342
+ descriptionClassName: 'crm-report-kpi-card__description',
343
+ },
344
+ {
345
+ key: 'followupsCompleted',
346
+ title: t('kpis.followupsCompleted.title'),
347
+ value: reports.summary.followups_completed,
348
+ description: t('kpis.followupsCompleted.description'),
349
+ icon: ChartNoAxesCombined,
350
+ accentClassName: crmImplementedSections[5]?.colorClass,
351
+ iconContainerClassName: crmImplementedSections[5]?.glowClass,
352
+ className: 'crm-report-kpi-card',
353
+ contentClassName: 'crm-report-kpi-card__content',
354
+ valueClassName: 'crm-report-kpi-card__value',
355
+ descriptionClassName: 'crm-report-kpi-card__description',
356
+ },
357
+ {
358
+ key: 'conversionRate',
359
+ title: t('kpis.conversionRate.title'),
360
+ value: percentFormatter.format(reports.summary.conversion_rate),
361
+ description: t('kpis.conversionRate.description'),
362
+ icon: TrendingUp,
363
+ accentClassName: 'from-slate-500/20 via-zinc-500/10 to-transparent',
364
+ iconContainerClassName: 'bg-slate-500/10 text-slate-700',
365
+ className: 'crm-report-kpi-card',
366
+ contentClassName: 'crm-report-kpi-card__content',
367
+ valueClassName: 'crm-report-kpi-card__value',
368
+ descriptionClassName: 'crm-report-kpi-card__description',
369
+ },
370
+ ];
371
+
372
+ const filters = (
373
+ <div className="flex flex-wrap items-center justify-end gap-2">
374
+ <Input
375
+ type="date"
376
+ value={dateFrom}
377
+ onChange={(event) => setDateFrom(event.target.value)}
378
+ className="w-full sm:w-[170px]"
379
+ aria-label={t('filters.dateFrom')}
380
+ max={dateTo}
381
+ />
382
+ <Input
383
+ type="date"
384
+ value={dateTo}
385
+ onChange={(event) => setDateTo(event.target.value)}
386
+ className="w-full sm:w-[170px]"
387
+ aria-label={t('filters.dateTo')}
388
+ min={dateFrom}
389
+ />
390
+ <Select
391
+ value={groupBy}
392
+ onValueChange={(value) => setGroupBy(value as ReportGroupBy)}
393
+ >
394
+ <SelectTrigger className="w-full sm:w-[170px]">
395
+ <SelectValue placeholder={t('filters.groupByPlaceholder')} />
396
+ </SelectTrigger>
397
+ <SelectContent>
398
+ <SelectItem value="day">{t('groupBy.day')}</SelectItem>
399
+ <SelectItem value="week">{t('groupBy.week')}</SelectItem>
400
+ <SelectItem value="month">{t('groupBy.month')}</SelectItem>
401
+ <SelectItem value="year">{t('groupBy.year')}</SelectItem>
402
+ </SelectContent>
403
+ </Select>
404
+ </div>
405
+ );
406
+
407
+ const handlePrintSelection = (layout: PrintLayoutMode) => {
408
+ if (!canPrint || isLoading || typeof window === 'undefined') {
409
+ return;
410
+ }
411
+
412
+ flushSync(() => {
413
+ setIsPrintDialogOpen(false);
414
+ setPrintLayoutMode(layout);
415
+ setPrintedAt(new Date());
416
+ });
417
+
418
+ window.requestAnimationFrame(() => {
419
+ window.requestAnimationFrame(() => {
420
+ window.print();
421
+ });
422
+ });
423
+ };
424
+
425
+ const actions = (
426
+ <div className="crm-report-screen-only flex gap-2">
427
+ <Button
428
+ variant="outline"
429
+ size="sm"
430
+ onClick={() => setIsPrintDialogOpen(true)}
431
+ disabled={!canPrint || isLoading}
432
+ >
433
+ <Printer className="size-4" />
434
+ {t('print')}
435
+ </Button>
436
+ <Button
437
+ variant="outline"
438
+ size="sm"
439
+ onClick={() => {
440
+ if (hasInvalidRange) {
441
+ return;
442
+ }
443
+
444
+ void refetch();
445
+ }}
446
+ disabled={hasInvalidRange}
447
+ >
448
+ {isFetching ? (
449
+ <Loader2 className="size-4 animate-spin" />
450
+ ) : (
451
+ <RefreshCcw className="size-4" />
452
+ )}
453
+ {t('refresh')}
454
+ </Button>
455
+ </div>
456
+ );
457
+
458
+ return (
459
+ <Page>
460
+ <style jsx global>{`
461
+ .crm-report-print-only {
462
+ display: none;
463
+ }
464
+
465
+ @media print {
466
+ @page {
467
+ size: A4 ${printLayoutMode};
468
+ margin: 9mm;
469
+ }
470
+
471
+ html,
472
+ body {
473
+ background: white !important;
474
+ }
475
+
476
+ body {
477
+ -webkit-print-color-adjust: exact;
478
+ print-color-adjust: exact;
479
+ }
480
+
481
+ .crm-report-screen-only {
482
+ display: none !important;
483
+ }
484
+
485
+ .crm-report-print-only {
486
+ display: block !important;
487
+ }
488
+
489
+ .crm-report-page {
490
+ color: #0f172a;
491
+ }
492
+
493
+ .crm-report-content {
494
+ gap: 0.75rem !important;
495
+ }
496
+
497
+ .crm-report-print-shell {
498
+ border: none !important;
499
+ border-radius: 0 !important;
500
+ padding: 0 0 0.5rem !important;
501
+ background: transparent !important;
502
+ }
503
+
504
+ .crm-report-print-header {
505
+ display: flex !important;
506
+ align-items: baseline;
507
+ justify-content: space-between;
508
+ gap: 1rem;
509
+ }
510
+
511
+ .crm-report-print-header h1 {
512
+ font-size: 1.55rem !important;
513
+ line-height: 1.1 !important;
514
+ }
515
+
516
+ .crm-report-print-header p {
517
+ max-width: 36rem;
518
+ font-size: 0.78rem !important;
519
+ }
520
+
521
+ .crm-report-print-meta {
522
+ margin-top: 0.75rem !important;
523
+ gap: 0.45rem !important;
524
+ }
525
+
526
+ .crm-report-page--portrait .crm-report-print-meta {
527
+ grid-template-columns: repeat(2, minmax(0, 1fr)) !important;
528
+ }
529
+
530
+ .crm-report-page--landscape .crm-report-print-meta {
531
+ grid-template-columns: repeat(4, minmax(0, 1fr)) !important;
532
+ }
533
+
534
+ .crm-report-print-meta-item {
535
+ border-radius: 0.65rem !important;
536
+ padding: 0.45rem 0.65rem !important;
537
+ break-inside: avoid;
538
+ page-break-inside: avoid;
539
+ }
540
+
541
+ .crm-report-kpi-grid {
542
+ gap: 0.45rem !important;
543
+ }
544
+
545
+ .crm-report-page--portrait .crm-report-kpi-grid {
546
+ grid-template-columns: repeat(3, minmax(0, 1fr)) !important;
547
+ }
548
+
549
+ .crm-report-page--landscape .crm-report-kpi-grid {
550
+ grid-template-columns: repeat(4, minmax(0, 1fr)) !important;
551
+ }
552
+
553
+ .crm-report-kpi-card {
554
+ break-inside: avoid;
555
+ page-break-inside: avoid;
556
+ border: 1px solid #e2e8f0 !important;
557
+ border-radius: 0.75rem !important;
558
+ background: white !important;
559
+ box-shadow: none !important;
560
+ }
561
+
562
+ .crm-report-kpi-card__content {
563
+ padding: 0.55rem 0.75rem !important;
564
+ gap: 0.5rem !important;
565
+ }
566
+
567
+ .crm-report-kpi-card__value {
568
+ margin-top: 0.25rem !important;
569
+ font-size: 1.6rem !important;
570
+ line-height: 1 !important;
571
+ }
572
+
573
+ .crm-report-kpi-card__description {
574
+ display: none !important;
575
+ }
576
+
577
+ .crm-report-section {
578
+ gap: 0 !important;
579
+ border: none !important;
580
+ border-radius: 0 !important;
581
+ background: transparent !important;
582
+ box-shadow: none !important;
583
+ padding: 0.45rem 0 0 !important;
584
+ break-inside: avoid;
585
+ page-break-inside: avoid;
586
+ border-top: 1px solid #dbe2ea !important;
587
+ }
588
+
589
+ .crm-report-section[data-kind='table'] {
590
+ break-inside: auto;
591
+ page-break-inside: auto;
592
+ }
593
+
594
+ .crm-report-section [data-slot='card-header'] {
595
+ padding: 0 0 0.35rem !important;
596
+ gap: 0.15rem !important;
597
+ }
598
+
599
+ .crm-report-section [data-slot='card-title'] {
600
+ font-size: 0.92rem !important;
601
+ line-height: 1.2 !important;
602
+ }
603
+
604
+ .crm-report-section [data-slot='card-description'] {
605
+ font-size: 0.73rem !important;
606
+ line-height: 1.25 !important;
607
+ color: #475569 !important;
608
+ }
609
+
610
+ .crm-report-section [data-slot='card-content'] {
611
+ padding: 0 !important;
612
+ }
613
+
614
+ .crm-report-chart-grid {
615
+ gap: 0.7rem !important;
616
+ }
617
+
618
+ .crm-report-page--portrait .crm-report-chart-grid {
619
+ grid-template-columns: 1fr !important;
620
+ }
621
+
622
+ .crm-report-page--landscape .crm-report-chart-grid {
623
+ grid-template-columns: repeat(2, minmax(0, 1fr)) !important;
624
+ }
625
+
626
+ .crm-report-chart-content {
627
+ height: 205px !important;
628
+ }
629
+
630
+ .crm-report-page--landscape .crm-report-chart-content {
631
+ height: 185px !important;
632
+ }
633
+
634
+ .crm-report-section--timeline .crm-report-chart-content {
635
+ height: 220px !important;
636
+ }
637
+
638
+ .crm-report-page--landscape .crm-report-section--timeline .crm-report-chart-content {
639
+ height: 190px !important;
640
+ }
641
+
642
+ .crm-report-split-chart-content {
643
+ gap: 0.55rem !important;
644
+ align-items: start !important;
645
+ }
646
+
647
+ .crm-report-page--portrait .crm-report-split-chart-content {
648
+ grid-template-columns: minmax(0, 0.85fr) minmax(0, 1.15fr) !important;
649
+ }
650
+
651
+ .crm-report-page--landscape .crm-report-split-chart-content {
652
+ grid-template-columns: minmax(0, 0.95fr) minmax(0, 1.05fr) !important;
653
+ }
654
+
655
+ .crm-report-pie-chart {
656
+ height: 190px !important;
657
+ }
658
+
659
+ .crm-report-chart-content .recharts-responsive-container,
660
+ .crm-report-pie-chart .recharts-responsive-container {
661
+ min-height: 170px !important;
662
+ }
663
+
664
+ .crm-report-section .recharts-default-legend {
665
+ font-size: 10px !important;
666
+ line-height: 1.2 !important;
667
+ }
668
+
669
+ .crm-report-section .recharts-cartesian-axis-tick-value,
670
+ .crm-report-section .recharts-legend-item-text {
671
+ font-size: 10px !important;
672
+ }
673
+
674
+ .crm-report-source-list {
675
+ gap: 0.35rem !important;
676
+ break-inside: avoid;
677
+ page-break-inside: avoid;
678
+ }
679
+
680
+ .crm-report-source-list-item {
681
+ border-radius: 0.65rem !important;
682
+ padding: 0.45rem 0.65rem !important;
683
+ }
684
+
685
+ .crm-report-empty-chart {
686
+ min-height: 150px !important;
687
+ border-radius: 0.75rem !important;
688
+ }
689
+
690
+ .crm-report-table-card {
691
+ margin-top: 0.25rem !important;
692
+ }
693
+
694
+ .crm-report-table-card table {
695
+ font-size: 10.5px;
696
+ }
697
+
698
+ .crm-report-table-card th,
699
+ .crm-report-table-card td {
700
+ padding-top: 0.38rem !important;
701
+ padding-bottom: 0.38rem !important;
702
+ padding-left: 0.4rem !important;
703
+ padding-right: 0.4rem !important;
704
+ }
705
+
706
+ .crm-report-table-card thead {
707
+ display: table-header-group;
708
+ }
709
+
710
+ .crm-report-table-card tr {
711
+ break-inside: avoid;
712
+ page-break-inside: avoid;
713
+ }
714
+ }
715
+ `}</style>
716
+
717
+ <div
718
+ className={cn(
719
+ 'crm-report-page',
720
+ printLayoutMode === 'landscape'
721
+ ? 'crm-report-page--landscape'
722
+ : 'crm-report-page--portrait'
723
+ )}
724
+ >
725
+ <div className="crm-report-screen-only">
726
+ <PageHeader
727
+ title={t('title')}
728
+ description={t('subtitle')}
729
+ breadcrumbs={[
730
+ { label: t('breadcrumbs.home'), href: '/' },
731
+ { label: t('breadcrumbs.crm'), href: '/contact/dashboard' },
732
+ { label: t('breadcrumbs.reports') },
733
+ ]}
734
+ extraContent={filters}
735
+ actions={actions}
736
+ />
737
+ </div>
738
+
739
+ <Dialog open={isPrintDialogOpen} onOpenChange={setIsPrintDialogOpen}>
740
+ <DialogContent className="crm-report-screen-only sm:max-w-2xl">
741
+ <DialogHeader>
742
+ <DialogTitle>{t('printDialog.title')}</DialogTitle>
743
+ <DialogDescription>{t('printDialog.description')}</DialogDescription>
744
+ </DialogHeader>
745
+ <div className="grid gap-4 md:grid-cols-2">
746
+ {(['portrait', 'landscape'] as const).map((layout) => (
747
+ <button
748
+ key={layout}
749
+ type="button"
750
+ onClick={() => handlePrintSelection(layout)}
751
+ className={cn(
752
+ 'rounded-3xl border border-border/70 bg-card p-5 text-left transition-colors hover:border-foreground/20 hover:bg-muted/40',
753
+ printLayoutMode === layout && 'border-foreground/30 bg-muted/50'
754
+ )}
755
+ >
756
+ <div className="text-[11px] font-medium uppercase tracking-[0.2em] text-muted-foreground">
757
+ {t(`printDialog.options.${layout}.eyebrow`)}
758
+ </div>
759
+ <div className="mt-3 text-lg font-semibold">
760
+ {t(`printDialog.options.${layout}.title`)}
761
+ </div>
762
+ <div className="mt-2 text-sm leading-6 text-muted-foreground">
763
+ {t(`printDialog.options.${layout}.description`)}
764
+ </div>
765
+ <div className="mt-5 inline-flex rounded-full bg-foreground px-3 py-1 text-xs font-medium text-background">
766
+ {t(`printDialog.options.${layout}.action`)}
767
+ </div>
768
+ </button>
769
+ ))}
770
+ </div>
771
+ <DialogFooter>
772
+ <Button
773
+ variant="outline"
774
+ onClick={() => setIsPrintDialogOpen(false)}
775
+ >
776
+ {t('printDialog.cancel')}
777
+ </Button>
778
+ </DialogFooter>
779
+ </DialogContent>
780
+ </Dialog>
781
+
782
+ <div className="crm-report-print-only crm-report-print-shell rounded-3xl border border-border/70 bg-background px-6 py-5">
783
+ <div className="crm-report-print-header space-y-1">
784
+ <div className="space-y-1">
785
+ <h1 className="text-2xl font-bold">{t('title')}</h1>
786
+ <p className="text-sm text-muted-foreground">
787
+ {t('printDescription')}
788
+ </p>
789
+ </div>
790
+ <div className="text-right text-xs uppercase tracking-[0.18em] text-muted-foreground">
791
+ {t(`printDialog.options.${printLayoutMode}.title`)}
792
+ </div>
793
+ </div>
794
+ <div className="crm-report-print-meta mt-4 grid gap-3 sm:grid-cols-2 xl:grid-cols-4">
795
+ {printFilterItems.map((item) => (
796
+ <div
797
+ key={item.label}
798
+ className="crm-report-print-meta-item rounded-2xl border border-border/70 px-4 py-3"
799
+ >
800
+ <div className="text-xs uppercase tracking-[0.18em] text-muted-foreground">
801
+ {item.label}
802
+ </div>
803
+ <div className="mt-1 text-sm font-medium">{item.value}</div>
804
+ </div>
805
+ ))}
806
+ </div>
807
+ </div>
808
+
809
+ <div className="crm-report-content space-y-6">
810
+ {hasInvalidRange ? (
811
+ <Card className="border-destructive/40 crm-report-section">
812
+ <CardContent className="py-6 text-sm text-destructive">
813
+ {t('common.invalidRange')}
814
+ </CardContent>
815
+ </Card>
816
+ ) : null}
817
+
818
+ <KpiCardsGrid
819
+ items={kpiCards}
820
+ className="crm-report-kpi-grid xl:grid-cols-4"
821
+ />
822
+
823
+ {isLoading ? (
824
+ <div className="space-y-6">
825
+ <Skeleton className="h-[360px] w-full" />
826
+ <div className="grid gap-6 xl:grid-cols-2">
827
+ <Skeleton className="h-[320px] w-full" />
828
+ <Skeleton className="h-[320px] w-full" />
829
+ </div>
830
+ <div className="grid gap-6 xl:grid-cols-2">
831
+ <Skeleton className="h-[320px] w-full" />
832
+ <Skeleton className="h-[320px] w-full" />
833
+ </div>
834
+ <Skeleton className="h-[320px] w-full" />
835
+ </div>
836
+ ) : (
837
+ <>
838
+ <Card className="crm-report-section crm-report-section--timeline">
839
+ <CardHeader>
840
+ <CardTitle>{t('charts.timeline.title')}</CardTitle>
841
+ <CardDescription>{t('charts.timeline.description')}</CardDescription>
842
+ </CardHeader>
843
+ <CardContent className="crm-report-chart-content h-[360px]">
844
+ {hasTimelineData ? (
845
+ <ResponsiveContainer width="100%" height="100%">
846
+ <LineChart
847
+ data={timelineData}
848
+ margin={{ top: 8, right: 8, left: -20, bottom: 4 }}
849
+ >
850
+ <CartesianGrid
851
+ strokeDasharray="3 3"
852
+ stroke="hsl(var(--border))"
853
+ vertical={false}
854
+ />
855
+ <XAxis
856
+ dataKey="label"
857
+ tickLine={false}
858
+ axisLine={false}
859
+ fontSize={12}
860
+ />
861
+ <YAxis tickLine={false} axisLine={false} fontSize={12} />
862
+ <Tooltip contentStyle={chartTooltipStyle} />
863
+ <Legend />
864
+ <Line
865
+ type="monotone"
866
+ dataKey="new_leads"
867
+ name={t('kpis.newLeads.title')}
868
+ stroke="#f97316"
869
+ strokeWidth={2.5}
870
+ dot={false}
871
+ />
872
+ <Line
873
+ type="monotone"
874
+ dataKey="qualified_moves"
875
+ name={t('kpis.qualifiedMoves.title')}
876
+ stroke="#0ea5e9"
877
+ strokeWidth={2.5}
878
+ dot={false}
879
+ />
880
+ <Line
881
+ type="monotone"
882
+ dataKey="customer_moves"
883
+ name={t('kpis.customerMoves.title')}
884
+ stroke="#14b8a6"
885
+ strokeWidth={2.5}
886
+ dot={false}
887
+ />
888
+ <Line
889
+ type="monotone"
890
+ dataKey="interactions"
891
+ name={t('kpis.interactions.title')}
892
+ stroke="#8b5cf6"
893
+ strokeWidth={2}
894
+ dot={false}
895
+ />
896
+ <Line
897
+ type="monotone"
898
+ dataKey="followups_completed"
899
+ name={t('kpis.followupsCompleted.title')}
900
+ stroke="#84cc16"
901
+ strokeWidth={2}
902
+ strokeDasharray="6 4"
903
+ dot={false}
904
+ />
905
+ </LineChart>
906
+ </ResponsiveContainer>
907
+ ) : (
908
+ emptyChartState
909
+ )}
910
+ </CardContent>
911
+ </Card>
912
+
913
+ <div className="crm-report-chart-grid grid gap-6 xl:grid-cols-2">
914
+ <Card className="crm-report-section crm-report-section--source overflow-hidden">
915
+ <CardHeader>
916
+ <CardTitle>{t('charts.source.title')}</CardTitle>
917
+ <CardDescription>{t('charts.source.description')}</CardDescription>
918
+ </CardHeader>
919
+ <CardContent className="crm-report-split-chart-content grid gap-4 lg:h-[320px] lg:grid-cols-[minmax(220px,0.9fr)_minmax(0,1fr)]">
920
+ <div className="crm-report-pie-chart h-[220px] lg:h-full">
921
+ {hasSourceData ? (
922
+ <ResponsiveContainer width="100%" height="100%">
923
+ <PieChart>
924
+ <Pie
925
+ data={sourceChartData}
926
+ dataKey="total"
927
+ nameKey="name"
928
+ innerRadius={48}
929
+ outerRadius={78}
930
+ paddingAngle={2}
931
+ >
932
+ {sourceChartData.map((entry) => (
933
+ <Cell key={entry.name} fill={entry.fill} />
934
+ ))}
935
+ </Pie>
936
+ <Tooltip contentStyle={chartTooltipStyle} />
937
+ </PieChart>
938
+ </ResponsiveContainer>
939
+ ) : (
940
+ emptyChartState
941
+ )}
942
+ </div>
943
+ <div className="crm-report-source-list space-y-3">
944
+ {sourceChartData.map((item) => (
945
+ <div
946
+ key={item.name}
947
+ className="crm-report-source-list-item flex items-center justify-between gap-3 rounded-2xl border border-border/70 px-4 py-3"
948
+ >
949
+ <div className="flex min-w-0 items-center gap-3">
950
+ <span
951
+ className="inline-block size-3 rounded-full"
952
+ style={{ backgroundColor: item.fill }}
953
+ />
954
+ <span className="truncate font-medium">{item.name}</span>
955
+ </div>
956
+ <span className="shrink-0 text-sm text-muted-foreground">
957
+ {item.total}
958
+ </span>
959
+ </div>
960
+ ))}
961
+ </div>
962
+ </CardContent>
963
+ </Card>
964
+
965
+ <Card className="crm-report-section crm-report-section--stage">
966
+ <CardHeader>
967
+ <CardTitle>{t('charts.currentStage.title')}</CardTitle>
968
+ <CardDescription>
969
+ {t('charts.currentStage.description')}
970
+ </CardDescription>
971
+ </CardHeader>
972
+ <CardContent className="crm-report-chart-content h-[320px]">
973
+ {hasStageData ? (
974
+ <ResponsiveContainer width="100%" height="100%">
975
+ <BarChart
976
+ data={stageChartData}
977
+ margin={{ top: 8, right: 8, left: -20, bottom: 4 }}
978
+ >
979
+ <CartesianGrid
980
+ strokeDasharray="3 3"
981
+ stroke="hsl(var(--border))"
982
+ vertical={false}
983
+ />
984
+ <XAxis
985
+ dataKey="name"
986
+ tickLine={false}
987
+ axisLine={false}
988
+ fontSize={12}
989
+ />
990
+ <YAxis
991
+ tickLine={false}
992
+ axisLine={false}
993
+ fontSize={12}
994
+ allowDecimals={false}
995
+ />
996
+ <Tooltip contentStyle={chartTooltipStyle} />
997
+ <Bar dataKey="total" radius={[8, 8, 0, 0]}>
998
+ {stageChartData.map((entry) => (
999
+ <Cell key={entry.name} fill={entry.fill} />
1000
+ ))}
1001
+ </Bar>
1002
+ </BarChart>
1003
+ </ResponsiveContainer>
1004
+ ) : (
1005
+ emptyChartState
1006
+ )}
1007
+ </CardContent>
1008
+ </Card>
1009
+ </div>
1010
+
1011
+ <div className="crm-report-chart-grid grid gap-6 xl:grid-cols-2">
1012
+ <Card className="crm-report-section crm-report-section--owners">
1013
+ <CardHeader>
1014
+ <CardTitle>{t('charts.owners.title')}</CardTitle>
1015
+ <CardDescription>{t('charts.owners.description')}</CardDescription>
1016
+ </CardHeader>
1017
+ <CardContent className="crm-report-chart-content h-[320px]">
1018
+ {hasOwnersData ? (
1019
+ <ResponsiveContainer width="100%" height="100%">
1020
+ <BarChart
1021
+ data={ownersChartData}
1022
+ margin={{ top: 8, right: 8, left: -20, bottom: 4 }}
1023
+ >
1024
+ <CartesianGrid
1025
+ strokeDasharray="3 3"
1026
+ stroke="hsl(var(--border))"
1027
+ vertical={false}
1028
+ />
1029
+ <XAxis
1030
+ dataKey="owner_name"
1031
+ tickLine={false}
1032
+ axisLine={false}
1033
+ fontSize={12}
1034
+ />
1035
+ <YAxis
1036
+ tickLine={false}
1037
+ axisLine={false}
1038
+ fontSize={12}
1039
+ allowDecimals={false}
1040
+ />
1041
+ <Tooltip contentStyle={chartTooltipStyle} />
1042
+ <Legend />
1043
+ <Bar
1044
+ dataKey="interactions"
1045
+ name={t('kpis.interactions.title')}
1046
+ fill="#8b5cf6"
1047
+ radius={[8, 8, 0, 0]}
1048
+ />
1049
+ <Bar
1050
+ dataKey="followups_completed"
1051
+ name={t('kpis.followupsCompleted.title')}
1052
+ fill="#84cc16"
1053
+ radius={[8, 8, 0, 0]}
1054
+ />
1055
+ <Bar
1056
+ dataKey="customer_moves"
1057
+ name={t('kpis.customerMoves.title')}
1058
+ fill="#14b8a6"
1059
+ radius={[8, 8, 0, 0]}
1060
+ />
1061
+ </BarChart>
1062
+ </ResponsiveContainer>
1063
+ ) : (
1064
+ emptyChartState
1065
+ )}
1066
+ </CardContent>
1067
+ </Card>
1068
+
1069
+ <Card className="crm-report-section crm-report-section--activity">
1070
+ <CardHeader>
1071
+ <CardTitle>{t('charts.activityType.title')}</CardTitle>
1072
+ <CardDescription>
1073
+ {t('charts.activityType.description')}
1074
+ </CardDescription>
1075
+ </CardHeader>
1076
+ <CardContent className="crm-report-chart-content h-[320px]">
1077
+ {hasActivityTypeData ? (
1078
+ <ResponsiveContainer width="100%" height="100%">
1079
+ <BarChart
1080
+ data={activityTypeChartData}
1081
+ margin={{ top: 8, right: 8, left: -20, bottom: 4 }}
1082
+ >
1083
+ <CartesianGrid
1084
+ strokeDasharray="3 3"
1085
+ stroke="hsl(var(--border))"
1086
+ vertical={false}
1087
+ />
1088
+ <XAxis
1089
+ dataKey="name"
1090
+ tickLine={false}
1091
+ axisLine={false}
1092
+ fontSize={12}
1093
+ />
1094
+ <YAxis
1095
+ tickLine={false}
1096
+ axisLine={false}
1097
+ fontSize={12}
1098
+ allowDecimals={false}
1099
+ />
1100
+ <Tooltip contentStyle={chartTooltipStyle} />
1101
+ <Bar dataKey="total" radius={[8, 8, 0, 0]}>
1102
+ {activityTypeChartData.map((entry) => (
1103
+ <Cell key={entry.name} fill={entry.fill} />
1104
+ ))}
1105
+ </Bar>
1106
+ </BarChart>
1107
+ </ResponsiveContainer>
1108
+ ) : (
1109
+ emptyChartState
1110
+ )}
1111
+ </CardContent>
1112
+ </Card>
1113
+ </div>
1114
+
1115
+ <Card className="crm-report-section crm-report-table-card" data-kind="table">
1116
+ <CardHeader>
1117
+ <CardTitle>{t('table.title')}</CardTitle>
1118
+ <CardDescription>{t('table.description')}</CardDescription>
1119
+ </CardHeader>
1120
+ <CardContent>
1121
+ <Table>
1122
+ <TableHeader>
1123
+ <TableRow>
1124
+ <TableHead>{t('table.headers.period')}</TableHead>
1125
+ <TableHead className="text-right">
1126
+ {t('table.headers.newLeads')}
1127
+ </TableHead>
1128
+ <TableHead className="text-right">
1129
+ {t('table.headers.qualifiedMoves')}
1130
+ </TableHead>
1131
+ <TableHead className="text-right">
1132
+ {t('table.headers.customerMoves')}
1133
+ </TableHead>
1134
+ <TableHead className="text-right">
1135
+ {t('table.headers.lostMoves')}
1136
+ </TableHead>
1137
+ <TableHead className="text-right">
1138
+ {t('table.headers.interactions')}
1139
+ </TableHead>
1140
+ <TableHead className="text-right">
1141
+ {t('table.headers.followupsCompleted')}
1142
+ </TableHead>
1143
+ <TableHead className="text-right">
1144
+ {t('table.headers.conversionRate')}
1145
+ </TableHead>
1146
+ </TableRow>
1147
+ </TableHeader>
1148
+ <TableBody>
1149
+ {reports.table.length === 0 ? (
1150
+ <TableRow>
1151
+ <TableCell
1152
+ colSpan={8}
1153
+ className="text-center text-muted-foreground"
1154
+ >
1155
+ {t('common.noData')}
1156
+ </TableCell>
1157
+ </TableRow>
1158
+ ) : (
1159
+ reports.table.map((row) => (
1160
+ <TableRow key={row.period}>
1161
+ <TableCell>{row.label}</TableCell>
1162
+ <TableCell className="text-right">
1163
+ {row.new_leads}
1164
+ </TableCell>
1165
+ <TableCell className="text-right">
1166
+ {row.qualified_moves}
1167
+ </TableCell>
1168
+ <TableCell className="text-right">
1169
+ {row.customer_moves}
1170
+ </TableCell>
1171
+ <TableCell className="text-right">
1172
+ {row.lost_moves}
1173
+ </TableCell>
1174
+ <TableCell className="text-right">
1175
+ {row.interactions}
1176
+ </TableCell>
1177
+ <TableCell className="text-right">
1178
+ {row.followups_completed}
1179
+ </TableCell>
1180
+ <TableCell className="text-right font-medium">
1181
+ {percentFormatter.format(row.conversion_rate)}
1182
+ </TableCell>
1183
+ </TableRow>
1184
+ ))
1185
+ )}
1186
+ </TableBody>
1187
+ </Table>
1188
+ </CardContent>
1189
+ </Card>
1190
+ </>
1191
+ )}
1192
+ </div>
1193
+ </div>
1194
+ </Page>
1195
+ );
1196
+ }