@hed-hog/finance 0.0.253 → 0.0.257

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.
@@ -0,0 +1,572 @@
1
+ 'use client';
2
+
3
+ import { Button } from '@/components/ui/button';
4
+ import {
5
+ Form,
6
+ FormControl,
7
+ FormField,
8
+ FormItem,
9
+ FormLabel,
10
+ FormMessage,
11
+ } from '@/components/ui/form';
12
+ import { Input } from '@/components/ui/input';
13
+ import {
14
+ Select,
15
+ SelectContent,
16
+ SelectItem,
17
+ SelectTrigger,
18
+ SelectValue,
19
+ } from '@/components/ui/select';
20
+ import {
21
+ Sheet,
22
+ SheetContent,
23
+ SheetDescription,
24
+ SheetHeader,
25
+ SheetTitle,
26
+ } from '@/components/ui/sheet';
27
+ import { useApp } from '@hed-hog/next-app-provider';
28
+ import { zodResolver } from '@hookform/resolvers/zod';
29
+ import { Plus } from 'lucide-react';
30
+ import { useTranslations } from 'next-intl';
31
+ import { useMemo, useRef, useState } from 'react';
32
+ import { FieldValues, Path, UseFormReturn, useForm } from 'react-hook-form';
33
+ import { z } from 'zod';
34
+
35
+ type FinanceCategory = {
36
+ id: number | string;
37
+ codigo?: string;
38
+ nome: string;
39
+ natureza?: string;
40
+ };
41
+
42
+ type CostCenter = {
43
+ id: number | string;
44
+ codigo?: string;
45
+ nome: string;
46
+ };
47
+
48
+ const extractCreatedId = (payload: any): string | null => {
49
+ const id =
50
+ payload?.id ??
51
+ payload?.data?.id ??
52
+ payload?.category_id ??
53
+ payload?.cost_center_id;
54
+
55
+ if (id === undefined || id === null || id === '') {
56
+ return null;
57
+ }
58
+
59
+ return String(id);
60
+ };
61
+
62
+ export function CategoryFieldWithCreate<TFieldValues extends FieldValues>({
63
+ form,
64
+ name,
65
+ label,
66
+ selectPlaceholder,
67
+ categories,
68
+ categoryKind,
69
+ onCreated,
70
+ }: {
71
+ form: UseFormReturn<TFieldValues>;
72
+ name: Path<TFieldValues>;
73
+ label: string;
74
+ selectPlaceholder: string;
75
+ categories: FinanceCategory[];
76
+ categoryKind: 'receita' | 'despesa';
77
+ onCreated?: () => Promise<any> | void;
78
+ }) {
79
+ const { request, showToastHandler } = useApp();
80
+ const t = useTranslations('finance.FinanceEntityFieldWithCreate');
81
+ const [open, setOpen] = useState(false);
82
+ const [localCategories, setLocalCategories] = useState<FinanceCategory[]>([]);
83
+ const parentScrollContainerRef = useRef<HTMLElement | null>(null);
84
+ const parentScrollTopRef = useRef(0);
85
+
86
+ const createSchema = z.object({
87
+ nome: z.string().trim().min(1, t('validation.nameRequired')),
88
+ });
89
+
90
+ const createForm = useForm<z.infer<typeof createSchema>>({
91
+ resolver: zodResolver(createSchema),
92
+ defaultValues: { nome: '' },
93
+ });
94
+
95
+ const filteredCategories = useMemo(() => {
96
+ const merged = [...(categories || []), ...localCategories];
97
+ const uniqueById = merged.filter(
98
+ (item, index, arr) =>
99
+ arr.findIndex(
100
+ (candidate) => String(candidate.id) === String(item.id)
101
+ ) === index
102
+ );
103
+
104
+ return uniqueById.filter((item) => item.natureza === categoryKind);
105
+ }, [categories, categoryKind, localCategories]);
106
+
107
+ const captureParentScrollPosition = (button: HTMLElement) => {
108
+ const parentSheetContent = button.closest(
109
+ '[data-radix-dialog-content]'
110
+ ) as HTMLElement | null;
111
+
112
+ if (!parentSheetContent) {
113
+ parentScrollContainerRef.current = null;
114
+ parentScrollTopRef.current = 0;
115
+ return;
116
+ }
117
+
118
+ parentScrollContainerRef.current = parentSheetContent;
119
+ parentScrollTopRef.current = parentSheetContent.scrollTop;
120
+ };
121
+
122
+ const restoreParentScrollPosition = () => {
123
+ const fallbackOpenDialog = (
124
+ Array.from(
125
+ document.querySelectorAll(
126
+ '[data-radix-dialog-content][data-state="open"]'
127
+ )
128
+ ) as HTMLElement[]
129
+ ).at(-1);
130
+
131
+ const container =
132
+ parentScrollContainerRef.current &&
133
+ document.body.contains(parentScrollContainerRef.current)
134
+ ? parentScrollContainerRef.current
135
+ : fallbackOpenDialog || null;
136
+
137
+ if (!container) {
138
+ return;
139
+ }
140
+
141
+ const restore = () => {
142
+ container.scrollTop = parentScrollTopRef.current;
143
+ };
144
+
145
+ requestAnimationFrame(restore);
146
+ setTimeout(restore, 0);
147
+ setTimeout(restore, 120);
148
+ };
149
+
150
+ const handleCreate = async (values: z.infer<typeof createSchema>) => {
151
+ try {
152
+ const response = await request<any>({
153
+ url: '/finance/categories',
154
+ method: 'POST',
155
+ data: {
156
+ name: values.nome,
157
+ kind: categoryKind,
158
+ parent_id: null,
159
+ },
160
+ });
161
+
162
+ const createdId = extractCreatedId(response?.data);
163
+
164
+ if (createdId) {
165
+ const normalizedCreatedId = String(createdId);
166
+
167
+ setLocalCategories((prev) => [
168
+ ...prev,
169
+ {
170
+ id: normalizedCreatedId,
171
+ nome: values.nome,
172
+ natureza: categoryKind,
173
+ },
174
+ ]);
175
+
176
+ form.setValue(name, normalizedCreatedId as any, {
177
+ shouldValidate: false,
178
+ shouldDirty: true,
179
+ shouldTouch: true,
180
+ });
181
+ }
182
+
183
+ await onCreated?.();
184
+
185
+ if (createdId) {
186
+ form.setValue(name, String(createdId) as any, {
187
+ shouldValidate: false,
188
+ shouldDirty: true,
189
+ shouldTouch: true,
190
+ });
191
+ }
192
+
193
+ createForm.reset({ nome: '' });
194
+ setOpen(false);
195
+ showToastHandler?.('success', t('messages.categoryCreateSuccess'));
196
+ } catch {
197
+ showToastHandler?.('error', t('messages.categoryCreateError'));
198
+ }
199
+ };
200
+
201
+ return (
202
+ <>
203
+ <FormField
204
+ control={form.control}
205
+ name={name}
206
+ render={({ field }) => (
207
+ <FormItem>
208
+ <FormLabel>{label}</FormLabel>
209
+ <div className="flex w-full min-w-0 items-center gap-2">
210
+ <div className="min-w-0 flex-1">
211
+ <Select
212
+ value={
213
+ field.value === null || field.value === undefined
214
+ ? ''
215
+ : String(field.value)
216
+ }
217
+ onValueChange={field.onChange}
218
+ >
219
+ <FormControl>
220
+ <SelectTrigger className="w-full">
221
+ <SelectValue placeholder={selectPlaceholder} />
222
+ </SelectTrigger>
223
+ </FormControl>
224
+ <SelectContent>
225
+ {filteredCategories.map((category) => (
226
+ <SelectItem key={category.id} value={String(category.id)}>
227
+ {category.codigo
228
+ ? `${category.codigo} - ${category.nome}`
229
+ : category.nome}
230
+ </SelectItem>
231
+ ))}
232
+ </SelectContent>
233
+ </Select>
234
+ </div>
235
+
236
+ <Button
237
+ type="button"
238
+ variant="outline"
239
+ size="icon"
240
+ className="shrink-0"
241
+ onClick={(event) => {
242
+ captureParentScrollPosition(event.currentTarget);
243
+ setOpen(true);
244
+ }}
245
+ aria-label={t('actions.createCategoryAria')}
246
+ >
247
+ <Plus className="h-4 w-4" />
248
+ </Button>
249
+ </div>
250
+ <FormMessage />
251
+ </FormItem>
252
+ )}
253
+ />
254
+
255
+ <Sheet
256
+ open={open}
257
+ onOpenChange={(nextOpen) => {
258
+ setOpen(nextOpen);
259
+ if (!nextOpen) {
260
+ createForm.reset({ nome: '' });
261
+ restoreParentScrollPosition();
262
+ }
263
+ }}
264
+ >
265
+ <SheetContent
266
+ className="w-full sm:max-w-lg"
267
+ onCloseAutoFocus={(event) => event.preventDefault()}
268
+ >
269
+ <SheetHeader>
270
+ <SheetTitle>{t('categorySheet.title')}</SheetTitle>
271
+ <SheetDescription>
272
+ {t('categorySheet.description')}
273
+ </SheetDescription>
274
+ </SheetHeader>
275
+
276
+ <Form {...createForm}>
277
+ <div className="space-y-4 p-4">
278
+ <FormField
279
+ control={createForm.control}
280
+ name="nome"
281
+ render={({ field }) => (
282
+ <FormItem>
283
+ <FormLabel>{t('fields.name')}</FormLabel>
284
+ <FormControl>
285
+ <Input
286
+ placeholder={t('categorySheet.namePlaceholder')}
287
+ {...field}
288
+ />
289
+ </FormControl>
290
+ <FormMessage />
291
+ </FormItem>
292
+ )}
293
+ />
294
+
295
+ <div className="flex justify-end gap-2">
296
+ <Button
297
+ type="button"
298
+ variant="outline"
299
+ onClick={() => setOpen(false)}
300
+ >
301
+ {t('actions.cancel')}
302
+ </Button>
303
+ <Button
304
+ type="button"
305
+ disabled={createForm.formState.isSubmitting}
306
+ onClick={() => {
307
+ void createForm.handleSubmit(handleCreate)();
308
+ }}
309
+ >
310
+ {t('actions.save')}
311
+ </Button>
312
+ </div>
313
+ </div>
314
+ </Form>
315
+ </SheetContent>
316
+ </Sheet>
317
+ </>
318
+ );
319
+ }
320
+
321
+ export function CostCenterFieldWithCreate<TFieldValues extends FieldValues>({
322
+ form,
323
+ name,
324
+ label,
325
+ selectPlaceholder,
326
+ costCenters,
327
+ onCreated,
328
+ }: {
329
+ form: UseFormReturn<TFieldValues>;
330
+ name: Path<TFieldValues>;
331
+ label: string;
332
+ selectPlaceholder: string;
333
+ costCenters: CostCenter[];
334
+ onCreated?: () => Promise<any> | void;
335
+ }) {
336
+ const { request, showToastHandler } = useApp();
337
+ const t = useTranslations('finance.FinanceEntityFieldWithCreate');
338
+ const [open, setOpen] = useState(false);
339
+ const [localCostCenters, setLocalCostCenters] = useState<CostCenter[]>([]);
340
+ const parentScrollContainerRef = useRef<HTMLElement | null>(null);
341
+ const parentScrollTopRef = useRef(0);
342
+
343
+ const createSchema = z.object({
344
+ nome: z.string().trim().min(1, t('validation.nameRequired')),
345
+ });
346
+
347
+ const createForm = useForm<z.infer<typeof createSchema>>({
348
+ resolver: zodResolver(createSchema),
349
+ defaultValues: { nome: '' },
350
+ });
351
+
352
+ const mergedCostCenters = useMemo(() => {
353
+ const merged = [...(costCenters || []), ...localCostCenters];
354
+
355
+ return merged.filter(
356
+ (item, index, arr) =>
357
+ arr.findIndex(
358
+ (candidate) => String(candidate.id) === String(item.id)
359
+ ) === index
360
+ );
361
+ }, [costCenters, localCostCenters]);
362
+
363
+ const captureParentScrollPosition = (button: HTMLElement) => {
364
+ const parentSheetContent = button.closest(
365
+ '[data-radix-dialog-content]'
366
+ ) as HTMLElement | null;
367
+
368
+ if (!parentSheetContent) {
369
+ parentScrollContainerRef.current = null;
370
+ parentScrollTopRef.current = 0;
371
+ return;
372
+ }
373
+
374
+ parentScrollContainerRef.current = parentSheetContent;
375
+ parentScrollTopRef.current = parentSheetContent.scrollTop;
376
+ };
377
+
378
+ const restoreParentScrollPosition = () => {
379
+ const fallbackOpenDialog = (
380
+ Array.from(
381
+ document.querySelectorAll(
382
+ '[data-radix-dialog-content][data-state="open"]'
383
+ )
384
+ ) as HTMLElement[]
385
+ ).at(-1);
386
+
387
+ const container =
388
+ parentScrollContainerRef.current &&
389
+ document.body.contains(parentScrollContainerRef.current)
390
+ ? parentScrollContainerRef.current
391
+ : fallbackOpenDialog || null;
392
+
393
+ if (!container) {
394
+ return;
395
+ }
396
+
397
+ const restore = () => {
398
+ container.scrollTop = parentScrollTopRef.current;
399
+ };
400
+
401
+ requestAnimationFrame(restore);
402
+ setTimeout(restore, 0);
403
+ setTimeout(restore, 120);
404
+ };
405
+
406
+ const handleCreate = async (values: z.infer<typeof createSchema>) => {
407
+ try {
408
+ const response = await request<any>({
409
+ url: '/finance/cost-centers',
410
+ method: 'POST',
411
+ data: {
412
+ name: values.nome,
413
+ },
414
+ });
415
+
416
+ const createdId = extractCreatedId(response?.data);
417
+
418
+ if (createdId) {
419
+ const normalizedCreatedId = String(createdId);
420
+
421
+ setLocalCostCenters((prev) => [
422
+ ...prev,
423
+ {
424
+ id: normalizedCreatedId,
425
+ nome: values.nome,
426
+ },
427
+ ]);
428
+
429
+ form.setValue(name, normalizedCreatedId as any, {
430
+ shouldValidate: false,
431
+ shouldDirty: true,
432
+ shouldTouch: true,
433
+ });
434
+ }
435
+
436
+ await onCreated?.();
437
+
438
+ if (createdId) {
439
+ form.setValue(name, String(createdId) as any, {
440
+ shouldValidate: false,
441
+ shouldDirty: true,
442
+ shouldTouch: true,
443
+ });
444
+ }
445
+
446
+ createForm.reset({ nome: '' });
447
+ setOpen(false);
448
+ showToastHandler?.('success', t('messages.costCenterCreateSuccess'));
449
+ } catch {
450
+ showToastHandler?.('error', t('messages.costCenterCreateError'));
451
+ }
452
+ };
453
+
454
+ return (
455
+ <>
456
+ <FormField
457
+ control={form.control}
458
+ name={name}
459
+ render={({ field }) => (
460
+ <FormItem>
461
+ <FormLabel>{label}</FormLabel>
462
+ <div className="flex w-full min-w-0 items-center gap-2">
463
+ <div className="min-w-0 flex-1">
464
+ <Select
465
+ value={
466
+ field.value === null || field.value === undefined
467
+ ? ''
468
+ : String(field.value)
469
+ }
470
+ onValueChange={field.onChange}
471
+ >
472
+ <FormControl>
473
+ <SelectTrigger className="w-full">
474
+ <SelectValue placeholder={selectPlaceholder} />
475
+ </SelectTrigger>
476
+ </FormControl>
477
+ <SelectContent>
478
+ {mergedCostCenters.map((center) => (
479
+ <SelectItem key={center.id} value={String(center.id)}>
480
+ {center.codigo
481
+ ? `${center.codigo} - ${center.nome}`
482
+ : center.nome}
483
+ </SelectItem>
484
+ ))}
485
+ </SelectContent>
486
+ </Select>
487
+ </div>
488
+
489
+ <Button
490
+ type="button"
491
+ variant="outline"
492
+ size="icon"
493
+ className="shrink-0"
494
+ onClick={(event) => {
495
+ captureParentScrollPosition(event.currentTarget);
496
+ setOpen(true);
497
+ }}
498
+ aria-label={t('actions.createCostCenterAria')}
499
+ >
500
+ <Plus className="h-4 w-4" />
501
+ </Button>
502
+ </div>
503
+ <FormMessage />
504
+ </FormItem>
505
+ )}
506
+ />
507
+
508
+ <Sheet
509
+ open={open}
510
+ onOpenChange={(nextOpen) => {
511
+ setOpen(nextOpen);
512
+ if (!nextOpen) {
513
+ createForm.reset({ nome: '' });
514
+ restoreParentScrollPosition();
515
+ }
516
+ }}
517
+ >
518
+ <SheetContent
519
+ className="w-full sm:max-w-lg"
520
+ onCloseAutoFocus={(event) => event.preventDefault()}
521
+ >
522
+ <SheetHeader>
523
+ <SheetTitle>{t('costCenterSheet.title')}</SheetTitle>
524
+ <SheetDescription>
525
+ {t('costCenterSheet.description')}
526
+ </SheetDescription>
527
+ </SheetHeader>
528
+
529
+ <Form {...createForm}>
530
+ <div className="space-y-4 p-4">
531
+ <FormField
532
+ control={createForm.control}
533
+ name="nome"
534
+ render={({ field }) => (
535
+ <FormItem>
536
+ <FormLabel>{t('fields.name')}</FormLabel>
537
+ <FormControl>
538
+ <Input
539
+ placeholder={t('costCenterSheet.namePlaceholder')}
540
+ {...field}
541
+ />
542
+ </FormControl>
543
+ <FormMessage />
544
+ </FormItem>
545
+ )}
546
+ />
547
+
548
+ <div className="flex justify-end gap-2">
549
+ <Button
550
+ type="button"
551
+ variant="outline"
552
+ onClick={() => setOpen(false)}
553
+ >
554
+ {t('actions.cancel')}
555
+ </Button>
556
+ <Button
557
+ type="button"
558
+ disabled={createForm.formState.isSubmitting}
559
+ onClick={() => {
560
+ void createForm.handleSubmit(handleCreate)();
561
+ }}
562
+ >
563
+ {t('actions.save')}
564
+ </Button>
565
+ </div>
566
+ </div>
567
+ </Form>
568
+ </SheetContent>
569
+ </Sheet>
570
+ </>
571
+ );
572
+ }