@hed-hog/contact 0.0.294 → 0.0.296

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (57) hide show
  1. package/dist/person/dto/account.dto.d.ts +28 -0
  2. package/dist/person/dto/account.dto.d.ts.map +1 -0
  3. package/dist/person/dto/account.dto.js +123 -0
  4. package/dist/person/dto/account.dto.js.map +1 -0
  5. package/dist/person/dto/activity.dto.d.ts +15 -0
  6. package/dist/person/dto/activity.dto.d.ts.map +1 -0
  7. package/dist/person/dto/activity.dto.js +65 -0
  8. package/dist/person/dto/activity.dto.js.map +1 -0
  9. package/dist/person/dto/dashboard-query.dto.d.ts +9 -0
  10. package/dist/person/dto/dashboard-query.dto.d.ts.map +1 -0
  11. package/dist/person/dto/dashboard-query.dto.js +40 -0
  12. package/dist/person/dto/dashboard-query.dto.js.map +1 -0
  13. package/dist/person/dto/followup-query.dto.d.ts +10 -0
  14. package/dist/person/dto/followup-query.dto.d.ts.map +1 -0
  15. package/dist/person/dto/followup-query.dto.js +45 -0
  16. package/dist/person/dto/followup-query.dto.js.map +1 -0
  17. package/dist/person/dto/reports-query.dto.d.ts +8 -0
  18. package/dist/person/dto/reports-query.dto.d.ts.map +1 -0
  19. package/dist/person/dto/reports-query.dto.js +33 -0
  20. package/dist/person/dto/reports-query.dto.js.map +1 -0
  21. package/dist/person/person.controller.d.ts +266 -5
  22. package/dist/person/person.controller.d.ts.map +1 -1
  23. package/dist/person/person.controller.js +164 -6
  24. package/dist/person/person.controller.js.map +1 -1
  25. package/dist/person/person.service.d.ts +295 -5
  26. package/dist/person/person.service.d.ts.map +1 -1
  27. package/dist/person/person.service.js +1752 -27
  28. package/dist/person/person.service.js.map +1 -1
  29. package/dist/person-relation-type/person-relation-type.controller.d.ts +2 -2
  30. package/dist/person-relation-type/person-relation-type.service.d.ts +2 -2
  31. package/hedhog/data/route.yaml +68 -19
  32. package/hedhog/frontend/app/_lib/crm-sections.tsx.ejs +9 -9
  33. package/hedhog/frontend/app/accounts/_components/account-form-sheet.tsx.ejs +573 -477
  34. package/hedhog/frontend/app/accounts/_components/account-types.ts.ejs +9 -6
  35. package/hedhog/frontend/app/accounts/page.tsx.ejs +970 -892
  36. package/hedhog/frontend/app/activities/_components/activity-detail-sheet.tsx.ejs +240 -0
  37. package/hedhog/frontend/app/activities/_components/activity-types.ts.ejs +66 -0
  38. package/hedhog/frontend/app/activities/page.tsx.ejs +460 -812
  39. package/hedhog/frontend/app/dashboard/_components/dashboard-types.ts.ejs +70 -0
  40. package/hedhog/frontend/app/dashboard/page.tsx.ejs +639 -491
  41. package/hedhog/frontend/app/follow-ups/page.tsx.ejs +785 -696
  42. package/hedhog/frontend/app/person/_components/person-interaction-dialog.tsx.ejs +10 -12
  43. package/hedhog/frontend/app/reports/_components/report-types.ts.ejs +84 -0
  44. package/hedhog/frontend/app/reports/page.tsx.ejs +1196 -15
  45. package/hedhog/frontend/messages/en.json +242 -38
  46. package/hedhog/frontend/messages/pt.json +242 -38
  47. package/hedhog/table/crm_activity.yaml +68 -0
  48. package/hedhog/table/crm_stage_history.yaml +34 -0
  49. package/hedhog/table/person_company.yaml +27 -5
  50. package/package.json +9 -9
  51. package/src/person/dto/account.dto.ts +100 -0
  52. package/src/person/dto/activity.dto.ts +54 -0
  53. package/src/person/dto/dashboard-query.dto.ts +25 -0
  54. package/src/person/dto/followup-query.dto.ts +25 -0
  55. package/src/person/dto/reports-query.dto.ts +25 -0
  56. package/src/person/person.controller.ts +176 -43
  57. package/src/person/person.service.ts +4825 -2226
@@ -1,812 +1,460 @@
1
- 'use client';
2
-
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
- }
236
-
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
-
482
- return (
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>
811
- );
812
- }
1
+ 'use client';
2
+
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 { Skeleton } from '@/components/ui/skeleton';
16
+ import {
17
+ Table,
18
+ TableBody,
19
+ TableCell,
20
+ TableHead,
21
+ TableHeader,
22
+ TableRow,
23
+ } from '@/components/ui/table';
24
+ import { ToggleGroup, ToggleGroupItem } from '@/components/ui/toggle-group';
25
+ import { formatDateTime } from '@/lib/format-date';
26
+ import { cn } from '@/lib/utils';
27
+ import { useApp, useQuery } from '@hed-hog/next-app-provider';
28
+ import {
29
+ Activity,
30
+ CalendarCheck,
31
+ CalendarClock,
32
+ CheckCircle2,
33
+ CircleAlert,
34
+ Clock3,
35
+ Eye,
36
+ LayoutGrid,
37
+ List,
38
+ Loader2,
39
+ Mail,
40
+ MessageCircle,
41
+ NotebookPen,
42
+ Phone,
43
+ User,
44
+ Users,
45
+ } from 'lucide-react';
46
+ import { useTranslations } from 'next-intl';
47
+ import { useEffect, useState } from 'react';
48
+ import { toast } from 'sonner';
49
+ import { ActivityDetailSheet } from './_components/activity-detail-sheet';
50
+ import type {
51
+ ActivityListItem,
52
+ ActivityPriority,
53
+ ActivityStats,
54
+ ActivityStatus,
55
+ ActivityType,
56
+ ActivityViewMode,
57
+ PaginatedResult,
58
+ } from './_components/activity-types';
59
+
60
+ const ACTIVITIES_VIEW_STORAGE_KEY = 'contact-activities-view-mode';
61
+
62
+ function getTypeIcon(type: ActivityType) {
63
+ if (type === 'call') return Phone;
64
+ if (type === 'email') return Mail;
65
+ if (type === 'meeting') return Users;
66
+ if (type === 'whatsapp') return MessageCircle;
67
+ if (type === 'note') return NotebookPen;
68
+ return Activity;
69
+ }
70
+
71
+ function getStatusBadgeClass(status: ActivityStatus) {
72
+ if (status === 'completed') return 'border-emerald-500/25 bg-emerald-500/10 text-emerald-700';
73
+ if (status === 'overdue') return 'border-red-500/25 bg-red-500/10 text-red-700';
74
+ return 'border-amber-500/25 bg-amber-500/10 text-amber-700';
75
+ }
76
+
77
+ function getPriorityBadgeClass(priority: ActivityPriority) {
78
+ if (priority === 'high') return 'border-red-500/25 bg-red-500/10 text-red-700';
79
+ if (priority === 'medium') return 'border-sky-500/25 bg-sky-500/10 text-sky-700';
80
+ return 'border-slate-500/25 bg-slate-500/10 text-slate-700';
81
+ }
82
+
83
+ export default function CrmActivitiesPage() {
84
+ const t = useTranslations('contact.CrmActivities');
85
+ const crmT = useTranslations('contact.CrmMenu');
86
+ const { request, currentLocaleCode, getSettingValue } = useApp();
87
+
88
+ const [searchInput, setSearchInput] = useState('');
89
+ const [debouncedSearch, setDebouncedSearch] = useState('');
90
+ const [statusFilter, setStatusFilter] = useState('all');
91
+ const [typeFilter, setTypeFilter] = useState('all');
92
+ const [priorityFilter, setPriorityFilter] = useState('all');
93
+ const [page, setPage] = useState(1);
94
+ const [pageSize, setPageSize] = useState(12);
95
+ const [viewMode, setViewMode] = useState<ActivityViewMode>('table');
96
+ const [selectedActivityId, setSelectedActivityId] = useState<number | null>(null);
97
+ const [detailOpen, setDetailOpen] = useState(false);
98
+ const [completingActivityId, setCompletingActivityId] = useState<number | null>(null);
99
+ const [detailRefreshKey, setDetailRefreshKey] = useState(0);
100
+
101
+ useEffect(() => {
102
+ const timeout = setTimeout(() => setDebouncedSearch(searchInput.trim()), 300);
103
+ return () => clearTimeout(timeout);
104
+ }, [searchInput]);
105
+
106
+ useEffect(() => {
107
+ try {
108
+ const saved = window.localStorage.getItem(ACTIVITIES_VIEW_STORAGE_KEY);
109
+ if (saved === 'table' || saved === 'timeline') setViewMode(saved);
110
+ } catch {
111
+ // Ignore storage read failures.
112
+ }
113
+ }, []);
114
+
115
+ const { data: stats = { total: 0, pending: 0, overdue: 0, completed: 0 }, refetch: refetchStats } =
116
+ useQuery<ActivityStats>({
117
+ queryKey: ['contact-activities-stats', currentLocaleCode],
118
+ queryFn: async () => {
119
+ const response = await request<ActivityStats>({
120
+ url: '/person/activities/stats',
121
+ method: 'GET',
122
+ });
123
+ return response.data;
124
+ },
125
+ placeholderData: (previous) =>
126
+ previous ?? { total: 0, pending: 0, overdue: 0, completed: 0 },
127
+ });
128
+
129
+ const {
130
+ data: paginate = {
131
+ data: [],
132
+ total: 0,
133
+ page: 1,
134
+ pageSize: 12,
135
+ lastPage: 1,
136
+ prev: null,
137
+ next: null,
138
+ },
139
+ isLoading,
140
+ refetch: refetchActivities,
141
+ } = useQuery<PaginatedResult<ActivityListItem>>({
142
+ queryKey: [
143
+ 'contact-activities',
144
+ page,
145
+ pageSize,
146
+ debouncedSearch,
147
+ statusFilter,
148
+ typeFilter,
149
+ priorityFilter,
150
+ currentLocaleCode,
151
+ ],
152
+ queryFn: async () => {
153
+ const params = new URLSearchParams({
154
+ page: String(page),
155
+ pageSize: String(pageSize),
156
+ });
157
+ if (debouncedSearch) params.set('search', debouncedSearch);
158
+ if (statusFilter !== 'all') params.set('status', statusFilter);
159
+ if (typeFilter !== 'all') params.set('type', typeFilter);
160
+ if (priorityFilter !== 'all') params.set('priority', priorityFilter);
161
+
162
+ const response = await request<PaginatedResult<ActivityListItem>>({
163
+ url: `/person/activities?${params.toString()}`,
164
+ method: 'GET',
165
+ });
166
+
167
+ return response.data;
168
+ },
169
+ placeholderData: (previous) =>
170
+ previous ?? {
171
+ data: [],
172
+ total: 0,
173
+ page: 1,
174
+ pageSize: 12,
175
+ lastPage: 1,
176
+ prev: null,
177
+ next: null,
178
+ },
179
+ });
180
+
181
+ const controls: SearchBarControl[] = [
182
+ {
183
+ id: 'activities-status-filter',
184
+ type: 'select',
185
+ value: statusFilter,
186
+ onChange: (value) => {
187
+ setStatusFilter(value);
188
+ setPage(1);
189
+ },
190
+ placeholder: t('filters.statusPlaceholder'),
191
+ options: [
192
+ { value: 'all', label: t('filters.allStatuses') },
193
+ { value: 'pending', label: t('status.pending') },
194
+ { value: 'overdue', label: t('status.overdue') },
195
+ { value: 'completed', label: t('status.completed') },
196
+ ],
197
+ },
198
+ {
199
+ id: 'activities-type-filter',
200
+ type: 'select',
201
+ value: typeFilter,
202
+ onChange: (value) => {
203
+ setTypeFilter(value);
204
+ setPage(1);
205
+ },
206
+ placeholder: t('filters.typePlaceholder'),
207
+ options: [
208
+ { value: 'all', label: t('filters.allTypes') },
209
+ { value: 'call', label: t('type.call') },
210
+ { value: 'email', label: t('type.email') },
211
+ { value: 'meeting', label: t('type.meeting') },
212
+ { value: 'whatsapp', label: t('type.whatsapp') },
213
+ { value: 'task', label: t('type.task') },
214
+ { value: 'note', label: t('type.note') },
215
+ ],
216
+ },
217
+ {
218
+ id: 'activities-priority-filter',
219
+ type: 'select',
220
+ value: priorityFilter,
221
+ onChange: (value) => {
222
+ setPriorityFilter(value);
223
+ setPage(1);
224
+ },
225
+ placeholder: t('filters.priorityPlaceholder'),
226
+ options: [
227
+ { value: 'all', label: t('filters.allPriorities') },
228
+ { value: 'high', label: t('priority.high') },
229
+ { value: 'medium', label: t('priority.medium') },
230
+ { value: 'low', label: t('priority.low') },
231
+ ],
232
+ },
233
+ ];
234
+
235
+ const statsCards = [
236
+ { key: 'total', title: t('stats.total'), value: stats.total, icon: Activity, accentClassName: 'from-violet-500/20 via-fuchsia-500/10 to-transparent', iconContainerClassName: 'bg-violet-500/10 text-violet-700' },
237
+ { key: 'pending', title: t('stats.pending'), value: stats.pending, icon: Clock3, accentClassName: 'from-amber-500/20 via-yellow-500/10 to-transparent', iconContainerClassName: 'bg-amber-500/10 text-amber-700' },
238
+ { key: 'overdue', title: t('stats.overdue'), value: stats.overdue, icon: CircleAlert, accentClassName: 'from-red-500/20 via-rose-500/10 to-transparent', iconContainerClassName: 'bg-red-500/10 text-red-700' },
239
+ { key: 'completed', title: t('stats.completed'), value: stats.completed, icon: CalendarCheck, accentClassName: 'from-emerald-500/20 via-green-500/10 to-transparent', iconContainerClassName: 'bg-emerald-500/10 text-emerald-700' },
240
+ ];
241
+
242
+ const handleViewModeChange = (value: string) => {
243
+ if (value !== 'table' && value !== 'timeline') return;
244
+ setViewMode(value);
245
+ try {
246
+ window.localStorage.setItem(ACTIVITIES_VIEW_STORAGE_KEY, value);
247
+ } catch {
248
+ // Ignore storage write failures.
249
+ }
250
+ };
251
+
252
+ const handleComplete = async (activityId: number) => {
253
+ setCompletingActivityId(activityId);
254
+ try {
255
+ await request({ url: `/person/activities/${activityId}/complete`, method: 'POST' });
256
+ setDetailRefreshKey((current) => current + 1);
257
+ await Promise.all([refetchActivities(), refetchStats()]);
258
+ toast.success(t('toasts.markedAsCompleted'));
259
+ } catch {
260
+ toast.error(t('toasts.completeError'));
261
+ } finally {
262
+ setCompletingActivityId(null);
263
+ }
264
+ };
265
+
266
+ return (
267
+ <Page>
268
+ <PageHeader
269
+ breadcrumbs={[
270
+ { label: 'Home', href: '/' },
271
+ { label: crmT('breadcrumbs.crm'), href: '/contact/dashboard' },
272
+ { label: t('title') },
273
+ ]}
274
+ title={t('title')}
275
+ description={t('description')}
276
+ />
277
+
278
+ <div className="space-y-6">
279
+ <KpiCardsGrid items={statsCards} />
280
+
281
+ <SearchBar
282
+ searchQuery={searchInput}
283
+ onSearchChange={(value) => {
284
+ setSearchInput(value);
285
+ setPage(1);
286
+ }}
287
+ onSearch={() => setPage(1)}
288
+ placeholder={t('filters.searchPlaceholder')}
289
+ controls={controls}
290
+ />
291
+
292
+ <div className="flex items-center justify-end gap-3">
293
+ <span className="text-xs font-medium text-muted-foreground">{t('viewMode')}</span>
294
+ <ToggleGroup type="single" value={viewMode} onValueChange={handleViewModeChange} variant="outline" size="sm" aria-label={t('viewMode')}>
295
+ <ToggleGroupItem value="table" className="gap-1.5 px-2.5" aria-label={t('viewModeTable')}>
296
+ <List className="h-4 w-4" />
297
+ <span className="hidden sm:inline">{t('viewModeTable')}</span>
298
+ </ToggleGroupItem>
299
+ <ToggleGroupItem value="timeline" className="gap-1.5 px-2.5" aria-label={t('viewModeTimeline')}>
300
+ <LayoutGrid className="h-4 w-4" />
301
+ <span className="hidden sm:inline">{t('viewModeTimeline')}</span>
302
+ </ToggleGroupItem>
303
+ </ToggleGroup>
304
+ </div>
305
+
306
+ {isLoading ? (
307
+ <div className="space-y-3">
308
+ <Skeleton className="h-14 w-full" />
309
+ <Skeleton className="h-14 w-full" />
310
+ <Skeleton className="h-14 w-full" />
311
+ </div>
312
+ ) : paginate.data.length === 0 ? (
313
+ <EmptyState
314
+ icon={<CalendarClock className="h-12 w-12" />}
315
+ title={t('empty.title')}
316
+ description={t('empty.description')}
317
+ actionLabel={t('empty.resetFilters')}
318
+ onAction={() => {
319
+ setSearchInput('');
320
+ setDebouncedSearch('');
321
+ setStatusFilter('all');
322
+ setTypeFilter('all');
323
+ setPriorityFilter('all');
324
+ setPage(1);
325
+ }}
326
+ />
327
+ ) : viewMode === 'table' ? (
328
+ <div className="overflow-x-auto rounded-md border">
329
+ <Table>
330
+ <TableHeader>
331
+ <TableRow>
332
+ <TableHead>{t('table.activity')}</TableHead>
333
+ <TableHead>{t('table.person')}</TableHead>
334
+ <TableHead>{t('table.owner')}</TableHead>
335
+ <TableHead>{t('table.dueAt')}</TableHead>
336
+ <TableHead>{t('table.status')}</TableHead>
337
+ <TableHead>{t('table.priority')}</TableHead>
338
+ <TableHead className="text-right">{t('table.actions')}</TableHead>
339
+ </TableRow>
340
+ </TableHeader>
341
+ <TableBody>
342
+ {paginate.data.map((item) => {
343
+ const TypeIcon = getTypeIcon(item.type);
344
+ const isCompleted = item.status === 'completed';
345
+ const isCompleting = completingActivityId === item.id;
346
+
347
+ return (
348
+ <TableRow key={item.id}>
349
+ <TableCell>
350
+ <div className="min-w-[260px] space-y-1">
351
+ <div className="flex items-center gap-2">
352
+ <TypeIcon className="h-4 w-4 text-muted-foreground" />
353
+ <p className="font-medium">{item.subject}</p>
354
+ </div>
355
+ <p className="line-clamp-2 text-xs text-muted-foreground">{item.notes || t('detail.emptyNotes')}</p>
356
+ <div className="text-[11px] text-muted-foreground">
357
+ {t('table.createdAt')}: {formatDateTime(item.created_at, getSettingValue, currentLocaleCode)}
358
+ </div>
359
+ </div>
360
+ </TableCell>
361
+ <TableCell><div className="inline-flex items-center gap-2"><Users className="h-3.5 w-3.5 text-muted-foreground" /><span>{item.person.name}</span></div></TableCell>
362
+ <TableCell><div className="inline-flex items-center gap-2"><User className="h-3.5 w-3.5 text-muted-foreground" /><span>{item.owner_user?.name || t('unassignedOwner')}</span></div></TableCell>
363
+ <TableCell>{formatDateTime(item.due_at, getSettingValue, currentLocaleCode)}</TableCell>
364
+ <TableCell><Badge variant="outline" className={cn('border', getStatusBadgeClass(item.status))}>{t(`status.${item.status}`)}</Badge></TableCell>
365
+ <TableCell><Badge variant="outline" className={cn('border', getPriorityBadgeClass(item.priority))}>{t(`priority.${item.priority}`)}</Badge></TableCell>
366
+ <TableCell className="text-right">
367
+ <div className="inline-flex items-center gap-2">
368
+ <Button type="button" variant="outline" size="sm" onClick={() => { setSelectedActivityId(item.id); setDetailOpen(true); }}>
369
+ <Eye className="mr-2 h-3.5 w-3.5" />
370
+ {t('actions.view')}
371
+ </Button>
372
+ <Button type="button" variant="outline" size="sm" onClick={() => handleComplete(item.id)} disabled={isCompleted || isCompleting} className={cn((isCompleted || isCompleting) && 'cursor-not-allowed opacity-60')}>
373
+ {isCompleting ? <Loader2 className="mr-2 h-3.5 w-3.5 animate-spin" /> : <CheckCircle2 className="mr-2 h-3.5 w-3.5" />}
374
+ {t('actions.complete')}
375
+ </Button>
376
+ </div>
377
+ </TableCell>
378
+ </TableRow>
379
+ );
380
+ })}
381
+ </TableBody>
382
+ </Table>
383
+ </div>
384
+ ) : (
385
+ <div className="space-y-4">
386
+ <div className="rounded-md border border-dashed border-border/70 bg-muted/20 px-4 py-3 text-sm text-muted-foreground">{t('timeline.description')}</div>
387
+ <div className="grid gap-3 md:grid-cols-2 xl:grid-cols-3">
388
+ {paginate.data.map((item) => {
389
+ const TypeIcon = getTypeIcon(item.type);
390
+ const isCompleted = item.status === 'completed';
391
+ const isCompleting = completingActivityId === item.id;
392
+
393
+ return (
394
+ <Card key={item.id} className="h-full overflow-hidden border-border/70 py-0">
395
+ <CardContent className="space-y-3 p-4">
396
+ <div className="flex items-start justify-between gap-3">
397
+ <div className="min-w-0 space-y-1">
398
+ <div className="flex items-center gap-2">
399
+ <TypeIcon className="h-4 w-4 text-muted-foreground" />
400
+ <p className="line-clamp-2 text-sm font-semibold">{item.subject}</p>
401
+ </div>
402
+ <p className="line-clamp-2 text-xs text-muted-foreground">{item.notes || t('detail.emptyNotes')}</p>
403
+ </div>
404
+ <Badge variant="outline" className={cn('shrink-0 border', getStatusBadgeClass(item.status))}>{t(`status.${item.status}`)}</Badge>
405
+ </div>
406
+ <div className="grid gap-2 rounded-md border border-border/70 bg-background px-3 py-2 text-xs">
407
+ <div className="flex items-center justify-between gap-2"><span className="text-muted-foreground">{t('timeline.dueLabel')}</span><span className="font-medium text-foreground">{formatDateTime(item.due_at, getSettingValue, currentLocaleCode)}</span></div>
408
+ <div className="flex items-center justify-between gap-2"><span className="text-muted-foreground">{t('timeline.createdLabel')}</span><span className="font-medium text-foreground">{formatDateTime(item.created_at, getSettingValue, currentLocaleCode)}</span></div>
409
+ <div className="flex items-center justify-between gap-2"><span className="text-muted-foreground">{t('table.person')}</span><span className="truncate font-medium text-foreground">{item.person.name}</span></div>
410
+ <div className="flex items-center justify-between gap-2"><span className="text-muted-foreground">{t('table.owner')}</span><span className="truncate font-medium text-foreground">{item.owner_user?.name || t('unassignedOwner')}</span></div>
411
+ </div>
412
+ <div className="flex items-center justify-between gap-2">
413
+ <Badge variant="outline" className={cn('border', getPriorityBadgeClass(item.priority))}>{t(`priority.${item.priority}`)}</Badge>
414
+ <div className="inline-flex items-center gap-2">
415
+ <Button type="button" variant="outline" size="sm" onClick={() => { setSelectedActivityId(item.id); setDetailOpen(true); }}>
416
+ <Eye className="mr-2 h-3.5 w-3.5" />
417
+ {t('actions.view')}
418
+ </Button>
419
+ <Button type="button" variant="outline" size="sm" onClick={() => handleComplete(item.id)} disabled={isCompleted || isCompleting} className={cn((isCompleted || isCompleting) && 'cursor-not-allowed opacity-60')}>
420
+ {isCompleting ? <Loader2 className="mr-2 h-3.5 w-3.5 animate-spin" /> : <CheckCircle2 className="mr-2 h-3.5 w-3.5" />}
421
+ {t('actions.complete')}
422
+ </Button>
423
+ </div>
424
+ </div>
425
+ </CardContent>
426
+ </Card>
427
+ );
428
+ })}
429
+ </div>
430
+ </div>
431
+ )}
432
+
433
+ <div className="border-t p-4">
434
+ <PaginationFooter
435
+ currentPage={paginate.page}
436
+ pageSize={paginate.pageSize}
437
+ totalItems={paginate.total}
438
+ onPageChange={setPage}
439
+ onPageSizeChange={(nextPageSize) => {
440
+ setPageSize(nextPageSize);
441
+ setPage(1);
442
+ }}
443
+ />
444
+ </div>
445
+ </div>
446
+
447
+ <ActivityDetailSheet
448
+ activityId={selectedActivityId}
449
+ open={detailOpen}
450
+ refreshKey={detailRefreshKey}
451
+ isCompleting={selectedActivityId === completingActivityId}
452
+ onOpenChange={(open) => {
453
+ setDetailOpen(open);
454
+ if (!open) setSelectedActivityId(null);
455
+ }}
456
+ onComplete={handleComplete}
457
+ />
458
+ </Page>
459
+ );
460
+ }