@hed-hog/contact 0.0.294 → 0.0.295

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 (46) 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/person.controller.d.ts +204 -0
  18. package/dist/person/person.controller.d.ts.map +1 -1
  19. package/dist/person/person.controller.js +138 -0
  20. package/dist/person/person.controller.js.map +1 -1
  21. package/dist/person/person.service.d.ts +234 -0
  22. package/dist/person/person.service.d.ts.map +1 -1
  23. package/dist/person/person.service.js +1367 -0
  24. package/dist/person/person.service.js.map +1 -1
  25. package/hedhog/data/menu.yaml +163 -163
  26. package/hedhog/data/route.yaml +41 -0
  27. package/hedhog/frontend/app/accounts/_components/account-form-sheet.tsx.ejs +210 -114
  28. package/hedhog/frontend/app/accounts/_components/account-types.ts.ejs +3 -0
  29. package/hedhog/frontend/app/accounts/page.tsx.ejs +323 -245
  30. package/hedhog/frontend/app/activities/_components/activity-detail-sheet.tsx.ejs +240 -0
  31. package/hedhog/frontend/app/activities/_components/activity-types.ts.ejs +66 -0
  32. package/hedhog/frontend/app/activities/page.tsx.ejs +165 -517
  33. package/hedhog/frontend/app/dashboard/_components/dashboard-types.ts.ejs +70 -0
  34. package/hedhog/frontend/app/dashboard/page.tsx.ejs +504 -356
  35. package/hedhog/frontend/app/follow-ups/page.tsx.ejs +242 -153
  36. package/hedhog/frontend/messages/en.json +91 -6
  37. package/hedhog/frontend/messages/pt.json +91 -6
  38. package/hedhog/table/crm_activity.yaml +68 -0
  39. package/hedhog/table/person_company.yaml +22 -0
  40. package/package.json +4 -4
  41. package/src/person/dto/account.dto.ts +100 -0
  42. package/src/person/dto/activity.dto.ts +54 -0
  43. package/src/person/dto/dashboard-query.dto.ts +25 -0
  44. package/src/person/dto/followup-query.dto.ts +25 -0
  45. package/src/person/person.controller.ts +116 -0
  46. package/src/person/person.service.ts +2139 -77
@@ -9,9 +9,19 @@ import {
9
9
  CardHeader,
10
10
  CardTitle,
11
11
  } from '@/components/ui/card';
12
+ import { Input } from '@/components/ui/input';
12
13
  import { KpiCardsGrid } from '@/components/ui/kpi-cards-grid';
13
14
  import { ScrollArea } from '@/components/ui/scroll-area';
14
- import { useApp } from '@hed-hog/next-app-provider';
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';
15
25
  import {
16
26
  AlertTriangle,
17
27
  ArrowUpRight,
@@ -19,6 +29,7 @@ import {
19
29
  CalendarClock,
20
30
  ChartNoAxesCombined,
21
31
  CircleDollarSign,
32
+ Loader2,
22
33
  RefreshCcw,
23
34
  Target,
24
35
  TrendingUp,
@@ -26,6 +37,7 @@ import {
26
37
  } from 'lucide-react';
27
38
  import { useTranslations } from 'next-intl';
28
39
  import Link from 'next/link';
40
+ import { useState } from 'react';
29
41
  import {
30
42
  Bar,
31
43
  BarChart,
@@ -38,13 +50,14 @@ import {
38
50
  XAxis,
39
51
  YAxis,
40
52
  } from 'recharts';
41
- import {
42
- crmMockLeads,
43
- crmOwners,
44
- crmSourceOrder,
45
- crmStageOrder,
46
- } from '../_lib/crm-mocks';
47
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';
48
61
 
49
62
  const chartPalette = [
50
63
  '#f97316',
@@ -66,98 +79,138 @@ const chartTooltipStyle = {
66
79
  const fallbackKpiAccentClass =
67
80
  'from-orange-500/20 via-amber-500/10 to-transparent';
68
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
+
69
125
  export default function CrmDashboardPage() {
70
126
  const t = useTranslations('contact.CrmDashboard');
71
127
  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
- );
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
+ });
104
186
 
105
- const stageChartData = crmStageOrder.map((stage, index) => ({
106
- name: t(`stageLabels.${stage}`),
107
- total: crmMockLeads.filter((lead) => lead.lifecycle_stage === stage).length,
187
+ const stageChartData = dashboard.charts.stage.map((item, index) => ({
188
+ name: t(`stageLabels.${item.key}`),
189
+ total: item.total,
108
190
  fill: chartPalette[index % chartPalette.length],
109
191
  }));
110
192
 
111
- const sourceChartData = crmSourceOrder.map((source, index) => ({
112
- name: t(`sourceLabels.${source}`),
113
- total: crmMockLeads.filter((lead) => lead.source === source).length,
193
+ const sourceChartData = dashboard.charts.source.map((item, index) => ({
194
+ name: t(`sourceLabels.${item.key}`),
195
+ total: item.total,
114
196
  fill: chartPalette[index % chartPalette.length],
115
197
  }));
116
198
 
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)
199
+ const topOwners = [...dashboard.charts.owner_performance]
200
+ .sort((left, right) => right.pipeline_value - left.pipeline_value)
148
201
  .slice(0, 4);
149
202
 
150
203
  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 },
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 },
158
211
  {
159
212
  key: 'nextActions',
160
- value: nextActions.length,
213
+ value: dashboard.kpis.next_actions,
161
214
  icon: ChartNoAxesCombined,
162
215
  },
163
216
  ].map((item, index) => {
@@ -186,6 +239,66 @@ export default function CrmDashboardPage() {
186
239
  }
187
240
  );
188
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
+
189
302
  return (
190
303
  <Page>
191
304
  <PageHeader
@@ -195,12 +308,19 @@ export default function CrmDashboardPage() {
195
308
  { label: t('breadcrumbs.home'), href: '/' },
196
309
  { label: t('breadcrumbs.crm') },
197
310
  ]}
311
+ extraContent={filters}
198
312
  actions={[
199
313
  {
200
314
  label: t('refresh'),
201
- onClick: () => window.location.reload(),
315
+ onClick: () => {
316
+ void refetch();
317
+ },
202
318
  variant: 'outline',
203
- icon: <RefreshCcw className="size-4" />,
319
+ icon: isFetching ? (
320
+ <Loader2 className="size-4 animate-spin" />
321
+ ) : (
322
+ <RefreshCcw className="size-4" />
323
+ ),
204
324
  },
205
325
  ]}
206
326
  />
@@ -208,283 +328,311 @@ export default function CrmDashboardPage() {
208
328
  <div className="min-w-0 space-y-6 overflow-x-hidden">
209
329
  <KpiCardsGrid items={kpiCards} />
210
330
 
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} />
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>
269
429
  ))}
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
430
  </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
- })}
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')}
361
484
  </p>
362
- </div>
363
- <span className="shrink-0 text-sm font-medium text-foreground">
364
- {currencyFormatter.format(owner.pipeline)}
365
- </span>
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
+ )}
366
508
  </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`)}
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')}
473
525
  </p>
474
- <p className="truncate text-xs text-muted-foreground">
475
- {menuT(
476
- `sections.${section.translationKey}.description`
477
- )}
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')}
478
572
  </p>
479
- </div>
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
+ )}
480
592
  </div>
481
- <ArrowUpRight className="size-4 shrink-0 text-muted-foreground" />
482
- </Link>
483
- );
484
- })}
485
- </CardContent>
486
- </Card>
487
- </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
+ )}
488
636
  </div>
489
637
  </Page>
490
638
  );