@hed-hog/contact 0.0.296 → 0.0.298

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