@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.
@@ -1,15 +1,812 @@
1
1
  'use client';
2
2
 
3
- import { CrmComingSoon } from '../_components/crm-coming-soon';
4
- import { Activity } from 'lucide-react';
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
- <CrmComingSoon
9
- currentHref="/contact/activities"
10
- titleKey="activities"
11
- descriptionKey="activities"
12
- icon={Activity}
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
  }