@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,831 @@
1
+ 'use client';
2
+
3
+ import { Button } from '@/components/ui/button';
4
+ import {
5
+ Command,
6
+ CommandEmpty,
7
+ CommandGroup,
8
+ CommandInput,
9
+ CommandItem,
10
+ CommandList,
11
+ } from '@/components/ui/command';
12
+ import {
13
+ Form,
14
+ FormControl,
15
+ FormField,
16
+ FormItem,
17
+ FormLabel,
18
+ FormMessage,
19
+ } from '@/components/ui/form';
20
+ import { Input } from '@/components/ui/input';
21
+ import { Label } from '@/components/ui/label';
22
+ import {
23
+ Popover,
24
+ PopoverContent,
25
+ PopoverTrigger,
26
+ } from '@/components/ui/popover';
27
+ import {
28
+ Select,
29
+ SelectContent,
30
+ SelectItem,
31
+ SelectTrigger,
32
+ SelectValue,
33
+ } from '@/components/ui/select';
34
+ import {
35
+ Sheet,
36
+ SheetContent,
37
+ SheetDescription,
38
+ SheetHeader,
39
+ SheetTitle,
40
+ } from '@/components/ui/sheet';
41
+ import { useApp, useQuery } from '@hed-hog/next-app-provider';
42
+ import { zodResolver } from '@hookform/resolvers/zod';
43
+ import { ChevronsUpDown, Plus, X } from 'lucide-react';
44
+ import { useTranslations } from 'next-intl';
45
+ import { useEffect, useMemo, useRef, useState } from 'react';
46
+ import {
47
+ Controller,
48
+ FieldValues,
49
+ Path,
50
+ UseFormReturn,
51
+ useForm,
52
+ } from 'react-hook-form';
53
+ import { z } from 'zod';
54
+
55
+ type PersonOption = {
56
+ id: number | string;
57
+ name: string;
58
+ };
59
+
60
+ type CreatePersonValues = {
61
+ name: string;
62
+ type: 'individual' | 'company';
63
+ email?: string;
64
+ phone?: string;
65
+ document?: string;
66
+ line1?: string;
67
+ city?: string;
68
+ state?: string;
69
+ };
70
+
71
+ type PersonTypeFilter = 'individual' | 'company' | 'all';
72
+ type FieldValueType = 'string' | 'number';
73
+
74
+ type ContactTypeLookup = {
75
+ code: string;
76
+ contact_type_id: number;
77
+ };
78
+
79
+ type DocumentTypeLookup = {
80
+ code: string;
81
+ document_type_id: number;
82
+ };
83
+
84
+ type CreatePersonResponse = {
85
+ id?: number;
86
+ data?: {
87
+ id?: number;
88
+ };
89
+ };
90
+
91
+ type PaginatedResponse<T> = {
92
+ data?: T[];
93
+ };
94
+
95
+ function usePersonFieldWithCreateTranslations() {
96
+ const tRoot = useTranslations();
97
+
98
+ return (
99
+ key: string,
100
+ values?: Record<string, string | number | boolean | null | undefined>
101
+ ) => {
102
+ const contactKey = `contact.PersonFieldWithCreate.${key}`;
103
+ if (tRoot.has(contactKey)) {
104
+ return tRoot(contactKey, values);
105
+ }
106
+
107
+ return tRoot(`finance.PersonFieldWithCreate.${key}`, values);
108
+ };
109
+ }
110
+
111
+ function CreatePersonSheet({
112
+ open,
113
+ onOpenChange,
114
+ onCreated,
115
+ entityLabel,
116
+ defaultCreateType = 'individual',
117
+ lockCreateType = false,
118
+ }: {
119
+ open: boolean;
120
+ onOpenChange: (open: boolean) => void;
121
+ onCreated: (person: PersonOption) => void;
122
+ entityLabel: string;
123
+ defaultCreateType?: Exclude<PersonTypeFilter, 'all'>;
124
+ lockCreateType?: boolean;
125
+ }) {
126
+ const { request, showToastHandler, currentLocaleCode, getSettingValue } =
127
+ useApp();
128
+ const t = usePersonFieldWithCreateTranslations();
129
+ const allowCompanyRegistration =
130
+ getSettingValue('contact-allow-company-registration') !== false;
131
+
132
+ const effectiveDefaultType: Exclude<PersonTypeFilter, 'all'> =
133
+ allowCompanyRegistration ? defaultCreateType : 'individual';
134
+
135
+ const createPersonSchema = useMemo(
136
+ () =>
137
+ z.object({
138
+ name: z.string().trim().min(2, 'Nome é obrigatório'),
139
+ type: allowCompanyRegistration
140
+ ? z.enum(['individual', 'company'])
141
+ : z.literal('individual'),
142
+ email: z
143
+ .string()
144
+ .trim()
145
+ .email('E-mail inválido')
146
+ .optional()
147
+ .or(z.literal('')),
148
+ phone: z.string().trim().optional(),
149
+ document: z.string().trim().optional(),
150
+ line1: z.string().optional(),
151
+ city: z.string().optional(),
152
+ state: z.string().optional(),
153
+ }),
154
+ [allowCompanyRegistration]
155
+ );
156
+
157
+ const form = useForm<CreatePersonValues>({
158
+ resolver: zodResolver(createPersonSchema),
159
+ defaultValues: {
160
+ name: '',
161
+ type: effectiveDefaultType,
162
+ email: '',
163
+ phone: '',
164
+ document: '',
165
+ line1: '',
166
+ city: '',
167
+ state: '',
168
+ },
169
+ });
170
+
171
+ const selectedType =
172
+ allowCompanyRegistration && !lockCreateType
173
+ ? form.watch('type')
174
+ : effectiveDefaultType;
175
+
176
+ useEffect(() => {
177
+ if (!allowCompanyRegistration || lockCreateType) {
178
+ form.setValue('type', effectiveDefaultType);
179
+ }
180
+ }, [allowCompanyRegistration, effectiveDefaultType, form, lockCreateType]);
181
+
182
+ const { data: contactTypes = [] } = useQuery<ContactTypeLookup[]>({
183
+ queryKey: ['person-field-contact-types', currentLocaleCode],
184
+ queryFn: async () => {
185
+ const response = await request<PaginatedResponse<ContactTypeLookup>>({
186
+ url: '/person-contact-type?pageSize=100',
187
+ method: 'GET',
188
+ });
189
+ return response?.data?.data || [];
190
+ },
191
+ placeholderData: (old) => old ?? [],
192
+ });
193
+
194
+ const { data: documentTypes = [] } = useQuery<DocumentTypeLookup[]>({
195
+ queryKey: ['person-field-document-types', currentLocaleCode],
196
+ queryFn: async () => {
197
+ const response = await request<PaginatedResponse<DocumentTypeLookup>>({
198
+ url: '/person-document-type?pageSize=100',
199
+ method: 'GET',
200
+ });
201
+ return response?.data?.data || [];
202
+ },
203
+ placeholderData: (old) => old ?? [],
204
+ });
205
+
206
+ const resolveContactTypeId = (code: string, fallbackIndex = 0) => {
207
+ const found = contactTypes.find(
208
+ (item) => String(item.code).toUpperCase() === code
209
+ );
210
+ return (
211
+ found?.contact_type_id || contactTypes[fallbackIndex]?.contact_type_id
212
+ );
213
+ };
214
+
215
+ const resolveDocumentTypeId = (code: string) => {
216
+ const found = documentTypes.find(
217
+ (item) => String(item.code).toUpperCase() === code
218
+ );
219
+ return found?.document_type_id || documentTypes[0]?.document_type_id;
220
+ };
221
+
222
+ const resolveAddressType = () => 'residential';
223
+
224
+ const handleSubmit = async (values: CreatePersonValues) => {
225
+ try {
226
+ const normalizedType =
227
+ allowCompanyRegistration && !lockCreateType
228
+ ? values.type
229
+ : effectiveDefaultType;
230
+
231
+ const createResponse = await request<CreatePersonResponse>({
232
+ url: '/person',
233
+ method: 'POST',
234
+ data: {
235
+ name: values.name,
236
+ type: normalizedType,
237
+ status: 'active',
238
+ },
239
+ });
240
+
241
+ const personId = Number(
242
+ createResponse?.data?.id ?? createResponse?.data?.data?.id
243
+ );
244
+
245
+ if (!personId) {
246
+ throw new Error('Não foi possível identificar o cadastro criado');
247
+ }
248
+
249
+ const emailTypeId = resolveContactTypeId('EMAIL', 0);
250
+ const phoneTypeId =
251
+ resolveContactTypeId('PHONE', 1) || resolveContactTypeId('MOBILE', 1);
252
+ const documentTypeId = resolveDocumentTypeId(
253
+ normalizedType === 'individual' ? 'CPF' : 'CNPJ'
254
+ );
255
+ const addressType = resolveAddressType();
256
+
257
+ const contacts = [
258
+ values.email && emailTypeId
259
+ ? {
260
+ value: values.email.trim(),
261
+ is_primary: true,
262
+ contact_type_id: emailTypeId,
263
+ }
264
+ : null,
265
+ values.phone && phoneTypeId
266
+ ? {
267
+ value: values.phone.trim(),
268
+ is_primary: true,
269
+ contact_type_id: phoneTypeId,
270
+ }
271
+ : null,
272
+ ].filter(Boolean);
273
+
274
+ const documents = values.document?.trim()
275
+ ? [
276
+ {
277
+ value: values.document.trim(),
278
+ document_type_id: documentTypeId,
279
+ },
280
+ ]
281
+ : [];
282
+
283
+ const addresses =
284
+ values.line1 && values.city && values.state
285
+ ? [
286
+ {
287
+ line1: values.line1.trim(),
288
+ city: values.city.trim(),
289
+ state: values.state.trim(),
290
+ is_primary: true,
291
+ address_type: addressType,
292
+ },
293
+ ]
294
+ : [];
295
+
296
+ await request({
297
+ url: `/person/${personId}`,
298
+ method: 'PATCH',
299
+ data: {
300
+ name: values.name,
301
+ type: normalizedType,
302
+ status: 'active',
303
+ contacts,
304
+ documents,
305
+ addresses,
306
+ },
307
+ });
308
+
309
+ onCreated({ id: personId, name: values.name });
310
+ form.reset();
311
+ onOpenChange(false);
312
+ showToastHandler?.(
313
+ 'success',
314
+ t('messages.createdSuccess', { entityLabel })
315
+ );
316
+ } catch {
317
+ showToastHandler?.('error', t('messages.createdError', { entityLabel }));
318
+ }
319
+ };
320
+
321
+ return (
322
+ <Sheet
323
+ open={open}
324
+ onOpenChange={(nextOpen) => {
325
+ onOpenChange(nextOpen);
326
+ if (!nextOpen) {
327
+ form.reset();
328
+ }
329
+ }}
330
+ >
331
+ <SheetContent
332
+ className="w-full overflow-y-auto sm:max-w-xl"
333
+ onCloseAutoFocus={(event) => event.preventDefault()}
334
+ >
335
+ <SheetHeader>
336
+ <SheetTitle>{t('sheet.title', { entityLabel })}</SheetTitle>
337
+ <SheetDescription>
338
+ {allowCompanyRegistration
339
+ ? t('sheet.description')
340
+ : t('sheet.descriptionIndividualOnly')}
341
+ </SheetDescription>
342
+ </SheetHeader>
343
+
344
+ <Form {...form}>
345
+ <div className="space-y-4 p-4">
346
+ <FormField
347
+ control={form.control}
348
+ name="name"
349
+ render={({ field }) => (
350
+ <FormItem>
351
+ <FormLabel>{t('fields.name')}</FormLabel>
352
+ <FormControl>
353
+ <Input
354
+ placeholder={t('placeholders.name', { entityLabel })}
355
+ {...field}
356
+ />
357
+ </FormControl>
358
+ <FormMessage />
359
+ </FormItem>
360
+ )}
361
+ />
362
+
363
+ {allowCompanyRegistration && !lockCreateType ? (
364
+ <FormField
365
+ control={form.control}
366
+ name="type"
367
+ render={({ field }) => (
368
+ <FormItem>
369
+ <FormLabel>{t('fields.type')}</FormLabel>
370
+ <Select value={field.value} onValueChange={field.onChange}>
371
+ <FormControl>
372
+ <SelectTrigger>
373
+ <SelectValue placeholder={t('common.select')} />
374
+ </SelectTrigger>
375
+ </FormControl>
376
+ <SelectContent>
377
+ <SelectItem value="individual">
378
+ {t('types.individual')}
379
+ </SelectItem>
380
+ <SelectItem value="company">
381
+ {t('types.company')}
382
+ </SelectItem>
383
+ </SelectContent>
384
+ </Select>
385
+ <FormMessage />
386
+ </FormItem>
387
+ )}
388
+ />
389
+ ) : null}
390
+
391
+ <div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
392
+ <FormField
393
+ control={form.control}
394
+ name="document"
395
+ render={({ field }) => (
396
+ <FormItem>
397
+ <FormLabel>
398
+ {selectedType === 'individual'
399
+ ? t('fields.documentIndividualOptional')
400
+ : t('fields.documentCompanyOptional')}
401
+ </FormLabel>
402
+ <FormControl>
403
+ <Input
404
+ placeholder={
405
+ selectedType === 'individual'
406
+ ? '000.000.000-00'
407
+ : '00.000.000/0000-00'
408
+ }
409
+ {...field}
410
+ value={field.value || ''}
411
+ />
412
+ </FormControl>
413
+ <FormMessage />
414
+ </FormItem>
415
+ )}
416
+ />
417
+
418
+ <FormField
419
+ control={form.control}
420
+ name="email"
421
+ render={({ field }) => (
422
+ <FormItem>
423
+ <FormLabel>{t('fields.emailOptional')}</FormLabel>
424
+ <FormControl>
425
+ <Input
426
+ placeholder={t('placeholders.email', { entityLabel })}
427
+ {...field}
428
+ value={field.value || ''}
429
+ />
430
+ </FormControl>
431
+ <FormMessage />
432
+ </FormItem>
433
+ )}
434
+ />
435
+ </div>
436
+
437
+ <FormField
438
+ control={form.control}
439
+ name="phone"
440
+ render={({ field }) => (
441
+ <FormItem>
442
+ <FormLabel>{t('fields.phoneOptional')}</FormLabel>
443
+ <FormControl>
444
+ <Input
445
+ placeholder={t('placeholders.phone')}
446
+ {...field}
447
+ value={field.value || ''}
448
+ />
449
+ </FormControl>
450
+ <FormMessage />
451
+ </FormItem>
452
+ )}
453
+ />
454
+
455
+ <div className="rounded-md border p-3">
456
+ <p className="mb-3 text-sm font-medium">
457
+ {t('fields.addressOptional')}
458
+ </p>
459
+ <div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
460
+ <FormField
461
+ control={form.control}
462
+ name="line1"
463
+ render={({ field }) => (
464
+ <FormItem className="sm:col-span-2">
465
+ <FormControl>
466
+ <Input
467
+ placeholder={t('placeholders.addressLine1')}
468
+ {...field}
469
+ value={field.value || ''}
470
+ />
471
+ </FormControl>
472
+ <FormMessage />
473
+ </FormItem>
474
+ )}
475
+ />
476
+
477
+ <FormField
478
+ control={form.control}
479
+ name="city"
480
+ render={({ field }) => (
481
+ <FormItem>
482
+ <FormControl>
483
+ <Input
484
+ placeholder={t('placeholders.city')}
485
+ {...field}
486
+ value={field.value || ''}
487
+ />
488
+ </FormControl>
489
+ <FormMessage />
490
+ </FormItem>
491
+ )}
492
+ />
493
+
494
+ <FormField
495
+ control={form.control}
496
+ name="state"
497
+ render={({ field }) => (
498
+ <FormItem>
499
+ <FormControl>
500
+ <Input
501
+ placeholder={t('placeholders.state')}
502
+ {...field}
503
+ value={field.value || ''}
504
+ />
505
+ </FormControl>
506
+ <FormMessage />
507
+ </FormItem>
508
+ )}
509
+ />
510
+ </div>
511
+ </div>
512
+
513
+ <div className="flex justify-end gap-2">
514
+ <Button
515
+ type="button"
516
+ variant="outline"
517
+ onClick={() => onOpenChange(false)}
518
+ >
519
+ {t('actions.cancel')}
520
+ </Button>
521
+ <Button
522
+ type="button"
523
+ disabled={form.formState.isSubmitting}
524
+ onClick={() => {
525
+ void form.handleSubmit(handleSubmit)();
526
+ }}
527
+ >
528
+ {t('actions.saveEntity', { entityLabel })}
529
+ </Button>
530
+ </div>
531
+ </div>
532
+ </Form>
533
+ </SheetContent>
534
+ </Sheet>
535
+ );
536
+ }
537
+
538
+ export function PersonFieldWithCreate<TFieldValues extends FieldValues>({
539
+ form,
540
+ name,
541
+ label,
542
+ entityLabel,
543
+ selectPlaceholder,
544
+ personTypeFilter = 'all',
545
+ createType = 'individual',
546
+ lockCreateType = false,
547
+ valueType = 'string',
548
+ initialSelectedLabel = '',
549
+ }: {
550
+ form: UseFormReturn<TFieldValues>;
551
+ name: Path<TFieldValues>;
552
+ label: string;
553
+ entityLabel: string;
554
+ selectPlaceholder: string;
555
+ personTypeFilter?: PersonTypeFilter;
556
+ createType?: Exclude<PersonTypeFilter, 'all'>;
557
+ lockCreateType?: boolean;
558
+ valueType?: FieldValueType;
559
+ initialSelectedLabel?: string;
560
+ }) {
561
+ const { request } = useApp();
562
+ const t = usePersonFieldWithCreateTranslations();
563
+ const [personOpen, setPersonOpen] = useState(false);
564
+ const [personSearch, setPersonSearch] = useState('');
565
+ const [debouncedPersonSearch, setDebouncedPersonSearch] = useState('');
566
+ const [createPersonOpen, setCreatePersonOpen] = useState(false);
567
+ const [selectedPersonLabel, setSelectedPersonLabel] =
568
+ useState(initialSelectedLabel);
569
+ const parentScrollContainerRef = useRef<HTMLElement | null>(null);
570
+ const parentScrollTopRef = useRef(0);
571
+
572
+ useEffect(() => {
573
+ const timeout = setTimeout(() => {
574
+ setDebouncedPersonSearch(personSearch);
575
+ }, 300);
576
+
577
+ return () => clearTimeout(timeout);
578
+ }, [personSearch]);
579
+
580
+ useEffect(() => {
581
+ setSelectedPersonLabel(initialSelectedLabel);
582
+ }, [initialSelectedLabel]);
583
+
584
+ const captureParentScrollPosition = (trigger: HTMLElement) => {
585
+ const parentSheetContent = trigger.closest(
586
+ '[data-radix-dialog-content]'
587
+ ) as HTMLElement | null;
588
+
589
+ if (!parentSheetContent) {
590
+ parentScrollContainerRef.current = null;
591
+ parentScrollTopRef.current = 0;
592
+ return;
593
+ }
594
+
595
+ parentScrollContainerRef.current = parentSheetContent;
596
+ parentScrollTopRef.current = parentSheetContent.scrollTop;
597
+ };
598
+
599
+ const restoreParentScrollPosition = () => {
600
+ const fallbackOpenDialog = (
601
+ Array.from(
602
+ document.querySelectorAll(
603
+ '[data-radix-dialog-content][data-state="open"]'
604
+ )
605
+ ) as HTMLElement[]
606
+ ).at(-1);
607
+
608
+ const container =
609
+ parentScrollContainerRef.current &&
610
+ document.body.contains(parentScrollContainerRef.current)
611
+ ? parentScrollContainerRef.current
612
+ : fallbackOpenDialog || null;
613
+
614
+ if (!container) {
615
+ return;
616
+ }
617
+
618
+ const restore = () => {
619
+ container.scrollTop = parentScrollTopRef.current;
620
+ };
621
+
622
+ requestAnimationFrame(restore);
623
+ setTimeout(restore, 0);
624
+ setTimeout(restore, 120);
625
+ };
626
+
627
+ const { data: personOptionsData = [], isLoading: isLoadingPersons } =
628
+ useQuery<PersonOption[]>({
629
+ queryKey: [
630
+ 'person-autocomplete-generic',
631
+ entityLabel,
632
+ debouncedPersonSearch,
633
+ personTypeFilter,
634
+ ],
635
+ queryFn: async () => {
636
+ const params = new URLSearchParams();
637
+ params.set('page', '1');
638
+ params.set('pageSize', '20');
639
+ if (personTypeFilter !== 'all') {
640
+ params.set('type', personTypeFilter);
641
+ }
642
+ if (debouncedPersonSearch.trim()) {
643
+ params.set('search', debouncedPersonSearch.trim());
644
+ }
645
+
646
+ const response = await request<PaginatedResponse<PersonOption> | PersonOption[]>({
647
+ url: `/person?${params.toString()}`,
648
+ method: 'GET',
649
+ });
650
+
651
+ const payload = response?.data;
652
+ if (Array.isArray(payload?.data)) {
653
+ return payload.data as PersonOption[];
654
+ }
655
+
656
+ if (Array.isArray(payload)) {
657
+ return payload as PersonOption[];
658
+ }
659
+
660
+ return [];
661
+ },
662
+ placeholderData: (old) => old ?? [],
663
+ });
664
+
665
+ const parseFieldValue = (personId: string | number) =>
666
+ valueType === 'number' ? Number(personId) : String(personId);
667
+
668
+ const fieldState = form.getFieldState(name, form.formState);
669
+
670
+ return (
671
+ <>
672
+ <div className="grid gap-2">
673
+ <Label data-error={!!fieldState.error} className="data-[error=true]:text-destructive">
674
+ {label}
675
+ </Label>
676
+ <Controller
677
+ control={form.control}
678
+ name={name}
679
+ render={({ field }) => {
680
+ const hasValue =
681
+ field.value !== undefined &&
682
+ field.value !== null &&
683
+ String(field.value).length > 0;
684
+
685
+ return (
686
+ <div className="flex w-full min-w-0 items-center gap-2">
687
+ <Popover open={personOpen} onOpenChange={setPersonOpen}>
688
+ <PopoverTrigger asChild>
689
+ <Button
690
+ type="button"
691
+ variant="outline"
692
+ role="combobox"
693
+ className="flex-1 min-w-0 justify-between overflow-hidden"
694
+ >
695
+ <span className="truncate text-left">
696
+ {hasValue
697
+ ? (personOptionsData.find(
698
+ (person) =>
699
+ String(person.id) === String(field.value)
700
+ )?.name ??
701
+ selectedPersonLabel ??
702
+ `ID #${String(field.value)}`)
703
+ : selectPlaceholder}
704
+ </span>
705
+ <ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
706
+ </Button>
707
+ </PopoverTrigger>
708
+ <PopoverContent
709
+ className="p-0"
710
+ style={{ width: 'var(--radix-popover-trigger-width)' }}
711
+ >
712
+ <Command shouldFilter={false}>
713
+ <CommandInput
714
+ placeholder={t('search.placeholder')}
715
+ value={personSearch}
716
+ onValueChange={setPersonSearch}
717
+ />
718
+ <CommandList>
719
+ <CommandEmpty>
720
+ {isLoadingPersons ? (
721
+ t('search.loading')
722
+ ) : (
723
+ <div className="space-y-2 p-2 text-center">
724
+ <p className="text-sm text-muted-foreground">
725
+ {t('search.noResults')}
726
+ </p>
727
+ <Button
728
+ type="button"
729
+ variant="outline"
730
+ className="w-full"
731
+ onClick={(event) => {
732
+ captureParentScrollPosition(
733
+ event.currentTarget
734
+ );
735
+ setPersonOpen(false);
736
+ setCreatePersonOpen(true);
737
+ }}
738
+ >
739
+ {t('actions.createNew')}
740
+ </Button>
741
+ </div>
742
+ )}
743
+ </CommandEmpty>
744
+ <CommandGroup>
745
+ {personOptionsData.map((person) => (
746
+ <CommandItem
747
+ key={String(person.id)}
748
+ value={`${person.name}-${person.id}`}
749
+ onSelect={() => {
750
+ field.onChange(parseFieldValue(person.id));
751
+ setSelectedPersonLabel(person.name);
752
+ setPersonOpen(false);
753
+ }}
754
+ >
755
+ {person.name}
756
+ </CommandItem>
757
+ ))}
758
+ </CommandGroup>
759
+ </CommandList>
760
+ </Command>
761
+ </PopoverContent>
762
+ </Popover>
763
+
764
+ {hasValue ? (
765
+ <Button
766
+ type="button"
767
+ variant="outline"
768
+ size="icon"
769
+ className="shrink-0"
770
+ onClick={() => {
771
+ field.onChange(valueType === 'number' ? null : '');
772
+ setPersonSearch('');
773
+ setSelectedPersonLabel('');
774
+ setPersonOpen(false);
775
+ }}
776
+ aria-label={t('actions.clearSelection')}
777
+ >
778
+ <X className="h-4 w-4" />
779
+ </Button>
780
+ ) : null}
781
+
782
+ <Button
783
+ type="button"
784
+ variant="outline"
785
+ size="icon"
786
+ className="shrink-0"
787
+ onClick={(event) => {
788
+ captureParentScrollPosition(event.currentTarget);
789
+ setPersonOpen(false);
790
+ setCreatePersonOpen(true);
791
+ }}
792
+ aria-label={t('actions.createEntityAria', { entityLabel })}
793
+ >
794
+ <Plus className="h-4 w-4" />
795
+ </Button>
796
+ </div>
797
+ );
798
+ }}
799
+ />
800
+ {fieldState.error?.message ? (
801
+ <p className="text-destructive text-sm">
802
+ {fieldState.error.message}
803
+ </p>
804
+ ) : null}
805
+ </div>
806
+
807
+ <CreatePersonSheet
808
+ open={createPersonOpen}
809
+ onOpenChange={(nextOpen) => {
810
+ setCreatePersonOpen(nextOpen);
811
+ if (!nextOpen) {
812
+ restoreParentScrollPosition();
813
+ }
814
+ }}
815
+ entityLabel={entityLabel}
816
+ defaultCreateType={createType}
817
+ lockCreateType={lockCreateType}
818
+ onCreated={(person) => {
819
+ form.setValue(name, parseFieldValue(person.id) as never, {
820
+ shouldValidate: false,
821
+ shouldDirty: true,
822
+ shouldTouch: true,
823
+ });
824
+ setSelectedPersonLabel(person.name);
825
+ setPersonSearch(person.name);
826
+ setCreatePersonOpen(false);
827
+ }}
828
+ />
829
+ </>
830
+ );
831
+ }