@hed-hog/finance 0.0.303 → 0.0.305

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,36 +1,10 @@
1
1
  'use client';
2
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';
3
+ import { EntityPicker } from '@/components/ui/entity-picker';
27
4
  import { useApp } from '@hed-hog/next-app-provider';
28
- import { zodResolver } from '@hookform/resolvers/zod';
29
- import { Plus } from 'lucide-react';
30
5
  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';
6
+ import { useMemo, useState } from 'react';
7
+ import { FieldValues, Path, UseFormReturn } from 'react-hook-form';
34
8
 
35
9
  type FinanceCategory = {
36
10
  id: number | string;
@@ -45,7 +19,18 @@ type CostCenter = {
45
19
  nome: string;
46
20
  };
47
21
 
48
- const extractCreatedId = (payload: any): string | null => {
22
+ type CreatedEntityResponse = {
23
+ id?: number | string | null;
24
+ data?: {
25
+ id?: number | string | null;
26
+ } | null;
27
+ category_id?: number | string | null;
28
+ cost_center_id?: number | string | null;
29
+ };
30
+
31
+ const extractCreatedId = (
32
+ payload: CreatedEntityResponse | null | undefined
33
+ ): string | null => {
49
34
  const id =
50
35
  payload?.id ??
51
36
  payload?.data?.id ??
@@ -74,23 +59,11 @@ export function CategoryFieldWithCreate<TFieldValues extends FieldValues>({
74
59
  selectPlaceholder: string;
75
60
  categories: FinanceCategory[];
76
61
  categoryKind: 'receita' | 'despesa';
77
- onCreated?: () => Promise<any> | void;
62
+ onCreated?: () => Promise<unknown> | void;
78
63
  }) {
79
64
  const { request, showToastHandler } = useApp();
80
65
  const t = useTranslations('finance.FinanceEntityFieldWithCreate');
81
- const [open, setOpen] = useState(false);
82
66
  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
67
 
95
68
  const filteredCategories = useMemo(() => {
96
69
  const merged = [...(categories || []), ...localCategories];
@@ -104,217 +77,70 @@ export function CategoryFieldWithCreate<TFieldValues extends FieldValues>({
104
77
  return uniqueById.filter((item) => item.natureza === categoryKind);
105
78
  }, [categories, categoryKind, localCategories]);
106
79
 
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,
80
+ return (
81
+ <EntityPicker<FinanceCategory, TFieldValues>
82
+ form={form}
83
+ name={name}
84
+ label={label}
85
+ placeholder={selectPlaceholder}
86
+ searchPlaceholder={selectPlaceholder}
87
+ emptySelectionLabel={selectPlaceholder}
88
+ clearable
89
+ allowEmptySelection
90
+ options={filteredCategories}
91
+ createActionLabel={t('actions.createCategoryAria')}
92
+ createTitle={t('categorySheet.title')}
93
+ createDescription={t('categorySheet.description')}
94
+ createFields={[
95
+ {
96
+ name: 'nome',
97
+ label: t('fields.name'),
98
+ placeholder: t('categorySheet.namePlaceholder'),
99
+ required: true,
159
100
  },
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
- });
101
+ ]}
102
+ getOptionValue={(category) => String(category.id)}
103
+ getOptionLabel={(category) =>
104
+ category.codigo
105
+ ? `${category.codigo} - ${category.nome}`
106
+ : category.nome
191
107
  }
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();
108
+ onCreate={async (values) => {
109
+ try {
110
+ const createdName = values.nome?.trim() ?? '';
111
+ const response = await request<CreatedEntityResponse>({
112
+ url: '/finance/categories',
113
+ method: 'POST',
114
+ data: {
115
+ name: createdName,
116
+ kind: categoryKind,
117
+ parent_id: null,
118
+ },
119
+ });
120
+
121
+ const createdId = extractCreatedId(response?.data);
122
+ const createdCategory = createdId
123
+ ? {
124
+ id: String(createdId),
125
+ nome: createdName,
126
+ natureza: categoryKind,
127
+ }
128
+ : null;
129
+
130
+ if (createdCategory) {
131
+ setLocalCategories((prev) => [...prev, createdCategory]);
262
132
  }
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
133
 
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
- />
134
+ await onCreated?.();
135
+ showToastHandler?.('success', t('messages.categoryCreateSuccess'));
294
136
 
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
- </>
137
+ return createdCategory;
138
+ } catch {
139
+ showToastHandler?.('error', t('messages.categoryCreateError'));
140
+ return null;
141
+ }
142
+ }}
143
+ />
318
144
  );
319
145
  }
320
146
 
@@ -331,23 +157,11 @@ export function CostCenterFieldWithCreate<TFieldValues extends FieldValues>({
331
157
  label: string;
332
158
  selectPlaceholder: string;
333
159
  costCenters: CostCenter[];
334
- onCreated?: () => Promise<any> | void;
160
+ onCreated?: () => Promise<unknown> | void;
335
161
  }) {
336
162
  const { request, showToastHandler } = useApp();
337
163
  const t = useTranslations('finance.FinanceEntityFieldWithCreate');
338
- const [open, setOpen] = useState(false);
339
164
  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
165
 
352
166
  const mergedCostCenters = useMemo(() => {
353
167
  const merged = [...(costCenters || []), ...localCostCenters];
@@ -360,213 +174,64 @@ export function CostCenterFieldWithCreate<TFieldValues extends FieldValues>({
360
174
  );
361
175
  }, [costCenters, localCostCenters]);
362
176
 
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,
177
+ return (
178
+ <EntityPicker<CostCenter, TFieldValues>
179
+ form={form}
180
+ name={name}
181
+ label={label}
182
+ placeholder={selectPlaceholder}
183
+ searchPlaceholder={selectPlaceholder}
184
+ emptySelectionLabel={selectPlaceholder}
185
+ clearable
186
+ allowEmptySelection
187
+ options={mergedCostCenters}
188
+ createActionLabel={t('actions.createCostCenterAria')}
189
+ createTitle={t('costCenterSheet.title')}
190
+ createDescription={t('costCenterSheet.description')}
191
+ createFields={[
192
+ {
193
+ name: 'nome',
194
+ label: t('fields.name'),
195
+ placeholder: t('costCenterSheet.namePlaceholder'),
196
+ required: true,
413
197
  },
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
- });
198
+ ]}
199
+ getOptionValue={(center) => String(center.id)}
200
+ getOptionLabel={(center) =>
201
+ center.codigo ? `${center.codigo} - ${center.nome}` : center.nome
444
202
  }
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();
203
+ onCreate={async (values) => {
204
+ try {
205
+ const createdName = values.nome?.trim() ?? '';
206
+ const response = await request<CreatedEntityResponse>({
207
+ url: '/finance/cost-centers',
208
+ method: 'POST',
209
+ data: {
210
+ name: createdName,
211
+ },
212
+ });
213
+
214
+ const createdId = extractCreatedId(response?.data);
215
+ const createdCostCenter = createdId
216
+ ? {
217
+ id: String(createdId),
218
+ nome: createdName,
219
+ }
220
+ : null;
221
+
222
+ if (createdCostCenter) {
223
+ setLocalCostCenters((prev) => [...prev, createdCostCenter]);
515
224
  }
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
225
 
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
- />
226
+ await onCreated?.();
227
+ showToastHandler?.('success', t('messages.costCenterCreateSuccess'));
547
228
 
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
- </>
229
+ return createdCostCenter;
230
+ } catch {
231
+ showToastHandler?.('error', t('messages.costCenterCreateError'));
232
+ return null;
233
+ }
234
+ }}
235
+ />
571
236
  );
572
237
  }