@hed-hog/contact 0.0.266 → 0.0.274

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 (83) hide show
  1. package/README.md +470 -0
  2. package/dist/address-type.enum.d.ts +10 -0
  3. package/dist/address-type.enum.d.ts.map +1 -0
  4. package/dist/address-type.enum.js +14 -0
  5. package/dist/address-type.enum.js.map +1 -0
  6. package/dist/contact.module.d.ts.map +1 -1
  7. package/dist/contact.module.js +0 -2
  8. package/dist/contact.module.js.map +1 -1
  9. package/dist/contact.service.d.ts +19 -22
  10. package/dist/contact.service.d.ts.map +1 -1
  11. package/dist/contact.service.js +22 -2
  12. package/dist/contact.service.js.map +1 -1
  13. package/dist/index.d.ts +5 -8
  14. package/dist/index.d.ts.map +1 -1
  15. package/dist/index.js +1 -4
  16. package/dist/index.js.map +1 -1
  17. package/dist/person/dto/create.dto.d.ts +14 -0
  18. package/dist/person/dto/create.dto.d.ts.map +1 -1
  19. package/dist/person/dto/create.dto.js +52 -1
  20. package/dist/person/dto/create.dto.js.map +1 -1
  21. package/dist/person/dto/interaction-create.dto.d.ts +16 -0
  22. package/dist/person/dto/interaction-create.dto.d.ts.map +1 -0
  23. package/dist/person/dto/interaction-create.dto.js +57 -0
  24. package/dist/person/dto/interaction-create.dto.js.map +1 -0
  25. package/dist/person/dto/update.dto.d.ts +17 -1
  26. package/dist/person/dto/update.dto.d.ts.map +1 -1
  27. package/dist/person/dto/update.dto.js +79 -3
  28. package/dist/person/dto/update.dto.js.map +1 -1
  29. package/dist/person/person.controller.d.ts +37 -8
  30. package/dist/person/person.controller.d.ts.map +1 -1
  31. package/dist/person/person.controller.js +29 -3
  32. package/dist/person/person.controller.js.map +1 -1
  33. package/dist/person/person.service.d.ts +71 -13
  34. package/dist/person/person.service.d.ts.map +1 -1
  35. package/dist/person/person.service.js +762 -108
  36. package/dist/person/person.service.js.map +1 -1
  37. package/dist/person-relation-type/person-relation-type.controller.d.ts +13 -9
  38. package/dist/person-relation-type/person-relation-type.controller.d.ts.map +1 -1
  39. package/dist/person-relation-type/person-relation-type.service.d.ts +16 -20
  40. package/dist/person-relation-type/person-relation-type.service.d.ts.map +1 -1
  41. package/dist/person-relation-type/person-relation-type.service.js +48 -41
  42. package/dist/person-relation-type/person-relation-type.service.js.map +1 -1
  43. package/hedhog/data/menu.yaml +2 -16
  44. package/hedhog/data/role.yaml +9 -1
  45. package/hedhog/data/route.yaml +10 -21
  46. package/hedhog/data/setting_group.yaml +21 -0
  47. package/hedhog/frontend/app/person/_components/delete-person-dialog.tsx.ejs +59 -0
  48. package/hedhog/frontend/app/person/_components/person-field-with-create.tsx.ejs +831 -0
  49. package/hedhog/frontend/app/person/_components/person-form-sheet.tsx.ejs +2277 -0
  50. package/hedhog/frontend/app/person/_components/person-types.ts.ejs +157 -0
  51. package/hedhog/frontend/app/person/page.tsx.ejs +1158 -1335
  52. package/hedhog/frontend/messages/en.json +114 -4
  53. package/hedhog/frontend/messages/pt.json +155 -4
  54. package/hedhog/table/person.yaml +7 -0
  55. package/hedhog/table/person_address.yaml +18 -0
  56. package/hedhog/table/person_company.yaml +26 -11
  57. package/hedhog/table/person_individual.yaml +4 -0
  58. package/hedhog/table/person_individual_relation.yaml +39 -0
  59. package/package.json +6 -5
  60. package/src/address-type.enum.ts +9 -0
  61. package/src/contact.module.ts +46 -48
  62. package/src/contact.service.ts +28 -13
  63. package/src/index.ts +6 -13
  64. package/src/language/en.json +8 -1
  65. package/src/language/pt.json +9 -1
  66. package/src/person/dto/create.dto.ts +49 -1
  67. package/src/person/dto/update.dto.ts +75 -3
  68. package/src/person/person.controller.ts +31 -14
  69. package/src/person/person.service.ts +1019 -121
  70. package/src/person-relation-type/person-relation-type.service.ts +84 -76
  71. package/hedhog/data/address_type.yaml +0 -28
  72. package/hedhog/frontend/app/address-type/page.tsx.ejs +0 -480
  73. package/hedhog/query/add-unique-address-type-locale.sql +0 -3
  74. package/hedhog/table/address.yaml +0 -28
  75. package/hedhog/table/address_type.yaml +0 -11
  76. package/hedhog/table/person_relation.yaml +0 -20
  77. package/hedhog/table/person_relation_type.yaml +0 -6
  78. package/src/address-type/address-type.controller.ts +0 -55
  79. package/src/address-type/address-type.enum.ts +0 -9
  80. package/src/address-type/address-type.module.ts +0 -18
  81. package/src/address-type/address-type.service.ts +0 -121
  82. package/src/address-type/dto/create.dto.ts +0 -19
  83. package/src/address-type/dto/update.dto.ts +0 -9
@@ -0,0 +1,2277 @@
1
+ 'use client';
2
+
3
+ import { PersonFieldWithCreate } from '@/app/(app)/(libraries)/contact/person/_components/person-field-with-create';
4
+ import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
5
+ import { Badge } from '@/components/ui/badge';
6
+ import { Button } from '@/components/ui/button';
7
+ import { Calendar } from '@/components/ui/calendar';
8
+ import {
9
+ Collapsible,
10
+ CollapsibleContent,
11
+ CollapsibleTrigger,
12
+ } from '@/components/ui/collapsible';
13
+ import { Input } from '@/components/ui/input';
14
+ import { Label } from '@/components/ui/label';
15
+ import {
16
+ Popover,
17
+ PopoverContent,
18
+ PopoverTrigger,
19
+ } from '@/components/ui/popover';
20
+ import { Progress } from '@/components/ui/progress';
21
+ import {
22
+ Select,
23
+ SelectContent,
24
+ SelectItem,
25
+ SelectTrigger,
26
+ SelectValue,
27
+ } from '@/components/ui/select';
28
+ import { Separator } from '@/components/ui/separator';
29
+ import {
30
+ Sheet,
31
+ SheetContent,
32
+ SheetDescription,
33
+ SheetHeader,
34
+ SheetTitle,
35
+ } from '@/components/ui/sheet';
36
+ import {
37
+ Tooltip,
38
+ TooltipContent,
39
+ TooltipTrigger,
40
+ } from '@/components/ui/tooltip';
41
+ import { COUNTRIES } from '@/constants/countries';
42
+ import { cn } from '@/lib/utils';
43
+ import { useApp, useQuery } from '@hed-hog/next-app-provider';
44
+ import { zodResolver } from '@hookform/resolvers/zod';
45
+ import { format } from 'date-fns';
46
+ import { enUS, ptBR } from 'date-fns/locale';
47
+ import {
48
+ Building2,
49
+ Calendar as CalendarIcon,
50
+ ChevronDown,
51
+ ChevronUp,
52
+ FileText,
53
+ Loader2,
54
+ Mail,
55
+ MapPin,
56
+ Plus,
57
+ Save,
58
+ Star,
59
+ Trash2,
60
+ Upload,
61
+ User,
62
+ } from 'lucide-react';
63
+ import { useTranslations } from 'next-intl';
64
+ import { type ChangeEvent, useEffect, useMemo, useRef, useState } from 'react';
65
+ import { useForm } from 'react-hook-form';
66
+ import { toast } from 'sonner';
67
+ import { z } from 'zod';
68
+
69
+ import {
70
+ ADDRESS_TYPE_OPTIONS,
71
+ type ContactTypeOption,
72
+ type DocumentTypeOption,
73
+ type Person,
74
+ type PersonAddress,
75
+ type PersonContact,
76
+ type PersonDocument,
77
+ type PersonGender,
78
+ type PersonLifecycleStage,
79
+ type PersonSource,
80
+ type UserOption,
81
+ } from './person-types';
82
+
83
+ type PersonFormSheetProps = {
84
+ open: boolean;
85
+ person: Person | null;
86
+ contactTypes: ContactTypeOption[];
87
+ documentTypes: DocumentTypeOption[];
88
+ onOpenChange: (open: boolean) => void;
89
+ onSuccess: () => void;
90
+ };
91
+
92
+ type PersonFormValues = {
93
+ name: string;
94
+ type: 'individual' | 'company';
95
+ status: 'active' | 'inactive';
96
+ birth_date?: Date | null;
97
+ gender?: PersonGender | null;
98
+ job_title?: string | null;
99
+ employer_company_id?: number | null;
100
+ trade_name?: string | null;
101
+ foundation_date?: Date | null;
102
+ legal_nature?: string | null;
103
+ owner_user_id?: number | null;
104
+ source?: PersonSource | null;
105
+ lifecycle_stage?: PersonLifecycleStage | null;
106
+ next_action_at?: string | null;
107
+ };
108
+
109
+ type CreatePersonPayload = {
110
+ id?: number;
111
+ data?: {
112
+ id?: number;
113
+ };
114
+ };
115
+
116
+ type UploadedFilePayload = {
117
+ id?: number;
118
+ filename?: string;
119
+ };
120
+
121
+ type OpenFilePayload = {
122
+ url?: string;
123
+ };
124
+
125
+ type EditablePersonContact = Omit<PersonContact, 'contact_type_id'> & {
126
+ clientId: string;
127
+ contact_type_id: number | null;
128
+ };
129
+
130
+ type EditablePersonAddress = PersonAddress & {
131
+ clientId: string;
132
+ };
133
+
134
+ type EditablePersonDocument = Omit<PersonDocument, 'document_type_id'> & {
135
+ clientId: string;
136
+ document_type_id: number | null;
137
+ };
138
+
139
+ function createClientId(prefix: string) {
140
+ return `${prefix}-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
141
+ }
142
+
143
+ function onlyDigits(value: string) {
144
+ return value.replace(/\D/g, '');
145
+ }
146
+
147
+ function applyPhoneMask(value: string) {
148
+ const digits = onlyDigits(value).slice(0, 11);
149
+
150
+ if (digits.length <= 2) {
151
+ return digits.length ? `(${digits}` : '';
152
+ }
153
+
154
+ if (digits.length <= 6) {
155
+ return `(${digits.slice(0, 2)}) ${digits.slice(2)}`;
156
+ }
157
+
158
+ if (digits.length <= 10) {
159
+ return `(${digits.slice(0, 2)}) ${digits.slice(2, 6)}-${digits.slice(6)}`;
160
+ }
161
+
162
+ return `(${digits.slice(0, 2)}) ${digits.slice(2, 7)}-${digits.slice(7)}`;
163
+ }
164
+
165
+ function applyCpfMask(value: string) {
166
+ const digits = onlyDigits(value).slice(0, 11);
167
+
168
+ if (digits.length <= 3) return digits;
169
+ if (digits.length <= 6) return `${digits.slice(0, 3)}.${digits.slice(3)}`;
170
+ if (digits.length <= 9)
171
+ return `${digits.slice(0, 3)}.${digits.slice(3, 6)}.${digits.slice(6)}`;
172
+
173
+ return `${digits.slice(0, 3)}.${digits.slice(3, 6)}.${digits.slice(6, 9)}-${digits.slice(9)}`;
174
+ }
175
+
176
+ function applyCnpjMask(value: string) {
177
+ const digits = onlyDigits(value).slice(0, 14);
178
+
179
+ if (digits.length <= 2) return digits;
180
+ if (digits.length <= 5) return `${digits.slice(0, 2)}.${digits.slice(2)}`;
181
+ if (digits.length <= 8)
182
+ return `${digits.slice(0, 2)}.${digits.slice(2, 5)}.${digits.slice(5)}`;
183
+ if (digits.length <= 12)
184
+ return `${digits.slice(0, 2)}.${digits.slice(2, 5)}.${digits.slice(5, 8)}/${digits.slice(8)}`;
185
+
186
+ return `${digits.slice(0, 2)}.${digits.slice(2, 5)}.${digits.slice(5, 8)}/${digits.slice(8, 12)}-${digits.slice(12)}`;
187
+ }
188
+
189
+ function toDatetimeLocalValue(value?: string | null) {
190
+ if (!value) return '';
191
+ const date = new Date(value);
192
+ if (Number.isNaN(date.getTime())) return '';
193
+
194
+ const offset = date.getTimezoneOffset();
195
+ const localDate = new Date(date.getTime() - offset * 60 * 1000);
196
+ return localDate.toISOString().slice(0, 16);
197
+ }
198
+
199
+ async function fetchViaCep(cep: string) {
200
+ try {
201
+ const response = await fetch(`https://viacep.com.br/ws/${cep}/json/`);
202
+ if (!response.ok) return null;
203
+
204
+ const data = await response.json();
205
+ if (data.erro) return null;
206
+
207
+ return data as {
208
+ logradouro?: string;
209
+ localidade?: string;
210
+ uf?: string;
211
+ };
212
+ } catch {
213
+ return null;
214
+ }
215
+ }
216
+
217
+ function DatePickerWithYearMonth({
218
+ date,
219
+ onSelect,
220
+ maxDate,
221
+ placeholder,
222
+ localeCode,
223
+ }: {
224
+ date?: Date;
225
+ onSelect: (date: Date | undefined) => void;
226
+ maxDate?: Date;
227
+ placeholder: string;
228
+ localeCode: string;
229
+ }) {
230
+ const [isOpen, setIsOpen] = useState(false);
231
+ const locale = localeCode.startsWith('pt') ? ptBR : enUS;
232
+ const [viewDate, setViewDate] = useState(date || new Date(2000, 0, 1));
233
+ const currentViewDate = date || viewDate;
234
+
235
+ const currentYear = new Date().getFullYear();
236
+ const years = useMemo(() => {
237
+ const values: number[] = [];
238
+ for (let year = currentYear; year >= currentYear - 100; year -= 1) {
239
+ values.push(year);
240
+ }
241
+ return values;
242
+ }, [currentYear]);
243
+
244
+ const months = useMemo(
245
+ () =>
246
+ Array.from({ length: 12 }, (_, index) =>
247
+ format(new Date(2024, index, 1), 'LLLL', { locale })
248
+ ),
249
+ [locale]
250
+ );
251
+
252
+ const handleYearChange = (year: string) => {
253
+ const nextDate = new Date(currentViewDate);
254
+ nextDate.setFullYear(Number(year));
255
+ setViewDate(nextDate);
256
+ };
257
+
258
+ const handleMonthChange = (month: string) => {
259
+ const nextDate = new Date(currentViewDate);
260
+ nextDate.setMonth(Number(month));
261
+ setViewDate(nextDate);
262
+ };
263
+
264
+ return (
265
+ <Popover open={isOpen} onOpenChange={setIsOpen}>
266
+ <PopoverTrigger asChild>
267
+ <Button
268
+ type="button"
269
+ variant="outline"
270
+ className={cn(
271
+ 'h-9 w-full min-w-0 justify-start text-left text-xs font-normal',
272
+ !date && 'text-muted-foreground'
273
+ )}
274
+ >
275
+ <CalendarIcon className="mr-2 h-3.5 w-3.5" />
276
+ {date ? (
277
+ format(date, 'dd/MM/yyyy', { locale })
278
+ ) : (
279
+ <span className="block truncate text-xs">{placeholder}</span>
280
+ )}
281
+ </Button>
282
+ </PopoverTrigger>
283
+ <PopoverContent className="w-auto p-0" align="start">
284
+ <div className="flex items-center gap-1 border-b p-2">
285
+ <Select
286
+ value={String(currentViewDate.getMonth())}
287
+ onValueChange={handleMonthChange}
288
+ >
289
+ <SelectTrigger className="h-8 flex-1 text-xs">
290
+ <SelectValue />
291
+ </SelectTrigger>
292
+ <SelectContent>
293
+ {months.map((month, index) => (
294
+ <SelectItem key={month} value={String(index)}>
295
+ {month}
296
+ </SelectItem>
297
+ ))}
298
+ </SelectContent>
299
+ </Select>
300
+ <Select
301
+ value={String(currentViewDate.getFullYear())}
302
+ onValueChange={handleYearChange}
303
+ >
304
+ <SelectTrigger className="h-8 w-24 text-xs">
305
+ <SelectValue />
306
+ </SelectTrigger>
307
+ <SelectContent className="max-h-60">
308
+ {years.map((year) => (
309
+ <SelectItem key={year} value={String(year)}>
310
+ {year}
311
+ </SelectItem>
312
+ ))}
313
+ </SelectContent>
314
+ </Select>
315
+ </div>
316
+ <Calendar
317
+ mode="single"
318
+ selected={date}
319
+ onSelect={(selectedDate) => {
320
+ onSelect(selectedDate);
321
+ setIsOpen(false);
322
+ }}
323
+ month={currentViewDate}
324
+ onMonthChange={setViewDate}
325
+ disabled={(value) => (maxDate ? value > maxDate : false)}
326
+ initialFocus
327
+ />
328
+ </PopoverContent>
329
+ </Popover>
330
+ );
331
+ }
332
+
333
+ export function PersonFormSheet({
334
+ open,
335
+ person,
336
+ contactTypes,
337
+ documentTypes,
338
+ onOpenChange,
339
+ onSuccess,
340
+ }: PersonFormSheetProps) {
341
+ const t = useTranslations('contact.ContactPage');
342
+ const { request, currentLocaleCode, getSettingValue, user } = useApp();
343
+ const isEditing = Boolean(person);
344
+ const allowCompanyRegistration =
345
+ getSettingValue('contact-allow-company-registration') !== false;
346
+ const canUseCompanyType = allowCompanyRegistration;
347
+
348
+ const personSchema = useMemo(
349
+ () =>
350
+ z.object({
351
+ name: z.string().trim().min(2, t('nameRequired')),
352
+ type: z.enum(['individual', 'company']),
353
+ status: z.enum(['active', 'inactive']),
354
+ birth_date: z.date().nullable().optional(),
355
+ gender: z.enum(['male', 'female', 'other']).nullable().optional(),
356
+ job_title: z.string().nullable().optional(),
357
+ employer_company_id: z.number().int().nullable().optional(),
358
+ trade_name: z.string().nullable().optional(),
359
+ foundation_date: z.date().nullable().optional(),
360
+ legal_nature: z.string().nullable().optional(),
361
+ owner_user_id: z.number().int().nullable().optional(),
362
+ source: z
363
+ .enum([
364
+ 'referral',
365
+ 'website',
366
+ 'social',
367
+ 'inbound',
368
+ 'outbound',
369
+ 'other',
370
+ ])
371
+ .nullable()
372
+ .optional(),
373
+ lifecycle_stage: z
374
+ .enum([
375
+ 'new',
376
+ 'contacted',
377
+ 'qualified',
378
+ 'proposal',
379
+ 'negotiation',
380
+ 'customer',
381
+ 'lost',
382
+ ])
383
+ .nullable()
384
+ .optional(),
385
+ next_action_at: z.string().nullable().optional(),
386
+ }),
387
+ [t]
388
+ );
389
+
390
+ const form = useForm<PersonFormValues>({
391
+ resolver: zodResolver(personSchema),
392
+ defaultValues: {
393
+ name: '',
394
+ type: 'individual',
395
+ status: 'active',
396
+ birth_date: null,
397
+ gender: null,
398
+ job_title: '',
399
+ employer_company_id: null,
400
+ trade_name: '',
401
+ foundation_date: null,
402
+ legal_nature: '',
403
+ owner_user_id: null,
404
+ source: null,
405
+ lifecycle_stage: 'new',
406
+ next_action_at: '',
407
+ },
408
+ });
409
+ const {
410
+ register,
411
+ handleSubmit,
412
+ watch,
413
+ reset,
414
+ setValue,
415
+ formState: { errors },
416
+ } = form;
417
+
418
+ const [contacts, setContacts] = useState<EditablePersonContact[]>([]);
419
+ const [addresses, setAddresses] = useState<EditablePersonAddress[]>([]);
420
+ const [documents, setDocuments] = useState<EditablePersonDocument[]>([]);
421
+ const [contactsOpen, setContactsOpen] = useState(true);
422
+ const [addressesOpen, setAddressesOpen] = useState(true);
423
+ const [documentsOpen, setDocumentsOpen] = useState(true);
424
+ const [loadingCEP, setLoadingCEP] = useState<Record<string, boolean>>({});
425
+ const [isSubmitting, setIsSubmitting] = useState(false);
426
+ const [avatarId, setAvatarId] = useState<number | null>(null);
427
+ const [persistedAvatarId, setPersistedAvatarId] = useState<number | null>(
428
+ null
429
+ );
430
+ const [avatarPreviewUrl, setAvatarPreviewUrl] =
431
+ useState<string>('/placeholder.png');
432
+ const [isUploadingAvatar, setIsUploadingAvatar] = useState(false);
433
+ const [avatarUploadProgress, setAvatarUploadProgress] = useState(0);
434
+ const fileInputRef = useRef<HTMLInputElement>(null);
435
+ const hasSavedChangesRef = useRef(false);
436
+ const contactValueRefs = useRef<Record<string, HTMLInputElement | null>>({});
437
+ const addressLine1Refs = useRef<Record<string, HTMLInputElement | null>>({});
438
+ const documentValueRefs = useRef<Record<string, HTMLInputElement | null>>({});
439
+
440
+ const focusWhenAvailable = (getElement: () => HTMLInputElement | null) => {
441
+ requestAnimationFrame(() => {
442
+ const element = getElement();
443
+ if (!element) {
444
+ setTimeout(() => {
445
+ getElement()?.focus();
446
+ }, 0);
447
+ return;
448
+ }
449
+
450
+ element.focus();
451
+ });
452
+ };
453
+
454
+ const watchType = watch('type');
455
+
456
+ const { data: ownerOptions = [] } = useQuery<UserOption[]>({
457
+ queryKey: ['contact-person-owner-options', currentLocaleCode],
458
+ queryFn: async () => {
459
+ const response = await request<UserOption[] | { data?: UserOption[] }>({
460
+ url: '/person/owner-options',
461
+ method: 'GET',
462
+ });
463
+ return Array.isArray(response.data)
464
+ ? response.data
465
+ : response.data?.data || [];
466
+ },
467
+ placeholderData: (old: UserOption[] | undefined) => old ?? [],
468
+ });
469
+
470
+ useEffect(() => {
471
+ if (canUseCompanyType || watchType !== 'company') {
472
+ return;
473
+ }
474
+
475
+ setValue('type', 'individual');
476
+ }, [canUseCompanyType, setValue, watchType]);
477
+
478
+ useEffect(() => {
479
+ if (!open) return;
480
+
481
+ hasSavedChangesRef.current = false;
482
+ reset({
483
+ name: person?.name || '',
484
+ type: allowCompanyRegistration
485
+ ? person?.type || 'individual'
486
+ : 'individual',
487
+ status: person?.status || 'active',
488
+ birth_date: person?.birth_date ? new Date(person.birth_date) : null,
489
+ gender: person?.gender || null,
490
+ job_title: person?.job_title || '',
491
+ employer_company_id: person?.employer_company_id ?? null,
492
+ trade_name: person?.trade_name || '',
493
+ foundation_date: person?.foundation_date
494
+ ? new Date(person.foundation_date)
495
+ : null,
496
+ legal_nature: person?.legal_nature || '',
497
+ owner_user_id:
498
+ person?.owner_user_id ??
499
+ (person ? null : Number(user?.id || 0) || null),
500
+ source: person?.source || null,
501
+ lifecycle_stage: person?.lifecycle_stage || 'new',
502
+ next_action_at: toDatetimeLocalValue(person?.next_action_at || null),
503
+ });
504
+ const initialAvatarId = person?.avatar_id ?? null;
505
+ setAvatarId(initialAvatarId);
506
+ setPersistedAvatarId(initialAvatarId);
507
+ setAvatarPreviewUrl(getPersonAvatarUrl(initialAvatarId));
508
+ setIsUploadingAvatar(false);
509
+ setAvatarUploadProgress(0);
510
+
511
+ setContacts(
512
+ (person?.contact || []).map((contact, index) => ({
513
+ ...contact,
514
+ value: ['PHONE', 'MOBILE', 'WHATSAPP'].includes(
515
+ String(contact.contact_type?.code || '').toUpperCase()
516
+ )
517
+ ? applyPhoneMask(contact.value || '')
518
+ : contact.value || '',
519
+ clientId: `contact-${contact.id ?? index}`,
520
+ }))
521
+ );
522
+
523
+ setAddresses(
524
+ (person?.address || []).map((address, index) => ({
525
+ ...address,
526
+ clientId: `address-${address.id ?? index}`,
527
+ }))
528
+ );
529
+
530
+ setDocuments(
531
+ (person?.document || []).map((document, index) => ({
532
+ ...document,
533
+ value:
534
+ String(document.document_type?.code || '').toUpperCase() === 'CPF'
535
+ ? applyCpfMask(document.value || '')
536
+ : String(document.document_type?.code || '').toUpperCase() ===
537
+ 'CNPJ'
538
+ ? applyCnpjMask(document.value || '')
539
+ : document.value || '',
540
+ clientId: `document-${document.id ?? index}`,
541
+ }))
542
+ );
543
+
544
+ setContactsOpen(true);
545
+ setAddressesOpen(true);
546
+ setDocumentsOpen(true);
547
+ setLoadingCEP({});
548
+ }, [allowCompanyRegistration, open, person, reset, user?.id]);
549
+
550
+ const getPersonInitials = (name: string) =>
551
+ name
552
+ .split(' ')
553
+ .filter(Boolean)
554
+ .slice(0, 2)
555
+ .map((part) => part[0]?.toUpperCase() || '')
556
+ .join('');
557
+
558
+ const getPersonAvatarUrl = (fileId?: number | null) =>
559
+ typeof fileId === 'number' && fileId > 0
560
+ ? `${process.env.NEXT_PUBLIC_API_BASE_URL}/person/avatar/${fileId}`
561
+ : '/placeholder.png';
562
+
563
+ const resolveApiUrl = (url: string) =>
564
+ /^https?:\/\//i.test(url)
565
+ ? url
566
+ : `${String(process.env.NEXT_PUBLIC_API_BASE_URL || '')}${url}`;
567
+
568
+ const deleteFileById = async (fileId?: number | null) => {
569
+ if (!fileId || fileId <= 0) return;
570
+
571
+ try {
572
+ await request({
573
+ url: '/file',
574
+ method: 'DELETE',
575
+ data: { ids: [fileId] },
576
+ });
577
+ } catch {
578
+ // Ignore cleanup failure to keep form interaction stable.
579
+ }
580
+ };
581
+
582
+ const cleanupUnsavedAvatar = async () => {
583
+ if (avatarId && avatarId !== persistedAvatarId) {
584
+ await deleteFileById(avatarId);
585
+ }
586
+ };
587
+
588
+ const handleAvatarUpload = async (file: File) => {
589
+ if (!file.type.startsWith('image/')) {
590
+ toast.error(t('avatarInvalidType'));
591
+ return;
592
+ }
593
+
594
+ if (file.size > 5 * 1024 * 1024) {
595
+ toast.error(t('avatarTooLarge'));
596
+ return;
597
+ }
598
+
599
+ setIsUploadingAvatar(true);
600
+ setAvatarUploadProgress(0);
601
+
602
+ try {
603
+ const formData = new FormData();
604
+ formData.append('file', file);
605
+ formData.append('destination', 'contact/person/avatar');
606
+
607
+ const previousAvatarId = avatarId;
608
+
609
+ const { data } = await request<UploadedFilePayload>({
610
+ url: '/file',
611
+ method: 'POST',
612
+ data: formData,
613
+ headers: {
614
+ 'Content-Type': 'multipart/form-data',
615
+ },
616
+ onUploadProgress: (event) => {
617
+ if (!event.total) return;
618
+ const progress = Math.round((event.loaded * 100) / event.total);
619
+ setAvatarUploadProgress(progress);
620
+ },
621
+ });
622
+
623
+ const nextAvatarId = Number(data?.id);
624
+ if (!nextAvatarId) {
625
+ throw new Error(t('avatarUploadError'));
626
+ }
627
+
628
+ const openResponse = await request<OpenFilePayload>({
629
+ url: `/file/open/${nextAvatarId}`,
630
+ method: 'PUT',
631
+ });
632
+ const tempOpenUrl = String(openResponse?.data?.url || '').trim();
633
+
634
+ if (previousAvatarId && previousAvatarId !== persistedAvatarId) {
635
+ await deleteFileById(previousAvatarId);
636
+ }
637
+
638
+ setAvatarId(nextAvatarId);
639
+ setAvatarPreviewUrl(
640
+ tempOpenUrl.length > 0
641
+ ? resolveApiUrl(tempOpenUrl)
642
+ : getPersonAvatarUrl(nextAvatarId)
643
+ );
644
+ setAvatarUploadProgress(100);
645
+ toast.success(t('avatarUploadSuccess'));
646
+ } catch {
647
+ toast.error(t('avatarUploadError'));
648
+ setAvatarUploadProgress(0);
649
+ } finally {
650
+ setIsUploadingAvatar(false);
651
+ if (fileInputRef.current) {
652
+ fileInputRef.current.value = '';
653
+ }
654
+ }
655
+ };
656
+
657
+ const handleSelectAvatar = () => {
658
+ if (!fileInputRef.current) return;
659
+ fileInputRef.current.value = '';
660
+ fileInputRef.current.click();
661
+ };
662
+
663
+ const handleRemoveAvatar = async () => {
664
+ if (isUploadingAvatar) return;
665
+
666
+ if (avatarId && avatarId !== persistedAvatarId) {
667
+ await deleteFileById(avatarId);
668
+ }
669
+
670
+ setAvatarId(null);
671
+ setAvatarPreviewUrl('/placeholder.png');
672
+ setAvatarUploadProgress(0);
673
+ toast.success(t('avatarRemoveSuccess'));
674
+ };
675
+
676
+ const handleSheetOpenChange = (nextOpen: boolean) => {
677
+ if (nextOpen) {
678
+ hasSavedChangesRef.current = false;
679
+ onOpenChange(true);
680
+ return;
681
+ }
682
+
683
+ if (isUploadingAvatar || isSubmitting) {
684
+ return;
685
+ }
686
+
687
+ const shouldCleanup = !hasSavedChangesRef.current;
688
+ hasSavedChangesRef.current = false;
689
+
690
+ if (shouldCleanup) {
691
+ void cleanupUnsavedAvatar();
692
+ }
693
+
694
+ onOpenChange(false);
695
+ };
696
+
697
+ useEffect(() => {
698
+ if (!open) {
699
+ return;
700
+ }
701
+
702
+ const onKeyDown = (event: KeyboardEvent) => {
703
+ if ((event.ctrlKey || event.metaKey) && event.key.toLowerCase() === 's') {
704
+ event.preventDefault();
705
+ const formElement = document.getElementById(
706
+ 'person-form'
707
+ ) as HTMLFormElement | null;
708
+ formElement?.requestSubmit();
709
+ return;
710
+ }
711
+
712
+ if (event.key === 'Escape' && !isSubmitting && !isUploadingAvatar) {
713
+ event.preventDefault();
714
+ handleSheetOpenChange(false);
715
+ }
716
+ };
717
+
718
+ window.addEventListener('keydown', onKeyDown);
719
+ return () => window.removeEventListener('keydown', onKeyDown);
720
+ }, [open, isSubmitting, isUploadingAvatar, handleSheetOpenChange]);
721
+
722
+ const getContactTypeCode = (contactTypeId?: number | null) =>
723
+ String(
724
+ contactTypes.find(
725
+ (contactType) => contactType.contact_type_id === contactTypeId
726
+ )?.code || ''
727
+ ).toUpperCase();
728
+
729
+ const getDocumentTypeCode = (documentTypeId?: number | null) =>
730
+ String(
731
+ documentTypes.find(
732
+ (documentType) => documentType.document_type_id === documentTypeId
733
+ )?.code || ''
734
+ ).toUpperCase();
735
+
736
+ const maskContactValueByType = (
737
+ value: string,
738
+ contactTypeId?: number | null,
739
+ fallbackCode?: string
740
+ ) => {
741
+ const code = (
742
+ fallbackCode || getContactTypeCode(contactTypeId)
743
+ ).toUpperCase();
744
+ if (['PHONE', 'MOBILE', 'WHATSAPP'].includes(code)) {
745
+ return applyPhoneMask(value);
746
+ }
747
+
748
+ return value;
749
+ };
750
+
751
+ const maskDocumentValueByType = (
752
+ value: string,
753
+ documentTypeId?: number | null,
754
+ fallbackCode?: string
755
+ ) => {
756
+ const code = (
757
+ fallbackCode || getDocumentTypeCode(documentTypeId)
758
+ ).toUpperCase();
759
+
760
+ if (code === 'CPF') {
761
+ return applyCpfMask(value);
762
+ }
763
+
764
+ if (code === 'CNPJ') {
765
+ return applyCnpjMask(value);
766
+ }
767
+
768
+ return value;
769
+ };
770
+
771
+ const resolveContactTypeIdByCodes = (codes: string[]) => {
772
+ const normalizedCodes = codes.map((code) => code.toUpperCase());
773
+ return (
774
+ contactTypes.find((contactType) =>
775
+ normalizedCodes.includes(String(contactType.code || '').toUpperCase())
776
+ )?.contact_type_id || null
777
+ );
778
+ };
779
+
780
+ const resolveDocumentTypeIdByCodes = (codes: string[]) => {
781
+ const normalizedCodes = codes.map((code) => code.toUpperCase());
782
+ return (
783
+ documentTypes.find((documentType) =>
784
+ normalizedCodes.includes(String(documentType.code || '').toUpperCase())
785
+ )?.document_type_id || null
786
+ );
787
+ };
788
+
789
+ const addContact = (preset: 'email' | 'phone' | 'blank' = 'blank') => {
790
+ const presetTypeId =
791
+ preset === 'email'
792
+ ? resolveContactTypeIdByCodes(['EMAIL'])
793
+ : preset === 'phone'
794
+ ? resolveContactTypeIdByCodes(['PHONE', 'MOBILE', 'WHATSAPP'])
795
+ : null;
796
+
797
+ const selectedTypeId = presetTypeId;
798
+ const hasPrimaryForType = contacts.some(
799
+ (contact) =>
800
+ selectedTypeId !== null &&
801
+ contact.contact_type_id === selectedTypeId &&
802
+ contact.is_primary
803
+ );
804
+
805
+ const clientId = createClientId('contact');
806
+
807
+ setContactsOpen(true);
808
+ setContacts((previous) => [
809
+ ...previous,
810
+ {
811
+ clientId,
812
+ value: '',
813
+ is_primary: selectedTypeId !== null ? !hasPrimaryForType : false,
814
+ contact_type_id: selectedTypeId,
815
+ },
816
+ ]);
817
+ focusWhenAvailable(() => contactValueRefs.current[clientId] || null);
818
+ };
819
+
820
+ const updateContact = (
821
+ clientId: string,
822
+ updates: Partial<EditablePersonContact>
823
+ ) => {
824
+ setContacts((previous) => {
825
+ const next = previous.map((contact) =>
826
+ contact.clientId === clientId ? { ...contact, ...updates } : contact
827
+ );
828
+ const current = next.find((contact) => contact.clientId === clientId);
829
+
830
+ if (!current?.is_primary) {
831
+ return next;
832
+ }
833
+
834
+ return next.map((contact) =>
835
+ contact.clientId !== clientId &&
836
+ contact.contact_type_id === current.contact_type_id
837
+ ? { ...contact, is_primary: false }
838
+ : contact
839
+ );
840
+ });
841
+ };
842
+
843
+ const removeContact = (clientId: string) => {
844
+ delete contactValueRefs.current[clientId];
845
+ setContacts((previous) => {
846
+ const removed = previous.find((contact) => contact.clientId === clientId);
847
+ const next = previous.filter((contact) => contact.clientId !== clientId);
848
+
849
+ if (!removed) {
850
+ return next;
851
+ }
852
+
853
+ const sameTypeContacts = next.filter(
854
+ (contact) => contact.contact_type_id === removed.contact_type_id
855
+ );
856
+ const fallbackContact = sameTypeContacts[0];
857
+
858
+ if (
859
+ removed.is_primary &&
860
+ fallbackContact &&
861
+ !sameTypeContacts.some((contact) => contact.is_primary)
862
+ ) {
863
+ return next.map((contact) =>
864
+ contact.clientId === fallbackContact.clientId
865
+ ? { ...contact, is_primary: true }
866
+ : contact
867
+ );
868
+ }
869
+
870
+ return next;
871
+ });
872
+ };
873
+
874
+ const setPrimaryContact = (clientId: string) => {
875
+ setContacts((previous) => {
876
+ const selected = previous.find(
877
+ (contact) => contact.clientId === clientId
878
+ );
879
+ if (!selected) return previous;
880
+
881
+ return previous.map((contact) =>
882
+ contact.contact_type_id === selected.contact_type_id
883
+ ? { ...contact, is_primary: contact.clientId === clientId }
884
+ : contact
885
+ );
886
+ });
887
+ };
888
+
889
+ const addAddress = () => {
890
+ const defaultType = ADDRESS_TYPE_OPTIONS[0]?.value || 'residential';
891
+ const hasPrimaryForType = addresses.some(
892
+ (address) => address.address_type === defaultType && address.is_primary
893
+ );
894
+
895
+ const clientId = createClientId('address');
896
+
897
+ setAddressesOpen(true);
898
+ setAddresses((previous) => [
899
+ ...previous,
900
+ {
901
+ clientId,
902
+ line1: '',
903
+ line2: '',
904
+ city: '',
905
+ state: '',
906
+ is_primary: !hasPrimaryForType,
907
+ address_type: defaultType,
908
+ postal_code: '',
909
+ country_code: 'BRA',
910
+ },
911
+ ]);
912
+ focusWhenAvailable(() => addressLine1Refs.current[clientId] || null);
913
+ };
914
+
915
+ const updateAddress = (
916
+ clientId: string,
917
+ updates: Partial<EditablePersonAddress>
918
+ ) => {
919
+ setAddresses((previous) => {
920
+ const next = previous.map((address) =>
921
+ address.clientId === clientId ? { ...address, ...updates } : address
922
+ );
923
+ const current = next.find((address) => address.clientId === clientId);
924
+
925
+ if (!current?.is_primary) {
926
+ return next;
927
+ }
928
+
929
+ return next.map((address) =>
930
+ address.clientId !== clientId &&
931
+ address.address_type === current.address_type
932
+ ? { ...address, is_primary: false }
933
+ : address
934
+ );
935
+ });
936
+ };
937
+
938
+ const removeAddress = (clientId: string) => {
939
+ delete addressLine1Refs.current[clientId];
940
+ setAddresses((previous) => {
941
+ const removed = previous.find((address) => address.clientId === clientId);
942
+ const next = previous.filter((address) => address.clientId !== clientId);
943
+
944
+ if (!removed) {
945
+ return next;
946
+ }
947
+
948
+ const sameTypeAddresses = next.filter(
949
+ (address) => address.address_type === removed.address_type
950
+ );
951
+ const fallbackAddress = sameTypeAddresses[0];
952
+
953
+ if (
954
+ removed.is_primary &&
955
+ fallbackAddress &&
956
+ !sameTypeAddresses.some((address) => address.is_primary)
957
+ ) {
958
+ return next.map((address) =>
959
+ address.clientId === fallbackAddress.clientId
960
+ ? { ...address, is_primary: true }
961
+ : address
962
+ );
963
+ }
964
+
965
+ return next;
966
+ });
967
+ };
968
+
969
+ const setPrimaryAddress = (clientId: string) => {
970
+ setAddresses((previous) => {
971
+ const selected = previous.find(
972
+ (address) => address.clientId === clientId
973
+ );
974
+ if (!selected) return previous;
975
+
976
+ return previous.map((address) =>
977
+ address.address_type === selected.address_type
978
+ ? { ...address, is_primary: address.clientId === clientId }
979
+ : address
980
+ );
981
+ });
982
+ };
983
+
984
+ const addDocument = (preset: 'cpf' | 'cnpj' | 'rg' | 'blank' = 'blank') => {
985
+ const presetTypeId =
986
+ preset === 'cpf'
987
+ ? resolveDocumentTypeIdByCodes(['CPF'])
988
+ : preset === 'cnpj'
989
+ ? resolveDocumentTypeIdByCodes(['CNPJ'])
990
+ : preset === 'rg'
991
+ ? resolveDocumentTypeIdByCodes(['RG'])
992
+ : null;
993
+
994
+ const clientId = createClientId('document');
995
+
996
+ setDocumentsOpen(true);
997
+ setDocuments((previous) => [
998
+ ...previous,
999
+ {
1000
+ clientId,
1001
+ value: '',
1002
+ document_type_id: presetTypeId,
1003
+ },
1004
+ ]);
1005
+ focusWhenAvailable(() => documentValueRefs.current[clientId] || null);
1006
+ };
1007
+
1008
+ const updateDocument = (
1009
+ clientId: string,
1010
+ updates: Partial<EditablePersonDocument>
1011
+ ) => {
1012
+ setDocuments((previous) =>
1013
+ previous.map((document) =>
1014
+ document.clientId === clientId ? { ...document, ...updates } : document
1015
+ )
1016
+ );
1017
+ };
1018
+
1019
+ const removeDocument = (clientId: string) => {
1020
+ delete documentValueRefs.current[clientId];
1021
+ setDocuments((previous) =>
1022
+ previous.filter((document) => document.clientId !== clientId)
1023
+ );
1024
+ };
1025
+
1026
+ const handleCEP = async (
1027
+ event: ChangeEvent<HTMLInputElement>,
1028
+ clientId: string
1029
+ ) => {
1030
+ let value = event.target.value.replace(/\D/g, '');
1031
+ if (value.length > 5) {
1032
+ value = `${value.slice(0, 5)}-${value.slice(5, 8)}`;
1033
+ }
1034
+
1035
+ updateAddress(clientId, { postal_code: value });
1036
+
1037
+ const rawCep = value.replace(/\D/g, '');
1038
+ if (rawCep.length !== 8) return;
1039
+
1040
+ setLoadingCEP((previous) => ({ ...previous, [clientId]: true }));
1041
+ const data = await fetchViaCep(rawCep);
1042
+
1043
+ if (data) {
1044
+ updateAddress(clientId, {
1045
+ line1: data.logradouro || '',
1046
+ city: data.localidade || '',
1047
+ state: data.uf || '',
1048
+ });
1049
+ }
1050
+
1051
+ setLoadingCEP((previous) => ({ ...previous, [clientId]: false }));
1052
+ };
1053
+
1054
+ const handleFormSubmit = async (values: PersonFormValues) => {
1055
+ try {
1056
+ setIsSubmitting(true);
1057
+ const normalizedType = allowCompanyRegistration
1058
+ ? values.type
1059
+ : 'individual';
1060
+
1061
+ const payload = {
1062
+ name: values.name.trim(),
1063
+ type: normalizedType,
1064
+ status: values.status,
1065
+ avatar_id: avatarId,
1066
+ birth_date: values.birth_date ? values.birth_date.toISOString() : null,
1067
+ gender: values.gender || null,
1068
+ job_title:
1069
+ normalizedType === 'individual'
1070
+ ? values.job_title?.trim() || null
1071
+ : null,
1072
+ employer_company_id:
1073
+ normalizedType === 'individual' && allowCompanyRegistration
1074
+ ? (values.employer_company_id ?? null)
1075
+ : null,
1076
+ trade_name: values.trade_name?.trim() || null,
1077
+ foundation_date: values.foundation_date
1078
+ ? values.foundation_date.toISOString()
1079
+ : null,
1080
+ legal_nature: values.legal_nature?.trim() || null,
1081
+ contacts: contacts
1082
+ .filter(
1083
+ (contact) =>
1084
+ contact.value.trim().length > 0 && !!contact.contact_type_id
1085
+ )
1086
+ .map((contact) => ({
1087
+ id: contact.id,
1088
+ value: contact.value.trim(),
1089
+ is_primary: contact.is_primary,
1090
+ contact_type_id: contact.contact_type_id as number,
1091
+ })),
1092
+ addresses: addresses
1093
+ .filter(
1094
+ (address) =>
1095
+ address.line1.trim().length > 0 ||
1096
+ address.city.trim().length > 0 ||
1097
+ address.state.trim().length > 0 ||
1098
+ (address.postal_code || '').trim().length > 0
1099
+ )
1100
+ .map((address) => ({
1101
+ id: address.id,
1102
+ line1: address.line1.trim(),
1103
+ line2: address.line2?.trim() || '',
1104
+ city: address.city.trim(),
1105
+ state: address.state.trim(),
1106
+ country_code: address.country_code || 'BRA',
1107
+ postal_code: address.postal_code?.trim() || '',
1108
+ is_primary: address.is_primary,
1109
+ address_type: address.address_type,
1110
+ })),
1111
+ documents: documents
1112
+ .filter(
1113
+ (document) =>
1114
+ document.value.trim().length > 0 && !!document.document_type_id
1115
+ )
1116
+ .map((document) => ({
1117
+ id: document.id,
1118
+ value: document.value.trim(),
1119
+ document_type_id: document.document_type_id as number,
1120
+ })),
1121
+ };
1122
+
1123
+ const primaryEmail = payload.contacts.find((contact) =>
1124
+ ['EMAIL'].includes(getContactTypeCode(contact.contact_type_id))
1125
+ );
1126
+ const primaryPhone = payload.contacts.find((contact) =>
1127
+ ['PHONE', 'MOBILE', 'WHATSAPP'].includes(
1128
+ getContactTypeCode(contact.contact_type_id)
1129
+ )
1130
+ );
1131
+ const firstDocument = payload.documents[0];
1132
+
1133
+ if (primaryEmail || primaryPhone || firstDocument) {
1134
+ const duplicateParams = new URLSearchParams();
1135
+ if (person?.id) duplicateParams.set('person_id', String(person.id));
1136
+ if (primaryEmail?.value)
1137
+ duplicateParams.set('email', primaryEmail.value);
1138
+ if (primaryPhone?.value)
1139
+ duplicateParams.set('phone', primaryPhone.value);
1140
+ if (firstDocument?.value && firstDocument?.document_type_id) {
1141
+ duplicateParams.set('document_value', firstDocument.value);
1142
+ duplicateParams.set(
1143
+ 'document_type_id',
1144
+ String(firstDocument.document_type_id)
1145
+ );
1146
+ }
1147
+
1148
+ if (duplicateParams.size > 0) {
1149
+ const duplicateResponse = await request<{
1150
+ hasDuplicates: boolean;
1151
+ matches: Array<{ id: number; name: string; reasons: string[] }>;
1152
+ }>({
1153
+ url: `/person/duplicates?${duplicateParams.toString()}`,
1154
+ method: 'GET',
1155
+ });
1156
+
1157
+ if (duplicateResponse.data.hasDuplicates) {
1158
+ toast.warning(
1159
+ t('duplicateWarning', {
1160
+ names: duplicateResponse.data.matches
1161
+ .map((item) => item.name)
1162
+ .join(', '),
1163
+ })
1164
+ );
1165
+ }
1166
+ }
1167
+ }
1168
+
1169
+ if (person) {
1170
+ await request({
1171
+ url: `/person/${person.id}`,
1172
+ method: 'PATCH',
1173
+ data: payload,
1174
+ });
1175
+ setPersistedAvatarId(avatarId);
1176
+ toast.success(t('updateSuccess'));
1177
+ } else {
1178
+ const createResponse = await request<CreatePersonPayload>({
1179
+ url: '/person',
1180
+ method: 'POST',
1181
+ data: {
1182
+ name: payload.name,
1183
+ type: normalizedType,
1184
+ status: payload.status,
1185
+ avatar_id: payload.avatar_id,
1186
+ birth_date: payload.birth_date,
1187
+ gender: payload.gender,
1188
+ job_title: payload.job_title,
1189
+ employer_company_id: payload.employer_company_id,
1190
+ trade_name: payload.trade_name,
1191
+ foundation_date: payload.foundation_date,
1192
+ legal_nature: payload.legal_nature,
1193
+ },
1194
+ });
1195
+
1196
+ const personId = Number(
1197
+ createResponse?.data?.id ?? createResponse?.data?.data?.id
1198
+ );
1199
+
1200
+ if (!personId) {
1201
+ throw new Error('Could not resolve created person id');
1202
+ }
1203
+
1204
+ setPersistedAvatarId(avatarId);
1205
+ await request({
1206
+ url: `/person/${personId}`,
1207
+ method: 'PATCH',
1208
+ data: payload,
1209
+ });
1210
+ toast.success(t('createSuccess'));
1211
+ }
1212
+
1213
+ hasSavedChangesRef.current = true;
1214
+ handleSheetOpenChange(false);
1215
+ onSuccess();
1216
+ } catch (error: unknown) {
1217
+ const message = error instanceof Error ? error.message : null;
1218
+ toast.error(message || (isEditing ? t('updateError') : t('createError')));
1219
+ } finally {
1220
+ setIsSubmitting(false);
1221
+ }
1222
+ };
1223
+
1224
+ return (
1225
+ <Sheet open={open} onOpenChange={handleSheetOpenChange}>
1226
+ <SheetContent className="flex h-full w-full max-w-full flex-col overflow-hidden p-0 lg:max-w-4xl xl:max-w-5xl">
1227
+ <SheetHeader className="shrink-0 border-b p-4">
1228
+ <div className="flex items-center gap-3">
1229
+ <div
1230
+ className={cn(
1231
+ 'flex h-10 w-10 items-center justify-center rounded-full',
1232
+ watchType === 'individual'
1233
+ ? 'bg-blue-500/10'
1234
+ : 'bg-amber-500/10'
1235
+ )}
1236
+ >
1237
+ {watchType === 'individual' ? (
1238
+ <User className="h-5 w-5 text-blue-600" />
1239
+ ) : (
1240
+ <Building2 className="h-5 w-5 text-amber-600" />
1241
+ )}
1242
+ </div>
1243
+ <div>
1244
+ <SheetTitle>
1245
+ {isEditing ? t('sheetEditTitle') : t('sheetCreateTitle')}
1246
+ </SheetTitle>
1247
+ <SheetDescription>
1248
+ {isEditing
1249
+ ? t('sheetEditDescription')
1250
+ : allowCompanyRegistration
1251
+ ? t('sheetCreateDescription')
1252
+ : t('sheetCreateDescriptionIndividualOnly')}
1253
+ </SheetDescription>
1254
+ </div>
1255
+ </div>
1256
+ </SheetHeader>
1257
+
1258
+ <div className="flex-1 overflow-y-auto">
1259
+ <form
1260
+ id="person-form"
1261
+ onSubmit={handleSubmit(handleFormSubmit)}
1262
+ className="space-y-4 px-4 [&_button[role='combobox']]:text-xs [&_button[role='combobox']_span]:truncate [&_input]:text-xs"
1263
+ >
1264
+ <div className="space-y-3">
1265
+ <h3 className="text-sm font-semibold tracking-wider text-muted-foreground uppercase">
1266
+ {t('dialogBasicInformationTitle')}
1267
+ </h3>
1268
+
1269
+ <div className="flex items-start gap-3">
1270
+ <Avatar className="h-16 w-16 border rounded-md">
1271
+ <AvatarImage
1272
+ src={avatarPreviewUrl}
1273
+ alt={watch('name') || t('name')}
1274
+ className="rounded-md object-cover"
1275
+ />
1276
+ <AvatarFallback className="text-sm font-semibold uppercase">
1277
+ {getPersonInitials(watch('name') || person?.name || 'NA') ||
1278
+ 'NA'}
1279
+ </AvatarFallback>
1280
+ </Avatar>
1281
+
1282
+ <div className="min-w-0 flex-1 space-y-2">
1283
+ <input
1284
+ ref={fileInputRef}
1285
+ type="file"
1286
+ accept="image/*"
1287
+ className="hidden"
1288
+ onChange={(event) => {
1289
+ const file = event.target.files?.[0];
1290
+ if (!file) return;
1291
+ void handleAvatarUpload(file);
1292
+ }}
1293
+ />
1294
+
1295
+ <div className="flex flex-wrap gap-2">
1296
+ <Button
1297
+ type="button"
1298
+ variant="outline"
1299
+ size="sm"
1300
+ onClick={handleSelectAvatar}
1301
+ disabled={isUploadingAvatar}
1302
+ >
1303
+ {isUploadingAvatar ? (
1304
+ <Loader2 className="mr-2 h-4 w-4 animate-spin" />
1305
+ ) : (
1306
+ <Upload className="mr-2 h-4 w-4" />
1307
+ )}
1308
+ {avatarId ? t('avatarReplace') : t('avatarUpload')}
1309
+ </Button>
1310
+ {avatarId || isUploadingAvatar ? (
1311
+ <Button
1312
+ type="button"
1313
+ variant="ghost"
1314
+ size="sm"
1315
+ className="text-red-600 hover:text-red-700"
1316
+ onClick={() => void handleRemoveAvatar()}
1317
+ disabled={!avatarId || isUploadingAvatar}
1318
+ >
1319
+ <Trash2 className="mr-2 h-4 w-4" />
1320
+ {t('avatarRemove')}
1321
+ </Button>
1322
+ ) : null}
1323
+ </div>
1324
+
1325
+ <p className="text-xs text-muted-foreground">
1326
+ {t('avatarGuidelines')}
1327
+ </p>
1328
+
1329
+ {isUploadingAvatar ? (
1330
+ <div className="space-y-1">
1331
+ <Progress value={avatarUploadProgress} className="h-2" />
1332
+ <p className="text-xs text-muted-foreground">
1333
+ {t('avatarUploadingProgress', {
1334
+ progress: avatarUploadProgress,
1335
+ })}
1336
+ </p>
1337
+ </div>
1338
+ ) : null}
1339
+ </div>
1340
+ </div>
1341
+
1342
+ <div className="space-y-1.5">
1343
+ <Label className="text-xs font-medium">
1344
+ {t('name')} <span className="text-destructive">*</span>
1345
+ </Label>
1346
+ <Input
1347
+ placeholder={t('namePlaceholder')}
1348
+ {...register('name')}
1349
+ className={cn('h-9', errors.name && 'border-destructive')}
1350
+ />
1351
+ {errors.name ? (
1352
+ <p className="text-xs text-destructive">
1353
+ {errors.name.message}
1354
+ </p>
1355
+ ) : null}
1356
+ </div>
1357
+
1358
+ <div
1359
+ className={cn(
1360
+ 'grid gap-3',
1361
+ watchType === 'individual'
1362
+ ? 'grid-cols-1 sm:grid-cols-2 xl:grid-cols-4'
1363
+ : 'grid-cols-1 sm:grid-cols-2'
1364
+ )}
1365
+ >
1366
+ {allowCompanyRegistration ? (
1367
+ <div className="space-y-1.5">
1368
+ <Label className="text-xs font-medium">{t('type')}</Label>
1369
+ <Select
1370
+ value={watch('type')}
1371
+ onValueChange={(value: 'individual' | 'company') =>
1372
+ setValue('type', value)
1373
+ }
1374
+ >
1375
+ <SelectTrigger className="h-9 w-full min-w-0">
1376
+ <SelectValue />
1377
+ </SelectTrigger>
1378
+ <SelectContent>
1379
+ <SelectItem value="individual">
1380
+ {t('individual')}
1381
+ </SelectItem>
1382
+ {canUseCompanyType ? (
1383
+ <SelectItem value="company">
1384
+ {t('company')}
1385
+ </SelectItem>
1386
+ ) : null}
1387
+ </SelectContent>
1388
+ </Select>
1389
+ </div>
1390
+ ) : null}
1391
+ <div className="space-y-1.5">
1392
+ <Label className="text-xs font-medium">{t('status')}</Label>
1393
+ <Select
1394
+ value={watch('status')}
1395
+ onValueChange={(value: 'active' | 'inactive') =>
1396
+ setValue('status', value)
1397
+ }
1398
+ >
1399
+ <SelectTrigger className="h-9 w-full min-w-0">
1400
+ <SelectValue />
1401
+ </SelectTrigger>
1402
+ <SelectContent>
1403
+ <SelectItem value="active">{t('active')}</SelectItem>
1404
+ <SelectItem value="inactive">{t('inactive')}</SelectItem>
1405
+ </SelectContent>
1406
+ </Select>
1407
+ </div>
1408
+
1409
+ {watchType === 'individual' ? (
1410
+ <>
1411
+ <div className="space-y-1.5">
1412
+ <Label className="text-xs font-medium">
1413
+ {t('birthDate')}
1414
+ </Label>
1415
+ <DatePickerWithYearMonth
1416
+ date={watch('birth_date') || undefined}
1417
+ onSelect={(date) =>
1418
+ setValue('birth_date', date || null)
1419
+ }
1420
+ maxDate={new Date()}
1421
+ placeholder={t('selectDate')}
1422
+ localeCode={currentLocaleCode}
1423
+ />
1424
+ </div>
1425
+
1426
+ <div className="space-y-1.5">
1427
+ <Label className="text-xs font-medium">
1428
+ {t('gender')}
1429
+ </Label>
1430
+ <Select
1431
+ value={watch('gender') || ''}
1432
+ onValueChange={(value: PersonGender) =>
1433
+ setValue('gender', value)
1434
+ }
1435
+ >
1436
+ <SelectTrigger className="h-9 w-full min-w-0">
1437
+ <SelectValue placeholder={t('selectGender')} />
1438
+ </SelectTrigger>
1439
+ <SelectContent>
1440
+ <SelectItem value="male">
1441
+ {t('genderMale')}
1442
+ </SelectItem>
1443
+ <SelectItem value="female">
1444
+ {t('genderFemale')}
1445
+ </SelectItem>
1446
+ <SelectItem value="other">
1447
+ {t('genderOther')}
1448
+ </SelectItem>
1449
+ </SelectContent>
1450
+ </Select>
1451
+ </div>
1452
+ </>
1453
+ ) : null}
1454
+ </div>
1455
+
1456
+ <div className="grid gap-3 sm:grid-cols-2 xl:grid-cols-4">
1457
+ <div className="space-y-1.5">
1458
+ <Label className="text-xs font-medium">{t('owner')}</Label>
1459
+ <Select
1460
+ value={
1461
+ watch('owner_user_id')
1462
+ ? String(watch('owner_user_id'))
1463
+ : 'none'
1464
+ }
1465
+ onValueChange={(value) =>
1466
+ setValue(
1467
+ 'owner_user_id',
1468
+ value === 'none' ? null : Number(value)
1469
+ )
1470
+ }
1471
+ >
1472
+ <SelectTrigger className="h-9 w-full min-w-0">
1473
+ <SelectValue placeholder={t('unassigned')} />
1474
+ </SelectTrigger>
1475
+ <SelectContent>
1476
+ <SelectItem value="none">{t('unassigned')}</SelectItem>
1477
+ {ownerOptions.map((owner) => (
1478
+ <SelectItem key={owner.id} value={String(owner.id)}>
1479
+ {owner.name}
1480
+ </SelectItem>
1481
+ ))}
1482
+ </SelectContent>
1483
+ </Select>
1484
+ </div>
1485
+
1486
+ <div className="space-y-1.5">
1487
+ <Label className="text-xs font-medium">{t('source')}</Label>
1488
+ <Select
1489
+ value={watch('source') || 'none'}
1490
+ onValueChange={(value) =>
1491
+ setValue(
1492
+ 'source',
1493
+ value === 'none' ? null : (value as PersonSource)
1494
+ )
1495
+ }
1496
+ >
1497
+ <SelectTrigger className="h-9 w-full min-w-0">
1498
+ <SelectValue placeholder={t('source')} />
1499
+ </SelectTrigger>
1500
+ <SelectContent>
1501
+ <SelectItem value="none">{t('all')}</SelectItem>
1502
+ <SelectItem value="referral">
1503
+ {t('sourceReferral')}
1504
+ </SelectItem>
1505
+ <SelectItem value="website">
1506
+ {t('sourceWebsite')}
1507
+ </SelectItem>
1508
+ <SelectItem value="social">
1509
+ {t('sourceSocial')}
1510
+ </SelectItem>
1511
+ <SelectItem value="inbound">
1512
+ {t('sourceInbound')}
1513
+ </SelectItem>
1514
+ <SelectItem value="outbound">
1515
+ {t('sourceOutbound')}
1516
+ </SelectItem>
1517
+ <SelectItem value="other">{t('sourceOther')}</SelectItem>
1518
+ </SelectContent>
1519
+ </Select>
1520
+ </div>
1521
+
1522
+ <div className="space-y-1.5">
1523
+ <Label className="text-xs font-medium">
1524
+ {t('lifecycleStage')}
1525
+ </Label>
1526
+ <Select
1527
+ value={watch('lifecycle_stage') || 'new'}
1528
+ onValueChange={(value) =>
1529
+ setValue('lifecycle_stage', value as PersonLifecycleStage)
1530
+ }
1531
+ >
1532
+ <SelectTrigger className="h-9 w-full min-w-0">
1533
+ <SelectValue />
1534
+ </SelectTrigger>
1535
+ <SelectContent>
1536
+ <SelectItem value="new">{t('lifecycleNew')}</SelectItem>
1537
+ <SelectItem value="contacted">
1538
+ {t('lifecycleContacted')}
1539
+ </SelectItem>
1540
+ <SelectItem value="qualified">
1541
+ {t('lifecycleQualified')}
1542
+ </SelectItem>
1543
+ <SelectItem value="proposal">
1544
+ {t('lifecycleProposal')}
1545
+ </SelectItem>
1546
+ <SelectItem value="negotiation">
1547
+ {t('lifecycleNegotiation')}
1548
+ </SelectItem>
1549
+ <SelectItem value="customer">
1550
+ {t('lifecycleCustomer')}
1551
+ </SelectItem>
1552
+ <SelectItem value="lost">{t('lifecycleLost')}</SelectItem>
1553
+ </SelectContent>
1554
+ </Select>
1555
+ </div>
1556
+
1557
+ <div className="space-y-1.5">
1558
+ <Label className="text-xs font-medium">
1559
+ {t('nextActionAt')}
1560
+ </Label>
1561
+ <Input
1562
+ type="datetime-local"
1563
+ value={watch('next_action_at') || ''}
1564
+ onChange={(event) =>
1565
+ setValue('next_action_at', event.target.value)
1566
+ }
1567
+ className="h-9"
1568
+ />
1569
+ </div>
1570
+ </div>
1571
+
1572
+ {watchType === 'individual' ? (
1573
+ <>
1574
+ <div className="grid grid-cols-2 gap-3">
1575
+ <div className="space-y-1.5">
1576
+ <Label className="text-xs font-medium">
1577
+ {t('jobTitle')}
1578
+ </Label>
1579
+ <Input
1580
+ placeholder={t('jobTitlePlaceholder')}
1581
+ {...register('job_title')}
1582
+ className="h-9"
1583
+ />
1584
+ </div>
1585
+
1586
+ {allowCompanyRegistration ? (
1587
+ <div className="space-y-1.5">
1588
+ <PersonFieldWithCreate<PersonFormValues>
1589
+ form={form}
1590
+ name="employer_company_id"
1591
+ label={t('employerCompany')}
1592
+ entityLabel={t('company')}
1593
+ selectPlaceholder={t('employerCompanyPlaceholder')}
1594
+ personTypeFilter="company"
1595
+ createType="company"
1596
+ lockCreateType
1597
+ valueType="number"
1598
+ initialSelectedLabel={
1599
+ person?.employer_company?.name || ''
1600
+ }
1601
+ />
1602
+ </div>
1603
+ ) : null}
1604
+ </div>
1605
+ </>
1606
+ ) : (
1607
+ <>
1608
+ <div className="space-y-1.5">
1609
+ <Label className="text-xs font-medium">
1610
+ {t('tradeName')}
1611
+ </Label>
1612
+ <Input
1613
+ placeholder={t('tradeNamePlaceholder')}
1614
+ {...register('trade_name')}
1615
+ className="h-9"
1616
+ />
1617
+ </div>
1618
+
1619
+ <div className="grid grid-cols-2 gap-3">
1620
+ <div className="space-y-1.5">
1621
+ <Label className="text-xs font-medium">
1622
+ {t('foundationDate')}
1623
+ </Label>
1624
+ <DatePickerWithYearMonth
1625
+ date={watch('foundation_date') || undefined}
1626
+ onSelect={(date) =>
1627
+ setValue('foundation_date', date || null)
1628
+ }
1629
+ maxDate={new Date()}
1630
+ placeholder={t('selectDate')}
1631
+ localeCode={currentLocaleCode}
1632
+ />
1633
+ </div>
1634
+
1635
+ <div className="space-y-1.5">
1636
+ <Label className="text-xs font-medium">
1637
+ {t('legalNature')}
1638
+ </Label>
1639
+ <Input
1640
+ placeholder={t('legalNaturePlaceholder')}
1641
+ {...register('legal_nature')}
1642
+ className="h-9"
1643
+ />
1644
+ </div>
1645
+ </div>
1646
+ </>
1647
+ )}
1648
+ </div>
1649
+
1650
+ <Separator />
1651
+
1652
+ <Collapsible open={contactsOpen} onOpenChange={setContactsOpen}>
1653
+ <CollapsibleTrigger asChild>
1654
+ <div className="group flex cursor-pointer items-center justify-between">
1655
+ <div className="flex items-center gap-2">
1656
+ <Mail className="h-4 w-4 text-blue-500" />
1657
+ <h3 className="text-sm font-semibold">
1658
+ {t('tabContacts')}
1659
+ </h3>
1660
+ {contacts.length > 0 ? (
1661
+ <Badge
1662
+ variant="secondary"
1663
+ className="bg-blue-500/10 text-blue-600"
1664
+ >
1665
+ {contacts.length}
1666
+ </Badge>
1667
+ ) : null}
1668
+ </div>
1669
+
1670
+ <div className="flex items-center gap-1">
1671
+ <Button
1672
+ type="button"
1673
+ variant="ghost"
1674
+ size="sm"
1675
+ className="h-7 px-2 text-xs"
1676
+ onClick={(event) => {
1677
+ event.stopPropagation();
1678
+ addContact('email');
1679
+ }}
1680
+ >
1681
+ <Plus className="mr-1 h-3.5 w-3.5" />
1682
+ {t('addEmail')}
1683
+ </Button>
1684
+ <Button
1685
+ type="button"
1686
+ variant="ghost"
1687
+ size="sm"
1688
+ className="h-7 px-2 text-xs"
1689
+ onClick={(event) => {
1690
+ event.stopPropagation();
1691
+ addContact('phone');
1692
+ }}
1693
+ >
1694
+ <Plus className="mr-1 h-3.5 w-3.5" />
1695
+ {t('addPhone')}
1696
+ </Button>
1697
+ <Button
1698
+ type="button"
1699
+ variant="ghost"
1700
+ size="sm"
1701
+ className="h-7 px-2 text-xs"
1702
+ onClick={(event) => {
1703
+ event.stopPropagation();
1704
+ addContact('blank');
1705
+ }}
1706
+ >
1707
+ <Plus className="mr-1 h-3.5 w-3.5" />
1708
+ {t('addContact')}
1709
+ </Button>
1710
+ {contactsOpen ? (
1711
+ <ChevronUp className="h-4 w-4" />
1712
+ ) : (
1713
+ <ChevronDown className="h-4 w-4" />
1714
+ )}
1715
+ </div>
1716
+ </div>
1717
+ </CollapsibleTrigger>
1718
+
1719
+ <CollapsibleContent className="mt-2 space-y-2">
1720
+ {contacts.length === 0 ? (
1721
+ <div className="rounded-lg border-2 border-dashed py-4 text-center text-sm text-muted-foreground">
1722
+ {t('noContacts')}
1723
+ </div>
1724
+ ) : (
1725
+ contacts.map((contact) => (
1726
+ <div
1727
+ key={contact.clientId}
1728
+ className={cn(
1729
+ 'space-y-2 rounded-lg border p-2',
1730
+ contact.is_primary && 'border-blue-500/50 bg-blue-500/5'
1731
+ )}
1732
+ >
1733
+ <div className="flex items-center gap-2">
1734
+ <Select
1735
+ value={
1736
+ contact.contact_type_id
1737
+ ? String(contact.contact_type_id)
1738
+ : undefined
1739
+ }
1740
+ onValueChange={(value) => {
1741
+ const nextTypeId = Number(value);
1742
+ updateContact(contact.clientId, {
1743
+ contact_type_id: nextTypeId,
1744
+ value: maskContactValueByType(
1745
+ contact.value || '',
1746
+ nextTypeId
1747
+ ),
1748
+ });
1749
+ }}
1750
+ >
1751
+ <SelectTrigger className="h-8 w-36 text-xs">
1752
+ <SelectValue placeholder={t('selectContactType')} />
1753
+ </SelectTrigger>
1754
+ <SelectContent>
1755
+ {contactTypes.map((contactType) => (
1756
+ <SelectItem
1757
+ key={contactType.contact_type_id}
1758
+ value={String(contactType.contact_type_id)}
1759
+ >
1760
+ {contactType.name}
1761
+ </SelectItem>
1762
+ ))}
1763
+ </SelectContent>
1764
+ </Select>
1765
+
1766
+ <Input
1767
+ ref={(element) => {
1768
+ contactValueRefs.current[contact.clientId] =
1769
+ element;
1770
+ }}
1771
+ placeholder={(() => {
1772
+ const contactTypeCode = getContactTypeCode(
1773
+ contact.contact_type_id
1774
+ );
1775
+ if (contactTypeCode === 'EMAIL') {
1776
+ return 'email@exemplo.com';
1777
+ }
1778
+ if (contactTypeCode.length === 0) {
1779
+ return t('contactValue');
1780
+ }
1781
+ return '(00) 00000-0000';
1782
+ })()}
1783
+ value={contact.value}
1784
+ onChange={(event) =>
1785
+ updateContact(contact.clientId, {
1786
+ value: maskContactValueByType(
1787
+ event.target.value,
1788
+ contact.contact_type_id
1789
+ ),
1790
+ })
1791
+ }
1792
+ className="h-8 flex-1 text-xs"
1793
+ />
1794
+
1795
+ <Tooltip>
1796
+ <TooltipTrigger asChild>
1797
+ <Button
1798
+ type="button"
1799
+ variant="ghost"
1800
+ size="icon"
1801
+ className={cn(
1802
+ 'h-8 w-8',
1803
+ contact.is_primary && 'text-amber-500'
1804
+ )}
1805
+ onClick={() =>
1806
+ setPrimaryContact(contact.clientId)
1807
+ }
1808
+ aria-label={t('main')}
1809
+ >
1810
+ <Star
1811
+ className={cn(
1812
+ 'h-4 w-4',
1813
+ contact.is_primary && 'fill-current'
1814
+ )}
1815
+ />
1816
+ </Button>
1817
+ </TooltipTrigger>
1818
+ <TooltipContent>{t('main')}</TooltipContent>
1819
+ </Tooltip>
1820
+
1821
+ <Tooltip>
1822
+ <TooltipTrigger asChild>
1823
+ <Button
1824
+ type="button"
1825
+ variant="ghost"
1826
+ size="icon"
1827
+ className="h-8 w-8 text-red-500 hover:text-red-600"
1828
+ onClick={() => removeContact(contact.clientId)}
1829
+ aria-label={t('remove')}
1830
+ >
1831
+ <Trash2 className="h-4 w-4" />
1832
+ </Button>
1833
+ </TooltipTrigger>
1834
+ <TooltipContent>{t('remove')}</TooltipContent>
1835
+ </Tooltip>
1836
+ </div>
1837
+ </div>
1838
+ ))
1839
+ )}
1840
+ </CollapsibleContent>
1841
+ </Collapsible>
1842
+
1843
+ <Separator />
1844
+
1845
+ <Collapsible open={addressesOpen} onOpenChange={setAddressesOpen}>
1846
+ <CollapsibleTrigger asChild>
1847
+ <div className="group flex cursor-pointer items-center justify-between">
1848
+ <div className="flex items-center gap-2">
1849
+ <MapPin className="h-4 w-4 text-green-500" />
1850
+ <h3 className="text-sm font-semibold">
1851
+ {t('tabAddresses')}
1852
+ </h3>
1853
+ {addresses.length > 0 ? (
1854
+ <Badge
1855
+ variant="secondary"
1856
+ className="bg-green-500/10 text-green-600"
1857
+ >
1858
+ {addresses.length}
1859
+ </Badge>
1860
+ ) : null}
1861
+ </div>
1862
+
1863
+ <div className="flex items-center gap-2">
1864
+ <Button
1865
+ type="button"
1866
+ variant="ghost"
1867
+ size="sm"
1868
+ className="h-7 px-2 text-xs"
1869
+ onClick={(event) => {
1870
+ event.stopPropagation();
1871
+ addAddress();
1872
+ }}
1873
+ >
1874
+ <Plus className="mr-1 h-3.5 w-3.5" />
1875
+ {t('addAddress')}
1876
+ </Button>
1877
+ {addressesOpen ? (
1878
+ <ChevronUp className="h-4 w-4" />
1879
+ ) : (
1880
+ <ChevronDown className="h-4 w-4" />
1881
+ )}
1882
+ </div>
1883
+ </div>
1884
+ </CollapsibleTrigger>
1885
+
1886
+ <CollapsibleContent className="mt-2 space-y-2">
1887
+ {addresses.length === 0 ? (
1888
+ <div className="rounded-lg border-2 border-dashed py-4 text-center text-sm text-muted-foreground">
1889
+ {t('noAddresses')}
1890
+ </div>
1891
+ ) : (
1892
+ addresses.map((address) => (
1893
+ <div
1894
+ key={address.clientId}
1895
+ className={cn(
1896
+ 'space-y-2 rounded-lg border p-2',
1897
+ address.is_primary &&
1898
+ 'border-green-500/50 bg-green-500/5'
1899
+ )}
1900
+ >
1901
+ <div className="flex items-center gap-2">
1902
+ <Select
1903
+ value={address.address_type}
1904
+ onValueChange={(value) =>
1905
+ updateAddress(address.clientId, {
1906
+ address_type:
1907
+ value as PersonAddress['address_type'],
1908
+ })
1909
+ }
1910
+ >
1911
+ <SelectTrigger className="h-8 w-36 text-xs">
1912
+ <SelectValue />
1913
+ </SelectTrigger>
1914
+ <SelectContent>
1915
+ {ADDRESS_TYPE_OPTIONS.map((option) => (
1916
+ <SelectItem
1917
+ key={option.value}
1918
+ value={option.value}
1919
+ >
1920
+ {t(option.labelKey)}
1921
+ </SelectItem>
1922
+ ))}
1923
+ </SelectContent>
1924
+ </Select>
1925
+
1926
+ <Input
1927
+ placeholder={t('zipCode')}
1928
+ value={address.postal_code || ''}
1929
+ maxLength={9}
1930
+ onChange={(event) =>
1931
+ void handleCEP(event, address.clientId)
1932
+ }
1933
+ className="h-8 w-28 text-xs"
1934
+ />
1935
+
1936
+ <Tooltip>
1937
+ <TooltipTrigger asChild>
1938
+ <Button
1939
+ type="button"
1940
+ variant="ghost"
1941
+ size="icon"
1942
+ className={cn(
1943
+ 'h-8 w-8',
1944
+ address.is_primary && 'text-amber-500'
1945
+ )}
1946
+ onClick={() =>
1947
+ setPrimaryAddress(address.clientId)
1948
+ }
1949
+ aria-label={t('main')}
1950
+ >
1951
+ <Star
1952
+ className={cn(
1953
+ 'h-4 w-4',
1954
+ address.is_primary && 'fill-current'
1955
+ )}
1956
+ />
1957
+ </Button>
1958
+ </TooltipTrigger>
1959
+ <TooltipContent>{t('main')}</TooltipContent>
1960
+ </Tooltip>
1961
+
1962
+ <Tooltip>
1963
+ <TooltipTrigger asChild>
1964
+ <Button
1965
+ type="button"
1966
+ variant="ghost"
1967
+ size="icon"
1968
+ className="h-8 w-8 text-red-500 hover:text-red-600"
1969
+ onClick={() => removeAddress(address.clientId)}
1970
+ aria-label={t('remove')}
1971
+ >
1972
+ <Trash2 className="h-4 w-4" />
1973
+ </Button>
1974
+ </TooltipTrigger>
1975
+ <TooltipContent>{t('remove')}</TooltipContent>
1976
+ </Tooltip>
1977
+ </div>
1978
+
1979
+ <div className="relative">
1980
+ <Input
1981
+ ref={(element) => {
1982
+ addressLine1Refs.current[address.clientId] =
1983
+ element;
1984
+ }}
1985
+ placeholder={t('addressPlaceholder')}
1986
+ value={address.line1}
1987
+ disabled={Boolean(loadingCEP[address.clientId])}
1988
+ onChange={(event) =>
1989
+ updateAddress(address.clientId, {
1990
+ line1: event.target.value,
1991
+ })
1992
+ }
1993
+ className="h-8 text-xs"
1994
+ />
1995
+ {loadingCEP[address.clientId] ? (
1996
+ <Loader2 className="absolute top-1/2 right-3 h-4 w-4 -translate-y-1/2 animate-spin text-muted-foreground" />
1997
+ ) : null}
1998
+ </div>
1999
+
2000
+ <Input
2001
+ placeholder={t('addressComplementPlaceholder')}
2002
+ value={address.line2 || ''}
2003
+ onChange={(event) =>
2004
+ updateAddress(address.clientId, {
2005
+ line2: event.target.value,
2006
+ })
2007
+ }
2008
+ className="h-8 text-xs"
2009
+ />
2010
+
2011
+ <div className="grid grid-cols-2 gap-2">
2012
+ <Input
2013
+ placeholder={t('addressCityPlaceholder')}
2014
+ value={address.city}
2015
+ disabled={Boolean(loadingCEP[address.clientId])}
2016
+ onChange={(event) =>
2017
+ updateAddress(address.clientId, {
2018
+ city: event.target.value,
2019
+ })
2020
+ }
2021
+ className="h-8 text-xs"
2022
+ />
2023
+ <Input
2024
+ placeholder={t('addressStatePlaceholder')}
2025
+ value={address.state}
2026
+ disabled={Boolean(loadingCEP[address.clientId])}
2027
+ onChange={(event) =>
2028
+ updateAddress(address.clientId, {
2029
+ state: event.target.value,
2030
+ })
2031
+ }
2032
+ className="h-8 text-xs"
2033
+ />
2034
+ </div>
2035
+
2036
+ <Select
2037
+ value={address.country_code || 'BRA'}
2038
+ onValueChange={(value) =>
2039
+ updateAddress(address.clientId, {
2040
+ country_code: value,
2041
+ })
2042
+ }
2043
+ >
2044
+ <SelectTrigger className="h-8 text-xs">
2045
+ <SelectValue
2046
+ placeholder={t('addressCountryPlaceholder')}
2047
+ />
2048
+ </SelectTrigger>
2049
+ <SelectContent>
2050
+ {COUNTRIES.map((country) => (
2051
+ <SelectItem key={country.code} value={country.code}>
2052
+ {country.name}
2053
+ </SelectItem>
2054
+ ))}
2055
+ </SelectContent>
2056
+ </Select>
2057
+ </div>
2058
+ ))
2059
+ )}
2060
+ </CollapsibleContent>
2061
+ </Collapsible>
2062
+
2063
+ <Separator />
2064
+
2065
+ <Collapsible open={documentsOpen} onOpenChange={setDocumentsOpen}>
2066
+ <CollapsibleTrigger asChild>
2067
+ <div className="group flex cursor-pointer flex-wrap items-center gap-2">
2068
+ <div className="flex min-w-0 items-center gap-2">
2069
+ <FileText className="h-4 w-4 text-amber-500" />
2070
+ <h3 className="text-sm font-semibold">
2071
+ {t('tabDocuments')}
2072
+ </h3>
2073
+ {documents.length > 0 ? (
2074
+ <Badge
2075
+ variant="secondary"
2076
+ className="bg-amber-500/10 text-amber-600"
2077
+ >
2078
+ {documents.length}
2079
+ </Badge>
2080
+ ) : null}
2081
+ </div>
2082
+
2083
+ <div className="ml-auto flex max-w-full flex-wrap items-center justify-end gap-1 sm:flex-nowrap">
2084
+ <Button
2085
+ type="button"
2086
+ variant="ghost"
2087
+ size="sm"
2088
+ className="h-7 shrink-0 px-2 text-xs"
2089
+ onClick={(event) => {
2090
+ event.stopPropagation();
2091
+ addDocument('cpf');
2092
+ }}
2093
+ >
2094
+ <Plus className="mr-1 h-3.5 w-3.5" />
2095
+ CPF
2096
+ </Button>
2097
+ <Button
2098
+ type="button"
2099
+ variant="ghost"
2100
+ size="sm"
2101
+ className="h-7 shrink-0 px-2 text-xs"
2102
+ onClick={(event) => {
2103
+ event.stopPropagation();
2104
+ addDocument('cnpj');
2105
+ }}
2106
+ >
2107
+ <Plus className="mr-1 h-3.5 w-3.5" />
2108
+ CNPJ
2109
+ </Button>
2110
+ <Button
2111
+ type="button"
2112
+ variant="ghost"
2113
+ size="sm"
2114
+ className="h-7 shrink-0 px-2 text-xs"
2115
+ onClick={(event) => {
2116
+ event.stopPropagation();
2117
+ addDocument('rg');
2118
+ }}
2119
+ >
2120
+ <Plus className="mr-1 h-3.5 w-3.5" />
2121
+ RG
2122
+ </Button>
2123
+ <Button
2124
+ type="button"
2125
+ variant="ghost"
2126
+ size="sm"
2127
+ className="h-7 shrink-0 px-2 text-xs"
2128
+ onClick={(event) => {
2129
+ event.stopPropagation();
2130
+ addDocument('blank');
2131
+ }}
2132
+ >
2133
+ <Plus className="mr-1 h-3.5 w-3.5" />
2134
+ <span className="max-w-20 truncate sm:max-w-none">
2135
+ {t('addDocument')}
2136
+ </span>
2137
+ </Button>
2138
+ {documentsOpen ? (
2139
+ <ChevronUp className="h-4 w-4" />
2140
+ ) : (
2141
+ <ChevronDown className="h-4 w-4" />
2142
+ )}
2143
+ </div>
2144
+ </div>
2145
+ </CollapsibleTrigger>
2146
+
2147
+ <CollapsibleContent className="mt-2 space-y-2">
2148
+ {documents.length === 0 ? (
2149
+ <div className="rounded-lg border-2 border-dashed py-4 text-center text-sm text-muted-foreground">
2150
+ {t('noDocuments')}
2151
+ </div>
2152
+ ) : (
2153
+ documents.map((document) => (
2154
+ <div
2155
+ key={document.clientId}
2156
+ className="rounded-lg border p-2"
2157
+ >
2158
+ <div className="flex items-center gap-2">
2159
+ <Select
2160
+ value={
2161
+ document.document_type_id
2162
+ ? String(document.document_type_id)
2163
+ : undefined
2164
+ }
2165
+ onValueChange={(value) => {
2166
+ const nextTypeId = Number(value);
2167
+ updateDocument(document.clientId, {
2168
+ document_type_id: nextTypeId,
2169
+ value: maskDocumentValueByType(
2170
+ document.value || '',
2171
+ nextTypeId
2172
+ ),
2173
+ });
2174
+ }}
2175
+ >
2176
+ <SelectTrigger className="h-8 w-36 text-xs">
2177
+ <SelectValue
2178
+ placeholder={t('selectDocumentType')}
2179
+ />
2180
+ </SelectTrigger>
2181
+ <SelectContent>
2182
+ {documentTypes.map((documentType) => (
2183
+ <SelectItem
2184
+ key={documentType.document_type_id}
2185
+ value={String(documentType.document_type_id)}
2186
+ >
2187
+ {documentType.name}
2188
+ </SelectItem>
2189
+ ))}
2190
+ </SelectContent>
2191
+ </Select>
2192
+
2193
+ <Input
2194
+ ref={(element) => {
2195
+ documentValueRefs.current[document.clientId] =
2196
+ element;
2197
+ }}
2198
+ placeholder={t('documentValuePlaceholder')}
2199
+ value={document.value}
2200
+ onChange={(event) =>
2201
+ updateDocument(document.clientId, {
2202
+ value: maskDocumentValueByType(
2203
+ event.target.value,
2204
+ document.document_type_id
2205
+ ),
2206
+ })
2207
+ }
2208
+ className="h-8 flex-1 text-xs"
2209
+ />
2210
+
2211
+ <Tooltip>
2212
+ <TooltipTrigger asChild>
2213
+ <Button
2214
+ type="button"
2215
+ variant="ghost"
2216
+ size="icon"
2217
+ className="h-8 w-8 text-red-500 hover:text-red-600"
2218
+ onClick={() => removeDocument(document.clientId)}
2219
+ aria-label={t('remove')}
2220
+ >
2221
+ <Trash2 className="h-4 w-4" />
2222
+ </Button>
2223
+ </TooltipTrigger>
2224
+ <TooltipContent>{t('remove')}</TooltipContent>
2225
+ </Tooltip>
2226
+ </div>
2227
+ </div>
2228
+ ))
2229
+ )}
2230
+ </CollapsibleContent>
2231
+ </Collapsible>
2232
+ </form>
2233
+ </div>
2234
+
2235
+ <div className="shrink-0 space-y-2 border-t p-4">
2236
+ <div className="grid gap-2 sm:grid-cols-2">
2237
+ {!isEditing ? (
2238
+ <Button
2239
+ type="button"
2240
+ variant="outline"
2241
+ onClick={() => {
2242
+ const formElement = document.getElementById(
2243
+ 'person-form'
2244
+ ) as HTMLFormElement | null;
2245
+ formElement?.requestSubmit();
2246
+ }}
2247
+ disabled={isSubmitting || isUploadingAvatar}
2248
+ >
2249
+ {t('saveAndNew')}
2250
+ </Button>
2251
+ ) : null}
2252
+ <Button
2253
+ type="submit"
2254
+ form="person-form"
2255
+ className="w-full"
2256
+ disabled={isSubmitting || isUploadingAvatar}
2257
+ >
2258
+ {isSubmitting ? (
2259
+ <Loader2 className="mr-2 h-4 w-4 animate-spin" />
2260
+ ) : (
2261
+ <Save className="mr-2 h-4 w-4" />
2262
+ )}
2263
+ {isSubmitting
2264
+ ? t('saving')
2265
+ : isEditing
2266
+ ? t('saveChanges')
2267
+ : t('createPerson')}
2268
+ </Button>
2269
+ </div>
2270
+ <p className="text-xs text-muted-foreground">
2271
+ {t('formShortcutsHint')}
2272
+ </p>
2273
+ </div>
2274
+ </SheetContent>
2275
+ </Sheet>
2276
+ );
2277
+ }