@hed-hog/contact 0.0.304 → 0.0.306

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 (44) hide show
  1. package/README.md +225 -17
  2. package/dist/person/dto/account.dto.d.ts +5 -0
  3. package/dist/person/dto/account.dto.d.ts.map +1 -1
  4. package/dist/person/dto/account.dto.js +29 -0
  5. package/dist/person/dto/account.dto.js.map +1 -1
  6. package/dist/person/dto/import-preview.dto.d.ts +7 -0
  7. package/dist/person/dto/import-preview.dto.d.ts.map +1 -0
  8. package/dist/person/dto/import-preview.dto.js +7 -0
  9. package/dist/person/dto/import-preview.dto.js.map +1 -0
  10. package/dist/person/dto/import.dto.d.ts +15 -0
  11. package/dist/person/dto/import.dto.d.ts.map +1 -0
  12. package/dist/person/dto/import.dto.js +51 -0
  13. package/dist/person/dto/import.dto.js.map +1 -0
  14. package/dist/person/person.controller.d.ts +14 -0
  15. package/dist/person/person.controller.d.ts.map +1 -1
  16. package/dist/person/person.controller.js +53 -0
  17. package/dist/person/person.controller.js.map +1 -1
  18. package/dist/person/person.service.d.ts +19 -0
  19. package/dist/person/person.service.d.ts.map +1 -1
  20. package/dist/person/person.service.js +481 -67
  21. package/dist/person/person.service.js.map +1 -1
  22. package/dist/person-relation-type/person-relation-type.controller.d.ts +2 -2
  23. package/dist/person-relation-type/person-relation-type.service.d.ts +2 -2
  24. package/hedhog/data/route.yaml +6 -0
  25. package/hedhog/frontend/app/accounts/_components/account-form-sheet.tsx.ejs +2242 -484
  26. package/hedhog/frontend/app/accounts/_components/account-types.ts.ejs +51 -0
  27. package/hedhog/frontend/app/accounts/page.tsx.ejs +181 -16
  28. package/hedhog/frontend/app/contact-type/page.tsx.ejs +223 -29
  29. package/hedhog/frontend/app/document-type/page.tsx.ejs +248 -37
  30. package/hedhog/frontend/app/follow-ups/page.tsx.ejs +129 -19
  31. package/hedhog/frontend/app/person/_components/person-field-with-create.tsx.ejs +78 -212
  32. package/hedhog/frontend/app/person/_components/person-form-sheet.tsx.ejs +760 -178
  33. package/hedhog/frontend/app/person/_components/person-import-sheet.tsx.ejs +1120 -0
  34. package/hedhog/frontend/app/person/_components/person-interaction-dialog.tsx.ejs +171 -4
  35. package/hedhog/frontend/app/person/page.tsx.ejs +17 -0
  36. package/hedhog/frontend/app/pipeline/_components/lead-proposals-tab.tsx.ejs +160 -35
  37. package/hedhog/frontend/messages/en.json +104 -2
  38. package/hedhog/frontend/messages/pt.json +111 -9
  39. package/package.json +4 -4
  40. package/src/person/dto/account.dto.ts +31 -0
  41. package/src/person/dto/import-preview.dto.ts +6 -0
  42. package/src/person/dto/import.dto.ts +61 -0
  43. package/src/person/person.controller.ts +74 -12
  44. package/src/person/person.service.ts +615 -68
@@ -47,10 +47,12 @@ import {
47
47
  TooltipTrigger,
48
48
  } from '@/components/ui/tooltip';
49
49
  import { COUNTRIES } from '@/constants/countries';
50
+ import { useFormDraft } from '@/hooks/use-form-draft';
51
+ import { formatDateTime } from '@/lib/format-date';
50
52
  import { cn } from '@/lib/utils';
51
53
  import { useApp, useQuery } from '@hed-hog/next-app-provider';
52
54
  import { zodResolver } from '@hookform/resolvers/zod';
53
- import { format } from 'date-fns';
55
+ import { format, formatDistanceToNow } from 'date-fns';
54
56
  import { enUS, ptBR } from 'date-fns/locale';
55
57
  import {
56
58
  Building2,
@@ -69,8 +71,15 @@ import {
69
71
  User,
70
72
  } from 'lucide-react';
71
73
  import { useTranslations } from 'next-intl';
72
- import { type ChangeEvent, useEffect, useMemo, useRef, useState } from 'react';
73
- import { useForm } from 'react-hook-form';
74
+ import {
75
+ type ChangeEvent,
76
+ useCallback,
77
+ useEffect,
78
+ useMemo,
79
+ useRef,
80
+ useState,
81
+ } from 'react';
82
+ import { useForm, useWatch } from 'react-hook-form';
74
83
  import { toast } from 'sonner';
75
84
  import { z } from 'zod';
76
85
 
@@ -94,7 +103,12 @@ type PersonFormSheetProps = {
94
103
  contactTypes: ContactTypeOption[];
95
104
  documentTypes: DocumentTypeOption[];
96
105
  onOpenChange: (open: boolean) => void;
97
- onSuccess: () => void;
106
+ onSuccess: (person?: Person) => void | Promise<void>;
107
+ initialEmployerCompanyId?: number | null;
108
+ initialEmployerCompanyLabel?: string;
109
+ title?: string;
110
+ description?: string;
111
+ allowedTypes?: Array<Person['type']>;
98
112
  };
99
113
 
100
114
  type PersonFormValues = {
@@ -192,6 +206,34 @@ type PersonSubmitPayload = {
192
206
  }>;
193
207
  };
194
208
 
209
+ type PersonDraftPayload = {
210
+ mode: 'create' | 'edit';
211
+ personId: number | null;
212
+ values: {
213
+ name: string;
214
+ type: 'individual' | 'company';
215
+ status: 'active' | 'inactive';
216
+ birth_date: string | null;
217
+ gender: PersonGender | null;
218
+ job_title: string | null;
219
+ employer_company_id: number | null;
220
+ trade_name: string | null;
221
+ foundation_date: string | null;
222
+ legal_nature: string | null;
223
+ owner_user_id: number | null;
224
+ source: PersonSource | null;
225
+ lifecycle_stage: PersonLifecycleStage | null;
226
+ next_action_at: string | null;
227
+ };
228
+ contacts: EditablePersonContact[];
229
+ addresses: EditablePersonAddress[];
230
+ documents: EditablePersonDocument[];
231
+ avatarId: number | null;
232
+ avatarPreviewUrl: string;
233
+ };
234
+
235
+ const PERSON_FORM_DRAFT_STORAGE_KEY = 'contact-person-form-draft';
236
+
195
237
  type PendingDuplicateSubmission = {
196
238
  payload: PersonSubmitPayload;
197
239
  normalizedType: 'individual' | 'company';
@@ -258,6 +300,47 @@ function toDatetimeLocalValue(value?: string | null) {
258
300
  return localDate.toISOString().slice(0, 16);
259
301
  }
260
302
 
303
+ function padTwoDigits(value: number) {
304
+ return String(value).padStart(2, '0');
305
+ }
306
+
307
+ function parseDatetimeLocalValue(value?: string | null) {
308
+ if (!value) return undefined;
309
+
310
+ const match = value.match(/^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2})$/);
311
+
312
+ if (!match) return undefined;
313
+
314
+ const [, year, month, day, hours, minutes] = match;
315
+ const parsedDate = new Date(
316
+ Number(year),
317
+ Number(month) - 1,
318
+ Number(day),
319
+ Number(hours),
320
+ Number(minutes)
321
+ );
322
+
323
+ if (Number.isNaN(parsedDate.getTime())) {
324
+ return undefined;
325
+ }
326
+
327
+ if (
328
+ parsedDate.getFullYear() !== Number(year) ||
329
+ parsedDate.getMonth() !== Number(month) - 1 ||
330
+ parsedDate.getDate() !== Number(day) ||
331
+ parsedDate.getHours() !== Number(hours) ||
332
+ parsedDate.getMinutes() !== Number(minutes)
333
+ ) {
334
+ return undefined;
335
+ }
336
+
337
+ return parsedDate;
338
+ }
339
+
340
+ function buildDatetimeLocalValue(date: Date) {
341
+ return `${format(date, 'yyyy-MM-dd')}T${padTwoDigits(date.getHours())}:${padTwoDigits(date.getMinutes())}`;
342
+ }
343
+
261
344
  async function fetchViaCep(cep: string) {
262
345
  try {
263
346
  const response = await fetch(`https://viacep.com.br/ws/${cep}/json/`);
@@ -392,6 +475,217 @@ function DatePickerWithYearMonth({
392
475
  );
393
476
  }
394
477
 
478
+ function DateTimePickerWithTime({
479
+ value,
480
+ onChange,
481
+ placeholder,
482
+ localeCode,
483
+ clearLabel,
484
+ }: {
485
+ value?: string | null;
486
+ onChange: (value: string) => void;
487
+ placeholder: string;
488
+ localeCode: string;
489
+ clearLabel: string;
490
+ }) {
491
+ const [isOpen, setIsOpen] = useState(false);
492
+ const locale = localeCode.startsWith('pt') ? ptBR : enUS;
493
+ const parsedValue = useMemo(() => parseDatetimeLocalValue(value), [value]);
494
+ const [viewDate, setViewDate] = useState(parsedValue || new Date());
495
+ const currentViewDate = parsedValue || viewDate;
496
+
497
+ const currentYear = new Date().getFullYear();
498
+ const years = useMemo(() => {
499
+ const values: number[] = [];
500
+ for (let year = currentYear + 10; year >= currentYear - 10; year -= 1) {
501
+ values.push(year);
502
+ }
503
+ return values;
504
+ }, [currentYear]);
505
+
506
+ const months = useMemo(
507
+ () =>
508
+ Array.from({ length: 12 }, (_, index) =>
509
+ format(new Date(2024, index, 1), 'LLLL', { locale })
510
+ ),
511
+ [locale]
512
+ );
513
+
514
+ const hours = useMemo(
515
+ () => Array.from({ length: 24 }, (_, index) => padTwoDigits(index)),
516
+ []
517
+ );
518
+ const minutes = useMemo(
519
+ () => Array.from({ length: 60 }, (_, index) => padTwoDigits(index)),
520
+ []
521
+ );
522
+
523
+ const handleYearChange = (year: string) => {
524
+ const nextDate = new Date(currentViewDate);
525
+ nextDate.setFullYear(Number(year));
526
+ setViewDate(nextDate);
527
+ };
528
+
529
+ const handleMonthChange = (month: string) => {
530
+ const nextDate = new Date(currentViewDate);
531
+ nextDate.setMonth(Number(month));
532
+ setViewDate(nextDate);
533
+ };
534
+
535
+ const handleDateSelect = (selectedDate: Date | undefined) => {
536
+ if (!selectedDate) {
537
+ onChange('');
538
+ return;
539
+ }
540
+
541
+ const baseDate = parsedValue || new Date();
542
+ const nextDate = new Date(selectedDate);
543
+ nextDate.setHours(baseDate.getHours(), baseDate.getMinutes(), 0, 0);
544
+ setViewDate(nextDate);
545
+ onChange(buildDatetimeLocalValue(nextDate));
546
+ };
547
+
548
+ const handleTimePartChange = (
549
+ part: 'hours' | 'minutes',
550
+ nextValue: string
551
+ ) => {
552
+ const baseDate = parsedValue || new Date(currentViewDate);
553
+ const nextDate = new Date(baseDate);
554
+
555
+ if (part === 'hours') {
556
+ nextDate.setHours(Number(nextValue));
557
+ } else {
558
+ nextDate.setMinutes(Number(nextValue));
559
+ }
560
+
561
+ nextDate.setSeconds(0, 0);
562
+ setViewDate(nextDate);
563
+ onChange(buildDatetimeLocalValue(nextDate));
564
+ };
565
+
566
+ return (
567
+ <Popover open={isOpen} onOpenChange={setIsOpen}>
568
+ <PopoverTrigger asChild>
569
+ <Button
570
+ type="button"
571
+ variant="outline"
572
+ className={cn(
573
+ 'h-9 w-full min-w-0 justify-start text-left text-xs font-normal',
574
+ !parsedValue && 'text-muted-foreground'
575
+ )}
576
+ >
577
+ <CalendarIcon className="mr-2 h-3.5 w-3.5" />
578
+ {parsedValue ? (
579
+ format(parsedValue, 'dd/MM/yyyy HH:mm', { locale })
580
+ ) : (
581
+ <span className="block truncate text-xs">{placeholder}</span>
582
+ )}
583
+ </Button>
584
+ </PopoverTrigger>
585
+ <PopoverContent className="w-[320px] p-0" align="start">
586
+ <div className="flex items-center gap-1 border-b p-2">
587
+ <Select
588
+ value={String(currentViewDate.getMonth())}
589
+ onValueChange={handleMonthChange}
590
+ >
591
+ <SelectTrigger className="h-8 flex-1 text-xs">
592
+ <SelectValue />
593
+ </SelectTrigger>
594
+ <SelectContent>
595
+ {months.map((month, index) => (
596
+ <SelectItem key={month} value={String(index)}>
597
+ {month}
598
+ </SelectItem>
599
+ ))}
600
+ </SelectContent>
601
+ </Select>
602
+ <Select
603
+ value={String(currentViewDate.getFullYear())}
604
+ onValueChange={handleYearChange}
605
+ >
606
+ <SelectTrigger className="h-8 w-24 text-xs">
607
+ <SelectValue />
608
+ </SelectTrigger>
609
+ <SelectContent className="max-h-60">
610
+ {years.map((year) => (
611
+ <SelectItem key={year} value={String(year)}>
612
+ {year}
613
+ </SelectItem>
614
+ ))}
615
+ </SelectContent>
616
+ </Select>
617
+ </div>
618
+
619
+ <Calendar
620
+ mode="single"
621
+ selected={parsedValue}
622
+ onSelect={handleDateSelect}
623
+ month={currentViewDate}
624
+ onMonthChange={setViewDate}
625
+ initialFocus
626
+ />
627
+
628
+ <div className="border-t p-3">
629
+ <div className="grid grid-cols-[1fr_1fr_auto] gap-2">
630
+ <Select
631
+ value={
632
+ parsedValue ? padTwoDigits(parsedValue.getHours()) : undefined
633
+ }
634
+ onValueChange={(nextValue) =>
635
+ handleTimePartChange('hours', nextValue)
636
+ }
637
+ disabled={!parsedValue}
638
+ >
639
+ <SelectTrigger className="h-8 text-xs">
640
+ <SelectValue placeholder="HH" />
641
+ </SelectTrigger>
642
+ <SelectContent className="max-h-60">
643
+ {hours.map((hour) => (
644
+ <SelectItem key={hour} value={hour}>
645
+ {hour}
646
+ </SelectItem>
647
+ ))}
648
+ </SelectContent>
649
+ </Select>
650
+
651
+ <Select
652
+ value={
653
+ parsedValue ? padTwoDigits(parsedValue.getMinutes()) : undefined
654
+ }
655
+ onValueChange={(nextValue) =>
656
+ handleTimePartChange('minutes', nextValue)
657
+ }
658
+ disabled={!parsedValue}
659
+ >
660
+ <SelectTrigger className="h-8 text-xs">
661
+ <SelectValue placeholder="MM" />
662
+ </SelectTrigger>
663
+ <SelectContent className="max-h-60">
664
+ {minutes.map((minute) => (
665
+ <SelectItem key={minute} value={minute}>
666
+ {minute}
667
+ </SelectItem>
668
+ ))}
669
+ </SelectContent>
670
+ </Select>
671
+
672
+ <Button
673
+ type="button"
674
+ variant="ghost"
675
+ size="sm"
676
+ className="h-8 px-2 text-xs"
677
+ onClick={() => onChange('')}
678
+ disabled={!parsedValue}
679
+ >
680
+ {clearLabel}
681
+ </Button>
682
+ </div>
683
+ </div>
684
+ </PopoverContent>
685
+ </Popover>
686
+ );
687
+ }
688
+
395
689
  export function PersonFormSheet({
396
690
  open,
397
691
  person,
@@ -399,13 +693,39 @@ export function PersonFormSheet({
399
693
  documentTypes,
400
694
  onOpenChange,
401
695
  onSuccess,
696
+ initialEmployerCompanyId = null,
697
+ initialEmployerCompanyLabel = '',
698
+ title,
699
+ description,
700
+ allowedTypes,
402
701
  }: PersonFormSheetProps) {
403
702
  const t = useTranslations('contact.ContactPage');
404
703
  const { request, currentLocaleCode, getSettingValue, user } = useApp();
405
704
  const isEditing = Boolean(person);
406
705
  const allowCompanyRegistration =
407
706
  getSettingValue('contact-allow-company-registration') !== false;
408
- const canUseCompanyType = allowCompanyRegistration;
707
+ const effectiveAllowedTypes = useMemo<Array<Person['type']>>(() => {
708
+ const sanitized = Array.from(
709
+ new Set(
710
+ (allowedTypes ?? []).filter(
711
+ (type): type is Person['type'] =>
712
+ type === 'individual' ||
713
+ (type === 'company' && allowCompanyRegistration)
714
+ )
715
+ )
716
+ );
717
+
718
+ if (sanitized.length > 0) {
719
+ return sanitized;
720
+ }
721
+
722
+ return allowCompanyRegistration
723
+ ? ['individual', 'company']
724
+ : ['individual'];
725
+ }, [allowCompanyRegistration, allowedTypes]);
726
+ const defaultPersonType = effectiveAllowedTypes[0] ?? 'individual';
727
+ const canUseCompanyType = effectiveAllowedTypes.includes('company');
728
+ const showTypeField = effectiveAllowedTypes.length > 1;
409
729
 
410
730
  const personSchema = useMemo(
411
731
  () =>
@@ -522,7 +842,112 @@ export function PersonFormSheet({
522
842
  };
523
843
 
524
844
  const watchType = watch('type');
845
+ const watchedFormValues = useWatch({
846
+ control: form.control,
847
+ });
525
848
 
849
+ const hasDraftContent = useMemo(
850
+ () =>
851
+ Boolean(
852
+ (watchedFormValues.name ?? '').trim() ||
853
+ (watchedFormValues.job_title ?? '').trim() ||
854
+ (watchedFormValues.trade_name ?? '').trim() ||
855
+ (watchedFormValues.legal_nature ?? '').trim() ||
856
+ (watchedFormValues.next_action_at ?? '').trim() ||
857
+ watchedFormValues.type === 'company' ||
858
+ watchedFormValues.status === 'inactive' ||
859
+ watchedFormValues.birth_date ||
860
+ watchedFormValues.foundation_date ||
861
+ watchedFormValues.gender ||
862
+ watchedFormValues.source ||
863
+ (watchedFormValues.lifecycle_stage ?? 'new') !== 'new' ||
864
+ avatarId != null ||
865
+ contacts.length > 0 ||
866
+ addresses.length > 0 ||
867
+ documents.length > 0
868
+ ),
869
+ [watchedFormValues, avatarId, contacts, addresses, documents]
870
+ );
871
+
872
+ const draftValue = useMemo<PersonDraftPayload>(
873
+ () => ({
874
+ mode: person?.id ? 'edit' : 'create',
875
+ personId: person?.id ?? null,
876
+ values: {
877
+ name: watchedFormValues.name ?? '',
878
+ type: watchedFormValues.type ?? 'individual',
879
+ status: watchedFormValues.status ?? 'active',
880
+ birth_date: watchedFormValues.birth_date
881
+ ? watchedFormValues.birth_date.toISOString()
882
+ : null,
883
+ gender: watchedFormValues.gender ?? null,
884
+ job_title: watchedFormValues.job_title ?? null,
885
+ employer_company_id: watchedFormValues.employer_company_id ?? null,
886
+ trade_name: watchedFormValues.trade_name ?? null,
887
+ foundation_date: watchedFormValues.foundation_date
888
+ ? watchedFormValues.foundation_date.toISOString()
889
+ : null,
890
+ legal_nature: watchedFormValues.legal_nature ?? null,
891
+ owner_user_id: watchedFormValues.owner_user_id ?? null,
892
+ source: watchedFormValues.source ?? null,
893
+ lifecycle_stage: watchedFormValues.lifecycle_stage ?? 'new',
894
+ next_action_at: watchedFormValues.next_action_at ?? null,
895
+ },
896
+ contacts,
897
+ addresses,
898
+ documents,
899
+ avatarId,
900
+ avatarPreviewUrl,
901
+ }),
902
+ [
903
+ watchedFormValues,
904
+ contacts,
905
+ addresses,
906
+ documents,
907
+ avatarId,
908
+ avatarPreviewUrl,
909
+ person?.id,
910
+ ]
911
+ );
912
+
913
+ const {
914
+ clearDraft,
915
+ loadDraft,
916
+ hasDraft,
917
+ savedAt: draftSavedAt,
918
+ } = useFormDraft<PersonDraftPayload>({
919
+ storageKey: PERSON_FORM_DRAFT_STORAGE_KEY,
920
+ value: draftValue,
921
+ hasData: hasDraftContent,
922
+ enabled: open,
923
+ });
924
+
925
+ const draftStatusContent = useMemo(() => {
926
+ if (!hasDraft || !draftSavedAt) {
927
+ return null;
928
+ }
929
+
930
+ const savedDate = new Date(draftSavedAt);
931
+
932
+ if (Number.isNaN(savedDate.getTime())) {
933
+ return null;
934
+ }
935
+
936
+ const locale = currentLocaleCode.startsWith('pt') ? ptBR : enUS;
937
+ const relativeLabel = formatDistanceToNow(savedDate, {
938
+ addSuffix: true,
939
+ locale,
940
+ });
941
+ const absoluteLabel = formatDateTime(
942
+ savedDate,
943
+ getSettingValue,
944
+ currentLocaleCode
945
+ );
946
+
947
+ return currentLocaleCode.startsWith('pt')
948
+ ? `Rascunho salvo ${relativeLabel} • Último salvamento: ${absoluteLabel}`
949
+ : `Draft saved ${relativeLabel} • Last saved: ${absoluteLabel}`;
950
+ }, [draftSavedAt, currentLocaleCode, getSettingValue, hasDraft]);
526
951
  const { data: ownerOptions = [] } = useQuery<UserOption[]>({
527
952
  queryKey: ['contact-person-owner-options', currentLocaleCode],
528
953
  queryFn: async () => {
@@ -538,77 +963,112 @@ export function PersonFormSheet({
538
963
  });
539
964
 
540
965
  useEffect(() => {
541
- if (canUseCompanyType || watchType !== 'company') {
966
+ if (effectiveAllowedTypes.includes(watchType)) {
542
967
  return;
543
968
  }
544
969
 
545
- setValue('type', 'individual');
546
- }, [canUseCompanyType, setValue, watchType]);
970
+ setValue('type', defaultPersonType);
971
+ }, [defaultPersonType, effectiveAllowedTypes, setValue, watchType]);
547
972
 
548
973
  useEffect(() => {
549
974
  if (!open) return;
550
975
 
551
976
  hasSavedChangesRef.current = false;
977
+ const storedDraft = loadDraft();
978
+ const shouldRestoreDraft = Boolean(
979
+ storedDraft &&
980
+ ((!person && storedDraft.payload.mode === 'create') ||
981
+ (person &&
982
+ storedDraft.payload.mode === 'edit' &&
983
+ storedDraft.payload.personId === Number(person.id)))
984
+ );
985
+ const restoredDraft = shouldRestoreDraft ? storedDraft?.payload : null;
986
+ const initialTypeCandidate =
987
+ restoredDraft?.values.type ?? person?.type ?? defaultPersonType;
988
+
552
989
  reset({
553
- name: person?.name || '',
554
- type: allowCompanyRegistration
555
- ? person?.type || 'individual'
556
- : 'individual',
557
- status: person?.status || 'active',
558
- birth_date: person?.birth_date ? new Date(person.birth_date) : null,
559
- gender: person?.gender || null,
560
- job_title: person?.job_title || '',
561
- employer_company_id: person?.employer_company_id ?? null,
562
- trade_name: person?.trade_name || '',
563
- foundation_date: person?.foundation_date
564
- ? new Date(person.foundation_date)
565
- : null,
566
- legal_nature: person?.legal_nature || '',
990
+ name: restoredDraft?.values.name ?? person?.name ?? '',
991
+ type: effectiveAllowedTypes.includes(initialTypeCandidate)
992
+ ? initialTypeCandidate
993
+ : defaultPersonType,
994
+ status: restoredDraft?.values.status ?? person?.status ?? 'active',
995
+ birth_date: restoredDraft?.values.birth_date
996
+ ? new Date(restoredDraft.values.birth_date)
997
+ : person?.birth_date
998
+ ? new Date(person.birth_date)
999
+ : null,
1000
+ gender: restoredDraft?.values.gender ?? person?.gender ?? null,
1001
+ job_title: restoredDraft?.values.job_title ?? person?.job_title ?? '',
1002
+ employer_company_id:
1003
+ restoredDraft?.values.employer_company_id ??
1004
+ person?.employer_company_id ??
1005
+ initialEmployerCompanyId ??
1006
+ null,
1007
+ trade_name: restoredDraft?.values.trade_name ?? person?.trade_name ?? '',
1008
+ foundation_date: restoredDraft?.values.foundation_date
1009
+ ? new Date(restoredDraft.values.foundation_date)
1010
+ : person?.foundation_date
1011
+ ? new Date(person.foundation_date)
1012
+ : null,
1013
+ legal_nature:
1014
+ restoredDraft?.values.legal_nature ?? person?.legal_nature ?? '',
567
1015
  owner_user_id:
1016
+ restoredDraft?.values.owner_user_id ??
568
1017
  person?.owner_user_id ??
569
1018
  (person ? null : Number(user?.id || 0) || null),
570
- source: person?.source || null,
571
- lifecycle_stage: person?.lifecycle_stage || 'new',
572
- next_action_at: toDatetimeLocalValue(person?.next_action_at || null),
1019
+ source: restoredDraft?.values.source ?? person?.source ?? null,
1020
+ lifecycle_stage:
1021
+ restoredDraft?.values.lifecycle_stage ??
1022
+ person?.lifecycle_stage ??
1023
+ 'new',
1024
+ next_action_at:
1025
+ restoredDraft?.values.next_action_at ??
1026
+ toDatetimeLocalValue(person?.next_action_at || null),
573
1027
  });
574
- const initialAvatarId = person?.avatar_id ?? null;
1028
+ const initialAvatarId =
1029
+ restoredDraft?.avatarId ?? person?.avatar_id ?? null;
575
1030
  setAvatarId(initialAvatarId);
576
1031
  setPersistedAvatarId(initialAvatarId);
577
- setAvatarPreviewUrl(getPersonAvatarUrl(initialAvatarId));
1032
+ setAvatarPreviewUrl(
1033
+ restoredDraft?.avatarPreviewUrl || getPersonAvatarUrl(initialAvatarId)
1034
+ );
578
1035
  setIsUploadingAvatar(false);
579
1036
  setAvatarUploadProgress(0);
580
1037
 
581
1038
  setContacts(
582
- (person?.contact || []).map((contact, index) => ({
583
- ...contact,
584
- value: ['PHONE', 'MOBILE', 'WHATSAPP'].includes(
585
- String(contact.contact_type?.code || '').toUpperCase()
586
- )
587
- ? applyPhoneMask(contact.value || '')
588
- : contact.value || '',
589
- clientId: `contact-${contact.id ?? index}`,
590
- }))
1039
+ restoredDraft?.contacts ??
1040
+ (person?.contact || []).map((contact, index) => ({
1041
+ ...contact,
1042
+ value: ['PHONE', 'MOBILE', 'WHATSAPP'].includes(
1043
+ String(contact.contact_type?.code || '').toUpperCase()
1044
+ )
1045
+ ? applyPhoneMask(contact.value || '')
1046
+ : contact.value || '',
1047
+ clientId: `contact-${contact.id ?? index}`,
1048
+ }))
591
1049
  );
592
1050
 
593
1051
  setAddresses(
594
- (person?.address || []).map((address, index) => ({
595
- ...address,
596
- clientId: `address-${address.id ?? index}`,
597
- }))
1052
+ restoredDraft?.addresses ??
1053
+ (person?.address || []).map((address, index) => ({
1054
+ ...address,
1055
+ clientId: `address-${address.id ?? index}`,
1056
+ }))
598
1057
  );
599
1058
 
600
1059
  setDocuments(
601
- (person?.document || []).map((document, index) => ({
602
- ...document,
603
- value:
604
- String(document.document_type?.code || '').toUpperCase() === 'CPF'
605
- ? applyCpfMask(document.value || '')
606
- : String(document.document_type?.code || '').toUpperCase() ===
607
- 'CNPJ'
608
- ? applyCnpjMask(document.value || '')
609
- : document.value || '',
610
- clientId: `document-${document.id ?? index}`,
611
- }))
1060
+ restoredDraft?.documents ??
1061
+ (person?.document || []).map((document, index) => ({
1062
+ ...document,
1063
+ value:
1064
+ String(document.document_type?.code || '').toUpperCase() === 'CPF'
1065
+ ? applyCpfMask(document.value || '')
1066
+ : String(document.document_type?.code || '').toUpperCase() ===
1067
+ 'CNPJ'
1068
+ ? applyCnpjMask(document.value || '')
1069
+ : document.value || '',
1070
+ clientId: `document-${document.id ?? index}`,
1071
+ }))
612
1072
  );
613
1073
 
614
1074
  setContactsOpen(true);
@@ -619,7 +1079,25 @@ export function PersonFormSheet({
619
1079
  setPendingDuplicateSubmission(null);
620
1080
  setDuplicateTargetPersonId(null);
621
1081
  setIsResolvingDuplicateAction(false);
622
- }, [allowCompanyRegistration, open, person, reset, user?.id]);
1082
+ }, [
1083
+ defaultPersonType,
1084
+ effectiveAllowedTypes,
1085
+ initialEmployerCompanyId,
1086
+ loadDraft,
1087
+ open,
1088
+ person,
1089
+ reset,
1090
+ user?.id,
1091
+ ]);
1092
+
1093
+ const resolvePersonById = async (personId: number) => {
1094
+ const response = await request<Person>({
1095
+ url: `/person/${personId}`,
1096
+ method: 'GET',
1097
+ });
1098
+
1099
+ return response.data;
1100
+ };
623
1101
 
624
1102
  const getPersonInitials = (name: string) =>
625
1103
  name
@@ -639,25 +1117,28 @@ export function PersonFormSheet({
639
1117
  ? url
640
1118
  : `${String(process.env.NEXT_PUBLIC_API_BASE_URL || '')}${url}`;
641
1119
 
642
- const deleteFileById = async (fileId?: number | null) => {
643
- if (!fileId || fileId <= 0) return;
1120
+ const deleteFileById = useCallback(
1121
+ async (fileId?: number | null) => {
1122
+ if (!fileId || fileId <= 0) return;
644
1123
 
645
- try {
646
- await request({
647
- url: '/file',
648
- method: 'DELETE',
649
- data: { ids: [fileId] },
650
- });
651
- } catch {
652
- // Ignore cleanup failure to keep form interaction stable.
653
- }
654
- };
1124
+ try {
1125
+ await request({
1126
+ url: '/file',
1127
+ method: 'DELETE',
1128
+ data: { ids: [fileId] },
1129
+ });
1130
+ } catch {
1131
+ // Ignore cleanup failure to keep form interaction stable.
1132
+ }
1133
+ },
1134
+ [request]
1135
+ );
655
1136
 
656
- const cleanupUnsavedAvatar = async () => {
1137
+ const cleanupUnsavedAvatar = useCallback(async () => {
657
1138
  if (avatarId && avatarId !== persistedAvatarId) {
658
1139
  await deleteFileById(avatarId);
659
1140
  }
660
- };
1141
+ }, [avatarId, deleteFileById, persistedAvatarId]);
661
1142
 
662
1143
  const handleAvatarUpload = async (file: File) => {
663
1144
  if (!file.type.startsWith('image/')) {
@@ -747,26 +1228,35 @@ export function PersonFormSheet({
747
1228
  toast.success(t('avatarRemoveSuccess'));
748
1229
  };
749
1230
 
750
- const handleSheetOpenChange = (nextOpen: boolean) => {
751
- if (nextOpen) {
752
- hasSavedChangesRef.current = false;
753
- onOpenChange(true);
754
- return;
755
- }
1231
+ const handleSheetOpenChange = useCallback(
1232
+ (nextOpen: boolean) => {
1233
+ if (nextOpen) {
1234
+ hasSavedChangesRef.current = false;
1235
+ onOpenChange(true);
1236
+ return;
1237
+ }
756
1238
 
757
- if (isUploadingAvatar || isSubmitting) {
758
- return;
759
- }
1239
+ if (isUploadingAvatar || isSubmitting) {
1240
+ return;
1241
+ }
760
1242
 
761
- const shouldCleanup = !hasSavedChangesRef.current;
762
- hasSavedChangesRef.current = false;
1243
+ const shouldCleanup = !hasSavedChangesRef.current && !hasDraftContent;
1244
+ hasSavedChangesRef.current = false;
763
1245
 
764
- if (shouldCleanup) {
765
- void cleanupUnsavedAvatar();
766
- }
1246
+ if (shouldCleanup) {
1247
+ void cleanupUnsavedAvatar();
1248
+ }
767
1249
 
768
- onOpenChange(false);
769
- };
1250
+ onOpenChange(false);
1251
+ },
1252
+ [
1253
+ cleanupUnsavedAvatar,
1254
+ hasDraftContent,
1255
+ isSubmitting,
1256
+ isUploadingAvatar,
1257
+ onOpenChange,
1258
+ ]
1259
+ );
770
1260
 
771
1261
  useEffect(() => {
772
1262
  if (!open) {
@@ -1309,13 +1799,72 @@ export function PersonFormSheet({
1309
1799
  return personId;
1310
1800
  };
1311
1801
 
1312
- const finalizeSuccess = () => {
1802
+ const buildSuccessPerson = (
1803
+ personId: number,
1804
+ payload: PersonSubmitPayload
1805
+ ): Person => ({
1806
+ id: personId,
1807
+ name: payload.name,
1808
+ type: payload.type,
1809
+ status: payload.status,
1810
+ avatar_id: payload.avatar_id,
1811
+ birth_date: payload.birth_date,
1812
+ gender: payload.gender,
1813
+ job_title: payload.job_title,
1814
+ employer_company_id: payload.employer_company_id,
1815
+ employer_company:
1816
+ payload.employer_company_id && initialEmployerCompanyLabel
1817
+ ? {
1818
+ id: payload.employer_company_id,
1819
+ name: initialEmployerCompanyLabel,
1820
+ type: 'company',
1821
+ status: 'active',
1822
+ }
1823
+ : null,
1824
+ trade_name: payload.trade_name,
1825
+ foundation_date: payload.foundation_date,
1826
+ legal_nature: payload.legal_nature,
1827
+ owner_user_id: payload.owner_user_id,
1828
+ owner_user:
1829
+ ownerOptions.find((option) => option.id === payload.owner_user_id) ??
1830
+ null,
1831
+ source: payload.source,
1832
+ lifecycle_stage: payload.lifecycle_stage,
1833
+ next_action_at: payload.next_action_at,
1834
+ created_at: person?.created_at || new Date().toISOString(),
1835
+ contact: payload.contacts.map((contact) => ({
1836
+ ...contact,
1837
+ contact_type:
1838
+ contactTypes.find(
1839
+ (item) => item.contact_type_id === contact.contact_type_id
1840
+ ) ?? undefined,
1841
+ })),
1842
+ address: payload.addresses,
1843
+ document: payload.documents.map((document) => ({
1844
+ ...document,
1845
+ document_type:
1846
+ documentTypes.find(
1847
+ (item) => item.document_type_id === document.document_type_id
1848
+ ) ?? undefined,
1849
+ })),
1850
+ });
1851
+
1852
+ const finalizeSuccess = async (personId: number, fallbackPerson?: Person) => {
1853
+ let resolvedPerson = fallbackPerson;
1854
+
1855
+ try {
1856
+ resolvedPerson = await resolvePersonById(personId);
1857
+ } catch {
1858
+ resolvedPerson = fallbackPerson;
1859
+ }
1860
+
1313
1861
  hasSavedChangesRef.current = true;
1862
+ clearDraft();
1314
1863
  setPendingDuplicateSubmission(null);
1315
1864
  setDuplicateTargetPersonId(null);
1316
1865
  setDuplicateDialogOpen(false);
1317
1866
  handleSheetOpenChange(false);
1318
- onSuccess();
1867
+ await onSuccess(resolvedPerson);
1319
1868
  };
1320
1869
 
1321
1870
  const handleContinueWithDuplicate = async () => {
@@ -1323,11 +1872,14 @@ export function PersonFormSheet({
1323
1872
 
1324
1873
  try {
1325
1874
  setIsResolvingDuplicateAction(true);
1326
- await persistPersonPayload(
1875
+ const savedPersonId = await persistPersonPayload(
1327
1876
  pendingDuplicateSubmission.payload,
1328
1877
  pendingDuplicateSubmission.normalizedType
1329
1878
  );
1330
- finalizeSuccess();
1879
+ await finalizeSuccess(
1880
+ savedPersonId,
1881
+ buildSuccessPerson(savedPersonId, pendingDuplicateSubmission.payload)
1882
+ );
1331
1883
  } catch (error: unknown) {
1332
1884
  const message = error instanceof Error ? error.message : null;
1333
1885
  toast.error(message || (isEditing ? t('updateError') : t('createError')));
@@ -1371,7 +1923,13 @@ export function PersonFormSheet({
1371
1923
  })
1372
1924
  );
1373
1925
 
1374
- finalizeSuccess();
1926
+ await finalizeSuccess(
1927
+ duplicateTargetPersonId,
1928
+ buildSuccessPerson(
1929
+ duplicateTargetPersonId,
1930
+ pendingDuplicateSubmission.payload
1931
+ )
1932
+ );
1375
1933
  } catch (error: unknown) {
1376
1934
  const message = error instanceof Error ? error.message : null;
1377
1935
  toast.error(message || t('duplicateMergeError'));
@@ -1384,9 +1942,10 @@ export function PersonFormSheet({
1384
1942
  try {
1385
1943
  setIsSubmitting(true);
1386
1944
 
1387
- const normalizedType = allowCompanyRegistration
1388
- ? values.type
1389
- : 'individual';
1945
+ const normalizedType =
1946
+ canUseCompanyType && values.type === 'company'
1947
+ ? 'company'
1948
+ : 'individual';
1390
1949
 
1391
1950
  const payload = buildSubmitPayload(values, normalizedType);
1392
1951
  const matches = await findDuplicates(payload);
@@ -1408,8 +1967,11 @@ export function PersonFormSheet({
1408
1967
  return;
1409
1968
  }
1410
1969
 
1411
- await persistPersonPayload(payload, normalizedType);
1412
- finalizeSuccess();
1970
+ const savedPersonId = await persistPersonPayload(payload, normalizedType);
1971
+ await finalizeSuccess(
1972
+ savedPersonId,
1973
+ buildSuccessPerson(savedPersonId, payload)
1974
+ );
1413
1975
  } catch (error: unknown) {
1414
1976
  const message = error instanceof Error ? error.message : null;
1415
1977
  toast.error(message || (isEditing ? t('updateError') : t('createError')));
@@ -1421,7 +1983,10 @@ export function PersonFormSheet({
1421
1983
  return (
1422
1984
  <>
1423
1985
  <Sheet open={open} onOpenChange={handleSheetOpenChange}>
1424
- <SheetContent className="flex h-full w-full max-w-[95vw] flex-col overflow-hidden p-0 sm:max-w-3xl lg:max-w-5xl xl:max-w-6xl 2xl:max-w-7xl">
1986
+ <SheetContent
1987
+ side="right"
1988
+ className="flex w-full flex-col sm:max-w-lg overflow-y-auto"
1989
+ >
1425
1990
  <SheetHeader className="shrink-0 border-b p-4">
1426
1991
  <div className="flex items-center gap-3">
1427
1992
  <div
@@ -1440,14 +2005,16 @@ export function PersonFormSheet({
1440
2005
  </div>
1441
2006
  <div>
1442
2007
  <SheetTitle>
1443
- {isEditing ? t('sheetEditTitle') : t('sheetCreateTitle')}
2008
+ {title ||
2009
+ (isEditing ? t('sheetEditTitle') : t('sheetCreateTitle'))}
1444
2010
  </SheetTitle>
1445
2011
  <SheetDescription>
1446
- {isEditing
1447
- ? t('sheetEditDescription')
1448
- : allowCompanyRegistration
1449
- ? t('sheetCreateDescription')
1450
- : t('sheetCreateDescriptionIndividualOnly')}
2012
+ {description ||
2013
+ (isEditing
2014
+ ? t('sheetEditDescription')
2015
+ : canUseCompanyType
2016
+ ? t('sheetCreateDescription')
2017
+ : t('sheetCreateDescriptionIndividualOnly'))}
1451
2018
  </SheetDescription>
1452
2019
  </div>
1453
2020
  </div>
@@ -1565,7 +2132,7 @@ export function PersonFormSheet({
1565
2132
  : 'grid-cols-1 sm:grid-cols-2'
1566
2133
  )}
1567
2134
  >
1568
- {allowCompanyRegistration ? (
2135
+ {showTypeField ? (
1569
2136
  <div className="space-y-1.5">
1570
2137
  <Label className="text-xs font-medium">{t('type')}</Label>
1571
2138
  <Select
@@ -1590,74 +2157,78 @@ export function PersonFormSheet({
1590
2157
  </Select>
1591
2158
  </div>
1592
2159
  ) : null}
1593
- <div className="space-y-1.5">
1594
- <Label className="text-xs font-medium">{t('status')}</Label>
1595
- <Select
1596
- value={watch('status')}
1597
- onValueChange={(value: 'active' | 'inactive') =>
1598
- setValue('status', value)
1599
- }
1600
- >
1601
- <SelectTrigger className="h-9 w-full min-w-0">
1602
- <SelectValue />
1603
- </SelectTrigger>
1604
- <SelectContent>
1605
- <SelectItem value="active">{t('active')}</SelectItem>
1606
- <SelectItem value="inactive">
1607
- {t('inactive')}
1608
- </SelectItem>
1609
- </SelectContent>
1610
- </Select>
1611
- </div>
2160
+ <div className="flex items-center gap-3">
2161
+ <div className="space-y-1.5">
2162
+ <Label className="text-xs font-medium">
2163
+ {t('status')}
2164
+ </Label>
2165
+ <Select
2166
+ value={watch('status')}
2167
+ onValueChange={(value: 'active' | 'inactive') =>
2168
+ setValue('status', value)
2169
+ }
2170
+ >
2171
+ <SelectTrigger className="h-9 w-full min-w-0">
2172
+ <SelectValue />
2173
+ </SelectTrigger>
2174
+ <SelectContent>
2175
+ <SelectItem value="active">{t('active')}</SelectItem>
2176
+ <SelectItem value="inactive">
2177
+ {t('inactive')}
2178
+ </SelectItem>
2179
+ </SelectContent>
2180
+ </Select>
2181
+ </div>
1612
2182
 
1613
- {watchType === 'individual' ? (
1614
- <>
1615
- <div className="space-y-1.5">
1616
- <Label className="text-xs font-medium">
1617
- {t('birthDate')}
1618
- </Label>
1619
- <DatePickerWithYearMonth
1620
- date={watch('birth_date') || undefined}
1621
- onSelect={(date) =>
1622
- setValue('birth_date', date || null)
1623
- }
1624
- maxDate={new Date()}
1625
- placeholder={t('selectDate')}
1626
- localeCode={currentLocaleCode}
1627
- />
1628
- </div>
2183
+ {watchType === 'individual' ? (
2184
+ <>
2185
+ <div className="space-y-1.5 mb-1.5">
2186
+ <Label className="text-xs font-medium">
2187
+ {t('birthDate')}
2188
+ </Label>
2189
+ <DatePickerWithYearMonth
2190
+ date={watch('birth_date') || undefined}
2191
+ onSelect={(date) =>
2192
+ setValue('birth_date', date || null)
2193
+ }
2194
+ maxDate={new Date()}
2195
+ placeholder={t('selectDate')}
2196
+ localeCode={currentLocaleCode}
2197
+ />
2198
+ </div>
1629
2199
 
1630
- <div className="space-y-1.5">
1631
- <Label className="text-xs font-medium">
1632
- {t('gender')}
1633
- </Label>
1634
- <Select
1635
- value={watch('gender') || ''}
1636
- onValueChange={(value: PersonGender) =>
1637
- setValue('gender', value)
1638
- }
1639
- >
1640
- <SelectTrigger className="h-9 w-full min-w-0">
1641
- <SelectValue placeholder={t('selectGender')} />
1642
- </SelectTrigger>
1643
- <SelectContent>
1644
- <SelectItem value="male">
1645
- {t('genderMale')}
1646
- </SelectItem>
1647
- <SelectItem value="female">
1648
- {t('genderFemale')}
1649
- </SelectItem>
1650
- <SelectItem value="other">
1651
- {t('genderOther')}
1652
- </SelectItem>
1653
- </SelectContent>
1654
- </Select>
1655
- </div>
1656
- </>
1657
- ) : null}
2200
+ <div className="space-y-1.5">
2201
+ <Label className="text-xs font-medium">
2202
+ {t('gender')}
2203
+ </Label>
2204
+ <Select
2205
+ value={watch('gender') || ''}
2206
+ onValueChange={(value: PersonGender) =>
2207
+ setValue('gender', value)
2208
+ }
2209
+ >
2210
+ <SelectTrigger className="h-9 w-full min-w-0">
2211
+ <SelectValue placeholder={t('selectGender')} />
2212
+ </SelectTrigger>
2213
+ <SelectContent>
2214
+ <SelectItem value="male">
2215
+ {t('genderMale')}
2216
+ </SelectItem>
2217
+ <SelectItem value="female">
2218
+ {t('genderFemale')}
2219
+ </SelectItem>
2220
+ <SelectItem value="other">
2221
+ {t('genderOther')}
2222
+ </SelectItem>
2223
+ </SelectContent>
2224
+ </Select>
2225
+ </div>
2226
+ </>
2227
+ ) : null}
2228
+ </div>
1658
2229
  </div>
1659
2230
 
1660
- <div className="grid gap-3 sm:grid-cols-2 xl:grid-cols-4">
2231
+ <div className="grid gap-3 sm:grid-cols-2 xl:grid-cols-3">
1661
2232
  <div className="space-y-1.5">
1662
2233
  <Label className="text-xs font-medium">{t('owner')}</Label>
1663
2234
  <Select
@@ -1764,20 +2335,19 @@ export function PersonFormSheet({
1764
2335
  </SelectContent>
1765
2336
  </Select>
1766
2337
  </div>
2338
+ </div>
1767
2339
 
1768
- <div className="space-y-1.5">
1769
- <Label className="text-xs font-medium">
1770
- {t('nextActionAt')}
1771
- </Label>
1772
- <Input
1773
- type="datetime-local"
1774
- value={watch('next_action_at') || ''}
1775
- onChange={(event) =>
1776
- setValue('next_action_at', event.target.value)
1777
- }
1778
- className="h-9"
1779
- />
1780
- </div>
2340
+ <div className="space-y-1.5">
2341
+ <Label className="text-xs font-medium">
2342
+ {t('nextActionAt')}
2343
+ </Label>
2344
+ <DateTimePickerWithTime
2345
+ value={watch('next_action_at') || ''}
2346
+ onChange={(value) => setValue('next_action_at', value)}
2347
+ placeholder={t('selectDate')}
2348
+ localeCode={currentLocaleCode}
2349
+ clearLabel={t('remove')}
2350
+ />
1781
2351
  </div>
1782
2352
 
1783
2353
  {watchType === 'individual' ? (
@@ -1807,7 +2377,9 @@ export function PersonFormSheet({
1807
2377
  lockCreateType
1808
2378
  valueType="number"
1809
2379
  initialSelectedLabel={
1810
- person?.employer_company?.name || ''
2380
+ person?.employer_company?.name ||
2381
+ initialEmployerCompanyLabel ||
2382
+ ''
1811
2383
  }
1812
2384
  />
1813
2385
  </div>
@@ -2486,6 +3058,16 @@ export function PersonFormSheet({
2486
3058
  : t('createPerson')}
2487
3059
  </Button>
2488
3060
  </div>
3061
+ {draftStatusContent ? (
3062
+ <p className="text-xs text-muted-foreground">
3063
+ {draftStatusContent}
3064
+ </p>
3065
+ ) : null}
3066
+ {draftStatusContent ? (
3067
+ <p className="text-xs text-muted-foreground">
3068
+ {draftStatusContent}
3069
+ </p>
3070
+ ) : null}
2489
3071
  <p className="text-xs text-muted-foreground">
2490
3072
  {t('formShortcutsHint')}
2491
3073
  </p>