@hed-hog/finance 0.0.319 → 0.0.321

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 (34) hide show
  1. package/dist/dto/create-currency.dto.d.ts +6 -0
  2. package/dist/dto/create-currency.dto.d.ts.map +1 -0
  3. package/dist/dto/create-currency.dto.js +37 -0
  4. package/dist/dto/create-currency.dto.js.map +1 -0
  5. package/dist/dto/update-currency.dto.d.ts +7 -0
  6. package/dist/dto/update-currency.dto.d.ts.map +1 -0
  7. package/dist/dto/update-currency.dto.js +47 -0
  8. package/dist/dto/update-currency.dto.js.map +1 -0
  9. package/dist/finance-currencies.controller.d.ts +36 -0
  10. package/dist/finance-currencies.controller.d.ts.map +1 -0
  11. package/dist/finance-currencies.controller.js +74 -0
  12. package/dist/finance-currencies.controller.js.map +1 -0
  13. package/dist/finance.module.d.ts.map +1 -1
  14. package/dist/finance.module.js +2 -0
  15. package/dist/finance.module.js.map +1 -1
  16. package/dist/finance.service.d.ts +29 -0
  17. package/dist/finance.service.d.ts.map +1 -1
  18. package/dist/finance.service.js +79 -0
  19. package/dist/finance.service.js.map +1 -1
  20. package/hedhog/data/currency.yaml +14 -0
  21. package/hedhog/data/menu.yaml +16 -0
  22. package/hedhog/data/route.yaml +36 -0
  23. package/hedhog/frontend/app/administration/currencies/page.tsx.ejs +490 -0
  24. package/hedhog/frontend/app/cash-and-banks/bank-accounts/page.tsx.ejs +143 -48
  25. package/hedhog/frontend/messages/en.json +58 -0
  26. package/hedhog/frontend/messages/pt.json +58 -0
  27. package/hedhog/table/bank_account.yaml +8 -0
  28. package/hedhog/table/currency.yaml +21 -0
  29. package/package.json +6 -6
  30. package/src/dto/create-currency.dto.ts +21 -0
  31. package/src/dto/update-currency.dto.ts +31 -0
  32. package/src/finance-currencies.controller.ts +44 -0
  33. package/src/finance.module.ts +2 -0
  34. package/src/finance.service.ts +127 -31
@@ -0,0 +1,14 @@
1
+ - code: BRL
2
+ name: Real Brasileiro
3
+ symbol: "R$"
4
+ status: active
5
+
6
+ - code: USD
7
+ name: Dólar Americano
8
+ symbol: "$"
9
+ status: active
10
+
11
+ - code: EUR
12
+ name: Euro
13
+ symbol: "€"
14
+ status: active
@@ -427,3 +427,19 @@
427
427
  slug: admin
428
428
  - where:
429
429
  slug: admin-finance
430
+
431
+ - menu_id:
432
+ where:
433
+ slug: /finance/administration
434
+ icon: coins
435
+ url: /finance/administration/currencies
436
+ name:
437
+ en: Currencies
438
+ pt: Moedas
439
+ slug: /finance/administration/currencies
440
+ relations:
441
+ role:
442
+ - where:
443
+ slug: admin
444
+ - where:
445
+ slug: admin-finance
@@ -546,6 +546,42 @@
546
546
 
547
547
  - url: /finance/transfers
548
548
  method: POST
549
+ relations:
550
+ role:
551
+ - where:
552
+ slug: admin
553
+ - where:
554
+ slug: admin-finance
555
+
556
+ - url: /finance/currencies
557
+ method: GET
558
+ relations:
559
+ role:
560
+ - where:
561
+ slug: admin
562
+ - where:
563
+ slug: admin-finance
564
+
565
+ - url: /finance/currencies
566
+ method: POST
567
+ relations:
568
+ role:
569
+ - where:
570
+ slug: admin
571
+ - where:
572
+ slug: admin-finance
573
+
574
+ - url: /finance/currencies/:id
575
+ method: PATCH
576
+ relations:
577
+ role:
578
+ - where:
579
+ slug: admin
580
+ - where:
581
+ slug: admin-finance
582
+
583
+ - url: /finance/currencies/:id
584
+ method: DELETE
549
585
  relations:
550
586
  role:
551
587
  - where:
@@ -0,0 +1,490 @@
1
+ 'use client';
2
+
3
+ import { EmptyState, Page, PageHeader } from '@/components/entity-list';
4
+ import {
5
+ AlertDialog,
6
+ AlertDialogAction,
7
+ AlertDialogCancel,
8
+ AlertDialogContent,
9
+ AlertDialogDescription,
10
+ AlertDialogFooter,
11
+ AlertDialogHeader,
12
+ AlertDialogTitle,
13
+ } from '@/components/ui/alert-dialog';
14
+ import { Badge } from '@/components/ui/badge';
15
+ import { Button } from '@/components/ui/button';
16
+ import {
17
+ Form,
18
+ FormControl,
19
+ FormField,
20
+ FormItem,
21
+ FormLabel,
22
+ FormMessage,
23
+ } from '@/components/ui/form';
24
+ import { Input } from '@/components/ui/input';
25
+ import {
26
+ Sheet,
27
+ SheetContent,
28
+ SheetDescription,
29
+ SheetHeader,
30
+ SheetTitle,
31
+ } from '@/components/ui/sheet';
32
+ import { useFormDraft } from '@/hooks/use-form-draft';
33
+ import { formatDateTime } from '@/lib/format-date';
34
+ import { useApp, useQuery } from '@hed-hog/next-app-provider';
35
+ import { zodResolver } from '@hookform/resolvers/zod';
36
+ import { formatDistanceToNow } from 'date-fns';
37
+ import { enUS, ptBR } from 'date-fns/locale';
38
+ import { Coins, Pencil, Plus, Trash2 } from 'lucide-react';
39
+ import { useTranslations } from 'next-intl';
40
+ import { useEffect, useMemo, useState } from 'react';
41
+ import { useForm, useWatch } from 'react-hook-form';
42
+ import { z } from 'zod';
43
+
44
+ type CurrencyFormValues = {
45
+ code: string;
46
+ name: string;
47
+ symbol: string;
48
+ };
49
+
50
+ type Currency = {
51
+ id: string;
52
+ code: string;
53
+ name: string;
54
+ symbol: string;
55
+ status: 'active' | 'inactive';
56
+ ativo: boolean;
57
+ };
58
+
59
+ type CurrencyDraftPayload = {
60
+ mode: 'create' | 'edit';
61
+ currencyId: string | null;
62
+ values: CurrencyFormValues;
63
+ };
64
+
65
+ const CURRENCY_FORM_DRAFT_STORAGE_KEY = 'finance-currencies-form-draft';
66
+
67
+ function CurrencySheet({
68
+ open,
69
+ onOpenChange,
70
+ onSaved,
71
+ editingCurrency,
72
+ onEditingChange,
73
+ t,
74
+ }: {
75
+ open: boolean;
76
+ onOpenChange: (open: boolean) => void;
77
+ onSaved: () => Promise<unknown> | void;
78
+ editingCurrency: Currency | null;
79
+ onEditingChange: (currency: Currency | null) => void;
80
+ t: ReturnType<typeof useTranslations>;
81
+ }) {
82
+ const { request, showToastHandler, currentLocaleCode, getSettingValue } =
83
+ useApp();
84
+
85
+ const currencyFormSchema = z.object({
86
+ code: z
87
+ .string()
88
+ .trim()
89
+ .min(1, t('sheet.validation.codeRequired'))
90
+ .max(3, t('sheet.validation.codeMaxLength')),
91
+ name: z.string().trim().min(1, t('sheet.validation.nameRequired')),
92
+ symbol: z.string().trim().min(1, t('sheet.validation.symbolRequired')),
93
+ });
94
+
95
+ const form = useForm<CurrencyFormValues>({
96
+ resolver: zodResolver(currencyFormSchema),
97
+ defaultValues: {
98
+ code: '',
99
+ name: '',
100
+ symbol: '',
101
+ },
102
+ });
103
+
104
+ const watchedValues = useWatch({ control: form.control });
105
+
106
+ const {
107
+ clearDraft,
108
+ loadDraft,
109
+ hasDraft,
110
+ savedAt: draftSavedAt,
111
+ } = useFormDraft<CurrencyDraftPayload>({
112
+ storageKey: CURRENCY_FORM_DRAFT_STORAGE_KEY,
113
+ value: {
114
+ mode: editingCurrency ? 'edit' : 'create',
115
+ currencyId: editingCurrency?.id ?? null,
116
+ values: {
117
+ code: watchedValues.code ?? '',
118
+ name: watchedValues.name ?? '',
119
+ symbol: watchedValues.symbol ?? '',
120
+ },
121
+ },
122
+ hasData: Boolean(
123
+ (watchedValues.code ?? '').trim() ||
124
+ (watchedValues.name ?? '').trim() ||
125
+ (watchedValues.symbol ?? '').trim()
126
+ ),
127
+ enabled: open,
128
+ });
129
+
130
+ const draftStatusContent = useMemo(() => {
131
+ if (!hasDraft || !draftSavedAt) return null;
132
+ const savedDate = new Date(draftSavedAt);
133
+ if (Number.isNaN(savedDate.getTime())) return null;
134
+ const locale = currentLocaleCode.startsWith('pt') ? ptBR : enUS;
135
+ const relativeLabel = formatDistanceToNow(savedDate, {
136
+ addSuffix: true,
137
+ locale,
138
+ });
139
+ const absoluteLabel = formatDateTime(
140
+ savedDate,
141
+ getSettingValue,
142
+ currentLocaleCode
143
+ );
144
+ return currentLocaleCode.startsWith('pt')
145
+ ? `Rascunho salvo ${relativeLabel} • Último salvamento: ${absoluteLabel}`
146
+ : `Draft saved ${relativeLabel} • Last saved: ${absoluteLabel}`;
147
+ }, [draftSavedAt, currentLocaleCode, getSettingValue, hasDraft]);
148
+
149
+ useEffect(() => {
150
+ if (!open) return;
151
+
152
+ const storedDraft = loadDraft();
153
+ const shouldRestoreEditDraft =
154
+ Boolean(editingCurrency) &&
155
+ storedDraft?.payload.mode === 'edit' &&
156
+ storedDraft.payload.currencyId === editingCurrency?.id;
157
+
158
+ if (editingCurrency) {
159
+ form.reset(
160
+ shouldRestoreEditDraft
161
+ ? storedDraft.payload.values
162
+ : {
163
+ code: editingCurrency.code,
164
+ name: editingCurrency.name,
165
+ symbol: editingCurrency.symbol,
166
+ }
167
+ );
168
+ return;
169
+ }
170
+
171
+ form.reset(
172
+ storedDraft?.payload.mode === 'create'
173
+ ? storedDraft.payload.values
174
+ : { code: '', name: '', symbol: '' }
175
+ );
176
+ }, [open, editingCurrency, form, loadDraft]);
177
+
178
+ const handleSubmit = async (values: CurrencyFormValues) => {
179
+ try {
180
+ if (editingCurrency) {
181
+ await request({
182
+ url: `/finance/currencies/${editingCurrency.id}`,
183
+ method: 'PATCH',
184
+ data: {
185
+ code: values.code,
186
+ name: values.name,
187
+ symbol: values.symbol,
188
+ },
189
+ });
190
+ } else {
191
+ await request({
192
+ url: '/finance/currencies',
193
+ method: 'POST',
194
+ data: {
195
+ code: values.code,
196
+ name: values.name,
197
+ symbol: values.symbol,
198
+ },
199
+ });
200
+ }
201
+
202
+ clearDraft();
203
+ await onSaved();
204
+ form.reset({ code: '', name: '', symbol: '' });
205
+ onEditingChange(null);
206
+ onOpenChange(false);
207
+ showToastHandler?.(
208
+ 'success',
209
+ editingCurrency
210
+ ? t('messages.updateSuccess')
211
+ : t('messages.createSuccess')
212
+ );
213
+ } catch {
214
+ showToastHandler?.(
215
+ 'error',
216
+ editingCurrency ? t('messages.updateError') : t('messages.createError')
217
+ );
218
+ }
219
+ };
220
+
221
+ const handleOpenChange = (nextOpen: boolean) => {
222
+ onOpenChange(nextOpen);
223
+ if (!nextOpen) onEditingChange(null);
224
+ };
225
+
226
+ return (
227
+ <Sheet open={open} onOpenChange={handleOpenChange}>
228
+ <SheetContent className="w-full sm:max-w-lg">
229
+ <SheetHeader>
230
+ <SheetTitle>
231
+ {editingCurrency ? t('sheet.editTitle') : t('sheet.newTitle')}
232
+ </SheetTitle>
233
+ <SheetDescription>
234
+ {editingCurrency
235
+ ? t('sheet.editDescription')
236
+ : t('sheet.newDescription')}
237
+ </SheetDescription>
238
+ </SheetHeader>
239
+
240
+ <Form {...form}>
241
+ <form
242
+ className="space-y-4 p-4"
243
+ onSubmit={form.handleSubmit(handleSubmit)}
244
+ >
245
+ <FormField
246
+ control={form.control}
247
+ name="code"
248
+ render={({ field }) => (
249
+ <FormItem>
250
+ <FormLabel>{t('sheet.fields.code')}</FormLabel>
251
+ <FormControl>
252
+ <Input
253
+ placeholder={t('sheet.fields.codePlaceholder')}
254
+ maxLength={3}
255
+ {...field}
256
+ onChange={(e) =>
257
+ field.onChange(e.target.value.toUpperCase())
258
+ }
259
+ />
260
+ </FormControl>
261
+ <FormMessage />
262
+ </FormItem>
263
+ )}
264
+ />
265
+
266
+ <FormField
267
+ control={form.control}
268
+ name="name"
269
+ render={({ field }) => (
270
+ <FormItem>
271
+ <FormLabel>{t('sheet.fields.name')}</FormLabel>
272
+ <FormControl>
273
+ <Input
274
+ placeholder={t('sheet.fields.namePlaceholder')}
275
+ {...field}
276
+ />
277
+ </FormControl>
278
+ <FormMessage />
279
+ </FormItem>
280
+ )}
281
+ />
282
+
283
+ <FormField
284
+ control={form.control}
285
+ name="symbol"
286
+ render={({ field }) => (
287
+ <FormItem>
288
+ <FormLabel>{t('sheet.fields.symbol')}</FormLabel>
289
+ <FormControl>
290
+ <Input
291
+ placeholder={t('sheet.fields.symbolPlaceholder')}
292
+ {...field}
293
+ />
294
+ </FormControl>
295
+ <FormMessage />
296
+ </FormItem>
297
+ )}
298
+ />
299
+
300
+ {draftStatusContent ? (
301
+ <p className="text-xs text-muted-foreground">
302
+ {draftStatusContent}
303
+ </p>
304
+ ) : null}
305
+
306
+ <div className="flex justify-end gap-2">
307
+ <Button
308
+ type="button"
309
+ variant="outline"
310
+ onClick={() => handleOpenChange(false)}
311
+ >
312
+ {t('common.cancel')}
313
+ </Button>
314
+ <Button type="submit" disabled={form.formState.isSubmitting}>
315
+ {t('common.save')}
316
+ </Button>
317
+ </div>
318
+ </form>
319
+ </Form>
320
+ </SheetContent>
321
+ </Sheet>
322
+ );
323
+ }
324
+
325
+ export default function CurrenciesPage() {
326
+ const t = useTranslations('finance.AdminCurrenciesPage');
327
+ const { request, showToastHandler } = useApp();
328
+
329
+ const [sheetOpen, setSheetOpen] = useState(false);
330
+ const [editingCurrency, setEditingCurrency] = useState<Currency | null>(null);
331
+ const [currencyIdToDelete, setCurrencyIdToDelete] = useState<string | null>(
332
+ null
333
+ );
334
+
335
+ const { data: currencies, refetch } = useQuery<Currency[]>({
336
+ queryKey: ['finance-currencies'],
337
+ queryFn: async () => {
338
+ const response = await request({
339
+ url: '/finance/currencies',
340
+ method: 'GET',
341
+ });
342
+ return (response.data || []) as Currency[];
343
+ },
344
+ placeholderData: [],
345
+ });
346
+
347
+ const handleDelete = async () => {
348
+ if (!currencyIdToDelete) return;
349
+
350
+ try {
351
+ await request({
352
+ url: `/finance/currencies/${currencyIdToDelete}`,
353
+ method: 'DELETE',
354
+ });
355
+
356
+ await refetch();
357
+ setCurrencyIdToDelete(null);
358
+ showToastHandler?.('success', t('messages.deleteSuccess'));
359
+ } catch {
360
+ showToastHandler?.('error', t('messages.deleteError'));
361
+ }
362
+ };
363
+
364
+ const items = currencies || [];
365
+
366
+ return (
367
+ <Page>
368
+ <PageHeader
369
+ title={t('header.title')}
370
+ description={t('header.description')}
371
+ breadcrumbs={[
372
+ { label: t('breadcrumbs.finance'), href: '/finance' },
373
+ {
374
+ label: t('breadcrumbs.administration'),
375
+ href: '/finance/administration',
376
+ },
377
+ { label: t('breadcrumbs.current') },
378
+ ]}
379
+ actions={
380
+ <Button
381
+ onClick={() => {
382
+ setEditingCurrency(null);
383
+ setSheetOpen(true);
384
+ }}
385
+ className="gap-2"
386
+ >
387
+ <Plus className="h-4 w-4" />
388
+ {t('actions.newCurrency')}
389
+ </Button>
390
+ }
391
+ />
392
+
393
+ <div className="space-y-4">
394
+ {items.length === 0 ? (
395
+ <EmptyState
396
+ icon={<Coins className="h-12 w-12" />}
397
+ title={t('table.empty')}
398
+ description={t('header.description')}
399
+ actionLabel={t('actions.newCurrency')}
400
+ actionIcon={<Plus className="mr-2 h-4 w-4" />}
401
+ onAction={() => {
402
+ setEditingCurrency(null);
403
+ setSheetOpen(true);
404
+ }}
405
+ />
406
+ ) : (
407
+ <div className="grid gap-3">
408
+ {items.map((currency) => (
409
+ <div
410
+ key={currency.id}
411
+ className="flex flex-col gap-3 rounded-md border p-4 sm:flex-row sm:items-center sm:justify-between"
412
+ >
413
+ <div className="flex items-start gap-3">
414
+ <div className="rounded-md bg-muted p-2">
415
+ <Coins className="h-4 w-4" />
416
+ </div>
417
+ <div>
418
+ <p className="text-sm font-medium">
419
+ {currency.symbol}{' '}
420
+ <span className="font-bold">{currency.code}</span>
421
+ {' — '}
422
+ {currency.name}
423
+ </p>
424
+ </div>
425
+ </div>
426
+
427
+ <div className="flex items-center gap-2">
428
+ <Badge variant={currency.ativo ? 'default' : 'secondary'}>
429
+ {currency.ativo
430
+ ? t('table.status.active')
431
+ : t('table.status.inactive')}
432
+ </Badge>
433
+ <Button
434
+ variant="outline"
435
+ size="icon"
436
+ onClick={() => {
437
+ setEditingCurrency(currency);
438
+ setSheetOpen(true);
439
+ }}
440
+ >
441
+ <Pencil className="h-4 w-4" />
442
+ </Button>
443
+ <Button
444
+ variant="outline"
445
+ size="icon"
446
+ onClick={() => setCurrencyIdToDelete(currency.id)}
447
+ disabled={!currency.ativo}
448
+ >
449
+ <Trash2 className="h-4 w-4" />
450
+ </Button>
451
+ </div>
452
+ </div>
453
+ ))}
454
+ </div>
455
+ )}
456
+ </div>
457
+
458
+ <CurrencySheet
459
+ open={sheetOpen}
460
+ onOpenChange={setSheetOpen}
461
+ onSaved={refetch}
462
+ editingCurrency={editingCurrency}
463
+ onEditingChange={setEditingCurrency}
464
+ t={t}
465
+ />
466
+
467
+ <AlertDialog
468
+ open={!!currencyIdToDelete}
469
+ onOpenChange={(open) => {
470
+ if (!open) setCurrencyIdToDelete(null);
471
+ }}
472
+ >
473
+ <AlertDialogContent>
474
+ <AlertDialogHeader>
475
+ <AlertDialogTitle>{t('deleteDialog.title')}</AlertDialogTitle>
476
+ <AlertDialogDescription>
477
+ {t('deleteDialog.description')}
478
+ </AlertDialogDescription>
479
+ </AlertDialogHeader>
480
+ <AlertDialogFooter>
481
+ <AlertDialogCancel>{t('common.cancel')}</AlertDialogCancel>
482
+ <AlertDialogAction onClick={handleDelete}>
483
+ {t('deleteDialog.confirm')}
484
+ </AlertDialogAction>
485
+ </AlertDialogFooter>
486
+ </AlertDialogContent>
487
+ </AlertDialog>
488
+ </Page>
489
+ );
490
+ }