@hed-hog/contact 0.0.279 → 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.
Files changed (73) hide show
  1. package/README.md +2 -0
  2. package/dist/contact.service.d.ts +2 -148
  3. package/dist/contact.service.d.ts.map +1 -1
  4. package/dist/person/dto/create-followup.dto.d.ts +5 -0
  5. package/dist/person/dto/create-followup.dto.d.ts.map +1 -0
  6. package/dist/person/dto/create-followup.dto.js +31 -0
  7. package/dist/person/dto/create-followup.dto.js.map +1 -0
  8. package/dist/person/dto/create-interaction.dto.d.ts +12 -0
  9. package/dist/person/dto/create-interaction.dto.d.ts.map +1 -0
  10. package/dist/person/dto/create-interaction.dto.js +39 -0
  11. package/dist/person/dto/create-interaction.dto.js.map +1 -0
  12. package/dist/person/dto/create.dto.d.ts +24 -0
  13. package/dist/person/dto/create.dto.d.ts.map +1 -1
  14. package/dist/person/dto/create.dto.js +56 -1
  15. package/dist/person/dto/create.dto.js.map +1 -1
  16. package/dist/person/dto/duplicates-query.dto.d.ts +8 -0
  17. package/dist/person/dto/duplicates-query.dto.d.ts.map +1 -0
  18. package/dist/person/dto/duplicates-query.dto.js +45 -0
  19. package/dist/person/dto/duplicates-query.dto.js.map +1 -0
  20. package/dist/person/dto/merge.dto.d.ts +6 -0
  21. package/dist/person/dto/merge.dto.d.ts.map +1 -0
  22. package/dist/person/dto/merge.dto.js +35 -0
  23. package/dist/person/dto/merge.dto.js.map +1 -0
  24. package/dist/person/dto/update-lifecycle-stage.dto.d.ts +13 -0
  25. package/dist/person/dto/update-lifecycle-stage.dto.d.ts.map +1 -0
  26. package/dist/person/dto/update-lifecycle-stage.dto.js +34 -0
  27. package/dist/person/dto/update-lifecycle-stage.dto.js.map +1 -0
  28. package/dist/person/dto/update.dto.d.ts +8 -1
  29. package/dist/person/dto/update.dto.d.ts.map +1 -1
  30. package/dist/person/dto/update.dto.js +36 -0
  31. package/dist/person/dto/update.dto.js.map +1 -1
  32. package/dist/person/person.controller.d.ts +57 -1
  33. package/dist/person/person.controller.d.ts.map +1 -1
  34. package/dist/person/person.controller.js +85 -3
  35. package/dist/person/person.controller.js.map +1 -1
  36. package/dist/person/person.service.d.ts +79 -0
  37. package/dist/person/person.service.d.ts.map +1 -1
  38. package/dist/person/person.service.js +730 -9
  39. package/dist/person/person.service.js.map +1 -1
  40. package/hedhog/data/route.yaml +18 -0
  41. package/hedhog/frontend/app/_components/crm-coming-soon.tsx.ejs +110 -110
  42. package/hedhog/frontend/app/_components/crm-nav.tsx.ejs +73 -73
  43. package/hedhog/frontend/app/_lib/crm-mocks.ts.ejs +498 -256
  44. package/hedhog/frontend/app/_lib/crm-sections.tsx.ejs +81 -81
  45. package/hedhog/frontend/app/accounts/_components/account-form-sheet.tsx.ejs +477 -0
  46. package/hedhog/frontend/app/accounts/_components/account-types.ts.ejs +62 -0
  47. package/hedhog/frontend/app/accounts/page.tsx.ejs +892 -15
  48. package/hedhog/frontend/app/activities/page.tsx.ejs +812 -15
  49. package/hedhog/frontend/app/contact-type/page.tsx.ejs +105 -91
  50. package/hedhog/frontend/app/dashboard/page.tsx.ejs +491 -573
  51. package/hedhog/frontend/app/document-type/page.tsx.ejs +105 -91
  52. package/hedhog/frontend/app/follow-ups/page.tsx.ejs +696 -15
  53. package/hedhog/frontend/app/page.tsx.ejs +5 -5
  54. package/hedhog/frontend/app/person/_components/person-form-sheet.tsx.ejs +1440 -1103
  55. package/hedhog/frontend/app/person/_components/person-interaction-dialog.tsx.ejs +4 -3
  56. package/hedhog/frontend/app/person/_components/person-types.ts.ejs +14 -0
  57. package/hedhog/frontend/app/person/page.tsx.ejs +112 -190
  58. package/hedhog/frontend/app/pipeline/_components/lead-detail-sheet.tsx.ejs +599 -0
  59. package/hedhog/frontend/app/pipeline/page.tsx.ejs +1048 -299
  60. package/hedhog/frontend/app/reports/page.tsx.ejs +15 -15
  61. package/hedhog/frontend/messages/en.json +268 -0
  62. package/hedhog/frontend/messages/pt.json +233 -0
  63. package/package.json +6 -6
  64. package/src/contact.service.ts +2 -2
  65. package/src/person/dto/create-followup.dto.ts +15 -0
  66. package/src/person/dto/create-interaction.dto.ts +23 -0
  67. package/src/person/dto/create.dto.ts +50 -0
  68. package/src/person/dto/duplicates-query.dto.ts +34 -0
  69. package/src/person/dto/merge.dto.ts +15 -0
  70. package/src/person/dto/update-lifecycle-stage.dto.ts +19 -0
  71. package/src/person/dto/update.dto.ts +31 -1
  72. package/src/person/person.controller.ts +63 -2
  73. package/src/person/person.service.ts +1096 -7
@@ -1,15 +1,696 @@
1
- 'use client';
2
-
3
- import { CrmComingSoon } from '../_components/crm-coming-soon';
4
- import { CalendarClock } from 'lucide-react';
5
-
6
- export default function CrmFollowupsPage() {
7
- return (
8
- <CrmComingSoon
9
- currentHref="/contact/follow-ups"
10
- titleKey="followups"
11
- descriptionKey="followups"
12
- icon={CalendarClock}
13
- />
14
- );
15
- }
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 {
14
+ Form,
15
+ FormControl,
16
+ FormField,
17
+ FormItem,
18
+ FormLabel,
19
+ FormMessage,
20
+ } from '@/components/ui/form';
21
+ import { Input } from '@/components/ui/input';
22
+ import { KpiCardsGrid } from '@/components/ui/kpi-cards-grid';
23
+ import {
24
+ Select,
25
+ SelectContent,
26
+ SelectItem,
27
+ SelectTrigger,
28
+ SelectValue,
29
+ } from '@/components/ui/select';
30
+ import {
31
+ Sheet,
32
+ SheetContent,
33
+ SheetDescription,
34
+ SheetFooter,
35
+ SheetHeader,
36
+ SheetTitle,
37
+ } from '@/components/ui/sheet';
38
+ import { Skeleton } from '@/components/ui/skeleton';
39
+ import {
40
+ Table,
41
+ TableBody,
42
+ TableCell,
43
+ TableHead,
44
+ TableHeader,
45
+ TableRow,
46
+ } from '@/components/ui/table';
47
+ import { Textarea } from '@/components/ui/textarea';
48
+ import { formatDateTime } from '@/lib/format-date';
49
+ import { cn } from '@/lib/utils';
50
+ import { useApp, useQuery } from '@hed-hog/next-app-provider';
51
+ import { zodResolver } from '@hookform/resolvers/zod';
52
+ import {
53
+ CalendarClock,
54
+ CalendarDays,
55
+ CheckCircle2,
56
+ Clock3,
57
+ Loader2,
58
+ Plus,
59
+ RotateCw,
60
+ TriangleAlert,
61
+ User,
62
+ } from 'lucide-react';
63
+ import { useTranslations } from 'next-intl';
64
+ import { useEffect, useMemo, useState } from 'react';
65
+ import { useForm } from 'react-hook-form';
66
+ import { toast } from 'sonner';
67
+ import { z } from 'zod';
68
+
69
+ type PaginatedResult<T> = {
70
+ data: T[];
71
+ total: number;
72
+ page: number;
73
+ pageSize: number;
74
+ };
75
+
76
+ type Person = {
77
+ id: number;
78
+ name: string;
79
+ status: 'active' | 'inactive';
80
+ owner_user?: {
81
+ id: number;
82
+ name: string;
83
+ } | null;
84
+ next_action_at?: string | null;
85
+ last_interaction_at?: string | null;
86
+ };
87
+
88
+ type FollowupStatus = 'today' | 'upcoming' | 'overdue';
89
+
90
+ type FollowupRow = {
91
+ person: Person;
92
+ nextActionAt: string;
93
+ status: FollowupStatus;
94
+ };
95
+
96
+ const SOURCE_PAGE_SIZE = 500;
97
+
98
+ function toInputDateTimeValue(value?: string | null) {
99
+ if (!value) {
100
+ return '';
101
+ }
102
+
103
+ const date = new Date(value);
104
+ if (Number.isNaN(date.getTime())) {
105
+ return '';
106
+ }
107
+
108
+ const pad = (part: number) => String(part).padStart(2, '0');
109
+ const year = date.getFullYear();
110
+ const month = pad(date.getMonth() + 1);
111
+ const day = pad(date.getDate());
112
+ const hour = pad(date.getHours());
113
+ const minute = pad(date.getMinutes());
114
+
115
+ return `${year}-${month}-${day}T${hour}:${minute}`;
116
+ }
117
+
118
+ function startOfDay(date: Date) {
119
+ return new Date(date.getFullYear(), date.getMonth(), date.getDate());
120
+ }
121
+
122
+ function getFollowupStatus(nextActionAt: string): FollowupStatus {
123
+ const target = new Date(nextActionAt);
124
+ const now = new Date();
125
+ const todayStart = startOfDay(now);
126
+ const tomorrowStart = new Date(todayStart);
127
+ tomorrowStart.setDate(tomorrowStart.getDate() + 1);
128
+
129
+ if (target < todayStart) {
130
+ return 'overdue';
131
+ }
132
+
133
+ if (target >= todayStart && target < tomorrowStart) {
134
+ return 'today';
135
+ }
136
+
137
+ return 'upcoming';
138
+ }
139
+
140
+ function isValidDateString(value?: string | null) {
141
+ if (!value) {
142
+ return false;
143
+ }
144
+
145
+ const date = new Date(value);
146
+ return !Number.isNaN(date.getTime());
147
+ }
148
+
149
+ function getStatusBadgeClass(status: FollowupStatus) {
150
+ if (status === 'overdue') {
151
+ return 'border-red-500/25 bg-red-500/10 text-red-700';
152
+ }
153
+
154
+ if (status === 'today') {
155
+ return 'border-amber-500/25 bg-amber-500/10 text-amber-700';
156
+ }
157
+
158
+ return 'border-emerald-500/25 bg-emerald-500/10 text-emerald-700';
159
+ }
160
+
161
+ export default function CrmFollowupsPage() {
162
+ const t = useTranslations('contact.CrmFollowups');
163
+ const crmT = useTranslations('contact.CrmMenu');
164
+ const { request, currentLocaleCode, getSettingValue } = useApp();
165
+
166
+ const scheduleSchema = useMemo(
167
+ () =>
168
+ z.object({
169
+ personId: z.string().min(1, t('form.personRequired')),
170
+ next_action_at: z.string().min(1, t('form.dateRequired')),
171
+ notes: z.string().optional(),
172
+ }),
173
+ [t]
174
+ );
175
+
176
+ type ScheduleFormValues = z.infer<typeof scheduleSchema>;
177
+
178
+ const form = useForm<ScheduleFormValues>({
179
+ resolver: zodResolver(scheduleSchema),
180
+ defaultValues: {
181
+ personId: '',
182
+ next_action_at: '',
183
+ notes: '',
184
+ },
185
+ });
186
+
187
+ const [searchInput, setSearchInput] = useState('');
188
+ const [debouncedSearch, setDebouncedSearch] = useState('');
189
+ const [statusFilter, setStatusFilter] = useState('all');
190
+ const [dateFrom, setDateFrom] = useState('');
191
+ const [dateTo, setDateTo] = useState('');
192
+ const [page, setPage] = useState(1);
193
+ const [pageSize, setPageSize] = useState(12);
194
+ const [sheetOpen, setSheetOpen] = useState(false);
195
+ const [isSubmitting, setIsSubmitting] = useState(false);
196
+
197
+ useEffect(() => {
198
+ const timeout = setTimeout(() => {
199
+ setDebouncedSearch(searchInput.trim());
200
+ }, 300);
201
+
202
+ return () => clearTimeout(timeout);
203
+ }, [searchInput]);
204
+
205
+ const {
206
+ data: source = { data: [], total: 0, page: 1, pageSize: SOURCE_PAGE_SIZE },
207
+ isLoading,
208
+ refetch,
209
+ } = useQuery<PaginatedResult<Person>>({
210
+ queryKey: ['contact-followups-source', debouncedSearch, currentLocaleCode],
211
+ queryFn: async () => {
212
+ const params = new URLSearchParams();
213
+ params.set('page', '1');
214
+ params.set('pageSize', String(SOURCE_PAGE_SIZE));
215
+ if (debouncedSearch) {
216
+ params.set('search', debouncedSearch);
217
+ }
218
+
219
+ const response = await request<PaginatedResult<Person>>({
220
+ url: `/person?${params.toString()}`,
221
+ method: 'GET',
222
+ });
223
+
224
+ return response.data;
225
+ },
226
+ placeholderData: (previous) =>
227
+ previous ?? {
228
+ data: [],
229
+ total: 0,
230
+ page: 1,
231
+ pageSize: SOURCE_PAGE_SIZE,
232
+ },
233
+ });
234
+
235
+ const allFollowups = useMemo<FollowupRow[]>(() => {
236
+ return (source.data || [])
237
+ .filter((person) => isValidDateString(person.next_action_at))
238
+ .map((person) => {
239
+ const nextActionAt = String(person.next_action_at);
240
+
241
+ return {
242
+ person,
243
+ nextActionAt,
244
+ status: getFollowupStatus(nextActionAt),
245
+ };
246
+ })
247
+ .sort(
248
+ (a, b) =>
249
+ new Date(a.nextActionAt).getTime() -
250
+ new Date(b.nextActionAt).getTime()
251
+ );
252
+ }, [source.data]);
253
+
254
+ const filteredFollowups = useMemo(() => {
255
+ return allFollowups.filter((item) => {
256
+ if (statusFilter !== 'all' && item.status !== statusFilter) {
257
+ return false;
258
+ }
259
+
260
+ const targetDate = new Date(item.nextActionAt);
261
+
262
+ if (dateFrom) {
263
+ const min = startOfDay(new Date(`${dateFrom}T00:00:00`));
264
+ if (targetDate < min) {
265
+ return false;
266
+ }
267
+ }
268
+
269
+ if (dateTo) {
270
+ const max = startOfDay(new Date(`${dateTo}T00:00:00`));
271
+ max.setDate(max.getDate() + 1);
272
+ if (targetDate >= max) {
273
+ return false;
274
+ }
275
+ }
276
+
277
+ return true;
278
+ });
279
+ }, [allFollowups, statusFilter, dateFrom, dateTo]);
280
+
281
+ const totalPages = Math.max(
282
+ 1,
283
+ Math.ceil(filteredFollowups.length / pageSize)
284
+ );
285
+
286
+ useEffect(() => {
287
+ if (page > totalPages) {
288
+ setPage(totalPages);
289
+ }
290
+ }, [page, totalPages]);
291
+
292
+ const pageData = useMemo(() => {
293
+ const start = (page - 1) * pageSize;
294
+ const end = start + pageSize;
295
+
296
+ return filteredFollowups.slice(start, end);
297
+ }, [filteredFollowups, page, pageSize]);
298
+
299
+ const stats = useMemo(() => {
300
+ const total = allFollowups.length;
301
+ const today = allFollowups.filter((item) => item.status === 'today').length;
302
+ const overdue = allFollowups.filter(
303
+ (item) => item.status === 'overdue'
304
+ ).length;
305
+ const upcoming = allFollowups.filter(
306
+ (item) => item.status === 'upcoming'
307
+ ).length;
308
+
309
+ return {
310
+ total,
311
+ today,
312
+ overdue,
313
+ upcoming,
314
+ };
315
+ }, [allFollowups]);
316
+
317
+ const personOptions = useMemo(
318
+ () =>
319
+ source.data.map((person) => ({
320
+ value: String(person.id),
321
+ label: person.name,
322
+ })),
323
+ [source.data]
324
+ );
325
+
326
+ const searchControls: SearchBarControl[] = [
327
+ {
328
+ id: 'followup-status',
329
+ type: 'select',
330
+ value: statusFilter,
331
+ onChange: (value: string) => {
332
+ setStatusFilter(value);
333
+ setPage(1);
334
+ },
335
+ placeholder: t('filters.statusPlaceholder'),
336
+ options: [
337
+ { value: 'all', label: t('filters.statusAll') },
338
+ { value: 'today', label: t('status.today') },
339
+ { value: 'upcoming', label: t('status.upcoming') },
340
+ { value: 'overdue', label: t('status.overdue') },
341
+ ],
342
+ },
343
+ {
344
+ id: 'followup-date-from',
345
+ type: 'date',
346
+ value: dateFrom,
347
+ onChange: (value: string) => {
348
+ setDateFrom(value);
349
+ setPage(1);
350
+ },
351
+ className: 'sm:w-[170px]',
352
+ },
353
+ {
354
+ id: 'followup-date-to',
355
+ type: 'date',
356
+ value: dateTo,
357
+ onChange: (value: string) => {
358
+ setDateTo(value);
359
+ setPage(1);
360
+ },
361
+ className: 'sm:w-[170px]',
362
+ },
363
+ ];
364
+
365
+ const openCreateSheet = () => {
366
+ form.reset({
367
+ personId: '',
368
+ next_action_at: '',
369
+ notes: '',
370
+ });
371
+ setSheetOpen(true);
372
+ };
373
+
374
+ const openRescheduleSheet = (row: FollowupRow) => {
375
+ form.reset({
376
+ personId: String(row.person.id),
377
+ next_action_at: toInputDateTimeValue(row.nextActionAt),
378
+ notes: '',
379
+ });
380
+ setSheetOpen(true);
381
+ };
382
+
383
+ const handleSubmit = async (values: ScheduleFormValues) => {
384
+ const parsedPersonId = Number(values.personId);
385
+ const nextActionDate = new Date(values.next_action_at);
386
+
387
+ if (!Number.isFinite(parsedPersonId) || parsedPersonId <= 0) {
388
+ toast.error(t('errors.invalidPerson'));
389
+ return;
390
+ }
391
+
392
+ if (Number.isNaN(nextActionDate.getTime())) {
393
+ toast.error(t('errors.invalidDate'));
394
+ return;
395
+ }
396
+
397
+ try {
398
+ setIsSubmitting(true);
399
+ await request({
400
+ url: `/person/${parsedPersonId}/followup`,
401
+ method: 'POST',
402
+ data: {
403
+ next_action_at: nextActionDate.toISOString(),
404
+ notes: values.notes?.trim() || undefined,
405
+ },
406
+ });
407
+
408
+ toast.success(t('toasts.scheduleSuccess'));
409
+ setSheetOpen(false);
410
+ await refetch();
411
+ } catch {
412
+ toast.error(t('toasts.scheduleError'));
413
+ } finally {
414
+ setIsSubmitting(false);
415
+ }
416
+ };
417
+
418
+ const statsCards = [
419
+ {
420
+ key: 'total',
421
+ title: t('stats.total'),
422
+ value: stats.total,
423
+ icon: CalendarClock,
424
+ accentClassName: 'from-sky-500/20 via-cyan-500/10 to-transparent',
425
+ iconContainerClassName: 'bg-sky-500/10 text-sky-700',
426
+ },
427
+ {
428
+ key: 'today',
429
+ title: t('stats.today'),
430
+ value: stats.today,
431
+ icon: Clock3,
432
+ accentClassName: 'from-amber-500/20 via-yellow-500/10 to-transparent',
433
+ iconContainerClassName: 'bg-amber-500/10 text-amber-700',
434
+ },
435
+ {
436
+ key: 'overdue',
437
+ title: t('stats.overdue'),
438
+ value: stats.overdue,
439
+ icon: TriangleAlert,
440
+ accentClassName: 'from-red-500/20 via-rose-500/10 to-transparent',
441
+ iconContainerClassName: 'bg-red-500/10 text-red-700',
442
+ },
443
+ {
444
+ key: 'upcoming',
445
+ title: t('stats.upcoming'),
446
+ value: stats.upcoming,
447
+ icon: CheckCircle2,
448
+ accentClassName: 'from-emerald-500/20 via-green-500/10 to-transparent',
449
+ iconContainerClassName: 'bg-emerald-500/10 text-emerald-700',
450
+ },
451
+ ];
452
+
453
+ return (
454
+ <Page>
455
+ <PageHeader
456
+ breadcrumbs={[
457
+ { label: 'Home', href: '/' },
458
+ { label: crmT('breadcrumbs.crm'), href: '/contact/dashboard' },
459
+ { label: t('title') },
460
+ ]}
461
+ title={t('title')}
462
+ description={t('description')}
463
+ actions={[
464
+ {
465
+ label: t('newFollowup'),
466
+ onClick: openCreateSheet,
467
+ icon: <Plus className="h-4 w-4" />,
468
+ },
469
+ ]}
470
+ />
471
+
472
+ <div className="space-y-6">
473
+ <KpiCardsGrid items={statsCards} />
474
+
475
+ <SearchBar
476
+ searchQuery={searchInput}
477
+ onSearchChange={(value) => {
478
+ setSearchInput(value);
479
+ setPage(1);
480
+ }}
481
+ onSearch={() => {
482
+ setPage(1);
483
+ void refetch();
484
+ }}
485
+ placeholder={t('filters.searchPlaceholder')}
486
+ controls={searchControls}
487
+ />
488
+
489
+ {isLoading ? (
490
+ <div className="space-y-2">
491
+ {Array.from({ length: 7 }).map((_, index) => (
492
+ <Skeleton key={index} className="h-12 w-full" />
493
+ ))}
494
+ </div>
495
+ ) : pageData.length === 0 ? (
496
+ <EmptyState
497
+ icon={<CalendarClock className="h-12 w-12" />}
498
+ title={t('empty.title')}
499
+ description={t('empty.description')}
500
+ actionLabel={t('newFollowup')}
501
+ actionIcon={<Plus className="mr-2 h-4 w-4" />}
502
+ onAction={openCreateSheet}
503
+ />
504
+ ) : (
505
+ <div className="overflow-x-auto rounded-md border">
506
+ <Table>
507
+ <TableHeader>
508
+ <TableRow>
509
+ <TableHead>{t('table.person')}</TableHead>
510
+ <TableHead>{t('table.owner')}</TableHead>
511
+ <TableHead>{t('table.nextAction')}</TableHead>
512
+ <TableHead>{t('table.lastInteraction')}</TableHead>
513
+ <TableHead>{t('table.status')}</TableHead>
514
+ <TableHead className="text-right">
515
+ {t('table.actions')}
516
+ </TableHead>
517
+ </TableRow>
518
+ </TableHeader>
519
+ <TableBody>
520
+ {pageData.map((row) => (
521
+ <TableRow key={row.person.id}>
522
+ <TableCell>
523
+ <div className="min-w-[180px]">
524
+ <div className="font-medium">{row.person.name}</div>
525
+ <div className="text-xs text-muted-foreground">
526
+ #{row.person.id}
527
+ </div>
528
+ </div>
529
+ </TableCell>
530
+ <TableCell>
531
+ <div className="inline-flex items-center gap-2 text-sm">
532
+ <User className="h-3.5 w-3.5 text-muted-foreground" />
533
+ <span>
534
+ {row.person.owner_user?.name || t('unassigned')}
535
+ </span>
536
+ </div>
537
+ </TableCell>
538
+ <TableCell>
539
+ {formatDateTime(
540
+ row.nextActionAt,
541
+ getSettingValue,
542
+ currentLocaleCode
543
+ )}
544
+ </TableCell>
545
+ <TableCell>
546
+ {row.person.last_interaction_at
547
+ ? formatDateTime(
548
+ row.person.last_interaction_at,
549
+ getSettingValue,
550
+ currentLocaleCode
551
+ )
552
+ : '-'}
553
+ </TableCell>
554
+ <TableCell>
555
+ <Badge
556
+ variant="outline"
557
+ className={cn(
558
+ 'border',
559
+ getStatusBadgeClass(row.status)
560
+ )}
561
+ >
562
+ {t(`status.${row.status}`)}
563
+ </Badge>
564
+ </TableCell>
565
+ <TableCell className="text-right">
566
+ <Button
567
+ type="button"
568
+ size="sm"
569
+ variant="outline"
570
+ onClick={() => openRescheduleSheet(row)}
571
+ >
572
+ <RotateCw className="mr-2 h-3.5 w-3.5" />
573
+ {t('reschedule')}
574
+ </Button>
575
+ </TableCell>
576
+ </TableRow>
577
+ ))}
578
+ </TableBody>
579
+ </Table>
580
+ </div>
581
+ )}
582
+
583
+ <div className="border-t p-4">
584
+ <PaginationFooter
585
+ currentPage={page}
586
+ pageSize={pageSize}
587
+ totalItems={filteredFollowups.length}
588
+ onPageChange={setPage}
589
+ onPageSizeChange={(nextPageSize) => {
590
+ setPageSize(nextPageSize);
591
+ setPage(1);
592
+ }}
593
+ />
594
+ </div>
595
+ </div>
596
+
597
+ <Sheet
598
+ open={sheetOpen}
599
+ onOpenChange={(open) => {
600
+ if (!isSubmitting) {
601
+ setSheetOpen(open);
602
+ }
603
+ }}
604
+ >
605
+ <SheetContent className="w-full sm:max-w-lg">
606
+ <SheetHeader>
607
+ <SheetTitle>{t('sheet.title')}</SheetTitle>
608
+ <SheetDescription>{t('sheet.description')}</SheetDescription>
609
+ </SheetHeader>
610
+
611
+ <Form {...form}>
612
+ <form
613
+ onSubmit={form.handleSubmit(handleSubmit)}
614
+ className="mt-6 flex h-full flex-col gap-4"
615
+ >
616
+ <FormField
617
+ control={form.control}
618
+ name="personId"
619
+ render={({ field }) => (
620
+ <FormItem>
621
+ <FormLabel>{t('form.person')}</FormLabel>
622
+ <Select value={field.value} onValueChange={field.onChange}>
623
+ <FormControl>
624
+ <SelectTrigger className="w-full">
625
+ <SelectValue
626
+ placeholder={t('form.personPlaceholder')}
627
+ />
628
+ </SelectTrigger>
629
+ </FormControl>
630
+ <SelectContent>
631
+ {personOptions.map((option) => (
632
+ <SelectItem key={option.value} value={option.value}>
633
+ {option.label}
634
+ </SelectItem>
635
+ ))}
636
+ </SelectContent>
637
+ </Select>
638
+ <FormMessage />
639
+ </FormItem>
640
+ )}
641
+ />
642
+
643
+ <FormField
644
+ control={form.control}
645
+ name="next_action_at"
646
+ render={({ field }) => (
647
+ <FormItem>
648
+ <FormLabel>{t('form.date')}</FormLabel>
649
+ <FormControl>
650
+ <Input type="datetime-local" {...field} />
651
+ </FormControl>
652
+ <FormMessage />
653
+ </FormItem>
654
+ )}
655
+ />
656
+
657
+ <FormField
658
+ control={form.control}
659
+ name="notes"
660
+ render={({ field }) => (
661
+ <FormItem>
662
+ <FormLabel>{t('form.notes')}</FormLabel>
663
+ <FormControl>
664
+ <Textarea
665
+ {...field}
666
+ value={field.value || ''}
667
+ placeholder={t('form.notesPlaceholder')}
668
+ rows={5}
669
+ />
670
+ </FormControl>
671
+ <FormMessage />
672
+ </FormItem>
673
+ )}
674
+ />
675
+
676
+ <SheetFooter className="mt-auto border-t pt-4">
677
+ <Button
678
+ type="submit"
679
+ className="w-full"
680
+ disabled={isSubmitting}
681
+ >
682
+ {isSubmitting ? (
683
+ <Loader2 className="mr-2 h-4 w-4 animate-spin" />
684
+ ) : (
685
+ <CalendarDays className="mr-2 h-4 w-4" />
686
+ )}
687
+ {t('sheet.submit')}
688
+ </Button>
689
+ </SheetFooter>
690
+ </form>
691
+ </Form>
692
+ </SheetContent>
693
+ </Sheet>
694
+ </Page>
695
+ );
696
+ }