@hed-hog/finance 0.0.365 → 0.0.370

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 (38) hide show
  1. package/dist/dto/create-financial-title.dto.d.ts +1 -0
  2. package/dist/dto/create-financial-title.dto.d.ts.map +1 -1
  3. package/dist/dto/create-financial-title.dto.js +6 -0
  4. package/dist/dto/create-financial-title.dto.js.map +1 -1
  5. package/dist/finance-data.controller.d.ts +4 -0
  6. package/dist/finance-data.controller.d.ts.map +1 -1
  7. package/dist/finance-installments.controller.d.ts +40 -0
  8. package/dist/finance-installments.controller.d.ts.map +1 -1
  9. package/dist/finance-statements.controller.d.ts +2 -0
  10. package/dist/finance-statements.controller.d.ts.map +1 -1
  11. package/dist/finance.service.d.ts +47 -0
  12. package/dist/finance.service.d.ts.map +1 -1
  13. package/dist/finance.service.js +156 -109
  14. package/dist/finance.service.js.map +1 -1
  15. package/dist/mcp-tools/finance-installments.mcp-tools.d.ts.map +1 -1
  16. package/dist/mcp-tools/finance-installments.mcp-tools.js +12 -2
  17. package/dist/mcp-tools/finance-installments.mcp-tools.js.map +1 -1
  18. package/hedhog/frontend/app/_components/bank-account-picker-field.tsx.ejs +3 -0
  19. package/hedhog/frontend/app/_components/bank-account-sheet.tsx.ejs +902 -0
  20. package/hedhog/frontend/app/_components/finance-picker.tsx.ejs +95 -0
  21. package/hedhog/frontend/app/accounts-payable/installments/page.tsx.ejs +117 -43
  22. package/hedhog/frontend/app/accounts-receivable/collections-default/page.tsx.ejs +8 -2
  23. package/hedhog/frontend/app/accounts-receivable/installments/page.tsx.ejs +114 -43
  24. package/hedhog/frontend/app/administration/categories/page.tsx.ejs +4 -1
  25. package/hedhog/frontend/app/administration/cost-centers/page.tsx.ejs +4 -1
  26. package/hedhog/frontend/app/administration/currencies/page.tsx.ejs +4 -1
  27. package/hedhog/frontend/app/administration/period-close/page.tsx.ejs +4 -1
  28. package/hedhog/frontend/app/cash-and-banks/bank-accounts/page.tsx.ejs +6 -893
  29. package/hedhog/frontend/app/cash-and-banks/bank-reconciliation/page.tsx.ejs +4 -1
  30. package/hedhog/frontend/app/cash-and-banks/statements/page.tsx.ejs +8 -2
  31. package/hedhog/frontend/app/cash-and-banks/transfers/page.tsx.ejs +4 -1
  32. package/hedhog/frontend/messages/en.json +14 -1
  33. package/hedhog/frontend/messages/pt.json +14 -1
  34. package/hedhog/table/financial_title.yaml +6 -1
  35. package/package.json +6 -6
  36. package/src/dto/create-financial-title.dto.ts +5 -0
  37. package/src/finance.service.ts +187 -134
  38. package/src/mcp-tools/finance-installments.mcp-tools.ts +12 -2
@@ -0,0 +1,902 @@
1
+ 'use client';
2
+
3
+ import { Button } from '@/components/ui/button';
4
+ import { EntityPicker } from '@/components/ui/entity-picker';
5
+ import {
6
+ Form,
7
+ FormControl,
8
+ FormField,
9
+ FormItem,
10
+ FormLabel,
11
+ FormMessage,
12
+ } from '@/components/ui/form';
13
+ import { Input } from '@/components/ui/input';
14
+ import { InputMoney } from '@/components/ui/input-money';
15
+ import { ResizableSheetContent } from '@/components/ui/resizable-sheet-content';
16
+ import {
17
+ Select,
18
+ SelectContent,
19
+ SelectItem,
20
+ SelectTrigger,
21
+ SelectValue,
22
+ } from '@/components/ui/select';
23
+ import {
24
+ Sheet,
25
+ SheetDescription,
26
+ SheetHeader,
27
+ SheetTitle,
28
+ } from '@/components/ui/sheet';
29
+ import { useFormDraft } from '@/hooks/use-form-draft';
30
+ import { formatDateTime } from '@/lib/format-date';
31
+ import { useApp, useQuery } from '@hed-hog/next-app-provider';
32
+ import { zodResolver } from '@hookform/resolvers/zod';
33
+ import { formatDistanceToNow } from 'date-fns';
34
+ import { enUS, ptBR } from 'date-fns/locale';
35
+ import { Landmark, Trash2, Upload } from 'lucide-react';
36
+ import { useTranslations } from 'next-intl';
37
+ import { useEffect, useMemo, useRef, useState, type ChangeEvent } from 'react';
38
+ import { useForm, useWatch } from 'react-hook-form';
39
+ import { z } from 'zod';
40
+ import { FinanceSheetBody, FinanceSheetSection } from './finance-layout';
41
+
42
+ const createBankAccountFormSchema = (t: ReturnType<typeof useTranslations>) =>
43
+ z.object({
44
+ banco: z.string().trim().min(1, t('validation.bankRequired')),
45
+ agencia: z.string().optional(),
46
+ conta: z.string().optional(),
47
+ tipo: z.string().min(1, t('validation.typeRequired')),
48
+ descricao: z.string().optional(),
49
+ logoFileId: z.number().int().nullable().optional(),
50
+ currencyId: z.number().int().nullable().optional(),
51
+ dataInicial: z.string().optional(),
52
+ saldoInicial: z.number().min(0, t('validation.initialBalanceInvalid')),
53
+ });
54
+
55
+ type BankAccountFormValues = z.infer<
56
+ ReturnType<typeof createBankAccountFormSchema>
57
+ >;
58
+
59
+ type Currency = {
60
+ id: string;
61
+ code: string;
62
+ name: string;
63
+ symbol: string;
64
+ ativo: boolean;
65
+ };
66
+
67
+ type BankAccountCurrency = {
68
+ id: string;
69
+ code: string;
70
+ symbol: string;
71
+ name: string;
72
+ };
73
+
74
+ export type BankAccount = {
75
+ id: string;
76
+ codigo: string;
77
+ descricao: string;
78
+ banco: string;
79
+ agencia: string;
80
+ conta: string;
81
+ tipo: 'corrente' | 'poupanca' | 'investimento' | 'caixa';
82
+ logoFileId: number | null;
83
+ currencyId: number | null;
84
+ currency: BankAccountCurrency | null;
85
+ saldoAtual: number;
86
+ saldoConciliado: number;
87
+ ativo: boolean;
88
+ dataInicial: string | null;
89
+ };
90
+
91
+ type UploadedFilePayload = {
92
+ id?: number | string | null;
93
+ };
94
+
95
+ type BankAccountDraftPayload = {
96
+ mode: 'create' | 'edit';
97
+ accountId: string | null;
98
+ values: BankAccountFormValues;
99
+ logoFileId: number | null;
100
+ logoPreviewUrl: string | null;
101
+ currencyId: number | null;
102
+ };
103
+
104
+ const BANK_ACCOUNT_FORM_DRAFT_STORAGE_KEY = 'finance-bank-account-form-draft';
105
+
106
+ export function BankAccountSheet({
107
+ t,
108
+ onCreated,
109
+ onCreatedAccount,
110
+ open,
111
+ onOpenChange,
112
+ editingAccount,
113
+ onEditingAccountChange,
114
+ }: {
115
+ t: ReturnType<typeof useTranslations>;
116
+ onCreated: () => Promise<unknown> | void;
117
+ onCreatedAccount?: (account: BankAccount) => void;
118
+ open: boolean;
119
+ onOpenChange: (open: boolean) => void;
120
+ editingAccount: BankAccount | null;
121
+ onEditingAccountChange: (account: BankAccount | null) => void;
122
+ }) {
123
+ const { request, showToastHandler, currentLocaleCode, getSettingValue } =
124
+ useApp();
125
+
126
+ const { data: currenciesData, refetch: refetchCurrencies } = useQuery<
127
+ Currency[]
128
+ >({
129
+ queryKey: ['finance-currencies-for-select'],
130
+ queryFn: async () => {
131
+ const response = await request({
132
+ url: '/finance/currencies',
133
+ method: 'GET',
134
+ });
135
+ return (response.data || []) as Currency[];
136
+ },
137
+ placeholderData: [],
138
+ });
139
+ const currencies = currenciesData || [];
140
+
141
+ const createSuccessMessage = t('messages.createSuccess');
142
+ const createErrorMessage = t('messages.createError');
143
+ const updateSuccessMessage = t('messages.updateSuccess');
144
+ const updateErrorMessage = t('messages.updateError');
145
+ const logoUploadSuccessMessage = t('messages.logoUploadSuccess');
146
+ const logoUploadErrorMessage = t('messages.logoUploadError');
147
+ const logoRemoveSuccessMessage = t('messages.logoRemoveSuccess');
148
+ const logoInvalidTypeMessage = t('messages.logoInvalidType');
149
+ const logoTooLargeMessage = t('messages.logoTooLarge');
150
+
151
+ const form = useForm<BankAccountFormValues>({
152
+ resolver: zodResolver(createBankAccountFormSchema(t)),
153
+ defaultValues: {
154
+ banco: '',
155
+ agencia: '',
156
+ conta: '',
157
+ tipo: '',
158
+ descricao: '',
159
+ logoFileId: null,
160
+ currencyId: null,
161
+ dataInicial: new Date().toISOString().slice(0, 10),
162
+ saldoInicial: 0,
163
+ },
164
+ });
165
+
166
+ const [logoFileId, setLogoFileId] = useState<number | null>(null);
167
+ const [persistedLogoFileId, setPersistedLogoFileId] = useState<number | null>(
168
+ null
169
+ );
170
+ const [logoPreviewUrl, setLogoPreviewUrl] = useState<string | null>(null);
171
+ const [isUploadingLogo, setIsUploadingLogo] = useState(false);
172
+ const [logoUploadProgress, setLogoUploadProgress] = useState(0);
173
+ const logoInputRef = useRef<HTMLInputElement | null>(null);
174
+ const watchedFormValues = useWatch({
175
+ control: form.control,
176
+ });
177
+
178
+ const hasDraftContent = useMemo(
179
+ () =>
180
+ Boolean(
181
+ (watchedFormValues.banco ?? '').trim() ||
182
+ (watchedFormValues.agencia ?? '').trim() ||
183
+ (watchedFormValues.conta ?? '').trim() ||
184
+ (watchedFormValues.tipo ?? '').trim() ||
185
+ (watchedFormValues.descricao ?? '').trim() ||
186
+ (watchedFormValues.dataInicial ?? '').trim() !==
187
+ new Date().toISOString().slice(0, 10) ||
188
+ (watchedFormValues.saldoInicial ?? 0) > 0 ||
189
+ logoFileId != null
190
+ ),
191
+ [watchedFormValues, logoFileId]
192
+ );
193
+
194
+ const draftValue = useMemo<BankAccountDraftPayload>(
195
+ () => ({
196
+ mode: editingAccount ? 'edit' : 'create',
197
+ accountId: editingAccount?.id ?? null,
198
+ values: {
199
+ banco: watchedFormValues.banco ?? '',
200
+ agencia: watchedFormValues.agencia ?? '',
201
+ conta: watchedFormValues.conta ?? '',
202
+ tipo: watchedFormValues.tipo ?? '',
203
+ descricao: watchedFormValues.descricao ?? '',
204
+ logoFileId: logoFileId ?? null,
205
+ dataInicial:
206
+ watchedFormValues.dataInicial ||
207
+ new Date().toISOString().slice(0, 10),
208
+ saldoInicial: watchedFormValues.saldoInicial ?? 0,
209
+ currencyId: watchedFormValues.currencyId ?? null,
210
+ },
211
+ logoFileId: logoFileId ?? null,
212
+ logoPreviewUrl,
213
+ currencyId: watchedFormValues.currencyId ?? null,
214
+ }),
215
+ [editingAccount, logoFileId, logoPreviewUrl, watchedFormValues]
216
+ );
217
+
218
+ const {
219
+ clearDraft,
220
+ loadDraft,
221
+ hasDraft,
222
+ savedAt: draftSavedAt,
223
+ } = useFormDraft<BankAccountDraftPayload>({
224
+ storageKey: BANK_ACCOUNT_FORM_DRAFT_STORAGE_KEY,
225
+ value: draftValue,
226
+ hasData: hasDraftContent,
227
+ enabled: open,
228
+ });
229
+
230
+ const draftStatusContent = useMemo(() => {
231
+ if (!hasDraft || !draftSavedAt) {
232
+ return null;
233
+ }
234
+
235
+ const savedDate = new Date(draftSavedAt);
236
+ if (Number.isNaN(savedDate.getTime())) {
237
+ return null;
238
+ }
239
+
240
+ const locale = currentLocaleCode.startsWith('pt') ? ptBR : enUS;
241
+ const relativeLabel = formatDistanceToNow(savedDate, {
242
+ addSuffix: true,
243
+ locale,
244
+ });
245
+ const absoluteLabel = formatDateTime(
246
+ savedDate,
247
+ getSettingValue,
248
+ currentLocaleCode
249
+ );
250
+
251
+ return t('draftStatus', {
252
+ relativeLabel,
253
+ absoluteLabel,
254
+ });
255
+ }, [draftSavedAt, currentLocaleCode, getSettingValue, hasDraft, t]);
256
+
257
+ const getLogoUrl = (fileId?: number | null) => {
258
+ if (typeof fileId !== 'number' || fileId <= 0) {
259
+ return null;
260
+ }
261
+
262
+ return `${String(process.env.NEXT_PUBLIC_API_BASE_URL || '')}/file/open/${fileId}`;
263
+ };
264
+
265
+ const deleteFileById = async (fileId?: number | null) => {
266
+ if (!fileId || fileId <= 0) {
267
+ return;
268
+ }
269
+
270
+ try {
271
+ await request({
272
+ url: '/file',
273
+ method: 'DELETE',
274
+ data: { ids: [fileId] },
275
+ });
276
+ } catch {
277
+ // Ignore cleanup failures to keep the form stable.
278
+ }
279
+ };
280
+
281
+ const cleanupUnsavedLogo = async () => {
282
+ if (logoFileId && logoFileId !== persistedLogoFileId) {
283
+ await deleteFileById(logoFileId);
284
+ }
285
+ };
286
+
287
+ const handleSelectLogo = () => {
288
+ if (!logoInputRef.current || isUploadingLogo) {
289
+ return;
290
+ }
291
+
292
+ logoInputRef.current.value = '';
293
+ logoInputRef.current.click();
294
+ };
295
+
296
+ const handleLogoUpload = async (event: ChangeEvent<HTMLInputElement>) => {
297
+ const file = event.target.files?.[0];
298
+
299
+ if (!file) {
300
+ return;
301
+ }
302
+
303
+ if (!file.type.startsWith('image/')) {
304
+ showToastHandler?.('error', logoInvalidTypeMessage);
305
+ event.target.value = '';
306
+ return;
307
+ }
308
+
309
+ if (file.size > 5 * 1024 * 1024) {
310
+ showToastHandler?.('error', logoTooLargeMessage);
311
+ event.target.value = '';
312
+ return;
313
+ }
314
+
315
+ setIsUploadingLogo(true);
316
+ setLogoUploadProgress(0);
317
+
318
+ try {
319
+ const previousUploadedLogoId = logoFileId;
320
+ const formData = new FormData();
321
+ formData.append('file', file);
322
+ formData.append('destination', 'finance/bank-account/logo');
323
+
324
+ const { data } = await request<UploadedFilePayload>({
325
+ url: '/file',
326
+ method: 'POST',
327
+ data: formData,
328
+ headers: {
329
+ 'Content-Type': 'multipart/form-data',
330
+ },
331
+ onUploadProgress: (progressEvent) => {
332
+ if (!progressEvent.total) {
333
+ return;
334
+ }
335
+
336
+ const progress = Math.round(
337
+ (progressEvent.loaded * 100) / progressEvent.total
338
+ );
339
+ setLogoUploadProgress(progress);
340
+ },
341
+ });
342
+
343
+ const nextLogoFileId = Number(data?.id);
344
+
345
+ if (!nextLogoFileId) {
346
+ throw new Error('Logo upload failed');
347
+ }
348
+
349
+ if (
350
+ previousUploadedLogoId &&
351
+ previousUploadedLogoId !== persistedLogoFileId
352
+ ) {
353
+ await deleteFileById(previousUploadedLogoId);
354
+ }
355
+
356
+ setLogoFileId(nextLogoFileId);
357
+ setLogoPreviewUrl(`${getLogoUrl(nextLogoFileId)}?ts=${Date.now()}`);
358
+ setLogoUploadProgress(100);
359
+ form.setValue('logoFileId', nextLogoFileId, {
360
+ shouldDirty: true,
361
+ shouldValidate: true,
362
+ });
363
+ showToastHandler?.('success', logoUploadSuccessMessage);
364
+ } catch {
365
+ showToastHandler?.('error', logoUploadErrorMessage);
366
+ setLogoUploadProgress(0);
367
+ } finally {
368
+ setIsUploadingLogo(false);
369
+ event.target.value = '';
370
+ }
371
+ };
372
+
373
+ const handleRemoveLogo = async () => {
374
+ if (isUploadingLogo) {
375
+ return;
376
+ }
377
+
378
+ if (logoFileId && logoFileId !== persistedLogoFileId) {
379
+ await deleteFileById(logoFileId);
380
+ }
381
+
382
+ setLogoFileId(null);
383
+ setLogoPreviewUrl(null);
384
+ setLogoUploadProgress(0);
385
+ form.setValue('logoFileId', null, {
386
+ shouldDirty: true,
387
+ shouldValidate: true,
388
+ });
389
+ showToastHandler?.('success', logoRemoveSuccessMessage);
390
+ };
391
+
392
+ useEffect(() => {
393
+ if (!open || !editingAccount) {
394
+ const brlCurrency = currencies.find((c) => c.code === 'BRL');
395
+ if (brlCurrency && open) {
396
+ form.setValue('currencyId', Number(brlCurrency.id));
397
+ }
398
+ }
399
+ }, [currencies, open, editingAccount, form]);
400
+
401
+ useEffect(() => {
402
+ if (!open) {
403
+ return;
404
+ }
405
+
406
+ const storedDraft = loadDraft();
407
+
408
+ if (editingAccount) {
409
+ const shouldRestoreDraft =
410
+ storedDraft?.payload.mode === 'edit' &&
411
+ storedDraft.payload.accountId === editingAccount.id;
412
+ const currentLogoFileId = shouldRestoreDraft
413
+ ? storedDraft.payload.logoFileId
414
+ : (editingAccount.logoFileId ?? null);
415
+
416
+ setLogoFileId(currentLogoFileId);
417
+ setPersistedLogoFileId(editingAccount.logoFileId ?? null);
418
+ setLogoPreviewUrl(
419
+ shouldRestoreDraft
420
+ ? storedDraft.payload.logoPreviewUrl
421
+ : currentLogoFileId
422
+ ? `${getLogoUrl(currentLogoFileId)}?ts=${Date.now()}`
423
+ : null
424
+ );
425
+ setLogoUploadProgress(0);
426
+
427
+ form.reset(
428
+ shouldRestoreDraft
429
+ ? storedDraft.payload.values
430
+ : {
431
+ banco: editingAccount.banco,
432
+ agencia:
433
+ editingAccount.agencia === '-' ? '' : editingAccount.agencia,
434
+ conta: editingAccount.conta === '-' ? '' : editingAccount.conta,
435
+ tipo: editingAccount.tipo,
436
+ descricao: editingAccount.descricao,
437
+ logoFileId: currentLogoFileId,
438
+ currencyId: editingAccount.currencyId ?? null,
439
+ dataInicial:
440
+ editingAccount.dataInicial ??
441
+ new Date().toISOString().slice(0, 10),
442
+ saldoInicial: editingAccount.saldoAtual,
443
+ }
444
+ );
445
+ return;
446
+ }
447
+
448
+ const shouldRestoreDraft = storedDraft?.payload.mode === 'create';
449
+
450
+ setLogoFileId(shouldRestoreDraft ? storedDraft.payload.logoFileId : null);
451
+ setPersistedLogoFileId(null);
452
+ setLogoPreviewUrl(
453
+ shouldRestoreDraft ? storedDraft.payload.logoPreviewUrl : null
454
+ );
455
+ setLogoUploadProgress(0);
456
+
457
+ form.reset(
458
+ shouldRestoreDraft
459
+ ? storedDraft.payload.values
460
+ : {
461
+ banco: '',
462
+ agencia: '',
463
+ conta: '',
464
+ tipo: '',
465
+ descricao: '',
466
+ logoFileId: null,
467
+ currencyId: null,
468
+ dataInicial: new Date().toISOString().slice(0, 10),
469
+ saldoInicial: 0,
470
+ }
471
+ );
472
+ }, [editingAccount, form, loadDraft, open]);
473
+
474
+ const handleSubmit = async (values: BankAccountFormValues) => {
475
+ const nextLogoFileId = values.logoFileId ?? null;
476
+ const previousPersistedLogoId = persistedLogoFileId;
477
+
478
+ try {
479
+ let createdAccount: BankAccount | null = null;
480
+
481
+ if (editingAccount) {
482
+ await request({
483
+ url: `/finance/bank-accounts/${editingAccount.id}`,
484
+ method: 'PATCH',
485
+ data: {
486
+ bank: values.banco,
487
+ branch: values.agencia || undefined,
488
+ account: values.conta || undefined,
489
+ type: values.tipo,
490
+ description: values.descricao?.trim() || undefined,
491
+ logo_file_id: nextLogoFileId,
492
+ currency_id: values.currencyId ?? null,
493
+ start_date: values.dataInicial || undefined,
494
+ },
495
+ });
496
+ } else {
497
+ const response = await request({
498
+ url: '/finance/bank-accounts',
499
+ method: 'POST',
500
+ data: {
501
+ bank: values.banco,
502
+ branch: values.agencia || undefined,
503
+ account: values.conta || undefined,
504
+ type: values.tipo,
505
+ description: values.descricao?.trim() || undefined,
506
+ logo_file_id: nextLogoFileId,
507
+ currency_id: values.currencyId ?? null,
508
+ start_date: values.dataInicial || undefined,
509
+ initial_balance: values.saldoInicial,
510
+ },
511
+ });
512
+ createdAccount = (response?.data ?? null) as BankAccount | null;
513
+ }
514
+
515
+ await onCreated();
516
+
517
+ if (!editingAccount && createdAccount) {
518
+ onCreatedAccount?.(createdAccount);
519
+ }
520
+
521
+ if (
522
+ previousPersistedLogoId &&
523
+ previousPersistedLogoId !== nextLogoFileId
524
+ ) {
525
+ await deleteFileById(previousPersistedLogoId);
526
+ }
527
+
528
+ clearDraft();
529
+ setPersistedLogoFileId(nextLogoFileId);
530
+ setLogoFileId(nextLogoFileId);
531
+ setLogoPreviewUrl(
532
+ nextLogoFileId ? `${getLogoUrl(nextLogoFileId)}?ts=${Date.now()}` : null
533
+ );
534
+ setLogoUploadProgress(0);
535
+ form.reset({
536
+ banco: '',
537
+ agencia: '',
538
+ conta: '',
539
+ tipo: '',
540
+ descricao: '',
541
+ logoFileId: null,
542
+ currencyId: null,
543
+ dataInicial: new Date().toISOString().slice(0, 10),
544
+ saldoInicial: 0,
545
+ });
546
+ onOpenChange(false);
547
+ onEditingAccountChange(null);
548
+ showToastHandler?.(
549
+ 'success',
550
+ editingAccount ? updateSuccessMessage : createSuccessMessage
551
+ );
552
+ } catch {
553
+ showToastHandler?.(
554
+ 'error',
555
+ editingAccount ? updateErrorMessage : createErrorMessage
556
+ );
557
+ }
558
+ };
559
+
560
+ const handleCancel = () => {
561
+ void cleanupUnsavedLogo();
562
+ clearDraft();
563
+ form.reset();
564
+ onEditingAccountChange(null);
565
+ onOpenChange(false);
566
+ };
567
+
568
+ return (
569
+ <Sheet
570
+ open={open}
571
+ onOpenChange={(nextOpen) => {
572
+ if (!nextOpen && !hasDraftContent) {
573
+ void cleanupUnsavedLogo();
574
+ }
575
+
576
+ onOpenChange(nextOpen);
577
+ if (!nextOpen) {
578
+ onEditingAccountChange(null);
579
+ }
580
+ }}
581
+ >
582
+ <ResizableSheetContent sheetId="finance-bank-account-form" defaultWidth={672} className="flex h-full w-full flex-col">
583
+ <SheetHeader>
584
+ <SheetTitle>
585
+ {editingAccount ? t('common.edit') : t('newAccount.title')}
586
+ </SheetTitle>
587
+ <SheetDescription>{t('newAccount.description')}</SheetDescription>
588
+ </SheetHeader>
589
+ <Form {...form}>
590
+ <form
591
+ className="flex h-full flex-col"
592
+ onSubmit={form.handleSubmit(handleSubmit)}
593
+ >
594
+ <FinanceSheetBody>
595
+ <FinanceSheetSection
596
+ title={t('sections.accountData.title')}
597
+ description={t('sections.accountData.description')}
598
+ >
599
+ <FormField
600
+ control={form.control}
601
+ name="banco"
602
+ render={({ field }) => (
603
+ <FormItem>
604
+ <FormLabel>{t('fields.bank')}</FormLabel>
605
+ <FormControl>
606
+ <Input
607
+ placeholder={t('fields.bankPlaceholder')}
608
+ {...field}
609
+ />
610
+ </FormControl>
611
+ <FormMessage />
612
+ </FormItem>
613
+ )}
614
+ />
615
+
616
+ <div className="grid gap-4 md:grid-cols-2">
617
+ <FormField
618
+ control={form.control}
619
+ name="agencia"
620
+ render={({ field }) => (
621
+ <FormItem>
622
+ <FormLabel>{t('fields.branch')}</FormLabel>
623
+ <FormControl>
624
+ <Input
625
+ placeholder="0000"
626
+ {...field}
627
+ value={field.value || ''}
628
+ />
629
+ </FormControl>
630
+ <FormMessage />
631
+ </FormItem>
632
+ )}
633
+ />
634
+
635
+ <FormField
636
+ control={form.control}
637
+ name="conta"
638
+ render={({ field }) => (
639
+ <FormItem>
640
+ <FormLabel>{t('fields.account')}</FormLabel>
641
+ <FormControl>
642
+ <Input
643
+ placeholder="00000-0"
644
+ {...field}
645
+ value={field.value || ''}
646
+ />
647
+ </FormControl>
648
+ <FormMessage />
649
+ </FormItem>
650
+ )}
651
+ />
652
+ </div>
653
+
654
+ <FormField
655
+ control={form.control}
656
+ name="tipo"
657
+ render={({ field }) => (
658
+ <FormItem>
659
+ <FormLabel>{t('fields.type')}</FormLabel>
660
+ <Select
661
+ value={field.value}
662
+ onValueChange={field.onChange}
663
+ >
664
+ <FormControl>
665
+ <SelectTrigger className="w-full">
666
+ <SelectValue placeholder={t('common.select')} />
667
+ </SelectTrigger>
668
+ </FormControl>
669
+ <SelectContent>
670
+ <SelectItem value="corrente">
671
+ {t('types.corrente')}
672
+ </SelectItem>
673
+ <SelectItem value="poupanca">
674
+ {t('types.poupanca')}
675
+ </SelectItem>
676
+ <SelectItem value="investimento">
677
+ {t('types.investimento')}
678
+ </SelectItem>
679
+ <SelectItem value="caixa">
680
+ {t('types.caixa')}
681
+ </SelectItem>
682
+ </SelectContent>
683
+ </Select>
684
+ <FormMessage />
685
+ </FormItem>
686
+ )}
687
+ />
688
+
689
+ <div className="grid gap-4 md:grid-cols-2">
690
+ <FormField
691
+ control={form.control}
692
+ name="saldoInicial"
693
+ render={({ field }) => (
694
+ <FormItem>
695
+ <FormLabel>{t('fields.initialBalance')}</FormLabel>
696
+ <FormControl>
697
+ <InputMoney
698
+ ref={field.ref}
699
+ name={field.name}
700
+ value={field.value}
701
+ onBlur={field.onBlur}
702
+ onValueChange={(value) =>
703
+ field.onChange(value ?? 0)
704
+ }
705
+ placeholder="0,00"
706
+ disabled={!!editingAccount}
707
+ />
708
+ </FormControl>
709
+ <FormMessage />
710
+ </FormItem>
711
+ )}
712
+ />
713
+
714
+ <FormField
715
+ control={form.control}
716
+ name="dataInicial"
717
+ render={({ field }) => (
718
+ <FormItem>
719
+ <FormLabel>Data inicial</FormLabel>
720
+ <FormControl>
721
+ <Input
722
+ type="date"
723
+ {...field}
724
+ onChange={(e) =>
725
+ field.onChange(
726
+ e.target.value ||
727
+ new Date().toISOString().slice(0, 10)
728
+ )
729
+ }
730
+ disabled={!!editingAccount}
731
+ />
732
+ </FormControl>
733
+ <FormMessage />
734
+ </FormItem>
735
+ )}
736
+ />
737
+ </div>
738
+
739
+ <div className="grid gap-4 md:grid-cols-2">
740
+ <EntityPicker<Currency>
741
+ form={form as never}
742
+ name="currencyId"
743
+ label={t('fields.currency')}
744
+ placeholder={t('fields.currencyPlaceholder')}
745
+ searchPlaceholder={t('fields.currencySearchPlaceholder')}
746
+ entityLabel={t('fields.currencyEntityLabel')}
747
+ valueType="number"
748
+ clearable
749
+ options={currencies}
750
+ getOptionValue={(c) => c.id}
751
+ getOptionLabel={(c) => `${c.symbol} ${c.code} — ${c.name}`}
752
+ createTitle={t('fields.currencyCreateTitle')}
753
+ createDescription={t('fields.currencyCreateDescription')}
754
+ mapSearchToCreateValues={(s) => ({ code: s.toUpperCase() })}
755
+ createFields={[
756
+ {
757
+ name: 'code',
758
+ label: t('fields.currencyCode'),
759
+ placeholder: t('fields.currencyCodePlaceholder'),
760
+ required: true,
761
+ },
762
+ {
763
+ name: 'name',
764
+ label: t('fields.currencyName'),
765
+ placeholder: t('fields.currencyNamePlaceholder'),
766
+ required: true,
767
+ },
768
+ {
769
+ name: 'symbol',
770
+ label: t('fields.currencySymbol'),
771
+ placeholder: t('fields.currencySymbolPlaceholder'),
772
+ required: true,
773
+ },
774
+ ]}
775
+ onCreate={async (values) => {
776
+ const response = await request({
777
+ url: '/finance/currencies',
778
+ method: 'POST',
779
+ data: {
780
+ code: (values.code ?? '').toUpperCase(),
781
+ name: values.name,
782
+ symbol: values.symbol,
783
+ },
784
+ });
785
+ void refetchCurrencies();
786
+ return response.data as Currency;
787
+ }}
788
+ />
789
+
790
+ <FormField
791
+ control={form.control}
792
+ name="descricao"
793
+ render={({ field }) => (
794
+ <FormItem>
795
+ <FormLabel>{t('fields.description')}</FormLabel>
796
+ <FormControl>
797
+ <Input
798
+ placeholder={t('fields.descriptionPlaceholder')}
799
+ {...field}
800
+ value={field.value || ''}
801
+ />
802
+ </FormControl>
803
+ <FormMessage />
804
+ </FormItem>
805
+ )}
806
+ />
807
+ </div>
808
+ </FinanceSheetSection>
809
+
810
+ <FinanceSheetSection
811
+ title={t('sections.logo.title')}
812
+ description={t('sections.logo.description')}
813
+ >
814
+ <FormItem>
815
+ <FormLabel>{t('fields.logo')}</FormLabel>
816
+ <div className="flex flex-col gap-4 rounded-xl border border-dashed border-border/70 bg-muted/20 p-4 sm:flex-row sm:items-start">
817
+ <div className="flex h-20 w-20 shrink-0 items-center justify-center overflow-hidden rounded-lg bg-muted">
818
+ {logoPreviewUrl ? (
819
+ <img
820
+ src={logoPreviewUrl}
821
+ alt={t('fields.logo')}
822
+ className="h-full w-full object-cover"
823
+ />
824
+ ) : (
825
+ <Landmark className="h-8 w-8 text-muted-foreground" />
826
+ )}
827
+ </div>
828
+
829
+ <div className="flex-1 space-y-2">
830
+ <input
831
+ ref={logoInputRef}
832
+ type="file"
833
+ accept="image/*"
834
+ className="hidden"
835
+ onChange={handleLogoUpload}
836
+ />
837
+
838
+ <div className="flex flex-wrap gap-2">
839
+ <Button
840
+ type="button"
841
+ variant="outline"
842
+ onClick={handleSelectLogo}
843
+ disabled={isUploadingLogo}
844
+ >
845
+ <Upload className="mr-2 h-4 w-4" />
846
+ {t('fields.logoAction')}
847
+ </Button>
848
+
849
+ {logoFileId ? (
850
+ <Button
851
+ type="button"
852
+ variant="ghost"
853
+ onClick={() => void handleRemoveLogo()}
854
+ disabled={isUploadingLogo}
855
+ >
856
+ <Trash2 className="mr-2 h-4 w-4" />
857
+ {t('fields.logoRemove')}
858
+ </Button>
859
+ ) : null}
860
+ </div>
861
+
862
+ <p className="text-xs text-muted-foreground">
863
+ {t('fields.logoHint')}
864
+ </p>
865
+
866
+ {isUploadingLogo ? (
867
+ <p className="text-xs text-muted-foreground">
868
+ {t('fields.logoUploading', {
869
+ progress: logoUploadProgress,
870
+ })}
871
+ </p>
872
+ ) : null}
873
+ </div>
874
+ </div>
875
+ </FormItem>
876
+ </FinanceSheetSection>
877
+ </FinanceSheetBody>
878
+
879
+ <div className="border-t px-4 py-4 sm:px-6">
880
+ {draftStatusContent ? (
881
+ <p className="mb-3 text-xs text-muted-foreground">
882
+ {draftStatusContent}
883
+ </p>
884
+ ) : null}
885
+ <div className="flex justify-end gap-2">
886
+ <Button type="button" variant="outline" onClick={handleCancel}>
887
+ {t('common.cancel')}
888
+ </Button>
889
+ <Button
890
+ type="submit"
891
+ disabled={form.formState.isSubmitting || isUploadingLogo}
892
+ >
893
+ {t('common.save')}
894
+ </Button>
895
+ </div>
896
+ </div>
897
+ </form>
898
+ </Form>
899
+ </ResizableSheetContent>
900
+ </Sheet>
901
+ );
902
+ }