@hed-hog/contact 0.0.295 → 0.0.297

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 (46) hide show
  1. package/dist/contact-type/contact-type.controller.d.ts +1 -1
  2. package/dist/contact-type/contact-type.service.d.ts +1 -1
  3. package/dist/document-type/document-type.controller.d.ts +1 -1
  4. package/dist/document-type/document-type.service.d.ts +1 -1
  5. package/dist/person/dto/reports-query.dto.d.ts +8 -0
  6. package/dist/person/dto/reports-query.dto.d.ts.map +1 -0
  7. package/dist/person/dto/reports-query.dto.js +33 -0
  8. package/dist/person/dto/reports-query.dto.js.map +1 -0
  9. package/dist/person/person.controller.d.ts +67 -10
  10. package/dist/person/person.controller.d.ts.map +1 -1
  11. package/dist/person/person.controller.js +26 -6
  12. package/dist/person/person.controller.js.map +1 -1
  13. package/dist/person/person.service.d.ts +61 -5
  14. package/dist/person/person.service.d.ts.map +1 -1
  15. package/dist/person/person.service.js +656 -298
  16. package/dist/person/person.service.js.map +1 -1
  17. package/dist/person-relation-type/person-relation-type.controller.d.ts +2 -2
  18. package/dist/person-relation-type/person-relation-type.service.d.ts +2 -2
  19. package/hedhog/data/menu.yaml +163 -163
  20. package/hedhog/data/route.yaml +68 -60
  21. package/hedhog/frontend/app/_lib/crm-sections.tsx.ejs +9 -9
  22. package/hedhog/frontend/app/accounts/_components/account-form-sheet.tsx.ejs +573 -573
  23. package/hedhog/frontend/app/accounts/_components/account-types.ts.ejs +9 -9
  24. package/hedhog/frontend/app/accounts/page.tsx.ejs +970 -970
  25. package/hedhog/frontend/app/activities/_components/activity-detail-sheet.tsx.ejs +240 -240
  26. package/hedhog/frontend/app/activities/_components/activity-types.ts.ejs +66 -66
  27. package/hedhog/frontend/app/activities/page.tsx.ejs +460 -460
  28. package/hedhog/frontend/app/dashboard/_components/dashboard-types.ts.ejs +70 -70
  29. package/hedhog/frontend/app/dashboard/page.tsx.ejs +639 -639
  30. package/hedhog/frontend/app/follow-ups/page.tsx.ejs +785 -785
  31. package/hedhog/frontend/app/person/_components/person-interaction-dialog.tsx.ejs +10 -12
  32. package/hedhog/frontend/app/reports/_components/report-types.ts.ejs +84 -0
  33. package/hedhog/frontend/app/reports/page.tsx.ejs +1196 -15
  34. package/hedhog/frontend/messages/en.json +242 -123
  35. package/hedhog/frontend/messages/pt.json +242 -123
  36. package/hedhog/table/crm_activity.yaml +68 -68
  37. package/hedhog/table/crm_stage_history.yaml +34 -0
  38. package/hedhog/table/person_company.yaml +27 -27
  39. package/package.json +9 -9
  40. package/src/person/dto/account.dto.ts +100 -100
  41. package/src/person/dto/activity.dto.ts +54 -54
  42. package/src/person/dto/dashboard-query.dto.ts +25 -25
  43. package/src/person/dto/followup-query.dto.ts +25 -25
  44. package/src/person/dto/reports-query.dto.ts +25 -0
  45. package/src/person/person.controller.ts +176 -159
  46. package/src/person/person.service.ts +4825 -4288
@@ -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
+ }