@hed-hog/finance 0.0.304 → 0.0.306

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