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