@hed-hog/contact 0.0.293 → 0.0.295

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/person/dto/account.dto.d.ts +28 -0
  2. package/dist/person/dto/account.dto.d.ts.map +1 -0
  3. package/dist/person/dto/account.dto.js +123 -0
  4. package/dist/person/dto/account.dto.js.map +1 -0
  5. package/dist/person/dto/activity.dto.d.ts +15 -0
  6. package/dist/person/dto/activity.dto.d.ts.map +1 -0
  7. package/dist/person/dto/activity.dto.js +65 -0
  8. package/dist/person/dto/activity.dto.js.map +1 -0
  9. package/dist/person/dto/dashboard-query.dto.d.ts +9 -0
  10. package/dist/person/dto/dashboard-query.dto.d.ts.map +1 -0
  11. package/dist/person/dto/dashboard-query.dto.js +40 -0
  12. package/dist/person/dto/dashboard-query.dto.js.map +1 -0
  13. package/dist/person/dto/followup-query.dto.d.ts +10 -0
  14. package/dist/person/dto/followup-query.dto.d.ts.map +1 -0
  15. package/dist/person/dto/followup-query.dto.js +45 -0
  16. package/dist/person/dto/followup-query.dto.js.map +1 -0
  17. package/dist/person/person.controller.d.ts +204 -0
  18. package/dist/person/person.controller.d.ts.map +1 -1
  19. package/dist/person/person.controller.js +138 -0
  20. package/dist/person/person.controller.js.map +1 -1
  21. package/dist/person/person.service.d.ts +234 -0
  22. package/dist/person/person.service.d.ts.map +1 -1
  23. package/dist/person/person.service.js +1367 -0
  24. package/dist/person/person.service.js.map +1 -1
  25. package/hedhog/data/menu.yaml +163 -163
  26. package/hedhog/data/route.yaml +41 -0
  27. package/hedhog/frontend/app/accounts/_components/account-form-sheet.tsx.ejs +210 -114
  28. package/hedhog/frontend/app/accounts/_components/account-types.ts.ejs +3 -0
  29. package/hedhog/frontend/app/accounts/page.tsx.ejs +323 -245
  30. package/hedhog/frontend/app/activities/_components/activity-detail-sheet.tsx.ejs +240 -0
  31. package/hedhog/frontend/app/activities/_components/activity-types.ts.ejs +66 -0
  32. package/hedhog/frontend/app/activities/page.tsx.ejs +165 -517
  33. package/hedhog/frontend/app/dashboard/_components/dashboard-types.ts.ejs +70 -0
  34. package/hedhog/frontend/app/dashboard/page.tsx.ejs +504 -356
  35. package/hedhog/frontend/app/follow-ups/page.tsx.ejs +242 -153
  36. package/hedhog/frontend/messages/en.json +91 -6
  37. package/hedhog/frontend/messages/pt.json +91 -6
  38. package/hedhog/table/crm_activity.yaml +68 -0
  39. package/hedhog/table/person_company.yaml +22 -0
  40. package/package.json +5 -5
  41. package/src/person/dto/account.dto.ts +100 -0
  42. package/src/person/dto/activity.dto.ts +54 -0
  43. package/src/person/dto/dashboard-query.dto.ts +25 -0
  44. package/src/person/dto/followup-query.dto.ts +25 -0
  45. package/src/person/person.controller.ts +116 -0
  46. package/src/person/person.service.ts +2139 -77
@@ -10,6 +10,14 @@ import {
10
10
  } from '@/components/entity-list';
11
11
  import { Badge } from '@/components/ui/badge';
12
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';
13
21
  import {
14
22
  Form,
15
23
  FormControl,
@@ -21,12 +29,10 @@ import {
21
29
  import { Input } from '@/components/ui/input';
22
30
  import { KpiCardsGrid } from '@/components/ui/kpi-cards-grid';
23
31
  import {
24
- Select,
25
- SelectContent,
26
- SelectItem,
27
- SelectTrigger,
28
- SelectValue,
29
- } from '@/components/ui/select';
32
+ Popover,
33
+ PopoverContent,
34
+ PopoverTrigger,
35
+ } from '@/components/ui/popover';
30
36
  import {
31
37
  Sheet,
32
38
  SheetContent,
@@ -52,7 +58,9 @@ import { zodResolver } from '@hookform/resolvers/zod';
52
58
  import {
53
59
  CalendarClock,
54
60
  CalendarDays,
61
+ Check,
55
62
  CheckCircle2,
63
+ ChevronsUpDown,
56
64
  Clock3,
57
65
  Loader2,
58
66
  Plus,
@@ -71,6 +79,9 @@ type PaginatedResult<T> = {
71
79
  total: number;
72
80
  page: number;
73
81
  pageSize: number;
82
+ lastPage?: number;
83
+ prev?: number | null;
84
+ next?: number | null;
74
85
  };
75
86
 
76
87
  type Person = {
@@ -85,15 +96,26 @@ type Person = {
85
96
  last_interaction_at?: string | null;
86
97
  };
87
98
 
99
+ type PersonOption = {
100
+ id: number;
101
+ name: string;
102
+ };
103
+
88
104
  type FollowupStatus = 'today' | 'upcoming' | 'overdue';
89
105
 
90
- type FollowupRow = {
106
+ type FollowupListItem = {
91
107
  person: Person;
92
- nextActionAt: string;
108
+ next_action_at: string;
109
+ last_interaction_at?: string | null;
93
110
  status: FollowupStatus;
94
111
  };
95
112
 
96
- const SOURCE_PAGE_SIZE = 500;
113
+ type FollowupStats = {
114
+ total: number;
115
+ today: number;
116
+ overdue: number;
117
+ upcoming: number;
118
+ };
97
119
 
98
120
  function toInputDateTimeValue(value?: string | null) {
99
121
  if (!value) {
@@ -115,37 +137,6 @@ function toInputDateTimeValue(value?: string | null) {
115
137
  return `${year}-${month}-${day}T${hour}:${minute}`;
116
138
  }
117
139
 
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
140
  function getStatusBadgeClass(status: FollowupStatus) {
150
141
  if (status === 'overdue') {
151
142
  return 'border-red-500/25 bg-red-500/10 text-red-700';
@@ -186,12 +177,16 @@ export default function CrmFollowupsPage() {
186
177
 
187
178
  const [searchInput, setSearchInput] = useState('');
188
179
  const [debouncedSearch, setDebouncedSearch] = useState('');
180
+ const [personSearch, setPersonSearch] = useState('');
181
+ const [debouncedPersonSearch, setDebouncedPersonSearch] = useState('');
182
+ const [selectedPersonLabel, setSelectedPersonLabel] = useState('');
189
183
  const [statusFilter, setStatusFilter] = useState('all');
190
184
  const [dateFrom, setDateFrom] = useState('');
191
185
  const [dateTo, setDateTo] = useState('');
192
186
  const [page, setPage] = useState(1);
193
187
  const [pageSize, setPageSize] = useState(12);
194
188
  const [sheetOpen, setSheetOpen] = useState(false);
189
+ const [personPickerOpen, setPersonPickerOpen] = useState(false);
195
190
  const [isSubmitting, setIsSubmitting] = useState(false);
196
191
 
197
192
  useEffect(() => {
@@ -202,22 +197,35 @@ export default function CrmFollowupsPage() {
202
197
  return () => clearTimeout(timeout);
203
198
  }, [searchInput]);
204
199
 
200
+ useEffect(() => {
201
+ const timeout = setTimeout(() => {
202
+ setDebouncedPersonSearch(personSearch.trim());
203
+ }, 300);
204
+
205
+ return () => clearTimeout(timeout);
206
+ }, [personSearch]);
207
+
205
208
  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],
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],
211
218
  queryFn: async () => {
212
219
  const params = new URLSearchParams();
213
- params.set('page', '1');
214
- params.set('pageSize', String(SOURCE_PAGE_SIZE));
215
220
  if (debouncedSearch) {
216
221
  params.set('search', debouncedSearch);
217
222
  }
218
223
 
219
- const response = await request<PaginatedResult<Person>>({
220
- url: `/person?${params.toString()}`,
224
+ const queryString = params.toString();
225
+ const response = await request<FollowupStats>({
226
+ url: queryString
227
+ ? `/person/followups/stats?${queryString}`
228
+ : '/person/followups/stats',
221
229
  method: 'GET',
222
230
  });
223
231
 
@@ -225,62 +233,97 @@ export default function CrmFollowupsPage() {
225
233
  },
226
234
  placeholderData: (previous) =>
227
235
  previous ?? {
228
- data: [],
229
236
  total: 0,
230
- page: 1,
231
- pageSize: SOURCE_PAGE_SIZE,
237
+ today: 0,
238
+ overdue: 0,
239
+ upcoming: 0,
232
240
  },
233
241
  });
234
242
 
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;
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);
258
273
  }
259
-
260
- const targetDate = new Date(item.nextActionAt);
261
-
262
274
  if (dateFrom) {
263
- const min = startOfDay(new Date(`${dateFrom}T00:00:00`));
264
- if (targetDate < min) {
265
- return false;
266
- }
275
+ params.set('date_from', dateFrom);
267
276
  }
268
-
269
277
  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
- }
278
+ params.set('date_to', dateTo);
275
279
  }
276
280
 
277
- return true;
278
- });
279
- }, [allFollowups, statusFilter, dateFrom, dateTo]);
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
+ });
280
323
 
281
324
  const totalPages = Math.max(
282
325
  1,
283
- Math.ceil(filteredFollowups.length / pageSize)
326
+ paginate.lastPage ?? (Math.ceil((paginate.total || 0) / pageSize) || 1)
284
327
  );
285
328
 
286
329
  useEffect(() => {
@@ -289,40 +332,6 @@ export default function CrmFollowupsPage() {
289
332
  }
290
333
  }, [page, totalPages]);
291
334
 
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
335
  const searchControls: SearchBarControl[] = [
327
336
  {
328
337
  id: 'followup-status',
@@ -363,6 +372,10 @@ export default function CrmFollowupsPage() {
363
372
  ];
364
373
 
365
374
  const openCreateSheet = () => {
375
+ setSelectedPersonLabel('');
376
+ setPersonSearch('');
377
+ setDebouncedPersonSearch('');
378
+ setPersonPickerOpen(false);
366
379
  form.reset({
367
380
  personId: '',
368
381
  next_action_at: '',
@@ -371,10 +384,14 @@ export default function CrmFollowupsPage() {
371
384
  setSheetOpen(true);
372
385
  };
373
386
 
374
- const openRescheduleSheet = (row: FollowupRow) => {
387
+ const openRescheduleSheet = (row: FollowupListItem) => {
388
+ setSelectedPersonLabel(row.person.name);
389
+ setPersonSearch(row.person.name);
390
+ setDebouncedPersonSearch(row.person.name);
391
+ setPersonPickerOpen(false);
375
392
  form.reset({
376
393
  personId: String(row.person.id),
377
- next_action_at: toInputDateTimeValue(row.nextActionAt),
394
+ next_action_at: toInputDateTimeValue(row.next_action_at),
378
395
  notes: '',
379
396
  });
380
397
  setSheetOpen(true);
@@ -407,7 +424,8 @@ export default function CrmFollowupsPage() {
407
424
 
408
425
  toast.success(t('toasts.scheduleSuccess'));
409
426
  setSheetOpen(false);
410
- await refetch();
427
+ setPersonPickerOpen(false);
428
+ await Promise.all([refetchFollowups(), refetchStats()]);
411
429
  } catch {
412
430
  toast.error(t('toasts.scheduleError'));
413
431
  } finally {
@@ -415,6 +433,13 @@ export default function CrmFollowupsPage() {
415
433
  }
416
434
  };
417
435
 
436
+ const selectedPersonName =
437
+ personOptions.find(
438
+ (option) => String(option.id) === String(form.watch('personId') || '')
439
+ )?.name ||
440
+ selectedPersonLabel ||
441
+ '';
442
+
418
443
  const statsCards = [
419
444
  {
420
445
  key: 'total',
@@ -479,8 +504,9 @@ export default function CrmFollowupsPage() {
479
504
  setPage(1);
480
505
  }}
481
506
  onSearch={() => {
507
+ setDebouncedSearch(searchInput.trim());
482
508
  setPage(1);
483
- void refetch();
509
+ void Promise.all([refetchFollowups(), refetchStats()]);
484
510
  }}
485
511
  placeholder={t('filters.searchPlaceholder')}
486
512
  controls={searchControls}
@@ -492,7 +518,7 @@ export default function CrmFollowupsPage() {
492
518
  <Skeleton key={index} className="h-12 w-full" />
493
519
  ))}
494
520
  </div>
495
- ) : pageData.length === 0 ? (
521
+ ) : paginate.data.length === 0 ? (
496
522
  <EmptyState
497
523
  icon={<CalendarClock className="h-12 w-12" />}
498
524
  title={t('empty.title')}
@@ -517,8 +543,8 @@ export default function CrmFollowupsPage() {
517
543
  </TableRow>
518
544
  </TableHeader>
519
545
  <TableBody>
520
- {pageData.map((row) => (
521
- <TableRow key={row.person.id}>
546
+ {paginate.data.map((row) => (
547
+ <TableRow key={`${row.person.id}-${row.next_action_at}`}>
522
548
  <TableCell>
523
549
  <div className="min-w-[180px]">
524
550
  <div className="font-medium">{row.person.name}</div>
@@ -537,15 +563,15 @@ export default function CrmFollowupsPage() {
537
563
  </TableCell>
538
564
  <TableCell>
539
565
  {formatDateTime(
540
- row.nextActionAt,
566
+ row.next_action_at,
541
567
  getSettingValue,
542
568
  currentLocaleCode
543
569
  )}
544
570
  </TableCell>
545
571
  <TableCell>
546
- {row.person.last_interaction_at
572
+ {row.last_interaction_at
547
573
  ? formatDateTime(
548
- row.person.last_interaction_at,
574
+ row.last_interaction_at,
549
575
  getSettingValue,
550
576
  currentLocaleCode
551
577
  )
@@ -584,7 +610,7 @@ export default function CrmFollowupsPage() {
584
610
  <PaginationFooter
585
611
  currentPage={page}
586
612
  pageSize={pageSize}
587
- totalItems={filteredFollowups.length}
613
+ totalItems={paginate.total}
588
614
  onPageChange={setPage}
589
615
  onPageSizeChange={(nextPageSize) => {
590
616
  setPageSize(nextPageSize);
@@ -599,6 +625,9 @@ export default function CrmFollowupsPage() {
599
625
  onOpenChange={(open) => {
600
626
  if (!isSubmitting) {
601
627
  setSheetOpen(open);
628
+ if (!open) {
629
+ setPersonPickerOpen(false);
630
+ }
602
631
  }
603
632
  }}
604
633
  >
@@ -617,24 +646,84 @@ export default function CrmFollowupsPage() {
617
646
  control={form.control}
618
647
  name="personId"
619
648
  render={({ field }) => (
620
- <FormItem>
649
+ <FormItem className="flex flex-col">
621
650
  <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')}
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}
627
686
  />
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>
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>
638
727
  <FormMessage />
639
728
  </FormItem>
640
729
  )}