@hed-hog/contact 0.0.285 → 0.0.286
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/contact.service.d.ts +2 -148
- package/dist/contact.service.d.ts.map +1 -1
- package/hedhog/frontend/app/_lib/crm-sections.tsx.ejs +2 -2
- package/hedhog/frontend/app/accounts/page.tsx.ejs +24 -18
- package/hedhog/frontend/app/activities/page.tsx.ejs +805 -8
- package/hedhog/frontend/app/dashboard/page.tsx.ejs +32 -47
- package/hedhog/frontend/app/follow-ups/page.tsx.ejs +689 -8
- package/hedhog/frontend/app/person/page.tsx.ejs +18 -14
- package/hedhog/frontend/app/pipeline/page.tsx.ejs +37 -63
- package/hedhog/frontend/messages/en.json +161 -0
- package/hedhog/frontend/messages/pt.json +127 -0
- package/package.json +6 -6
- package/src/contact.service.ts +2 -2
|
@@ -1,15 +1,812 @@
|
|
|
1
1
|
'use client';
|
|
2
2
|
|
|
3
|
-
import {
|
|
4
|
-
|
|
3
|
+
import {
|
|
4
|
+
EmptyState,
|
|
5
|
+
Page,
|
|
6
|
+
PageHeader,
|
|
7
|
+
PaginationFooter,
|
|
8
|
+
SearchBar,
|
|
9
|
+
type SearchBarControl,
|
|
10
|
+
} from '@/components/entity-list';
|
|
11
|
+
import { Badge } from '@/components/ui/badge';
|
|
12
|
+
import { Button } from '@/components/ui/button';
|
|
13
|
+
import { Card, CardContent } from '@/components/ui/card';
|
|
14
|
+
import { KpiCardsGrid } from '@/components/ui/kpi-cards-grid';
|
|
15
|
+
import {
|
|
16
|
+
Table,
|
|
17
|
+
TableBody,
|
|
18
|
+
TableCell,
|
|
19
|
+
TableHead,
|
|
20
|
+
TableHeader,
|
|
21
|
+
TableRow,
|
|
22
|
+
} from '@/components/ui/table';
|
|
23
|
+
import { ToggleGroup, ToggleGroupItem } from '@/components/ui/toggle-group';
|
|
24
|
+
import { formatDateTime } from '@/lib/format-date';
|
|
25
|
+
import { cn } from '@/lib/utils';
|
|
26
|
+
import { useApp } from '@hed-hog/next-app-provider';
|
|
27
|
+
import {
|
|
28
|
+
Activity,
|
|
29
|
+
CalendarCheck,
|
|
30
|
+
CalendarClock,
|
|
31
|
+
CheckCircle2,
|
|
32
|
+
CircleAlert,
|
|
33
|
+
Clock3,
|
|
34
|
+
Eye,
|
|
35
|
+
LayoutGrid,
|
|
36
|
+
List,
|
|
37
|
+
Mail,
|
|
38
|
+
MessageCircle,
|
|
39
|
+
NotebookPen,
|
|
40
|
+
Phone,
|
|
41
|
+
User,
|
|
42
|
+
Users,
|
|
43
|
+
} from 'lucide-react';
|
|
44
|
+
import { useTranslations } from 'next-intl';
|
|
45
|
+
import { useEffect, useMemo, useState } from 'react';
|
|
46
|
+
import { toast } from 'sonner';
|
|
47
|
+
import { crmMockLeads, crmOwners } from '../_lib/crm-mocks';
|
|
48
|
+
|
|
49
|
+
type ActivityType = 'call' | 'email' | 'meeting' | 'whatsapp' | 'task' | 'note';
|
|
50
|
+
type ActivityPriority = 'high' | 'medium' | 'low';
|
|
51
|
+
type ActivityStatus = 'pending' | 'overdue' | 'completed';
|
|
52
|
+
type ActivityViewMode = 'table' | 'timeline';
|
|
53
|
+
|
|
54
|
+
const ACTIVITIES_VIEW_STORAGE_KEY = 'contact-activities-view-mode';
|
|
55
|
+
|
|
56
|
+
type CrmActivity = {
|
|
57
|
+
id: number;
|
|
58
|
+
personId: number;
|
|
59
|
+
ownerId: number | null;
|
|
60
|
+
type: ActivityType;
|
|
61
|
+
subject: string;
|
|
62
|
+
notes: string;
|
|
63
|
+
dueAt: string;
|
|
64
|
+
completedAt?: string | null;
|
|
65
|
+
createdAt: string;
|
|
66
|
+
priority: ActivityPriority;
|
|
67
|
+
};
|
|
68
|
+
|
|
69
|
+
function buildMockDate(offsetDays: number, hour: number, minute: number) {
|
|
70
|
+
const value = new Date();
|
|
71
|
+
value.setSeconds(0, 0);
|
|
72
|
+
value.setDate(value.getDate() + offsetDays);
|
|
73
|
+
value.setHours(hour, minute, 0, 0);
|
|
74
|
+
return value.toISOString();
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function createMockActivities(): CrmActivity[] {
|
|
78
|
+
const leads = crmMockLeads.slice(0, 10);
|
|
79
|
+
|
|
80
|
+
return [
|
|
81
|
+
{
|
|
82
|
+
id: 9001,
|
|
83
|
+
personId: leads[0]?.id ?? 101,
|
|
84
|
+
ownerId: 1,
|
|
85
|
+
type: 'call',
|
|
86
|
+
subject: 'Follow-up da proposta comercial',
|
|
87
|
+
notes: 'Validar decisão até o final da tarde.',
|
|
88
|
+
dueAt: buildMockDate(-1, 10, 30),
|
|
89
|
+
createdAt: buildMockDate(-3, 8, 45),
|
|
90
|
+
priority: 'high',
|
|
91
|
+
},
|
|
92
|
+
{
|
|
93
|
+
id: 9002,
|
|
94
|
+
personId: leads[1]?.id ?? 102,
|
|
95
|
+
ownerId: 2,
|
|
96
|
+
type: 'email',
|
|
97
|
+
subject: 'Enviar comparativo de planos',
|
|
98
|
+
notes: 'Anexar proposta enterprise com SLA.',
|
|
99
|
+
dueAt: buildMockDate(0, 14, 0),
|
|
100
|
+
createdAt: buildMockDate(-2, 9, 15),
|
|
101
|
+
priority: 'medium',
|
|
102
|
+
},
|
|
103
|
+
{
|
|
104
|
+
id: 9003,
|
|
105
|
+
personId: leads[2]?.id ?? 103,
|
|
106
|
+
ownerId: 1,
|
|
107
|
+
type: 'whatsapp',
|
|
108
|
+
subject: 'Confirmar data da demo',
|
|
109
|
+
notes: 'Cliente pediu nova janela para sexta.',
|
|
110
|
+
dueAt: buildMockDate(1, 11, 0),
|
|
111
|
+
createdAt: buildMockDate(-1, 16, 10),
|
|
112
|
+
priority: 'low',
|
|
113
|
+
},
|
|
114
|
+
{
|
|
115
|
+
id: 9004,
|
|
116
|
+
personId: leads[3]?.id ?? 104,
|
|
117
|
+
ownerId: 3,
|
|
118
|
+
type: 'meeting',
|
|
119
|
+
subject: 'Reunião de alinhamento técnico',
|
|
120
|
+
notes: 'Levar material de integração.',
|
|
121
|
+
dueAt: buildMockDate(2, 15, 30),
|
|
122
|
+
createdAt: buildMockDate(-4, 10, 0),
|
|
123
|
+
priority: 'high',
|
|
124
|
+
},
|
|
125
|
+
{
|
|
126
|
+
id: 9005,
|
|
127
|
+
personId: leads[4]?.id ?? 105,
|
|
128
|
+
ownerId: 4,
|
|
129
|
+
type: 'task',
|
|
130
|
+
subject: 'Atualizar CRM com objeções de preço',
|
|
131
|
+
notes: 'Tag de sensibilidade ao valor.',
|
|
132
|
+
dueAt: buildMockDate(-2, 17, 0),
|
|
133
|
+
createdAt: buildMockDate(-5, 8, 30),
|
|
134
|
+
priority: 'medium',
|
|
135
|
+
},
|
|
136
|
+
{
|
|
137
|
+
id: 9006,
|
|
138
|
+
personId: leads[5]?.id ?? 106,
|
|
139
|
+
ownerId: 2,
|
|
140
|
+
type: 'note',
|
|
141
|
+
subject: 'Resumo do comitê de compra',
|
|
142
|
+
notes: 'Decisão prevista para próxima semana.',
|
|
143
|
+
dueAt: buildMockDate(3, 9, 45),
|
|
144
|
+
createdAt: buildMockDate(-1, 11, 50),
|
|
145
|
+
priority: 'low',
|
|
146
|
+
},
|
|
147
|
+
{
|
|
148
|
+
id: 9007,
|
|
149
|
+
personId: leads[6]?.id ?? 107,
|
|
150
|
+
ownerId: 3,
|
|
151
|
+
type: 'call',
|
|
152
|
+
subject: 'Reengajar oportunidade inativa',
|
|
153
|
+
notes: 'Sondar timing para reabertura.',
|
|
154
|
+
dueAt: buildMockDate(0, 16, 10),
|
|
155
|
+
createdAt: buildMockDate(-2, 13, 25),
|
|
156
|
+
completedAt: buildMockDate(0, 16, 40),
|
|
157
|
+
priority: 'low',
|
|
158
|
+
},
|
|
159
|
+
{
|
|
160
|
+
id: 9008,
|
|
161
|
+
personId: leads[7]?.id ?? 108,
|
|
162
|
+
ownerId: null,
|
|
163
|
+
type: 'email',
|
|
164
|
+
subject: 'Enviar material de onboarding',
|
|
165
|
+
notes: 'Sem owner definido, fila do SDR.',
|
|
166
|
+
dueAt: buildMockDate(1, 10, 0),
|
|
167
|
+
createdAt: buildMockDate(-1, 9, 0),
|
|
168
|
+
priority: 'high',
|
|
169
|
+
},
|
|
170
|
+
{
|
|
171
|
+
id: 9009,
|
|
172
|
+
personId: leads[8]?.id ?? 109,
|
|
173
|
+
ownerId: 4,
|
|
174
|
+
type: 'meeting',
|
|
175
|
+
subject: 'Sessão de revisão contratual',
|
|
176
|
+
notes: 'Ajustar cláusulas de prazo.',
|
|
177
|
+
dueAt: buildMockDate(-1, 12, 0),
|
|
178
|
+
createdAt: buildMockDate(-3, 15, 10),
|
|
179
|
+
completedAt: buildMockDate(-1, 12, 50),
|
|
180
|
+
priority: 'high',
|
|
181
|
+
},
|
|
182
|
+
{
|
|
183
|
+
id: 9010,
|
|
184
|
+
personId: leads[9]?.id ?? 110,
|
|
185
|
+
ownerId: 1,
|
|
186
|
+
type: 'task',
|
|
187
|
+
subject: 'Registrar decisão de comitê',
|
|
188
|
+
notes: 'Atualizar etapa no pipeline após retorno.',
|
|
189
|
+
dueAt: buildMockDate(4, 14, 15),
|
|
190
|
+
createdAt: buildMockDate(-1, 10, 20),
|
|
191
|
+
priority: 'medium',
|
|
192
|
+
},
|
|
193
|
+
];
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
function resolveActivityStatus(activity: CrmActivity): ActivityStatus {
|
|
197
|
+
if (activity.completedAt) {
|
|
198
|
+
return 'completed';
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
return new Date(activity.dueAt) < new Date() ? 'overdue' : 'pending';
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
function getTypeIcon(type: ActivityType) {
|
|
205
|
+
if (type === 'call') return Phone;
|
|
206
|
+
if (type === 'email') return Mail;
|
|
207
|
+
if (type === 'meeting') return Users;
|
|
208
|
+
if (type === 'whatsapp') return MessageCircle;
|
|
209
|
+
if (type === 'note') return NotebookPen;
|
|
210
|
+
return Activity;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
function statusBadgeClass(status: ActivityStatus) {
|
|
214
|
+
if (status === 'completed') {
|
|
215
|
+
return 'border-emerald-500/25 bg-emerald-500/10 text-emerald-700';
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
if (status === 'overdue') {
|
|
219
|
+
return 'border-red-500/25 bg-red-500/10 text-red-700';
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
return 'border-amber-500/25 bg-amber-500/10 text-amber-700';
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
function priorityBadgeClass(priority: ActivityPriority) {
|
|
226
|
+
if (priority === 'high') {
|
|
227
|
+
return 'border-red-500/25 bg-red-500/10 text-red-700';
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
if (priority === 'medium') {
|
|
231
|
+
return 'border-sky-500/25 bg-sky-500/10 text-sky-700';
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
return 'border-slate-500/25 bg-slate-500/10 text-slate-700';
|
|
235
|
+
}
|
|
5
236
|
|
|
6
237
|
export default function CrmActivitiesPage() {
|
|
238
|
+
const t = useTranslations('contact.CrmActivities');
|
|
239
|
+
const crmT = useTranslations('contact.CrmMenu');
|
|
240
|
+
const { currentLocaleCode, getSettingValue } = useApp();
|
|
241
|
+
|
|
242
|
+
const [activities, setActivities] = useState<CrmActivity[]>(() =>
|
|
243
|
+
createMockActivities()
|
|
244
|
+
);
|
|
245
|
+
const [searchInput, setSearchInput] = useState('');
|
|
246
|
+
const [debouncedSearch, setDebouncedSearch] = useState('');
|
|
247
|
+
const [statusFilter, setStatusFilter] = useState('all');
|
|
248
|
+
const [typeFilter, setTypeFilter] = useState('all');
|
|
249
|
+
const [priorityFilter, setPriorityFilter] = useState('all');
|
|
250
|
+
const [page, setPage] = useState(1);
|
|
251
|
+
const [pageSize, setPageSize] = useState(12);
|
|
252
|
+
const [viewMode, setViewMode] = useState<ActivityViewMode>('table');
|
|
253
|
+
|
|
254
|
+
useEffect(() => {
|
|
255
|
+
const timeout = setTimeout(() => {
|
|
256
|
+
setDebouncedSearch(searchInput.trim().toLowerCase());
|
|
257
|
+
}, 250);
|
|
258
|
+
|
|
259
|
+
return () => clearTimeout(timeout);
|
|
260
|
+
}, [searchInput]);
|
|
261
|
+
|
|
262
|
+
useEffect(() => {
|
|
263
|
+
try {
|
|
264
|
+
const saved = window.localStorage.getItem(ACTIVITIES_VIEW_STORAGE_KEY);
|
|
265
|
+
if (saved === 'table' || saved === 'timeline') {
|
|
266
|
+
setViewMode(saved);
|
|
267
|
+
}
|
|
268
|
+
} catch {
|
|
269
|
+
// Ignore storage read failures.
|
|
270
|
+
}
|
|
271
|
+
}, []);
|
|
272
|
+
|
|
273
|
+
const enrichedActivities = useMemo(() => {
|
|
274
|
+
return activities
|
|
275
|
+
.map((activity) => {
|
|
276
|
+
const lead = crmMockLeads.find((item) => item.id === activity.personId);
|
|
277
|
+
const owner = crmOwners.find((item) => item.id === activity.ownerId);
|
|
278
|
+
|
|
279
|
+
return {
|
|
280
|
+
...activity,
|
|
281
|
+
personName: lead?.name || t('unassignedPerson'),
|
|
282
|
+
ownerName: owner?.name || t('unassignedOwner'),
|
|
283
|
+
status: resolveActivityStatus(activity),
|
|
284
|
+
};
|
|
285
|
+
})
|
|
286
|
+
.sort(
|
|
287
|
+
(left, right) =>
|
|
288
|
+
new Date(left.dueAt).getTime() - new Date(right.dueAt).getTime()
|
|
289
|
+
);
|
|
290
|
+
}, [activities, t]);
|
|
291
|
+
|
|
292
|
+
const filteredActivities = useMemo(() => {
|
|
293
|
+
return enrichedActivities.filter((activity) => {
|
|
294
|
+
if (statusFilter !== 'all' && activity.status !== statusFilter) {
|
|
295
|
+
return false;
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
if (typeFilter !== 'all' && activity.type !== typeFilter) {
|
|
299
|
+
return false;
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
if (priorityFilter !== 'all' && activity.priority !== priorityFilter) {
|
|
303
|
+
return false;
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
if (!debouncedSearch) {
|
|
307
|
+
return true;
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
const target = [
|
|
311
|
+
activity.subject,
|
|
312
|
+
activity.notes,
|
|
313
|
+
activity.personName,
|
|
314
|
+
activity.ownerName,
|
|
315
|
+
]
|
|
316
|
+
.join(' ')
|
|
317
|
+
.toLowerCase();
|
|
318
|
+
|
|
319
|
+
return target.includes(debouncedSearch);
|
|
320
|
+
});
|
|
321
|
+
}, [
|
|
322
|
+
enrichedActivities,
|
|
323
|
+
statusFilter,
|
|
324
|
+
typeFilter,
|
|
325
|
+
priorityFilter,
|
|
326
|
+
debouncedSearch,
|
|
327
|
+
]);
|
|
328
|
+
|
|
329
|
+
const totalPages = Math.max(
|
|
330
|
+
1,
|
|
331
|
+
Math.ceil(filteredActivities.length / pageSize)
|
|
332
|
+
);
|
|
333
|
+
|
|
334
|
+
useEffect(() => {
|
|
335
|
+
if (page > totalPages) {
|
|
336
|
+
setPage(totalPages);
|
|
337
|
+
}
|
|
338
|
+
}, [page, totalPages]);
|
|
339
|
+
|
|
340
|
+
const pageData = useMemo(() => {
|
|
341
|
+
const start = (page - 1) * pageSize;
|
|
342
|
+
return filteredActivities.slice(start, start + pageSize);
|
|
343
|
+
}, [filteredActivities, page, pageSize]);
|
|
344
|
+
|
|
345
|
+
const stats = useMemo(() => {
|
|
346
|
+
const total = enrichedActivities.length;
|
|
347
|
+
const pending = enrichedActivities.filter(
|
|
348
|
+
(item) => item.status === 'pending'
|
|
349
|
+
).length;
|
|
350
|
+
const overdue = enrichedActivities.filter(
|
|
351
|
+
(item) => item.status === 'overdue'
|
|
352
|
+
).length;
|
|
353
|
+
const completed = enrichedActivities.filter(
|
|
354
|
+
(item) => item.status === 'completed'
|
|
355
|
+
).length;
|
|
356
|
+
|
|
357
|
+
return { total, pending, overdue, completed };
|
|
358
|
+
}, [enrichedActivities]);
|
|
359
|
+
|
|
360
|
+
const statsCards = [
|
|
361
|
+
{
|
|
362
|
+
key: 'total',
|
|
363
|
+
title: t('stats.total'),
|
|
364
|
+
value: stats.total,
|
|
365
|
+
icon: Activity,
|
|
366
|
+
accentClassName: 'from-violet-500/20 via-fuchsia-500/10 to-transparent',
|
|
367
|
+
iconContainerClassName: 'bg-violet-500/10 text-violet-700',
|
|
368
|
+
},
|
|
369
|
+
{
|
|
370
|
+
key: 'pending',
|
|
371
|
+
title: t('stats.pending'),
|
|
372
|
+
value: stats.pending,
|
|
373
|
+
icon: Clock3,
|
|
374
|
+
accentClassName: 'from-amber-500/20 via-yellow-500/10 to-transparent',
|
|
375
|
+
iconContainerClassName: 'bg-amber-500/10 text-amber-700',
|
|
376
|
+
},
|
|
377
|
+
{
|
|
378
|
+
key: 'overdue',
|
|
379
|
+
title: t('stats.overdue'),
|
|
380
|
+
value: stats.overdue,
|
|
381
|
+
icon: CircleAlert,
|
|
382
|
+
accentClassName: 'from-red-500/20 via-rose-500/10 to-transparent',
|
|
383
|
+
iconContainerClassName: 'bg-red-500/10 text-red-700',
|
|
384
|
+
},
|
|
385
|
+
{
|
|
386
|
+
key: 'completed',
|
|
387
|
+
title: t('stats.completed'),
|
|
388
|
+
value: stats.completed,
|
|
389
|
+
icon: CalendarCheck,
|
|
390
|
+
accentClassName: 'from-emerald-500/20 via-green-500/10 to-transparent',
|
|
391
|
+
iconContainerClassName: 'bg-emerald-500/10 text-emerald-700',
|
|
392
|
+
},
|
|
393
|
+
];
|
|
394
|
+
|
|
395
|
+
const controls: SearchBarControl[] = [
|
|
396
|
+
{
|
|
397
|
+
id: 'activities-status-filter',
|
|
398
|
+
type: 'select',
|
|
399
|
+
value: statusFilter,
|
|
400
|
+
onChange: (value) => {
|
|
401
|
+
setStatusFilter(value);
|
|
402
|
+
setPage(1);
|
|
403
|
+
},
|
|
404
|
+
placeholder: t('filters.statusPlaceholder'),
|
|
405
|
+
options: [
|
|
406
|
+
{ value: 'all', label: t('filters.allStatuses') },
|
|
407
|
+
{ value: 'pending', label: t('status.pending') },
|
|
408
|
+
{ value: 'overdue', label: t('status.overdue') },
|
|
409
|
+
{ value: 'completed', label: t('status.completed') },
|
|
410
|
+
],
|
|
411
|
+
},
|
|
412
|
+
{
|
|
413
|
+
id: 'activities-type-filter',
|
|
414
|
+
type: 'select',
|
|
415
|
+
value: typeFilter,
|
|
416
|
+
onChange: (value) => {
|
|
417
|
+
setTypeFilter(value);
|
|
418
|
+
setPage(1);
|
|
419
|
+
},
|
|
420
|
+
placeholder: t('filters.typePlaceholder'),
|
|
421
|
+
options: [
|
|
422
|
+
{ value: 'all', label: t('filters.allTypes') },
|
|
423
|
+
{ value: 'call', label: t('type.call') },
|
|
424
|
+
{ value: 'email', label: t('type.email') },
|
|
425
|
+
{ value: 'meeting', label: t('type.meeting') },
|
|
426
|
+
{ value: 'whatsapp', label: t('type.whatsapp') },
|
|
427
|
+
{ value: 'task', label: t('type.task') },
|
|
428
|
+
{ value: 'note', label: t('type.note') },
|
|
429
|
+
],
|
|
430
|
+
},
|
|
431
|
+
{
|
|
432
|
+
id: 'activities-priority-filter',
|
|
433
|
+
type: 'select',
|
|
434
|
+
value: priorityFilter,
|
|
435
|
+
onChange: (value) => {
|
|
436
|
+
setPriorityFilter(value);
|
|
437
|
+
setPage(1);
|
|
438
|
+
},
|
|
439
|
+
placeholder: t('filters.priorityPlaceholder'),
|
|
440
|
+
options: [
|
|
441
|
+
{ value: 'all', label: t('filters.allPriorities') },
|
|
442
|
+
{ value: 'high', label: t('priority.high') },
|
|
443
|
+
{ value: 'medium', label: t('priority.medium') },
|
|
444
|
+
{ value: 'low', label: t('priority.low') },
|
|
445
|
+
],
|
|
446
|
+
},
|
|
447
|
+
];
|
|
448
|
+
|
|
449
|
+
const handleComplete = (activityId: number) => {
|
|
450
|
+
setActivities((previous) =>
|
|
451
|
+
previous.map((item) =>
|
|
452
|
+
item.id === activityId
|
|
453
|
+
? {
|
|
454
|
+
...item,
|
|
455
|
+
completedAt: new Date().toISOString(),
|
|
456
|
+
}
|
|
457
|
+
: item
|
|
458
|
+
)
|
|
459
|
+
);
|
|
460
|
+
|
|
461
|
+
toast.success(t('toasts.markedAsCompleted'));
|
|
462
|
+
};
|
|
463
|
+
|
|
464
|
+
const handleView = (subject: string) => {
|
|
465
|
+
toast.info(t('toasts.openDetails', { subject }));
|
|
466
|
+
};
|
|
467
|
+
|
|
468
|
+
const handleViewModeChange = (value: string) => {
|
|
469
|
+
if (value !== 'table' && value !== 'timeline') {
|
|
470
|
+
return;
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
setViewMode(value);
|
|
474
|
+
|
|
475
|
+
try {
|
|
476
|
+
window.localStorage.setItem(ACTIVITIES_VIEW_STORAGE_KEY, value);
|
|
477
|
+
} catch {
|
|
478
|
+
// Ignore storage write failures.
|
|
479
|
+
}
|
|
480
|
+
};
|
|
481
|
+
|
|
7
482
|
return (
|
|
8
|
-
<
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
483
|
+
<Page>
|
|
484
|
+
<PageHeader
|
|
485
|
+
breadcrumbs={[
|
|
486
|
+
{ label: 'Home', href: '/' },
|
|
487
|
+
{ label: crmT('breadcrumbs.crm'), href: '/contact/dashboard' },
|
|
488
|
+
{ label: t('title') },
|
|
489
|
+
]}
|
|
490
|
+
title={t('title')}
|
|
491
|
+
description={t('description')}
|
|
492
|
+
/>
|
|
493
|
+
|
|
494
|
+
<div className="space-y-6">
|
|
495
|
+
<KpiCardsGrid items={statsCards} />
|
|
496
|
+
|
|
497
|
+
<SearchBar
|
|
498
|
+
searchQuery={searchInput}
|
|
499
|
+
onSearchChange={(value) => {
|
|
500
|
+
setSearchInput(value);
|
|
501
|
+
setPage(1);
|
|
502
|
+
}}
|
|
503
|
+
onSearch={() => setPage(1)}
|
|
504
|
+
placeholder={t('filters.searchPlaceholder')}
|
|
505
|
+
controls={controls}
|
|
506
|
+
/>
|
|
507
|
+
|
|
508
|
+
<div className="flex items-center justify-end gap-3">
|
|
509
|
+
<span className="text-xs font-medium text-muted-foreground">
|
|
510
|
+
{t('viewMode')}
|
|
511
|
+
</span>
|
|
512
|
+
<ToggleGroup
|
|
513
|
+
type="single"
|
|
514
|
+
value={viewMode}
|
|
515
|
+
onValueChange={handleViewModeChange}
|
|
516
|
+
variant="outline"
|
|
517
|
+
size="sm"
|
|
518
|
+
aria-label={t('viewMode')}
|
|
519
|
+
>
|
|
520
|
+
<ToggleGroupItem
|
|
521
|
+
value="table"
|
|
522
|
+
className="gap-1.5 px-2.5"
|
|
523
|
+
aria-label={t('viewModeTable')}
|
|
524
|
+
>
|
|
525
|
+
<List className="h-4 w-4" />
|
|
526
|
+
<span className="hidden sm:inline">{t('viewModeTable')}</span>
|
|
527
|
+
</ToggleGroupItem>
|
|
528
|
+
<ToggleGroupItem
|
|
529
|
+
value="timeline"
|
|
530
|
+
className="gap-1.5 px-2.5"
|
|
531
|
+
aria-label={t('viewModeTimeline')}
|
|
532
|
+
>
|
|
533
|
+
<LayoutGrid className="h-4 w-4" />
|
|
534
|
+
<span className="hidden sm:inline">{t('viewModeTimeline')}</span>
|
|
535
|
+
</ToggleGroupItem>
|
|
536
|
+
</ToggleGroup>
|
|
537
|
+
</div>
|
|
538
|
+
|
|
539
|
+
{pageData.length === 0 ? (
|
|
540
|
+
<EmptyState
|
|
541
|
+
icon={<CalendarClock className="h-12 w-12" />}
|
|
542
|
+
title={t('empty.title')}
|
|
543
|
+
description={t('empty.description')}
|
|
544
|
+
actionLabel={t('empty.resetFilters')}
|
|
545
|
+
onAction={() => {
|
|
546
|
+
setSearchInput('');
|
|
547
|
+
setDebouncedSearch('');
|
|
548
|
+
setStatusFilter('all');
|
|
549
|
+
setTypeFilter('all');
|
|
550
|
+
setPriorityFilter('all');
|
|
551
|
+
setPage(1);
|
|
552
|
+
}}
|
|
553
|
+
/>
|
|
554
|
+
) : viewMode === 'table' ? (
|
|
555
|
+
<div className="overflow-x-auto rounded-md border">
|
|
556
|
+
<Table>
|
|
557
|
+
<TableHeader>
|
|
558
|
+
<TableRow>
|
|
559
|
+
<TableHead>{t('table.activity')}</TableHead>
|
|
560
|
+
<TableHead>{t('table.person')}</TableHead>
|
|
561
|
+
<TableHead>{t('table.owner')}</TableHead>
|
|
562
|
+
<TableHead>{t('table.dueAt')}</TableHead>
|
|
563
|
+
<TableHead>{t('table.status')}</TableHead>
|
|
564
|
+
<TableHead>{t('table.priority')}</TableHead>
|
|
565
|
+
<TableHead className="text-right">
|
|
566
|
+
{t('table.actions')}
|
|
567
|
+
</TableHead>
|
|
568
|
+
</TableRow>
|
|
569
|
+
</TableHeader>
|
|
570
|
+
<TableBody>
|
|
571
|
+
{pageData.map((item) => {
|
|
572
|
+
const TypeIcon = getTypeIcon(item.type);
|
|
573
|
+
const completed = item.status === 'completed';
|
|
574
|
+
|
|
575
|
+
return (
|
|
576
|
+
<TableRow key={item.id}>
|
|
577
|
+
<TableCell>
|
|
578
|
+
<div className="min-w-[260px] space-y-1">
|
|
579
|
+
<div className="flex items-center gap-2">
|
|
580
|
+
<TypeIcon className="h-4 w-4 text-muted-foreground" />
|
|
581
|
+
<p className="font-medium">{item.subject}</p>
|
|
582
|
+
</div>
|
|
583
|
+
<p className="line-clamp-2 text-xs text-muted-foreground">
|
|
584
|
+
{item.notes}
|
|
585
|
+
</p>
|
|
586
|
+
<div className="text-[11px] text-muted-foreground">
|
|
587
|
+
{t('table.createdAt')}:{' '}
|
|
588
|
+
{formatDateTime(
|
|
589
|
+
item.createdAt,
|
|
590
|
+
getSettingValue,
|
|
591
|
+
currentLocaleCode
|
|
592
|
+
)}
|
|
593
|
+
</div>
|
|
594
|
+
</div>
|
|
595
|
+
</TableCell>
|
|
596
|
+
<TableCell>
|
|
597
|
+
<div className="inline-flex items-center gap-2">
|
|
598
|
+
<Users className="h-3.5 w-3.5 text-muted-foreground" />
|
|
599
|
+
<span>{item.personName}</span>
|
|
600
|
+
</div>
|
|
601
|
+
</TableCell>
|
|
602
|
+
<TableCell>
|
|
603
|
+
<div className="inline-flex items-center gap-2">
|
|
604
|
+
<User className="h-3.5 w-3.5 text-muted-foreground" />
|
|
605
|
+
<span>{item.ownerName}</span>
|
|
606
|
+
</div>
|
|
607
|
+
</TableCell>
|
|
608
|
+
<TableCell>
|
|
609
|
+
{formatDateTime(
|
|
610
|
+
item.dueAt,
|
|
611
|
+
getSettingValue,
|
|
612
|
+
currentLocaleCode
|
|
613
|
+
)}
|
|
614
|
+
</TableCell>
|
|
615
|
+
<TableCell>
|
|
616
|
+
<Badge
|
|
617
|
+
variant="outline"
|
|
618
|
+
className={cn(
|
|
619
|
+
'border',
|
|
620
|
+
statusBadgeClass(item.status)
|
|
621
|
+
)}
|
|
622
|
+
>
|
|
623
|
+
{t(`status.${item.status}`)}
|
|
624
|
+
</Badge>
|
|
625
|
+
</TableCell>
|
|
626
|
+
<TableCell>
|
|
627
|
+
<Badge
|
|
628
|
+
variant="outline"
|
|
629
|
+
className={cn(
|
|
630
|
+
'border',
|
|
631
|
+
priorityBadgeClass(item.priority)
|
|
632
|
+
)}
|
|
633
|
+
>
|
|
634
|
+
{t(`priority.${item.priority}`)}
|
|
635
|
+
</Badge>
|
|
636
|
+
</TableCell>
|
|
637
|
+
<TableCell className="text-right">
|
|
638
|
+
<div className="inline-flex items-center gap-2">
|
|
639
|
+
<Button
|
|
640
|
+
type="button"
|
|
641
|
+
variant="outline"
|
|
642
|
+
size="sm"
|
|
643
|
+
onClick={() => handleView(item.subject)}
|
|
644
|
+
>
|
|
645
|
+
<Eye className="mr-2 h-3.5 w-3.5" />
|
|
646
|
+
{t('actions.view')}
|
|
647
|
+
</Button>
|
|
648
|
+
|
|
649
|
+
<Button
|
|
650
|
+
type="button"
|
|
651
|
+
variant="outline"
|
|
652
|
+
size="sm"
|
|
653
|
+
onClick={() => handleComplete(item.id)}
|
|
654
|
+
disabled={completed}
|
|
655
|
+
className={cn(
|
|
656
|
+
completed && 'cursor-not-allowed opacity-60'
|
|
657
|
+
)}
|
|
658
|
+
>
|
|
659
|
+
<CheckCircle2 className="mr-2 h-3.5 w-3.5" />
|
|
660
|
+
{t('actions.complete')}
|
|
661
|
+
</Button>
|
|
662
|
+
</div>
|
|
663
|
+
</TableCell>
|
|
664
|
+
</TableRow>
|
|
665
|
+
);
|
|
666
|
+
})}
|
|
667
|
+
</TableBody>
|
|
668
|
+
</Table>
|
|
669
|
+
</div>
|
|
670
|
+
) : (
|
|
671
|
+
<div className="space-y-4">
|
|
672
|
+
<div className="rounded-md border border-dashed border-border/70 bg-muted/20 px-4 py-3 text-sm text-muted-foreground">
|
|
673
|
+
{t('timeline.description')}
|
|
674
|
+
</div>
|
|
675
|
+
|
|
676
|
+
<div className="grid gap-3 md:grid-cols-2 xl:grid-cols-3">
|
|
677
|
+
{pageData.map((item) => {
|
|
678
|
+
const TypeIcon = getTypeIcon(item.type);
|
|
679
|
+
const completed = item.status === 'completed';
|
|
680
|
+
|
|
681
|
+
return (
|
|
682
|
+
<Card
|
|
683
|
+
key={item.id}
|
|
684
|
+
className="h-full overflow-hidden border-border/70 py-0"
|
|
685
|
+
>
|
|
686
|
+
<CardContent className="space-y-3 p-4">
|
|
687
|
+
<div className="flex items-start justify-between gap-3">
|
|
688
|
+
<div className="min-w-0 space-y-1">
|
|
689
|
+
<div className="flex items-center gap-2">
|
|
690
|
+
<TypeIcon className="h-4 w-4 text-muted-foreground" />
|
|
691
|
+
<p className="line-clamp-2 text-sm font-semibold">
|
|
692
|
+
{item.subject}
|
|
693
|
+
</p>
|
|
694
|
+
</div>
|
|
695
|
+
<p className="line-clamp-2 text-xs text-muted-foreground">
|
|
696
|
+
{item.notes}
|
|
697
|
+
</p>
|
|
698
|
+
</div>
|
|
699
|
+
<Badge
|
|
700
|
+
variant="outline"
|
|
701
|
+
className={cn(
|
|
702
|
+
'shrink-0 border',
|
|
703
|
+
statusBadgeClass(item.status)
|
|
704
|
+
)}
|
|
705
|
+
>
|
|
706
|
+
{t(`status.${item.status}`)}
|
|
707
|
+
</Badge>
|
|
708
|
+
</div>
|
|
709
|
+
|
|
710
|
+
<div className="grid gap-2 rounded-md border border-border/70 bg-background px-3 py-2 text-xs">
|
|
711
|
+
<div className="flex items-center justify-between gap-2">
|
|
712
|
+
<span className="text-muted-foreground">
|
|
713
|
+
{t('timeline.dueLabel')}
|
|
714
|
+
</span>
|
|
715
|
+
<span className="font-medium text-foreground">
|
|
716
|
+
{formatDateTime(
|
|
717
|
+
item.dueAt,
|
|
718
|
+
getSettingValue,
|
|
719
|
+
currentLocaleCode
|
|
720
|
+
)}
|
|
721
|
+
</span>
|
|
722
|
+
</div>
|
|
723
|
+
<div className="flex items-center justify-between gap-2">
|
|
724
|
+
<span className="text-muted-foreground">
|
|
725
|
+
{t('timeline.createdLabel')}
|
|
726
|
+
</span>
|
|
727
|
+
<span className="font-medium text-foreground">
|
|
728
|
+
{formatDateTime(
|
|
729
|
+
item.createdAt,
|
|
730
|
+
getSettingValue,
|
|
731
|
+
currentLocaleCode
|
|
732
|
+
)}
|
|
733
|
+
</span>
|
|
734
|
+
</div>
|
|
735
|
+
<div className="flex items-center justify-between gap-2">
|
|
736
|
+
<span className="text-muted-foreground">
|
|
737
|
+
{t('table.person')}
|
|
738
|
+
</span>
|
|
739
|
+
<span className="truncate font-medium text-foreground">
|
|
740
|
+
{item.personName}
|
|
741
|
+
</span>
|
|
742
|
+
</div>
|
|
743
|
+
<div className="flex items-center justify-between gap-2">
|
|
744
|
+
<span className="text-muted-foreground">
|
|
745
|
+
{t('table.owner')}
|
|
746
|
+
</span>
|
|
747
|
+
<span className="truncate font-medium text-foreground">
|
|
748
|
+
{item.ownerName}
|
|
749
|
+
</span>
|
|
750
|
+
</div>
|
|
751
|
+
</div>
|
|
752
|
+
|
|
753
|
+
<div className="flex items-center justify-between gap-2">
|
|
754
|
+
<Badge
|
|
755
|
+
variant="outline"
|
|
756
|
+
className={cn(
|
|
757
|
+
'border',
|
|
758
|
+
priorityBadgeClass(item.priority)
|
|
759
|
+
)}
|
|
760
|
+
>
|
|
761
|
+
{t(`priority.${item.priority}`)}
|
|
762
|
+
</Badge>
|
|
763
|
+
|
|
764
|
+
<div className="inline-flex items-center gap-2">
|
|
765
|
+
<Button
|
|
766
|
+
type="button"
|
|
767
|
+
variant="outline"
|
|
768
|
+
size="sm"
|
|
769
|
+
onClick={() => handleView(item.subject)}
|
|
770
|
+
>
|
|
771
|
+
<Eye className="mr-2 h-3.5 w-3.5" />
|
|
772
|
+
{t('actions.view')}
|
|
773
|
+
</Button>
|
|
774
|
+
<Button
|
|
775
|
+
type="button"
|
|
776
|
+
variant="outline"
|
|
777
|
+
size="sm"
|
|
778
|
+
onClick={() => handleComplete(item.id)}
|
|
779
|
+
disabled={completed}
|
|
780
|
+
className={cn(
|
|
781
|
+
completed && 'cursor-not-allowed opacity-60'
|
|
782
|
+
)}
|
|
783
|
+
>
|
|
784
|
+
<CheckCircle2 className="mr-2 h-3.5 w-3.5" />
|
|
785
|
+
{t('actions.complete')}
|
|
786
|
+
</Button>
|
|
787
|
+
</div>
|
|
788
|
+
</div>
|
|
789
|
+
</CardContent>
|
|
790
|
+
</Card>
|
|
791
|
+
);
|
|
792
|
+
})}
|
|
793
|
+
</div>
|
|
794
|
+
</div>
|
|
795
|
+
)}
|
|
796
|
+
|
|
797
|
+
<div className="border-t p-4">
|
|
798
|
+
<PaginationFooter
|
|
799
|
+
currentPage={page}
|
|
800
|
+
pageSize={pageSize}
|
|
801
|
+
totalItems={filteredActivities.length}
|
|
802
|
+
onPageChange={setPage}
|
|
803
|
+
onPageSizeChange={(nextPageSize) => {
|
|
804
|
+
setPageSize(nextPageSize);
|
|
805
|
+
setPage(1);
|
|
806
|
+
}}
|
|
807
|
+
/>
|
|
808
|
+
</div>
|
|
809
|
+
</div>
|
|
810
|
+
</Page>
|
|
14
811
|
);
|
|
15
812
|
}
|