@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.
- package/dist/person/dto/account.dto.d.ts +28 -0
- package/dist/person/dto/account.dto.d.ts.map +1 -0
- package/dist/person/dto/account.dto.js +123 -0
- package/dist/person/dto/account.dto.js.map +1 -0
- package/dist/person/dto/activity.dto.d.ts +15 -0
- package/dist/person/dto/activity.dto.d.ts.map +1 -0
- package/dist/person/dto/activity.dto.js +65 -0
- package/dist/person/dto/activity.dto.js.map +1 -0
- package/dist/person/dto/dashboard-query.dto.d.ts +9 -0
- package/dist/person/dto/dashboard-query.dto.d.ts.map +1 -0
- package/dist/person/dto/dashboard-query.dto.js +40 -0
- package/dist/person/dto/dashboard-query.dto.js.map +1 -0
- package/dist/person/dto/followup-query.dto.d.ts +10 -0
- package/dist/person/dto/followup-query.dto.d.ts.map +1 -0
- package/dist/person/dto/followup-query.dto.js +45 -0
- package/dist/person/dto/followup-query.dto.js.map +1 -0
- package/dist/person/person.controller.d.ts +204 -0
- package/dist/person/person.controller.d.ts.map +1 -1
- package/dist/person/person.controller.js +138 -0
- package/dist/person/person.controller.js.map +1 -1
- package/dist/person/person.service.d.ts +234 -0
- package/dist/person/person.service.d.ts.map +1 -1
- package/dist/person/person.service.js +1367 -0
- package/dist/person/person.service.js.map +1 -1
- package/hedhog/data/menu.yaml +163 -163
- package/hedhog/data/route.yaml +41 -0
- package/hedhog/frontend/app/accounts/_components/account-form-sheet.tsx.ejs +210 -114
- package/hedhog/frontend/app/accounts/_components/account-types.ts.ejs +3 -0
- package/hedhog/frontend/app/accounts/page.tsx.ejs +323 -245
- package/hedhog/frontend/app/activities/_components/activity-detail-sheet.tsx.ejs +240 -0
- package/hedhog/frontend/app/activities/_components/activity-types.ts.ejs +66 -0
- package/hedhog/frontend/app/activities/page.tsx.ejs +165 -517
- package/hedhog/frontend/app/dashboard/_components/dashboard-types.ts.ejs +70 -0
- package/hedhog/frontend/app/dashboard/page.tsx.ejs +504 -356
- package/hedhog/frontend/app/follow-ups/page.tsx.ejs +242 -153
- package/hedhog/frontend/messages/en.json +91 -6
- package/hedhog/frontend/messages/pt.json +91 -6
- package/hedhog/table/crm_activity.yaml +68 -0
- package/hedhog/table/person_company.yaml +22 -0
- package/package.json +4 -4
- package/src/person/dto/account.dto.ts +100 -0
- package/src/person/dto/activity.dto.ts +54 -0
- package/src/person/dto/dashboard-query.dto.ts +25 -0
- package/src/person/dto/followup-query.dto.ts +25 -0
- package/src/person/person.controller.ts +116 -0
- 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 {
|
|
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
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
)
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
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 =
|
|
106
|
-
name: t(`stageLabels.${
|
|
107
|
-
total:
|
|
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 =
|
|
112
|
-
name: t(`sourceLabels.${
|
|
113
|
-
total:
|
|
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
|
|
118
|
-
|
|
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:
|
|
152
|
-
{ key: 'qualified', value:
|
|
153
|
-
{ key: 'proposal', value:
|
|
154
|
-
{ key: 'customers', value:
|
|
155
|
-
{ key: 'lost', value:
|
|
156
|
-
{ key: 'unassigned', value:
|
|
157
|
-
{ key: 'overdue', value:
|
|
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:
|
|
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: () =>
|
|
315
|
+
onClick: () => {
|
|
316
|
+
void refetch();
|
|
317
|
+
},
|
|
202
318
|
variant: 'outline',
|
|
203
|
-
icon:
|
|
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
|
-
|
|
212
|
-
<
|
|
213
|
-
<
|
|
214
|
-
<
|
|
215
|
-
<
|
|
216
|
-
</
|
|
217
|
-
<
|
|
218
|
-
<
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
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
|
-
</
|
|
297
|
-
</
|
|
298
|
-
</
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
</
|
|
336
|
-
</
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
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
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
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
|
-
</
|
|
369
|
-
</
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
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
|
-
|
|
475
|
-
|
|
476
|
-
|
|
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
|
-
|
|
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
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
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
|
);
|