@hed-hog/finance 0.0.274 → 0.0.276

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 (40) hide show
  1. package/README.md +228 -126
  2. package/dist/dto/create-bank-reconciliation.dto.d.ts +8 -0
  3. package/dist/dto/create-bank-reconciliation.dto.d.ts.map +1 -0
  4. package/dist/dto/create-bank-reconciliation.dto.js +43 -0
  5. package/dist/dto/create-bank-reconciliation.dto.js.map +1 -0
  6. package/dist/finance-data.controller.d.ts +2 -0
  7. package/dist/finance-data.controller.d.ts.map +1 -1
  8. package/dist/finance-statements.controller.d.ts +42 -0
  9. package/dist/finance-statements.controller.d.ts.map +1 -1
  10. package/dist/finance-statements.controller.js +13 -0
  11. package/dist/finance-statements.controller.js.map +1 -1
  12. package/dist/finance.service.d.ts +44 -0
  13. package/dist/finance.service.d.ts.map +1 -1
  14. package/dist/finance.service.js +98 -9
  15. package/dist/finance.service.js.map +1 -1
  16. package/hedhog/data/route.yaml +9 -0
  17. package/hedhog/frontend/app/_components/person-field-with-create.tsx.ejs +126 -126
  18. package/hedhog/frontend/app/accounts-payable/approvals/page.tsx.ejs +373 -373
  19. package/hedhog/frontend/app/accounts-payable/installments/[id]/page.tsx.ejs +1270 -1270
  20. package/hedhog/frontend/app/accounts-receivable/installments/[id]/page.tsx.ejs +982 -982
  21. package/hedhog/frontend/app/cash-and-banks/bank-accounts/page.tsx.ejs +686 -686
  22. package/hedhog/frontend/app/cash-and-banks/bank-reconciliation/page.tsx.ejs +152 -32
  23. package/hedhog/frontend/app/cash-and-banks/statements/page.tsx.ejs +986 -986
  24. package/hedhog/frontend/app/cash-and-banks/transfers/page.tsx.ejs +492 -492
  25. package/hedhog/frontend/app/page.tsx.ejs +372 -372
  26. package/hedhog/frontend/app/planning/cash-flow-forecast/page.tsx.ejs +329 -329
  27. package/hedhog/frontend/app/planning/receivables-calendar/page.tsx.ejs +227 -227
  28. package/hedhog/frontend/app/planning/scenarios/page.tsx.ejs +408 -408
  29. package/hedhog/frontend/messages/en.json +15 -5
  30. package/hedhog/frontend/messages/pt.json +15 -5
  31. package/package.json +7 -7
  32. package/src/dto/create-bank-reconciliation.dto.ts +24 -0
  33. package/src/finance-statements.controller.ts +14 -0
  34. package/src/finance.module.ts +43 -43
  35. package/src/finance.service.ts +118 -0
  36. package/src/index.ts +14 -14
  37. package/dist/finance.controller.d.ts +0 -276
  38. package/dist/finance.controller.d.ts.map +0 -1
  39. package/dist/finance.controller.js +0 -110
  40. package/dist/finance.controller.js.map +0 -1
@@ -1,986 +1,986 @@
1
- 'use client';
2
-
3
- import { Page, PageHeader } from '@/components/entity-list';
4
- import { Button } from '@/components/ui/button';
5
- import {
6
- Card,
7
- CardContent,
8
- CardDescription,
9
- CardHeader,
10
- CardTitle,
11
- } from '@/components/ui/card';
12
- import {
13
- Dialog,
14
- DialogContent,
15
- DialogDescription,
16
- DialogFooter,
17
- DialogHeader,
18
- DialogTitle,
19
- } from '@/components/ui/dialog';
20
- import { FilterBar } from '@/components/ui/filter-bar';
21
- import {
22
- Form,
23
- FormControl,
24
- FormField,
25
- FormItem,
26
- FormLabel,
27
- FormMessage,
28
- } from '@/components/ui/form';
29
- import { Input } from '@/components/ui/input';
30
- import { InputMoney } from '@/components/ui/input-money';
31
- import { Money } from '@/components/ui/money';
32
- import {
33
- Select,
34
- SelectContent,
35
- SelectItem,
36
- SelectTrigger,
37
- SelectValue,
38
- } from '@/components/ui/select';
39
- import {
40
- Sheet,
41
- SheetContent,
42
- SheetDescription,
43
- SheetHeader,
44
- SheetTitle,
45
- } from '@/components/ui/sheet';
46
- import { StatusBadge } from '@/components/ui/status-badge';
47
- import {
48
- Table,
49
- TableBody,
50
- TableCell,
51
- TableHead,
52
- TableHeader,
53
- TableRow,
54
- } from '@/components/ui/table';
55
- import { useApp, useQuery } from '@hed-hog/next-app-provider';
56
- import { zodResolver } from '@hookform/resolvers/zod';
57
- import {
58
- ArrowDownRight,
59
- ArrowUpRight,
60
- Download,
61
- Plus,
62
- Upload,
63
- } from 'lucide-react';
64
- import { useTranslations } from 'next-intl';
65
- import { usePathname, useRouter, useSearchParams } from 'next/navigation';
66
- import { useEffect, useState } from 'react';
67
- import { useForm } from 'react-hook-form';
68
- import { z } from 'zod';
69
- import { formatarData } from '../../_lib/formatters';
70
-
71
- type BankAccount = {
72
- id: string;
73
- banco: string;
74
- descricao: string;
75
- saldoAtual: number;
76
- };
77
-
78
- type Statement = {
79
- id: string;
80
- contaBancariaId: string;
81
- data: string;
82
- descricao: string;
83
- valor: number;
84
- tipo: 'entrada' | 'saida';
85
- statusConciliacao:
86
- | 'importado'
87
- | 'pendente'
88
- | 'conciliado'
89
- | 'estornado'
90
- | 'ajustado';
91
- };
92
-
93
- const bankAccountFormSchema = z.object({
94
- banco: z.string().trim().min(1, 'Banco é obrigatório'),
95
- agencia: z.string().optional(),
96
- conta: z.string().optional(),
97
- tipo: z.string().min(1, 'Tipo é obrigatório'),
98
- descricao: z.string().optional(),
99
- saldoInicial: z.number().min(0, 'Saldo inicial inválido'),
100
- });
101
-
102
- const importStatementSchema = z.object({
103
- bankAccountId: z.string().trim().min(1, 'Conta bancária é obrigatória'),
104
- file: z.instanceof(File, { message: 'Arquivo é obrigatório' }).refine(
105
- (value) => {
106
- const fileName = value.name.toLowerCase();
107
- return fileName.endsWith('.csv') || fileName.endsWith('.ofx');
108
- },
109
- { message: 'Apenas arquivos CSV ou OFX são permitidos' }
110
- ),
111
- });
112
-
113
- type ImportStatementFormValues = z.infer<typeof importStatementSchema>;
114
- type BankAccountFormValues = z.infer<typeof bankAccountFormSchema>;
115
-
116
- function NovaContaBancariaSheet({
117
- open,
118
- onOpenChange,
119
- onCreated,
120
- }: {
121
- open: boolean;
122
- onOpenChange: (open: boolean) => void;
123
- onCreated: (createdBankAccountId?: string) => Promise<void> | void;
124
- }) {
125
- const tBank = useTranslations('finance.BankAccountsPage');
126
- const { request, showToastHandler } = useApp();
127
-
128
- const form = useForm<BankAccountFormValues>({
129
- resolver: zodResolver(bankAccountFormSchema),
130
- defaultValues: {
131
- banco: '',
132
- agencia: '',
133
- conta: '',
134
- tipo: '',
135
- descricao: '',
136
- saldoInicial: 0,
137
- },
138
- });
139
-
140
- useEffect(() => {
141
- if (!open) {
142
- return;
143
- }
144
-
145
- form.reset({
146
- banco: '',
147
- agencia: '',
148
- conta: '',
149
- tipo: '',
150
- descricao: '',
151
- saldoInicial: 0,
152
- });
153
- }, [form, open]);
154
-
155
- const handleSubmit = async (values: BankAccountFormValues) => {
156
- try {
157
- const response = await request<{ id?: string | number }>({
158
- url: '/finance/bank-accounts',
159
- method: 'POST',
160
- data: {
161
- bank: values.banco,
162
- branch: values.agencia || undefined,
163
- account: values.conta || undefined,
164
- type: values.tipo,
165
- description: values.descricao?.trim() || undefined,
166
- initial_balance: values.saldoInicial,
167
- },
168
- });
169
-
170
- const createdBankAccountId = response?.data?.id;
171
-
172
- await onCreated(
173
- createdBankAccountId !== undefined
174
- ? String(createdBankAccountId)
175
- : undefined
176
- );
177
- onOpenChange(false);
178
- showToastHandler?.(
179
- 'success',
180
- tBank.has('messages.createSuccess')
181
- ? tBank('messages.createSuccess')
182
- : 'Conta bancária cadastrada com sucesso'
183
- );
184
- } catch {
185
- showToastHandler?.(
186
- 'error',
187
- tBank.has('messages.createError')
188
- ? tBank('messages.createError')
189
- : 'Erro ao cadastrar conta bancária'
190
- );
191
- }
192
- };
193
-
194
- return (
195
- <Sheet open={open} onOpenChange={onOpenChange}>
196
- <SheetContent className="w-full sm:max-w-lg overflow-y-auto">
197
- <SheetHeader>
198
- <SheetTitle>{tBank('newAccount.title')}</SheetTitle>
199
- <SheetDescription>{tBank('newAccount.description')}</SheetDescription>
200
- </SheetHeader>
201
-
202
- <Form {...form}>
203
- <form
204
- className="space-y-4 px-4"
205
- onSubmit={form.handleSubmit(handleSubmit)}
206
- >
207
- <FormField
208
- control={form.control}
209
- name="banco"
210
- render={({ field }) => (
211
- <FormItem>
212
- <FormLabel>{tBank('fields.bank')}</FormLabel>
213
- <FormControl>
214
- <Input
215
- placeholder={tBank('fields.bankPlaceholder')}
216
- {...field}
217
- />
218
- </FormControl>
219
- <FormMessage />
220
- </FormItem>
221
- )}
222
- />
223
-
224
- <div className="grid grid-cols-2 gap-4">
225
- <FormField
226
- control={form.control}
227
- name="agencia"
228
- render={({ field }) => (
229
- <FormItem>
230
- <FormLabel>{tBank('fields.branch')}</FormLabel>
231
- <FormControl>
232
- <Input
233
- placeholder="0000"
234
- {...field}
235
- value={field.value || ''}
236
- />
237
- </FormControl>
238
- <FormMessage />
239
- </FormItem>
240
- )}
241
- />
242
-
243
- <FormField
244
- control={form.control}
245
- name="conta"
246
- render={({ field }) => (
247
- <FormItem>
248
- <FormLabel>{tBank('fields.account')}</FormLabel>
249
- <FormControl>
250
- <Input
251
- placeholder="00000-0"
252
- {...field}
253
- value={field.value || ''}
254
- />
255
- </FormControl>
256
- <FormMessage />
257
- </FormItem>
258
- )}
259
- />
260
- </div>
261
-
262
- <div className="grid grid-cols-2 gap-4">
263
- <FormField
264
- control={form.control}
265
- name="tipo"
266
- render={({ field }) => (
267
- <FormItem>
268
- <FormLabel>{tBank('fields.type')}</FormLabel>
269
- <Select value={field.value} onValueChange={field.onChange}>
270
- <FormControl>
271
- <SelectTrigger className="w-full">
272
- <SelectValue placeholder={tBank('common.select')} />
273
- </SelectTrigger>
274
- </FormControl>
275
- <SelectContent>
276
- <SelectItem value="corrente">
277
- {tBank('types.corrente')}
278
- </SelectItem>
279
- <SelectItem value="poupanca">
280
- {tBank('types.poupanca')}
281
- </SelectItem>
282
- <SelectItem value="investimento">
283
- {tBank('types.investimento')}
284
- </SelectItem>
285
- <SelectItem value="caixa">
286
- {tBank('types.caixa')}
287
- </SelectItem>
288
- </SelectContent>
289
- </Select>
290
- <FormMessage />
291
- </FormItem>
292
- )}
293
- />
294
-
295
- <FormField
296
- control={form.control}
297
- name="saldoInicial"
298
- render={({ field }) => (
299
- <FormItem>
300
- <FormLabel>{tBank('fields.initialBalance')}</FormLabel>
301
- <FormControl>
302
- <InputMoney
303
- ref={field.ref}
304
- name={field.name}
305
- value={field.value}
306
- onBlur={field.onBlur}
307
- onValueChange={(value) => field.onChange(value ?? 0)}
308
- placeholder="0,00"
309
- />
310
- </FormControl>
311
- <FormMessage />
312
- </FormItem>
313
- )}
314
- />
315
- </div>
316
-
317
- <FormField
318
- control={form.control}
319
- name="descricao"
320
- render={({ field }) => (
321
- <FormItem>
322
- <FormLabel>{tBank('fields.description')}</FormLabel>
323
- <FormControl>
324
- <Input
325
- placeholder={tBank('fields.descriptionPlaceholder')}
326
- {...field}
327
- value={field.value || ''}
328
- />
329
- </FormControl>
330
- <FormMessage />
331
- </FormItem>
332
- )}
333
- />
334
-
335
- <div className="flex justify-end gap-2 pt-2">
336
- <Button
337
- type="button"
338
- variant="outline"
339
- onClick={() => onOpenChange(false)}
340
- >
341
- {tBank('common.cancel')}
342
- </Button>
343
- <Button type="submit" disabled={form.formState.isSubmitting}>
344
- {tBank('common.save')}
345
- </Button>
346
- </div>
347
- </form>
348
- </Form>
349
- </SheetContent>
350
- </Sheet>
351
- );
352
- }
353
-
354
- function ImportarExtratoSheet({
355
- contasBancarias,
356
- t,
357
- defaultBankAccountId,
358
- onImported,
359
- onBankAccountCreated,
360
- }: {
361
- contasBancarias: BankAccount[];
362
- t: ReturnType<typeof useTranslations>;
363
- defaultBankAccountId?: string;
364
- onImported: () => Promise<any> | void;
365
- onBankAccountCreated: (createdBankAccountId?: string) => Promise<void> | void;
366
- }) {
367
- const { request, showToastHandler } = useApp();
368
- const [open, setOpen] = useState(false);
369
- const [openNovaContaSheet, setOpenNovaContaSheet] = useState(false);
370
-
371
- const form = useForm<ImportStatementFormValues>({
372
- resolver: zodResolver(importStatementSchema),
373
- defaultValues: {
374
- bankAccountId: defaultBankAccountId || '',
375
- },
376
- });
377
-
378
- useEffect(() => {
379
- if (!open) {
380
- return;
381
- }
382
-
383
- form.reset({
384
- bankAccountId: defaultBankAccountId || '',
385
- file: undefined as unknown as File,
386
- });
387
- }, [defaultBankAccountId, form, open]);
388
-
389
- const handleSubmit = async (values: ImportStatementFormValues) => {
390
- const formData = new FormData();
391
- formData.append('bank_account_id', values.bankAccountId);
392
- formData.append('file', values.file);
393
-
394
- try {
395
- await request({
396
- url: '/finance/statements/import',
397
- method: 'POST',
398
- data: formData,
399
- });
400
-
401
- await onImported();
402
- showToastHandler?.('success', 'Extrato importado com sucesso');
403
- setOpen(false);
404
- } catch {
405
- showToastHandler?.('error', 'Não foi possível importar o extrato');
406
- }
407
- };
408
-
409
- return (
410
- <Sheet open={open} onOpenChange={setOpen}>
411
- <Button onClick={() => setOpen(true)}>
412
- <Upload className="mr-2 h-4 w-4" />
413
- {t('importDialog.action')}
414
- </Button>
415
-
416
- <SheetContent className="w-full sm:max-w-lg overflow-y-auto">
417
- <SheetHeader>
418
- <SheetTitle>{t('importDialog.title')}</SheetTitle>
419
- <SheetDescription>{t('importDialog.description')}</SheetDescription>
420
- </SheetHeader>
421
-
422
- <Form {...form}>
423
- <form
424
- className="space-y-4 px-4"
425
- onSubmit={form.handleSubmit(handleSubmit)}
426
- >
427
- <FormField
428
- control={form.control}
429
- name="bankAccountId"
430
- render={({ field }) => (
431
- <FormItem>
432
- <FormLabel>{t('importDialog.bankAccount')}</FormLabel>
433
- <div className="flex items-center gap-2">
434
- <div className="flex-1">
435
- <Select
436
- value={field.value}
437
- onValueChange={field.onChange}
438
- >
439
- <FormControl>
440
- <SelectTrigger className="w-full">
441
- <SelectValue
442
- placeholder={t('importDialog.selectAccount')}
443
- />
444
- </SelectTrigger>
445
- </FormControl>
446
- <SelectContent>
447
- {contasBancarias.map((conta) => (
448
- <SelectItem key={conta.id} value={conta.id}>
449
- {conta.banco} - {conta.descricao}
450
- </SelectItem>
451
- ))}
452
- </SelectContent>
453
- </Select>
454
- </div>
455
- <Button
456
- type="button"
457
- variant="outline"
458
- size="icon"
459
- onClick={() => setOpenNovaContaSheet(true)}
460
- aria-label="Nova conta bancária"
461
- >
462
- <Plus className="h-4 w-4" />
463
- </Button>
464
- </div>
465
- <FormMessage />
466
- </FormItem>
467
- )}
468
- />
469
-
470
- <NovaContaBancariaSheet
471
- open={openNovaContaSheet}
472
- onOpenChange={setOpenNovaContaSheet}
473
- onCreated={async (createdBankAccountId) => {
474
- await onBankAccountCreated(createdBankAccountId);
475
-
476
- if (createdBankAccountId) {
477
- form.setValue('bankAccountId', createdBankAccountId, {
478
- shouldDirty: true,
479
- shouldValidate: true,
480
- });
481
- }
482
- }}
483
- />
484
-
485
- <FormField
486
- control={form.control}
487
- name="file"
488
- render={({ field }) => (
489
- <FormItem>
490
- <FormLabel>{t('importDialog.file')}</FormLabel>
491
- <FormControl>
492
- <Input
493
- type="file"
494
- accept=".ofx,.csv"
495
- onChange={(event) => {
496
- const selectedFile = event.target.files?.[0];
497
- field.onChange(selectedFile);
498
- }}
499
- />
500
- </FormControl>
501
- <p className="text-xs text-muted-foreground">
502
- {t('importDialog.acceptedFormats')}
503
- </p>
504
- <FormMessage />
505
- </FormItem>
506
- )}
507
- />
508
-
509
- <div className="flex justify-end gap-2 pt-2">
510
- <Button
511
- type="button"
512
- variant="outline"
513
- onClick={() => setOpen(false)}
514
- >
515
- {t('common.cancel')}
516
- </Button>
517
- <Button type="submit" disabled={form.formState.isSubmitting}>
518
- {t('importDialog.submit')}
519
- </Button>
520
- </div>
521
- </form>
522
- </Form>
523
- </SheetContent>
524
- </Sheet>
525
- );
526
- }
527
-
528
- export default function ExtratosPage() {
529
- const t = useTranslations('finance.StatementsPage');
530
- const { request, showToastHandler } = useApp();
531
- const pathname = usePathname();
532
- const router = useRouter();
533
- const searchParams = useSearchParams();
534
- const bankAccountIdFromUrl = searchParams.get('bank_account_id');
535
-
536
- const [contaFilter, setContaFilter] = useState<string>('');
537
- const [search, setSearch] = useState('');
538
- const [debouncedSearch, setDebouncedSearch] = useState('');
539
- const [extratoSelecionado, setExtratoSelecionado] =
540
- useState<Statement | null>(null);
541
-
542
- useEffect(() => {
543
- const timeoutId = window.setTimeout(() => {
544
- setDebouncedSearch(search);
545
- }, 300);
546
-
547
- return () => {
548
- window.clearTimeout(timeoutId);
549
- };
550
- }, [search]);
551
-
552
- const { data: contasBancarias = [], refetch: refetchContasBancarias } =
553
- useQuery<BankAccount[]>({
554
- queryKey: ['finance-bank-accounts'],
555
- queryFn: async () => {
556
- const response = await request({
557
- url: '/finance/bank-accounts',
558
- method: 'GET',
559
- });
560
-
561
- return (response?.data || []) as BankAccount[];
562
- },
563
- });
564
-
565
- const handleBankAccountCreated = async (createdBankAccountId?: string) => {
566
- await refetchContasBancarias();
567
-
568
- if (!createdBankAccountId) {
569
- return;
570
- }
571
-
572
- setContaFilter(createdBankAccountId);
573
-
574
- const params = new URLSearchParams(searchParams.toString());
575
- params.set('bank_account_id', createdBankAccountId);
576
- router.replace(`${pathname}?${params.toString()}`);
577
- };
578
-
579
- useEffect(() => {
580
- const firstAccount = contasBancarias[0];
581
-
582
- if (!firstAccount) {
583
- return;
584
- }
585
-
586
- const hasAccountFromUrl =
587
- !!bankAccountIdFromUrl &&
588
- contasBancarias.some((account) => account.id === bankAccountIdFromUrl);
589
-
590
- const nextAccountId = hasAccountFromUrl
591
- ? (bankAccountIdFromUrl as string)
592
- : firstAccount.id;
593
-
594
- if (contaFilter !== nextAccountId) {
595
- setContaFilter(nextAccountId);
596
- }
597
-
598
- if (bankAccountIdFromUrl !== nextAccountId) {
599
- const params = new URLSearchParams(searchParams.toString());
600
- params.set('bank_account_id', nextAccountId);
601
- router.replace(`${pathname}?${params.toString()}`);
602
- }
603
- }, [
604
- bankAccountIdFromUrl,
605
- contaFilter,
606
- contasBancarias,
607
- pathname,
608
- router,
609
- searchParams,
610
- ]);
611
-
612
- const { data: extratos = [], refetch: refetchExtratos } = useQuery<
613
- Statement[]
614
- >({
615
- queryKey: ['finance-statements', contaFilter, debouncedSearch],
616
- queryFn: async () => {
617
- if (!contaFilter) {
618
- return [];
619
- }
620
-
621
- const params = new URLSearchParams();
622
- params.set('bank_account_id', contaFilter);
623
-
624
- const trimmedSearch = debouncedSearch.trim();
625
- if (trimmedSearch) {
626
- params.set('search', trimmedSearch);
627
- }
628
-
629
- const response = await request({
630
- url: `/finance/statements?${params.toString()}`,
631
- method: 'GET',
632
- });
633
-
634
- return (response?.data || []) as Statement[];
635
- },
636
- });
637
-
638
- const conta = contasBancarias.find((item) => item.id === contaFilter);
639
- const contaExtratoSelecionado = extratoSelecionado
640
- ? contasBancarias.find(
641
- (item) => item.id === extratoSelecionado.contaBancariaId
642
- )
643
- : undefined;
644
- const totalEntradas = extratos
645
- .filter((e) => e.tipo === 'entrada')
646
- .reduce((acc, e) => acc + e.valor, 0);
647
- const totalSaidas = extratos
648
- .filter((e) => e.tipo === 'saida')
649
- .reduce((acc, e) => acc + e.valor, 0);
650
-
651
- const handleExport = async () => {
652
- if (!contaFilter) {
653
- showToastHandler?.('error', 'Selecione uma conta bancária para exportar');
654
- return;
655
- }
656
-
657
- try {
658
- const params = new URLSearchParams();
659
- params.set('bank_account_id', contaFilter);
660
-
661
- const trimmedSearch = search.trim();
662
- if (trimmedSearch) {
663
- params.set('search', trimmedSearch);
664
- }
665
-
666
- const response = await request<Blob>({
667
- url: `/finance/statements/export?${params.toString()}`,
668
- method: 'GET',
669
- responseType: 'blob',
670
- });
671
-
672
- const contentDisposition =
673
- response.headers?.['content-disposition'] ||
674
- response.headers?.['Content-Disposition'];
675
- const fileNameMatch =
676
- typeof contentDisposition === 'string'
677
- ? contentDisposition.match(/filename\*?=(?:UTF-8'')?"?([^";]+)"?/i)
678
- : null;
679
- const fileName = fileNameMatch?.[1]
680
- ? decodeURIComponent(fileNameMatch[1])
681
- : `extrato-bancario-${contaFilter}.csv`;
682
-
683
- const blob = new Blob([response.data], {
684
- type: 'text/csv;charset=utf-8;',
685
- });
686
- const url = window.URL.createObjectURL(blob);
687
- const link = document.createElement('a');
688
-
689
- link.href = url;
690
- link.setAttribute('download', fileName);
691
- document.body.appendChild(link);
692
- link.click();
693
- link.remove();
694
- window.URL.revokeObjectURL(url);
695
- } catch {
696
- showToastHandler?.('error', 'Não foi possível exportar o extrato');
697
- }
698
- };
699
-
700
- return (
701
- <Page>
702
- <PageHeader
703
- title={t('header.title')}
704
- description={t('header.description')}
705
- breadcrumbs={[
706
- { label: t('breadcrumbs.home'), href: '/' },
707
- { label: t('breadcrumbs.finance'), href: '/finance' },
708
- { label: t('breadcrumbs.current') },
709
- ]}
710
- actions={
711
- <div className="flex gap-2">
712
- <Button variant="outline" onClick={() => void handleExport()}>
713
- <Download className="mr-2 h-4 w-4" />
714
- {t('actions.export')}
715
- </Button>
716
- <ImportarExtratoSheet
717
- contasBancarias={contasBancarias}
718
- t={t}
719
- defaultBankAccountId={contaFilter}
720
- onImported={refetchExtratos}
721
- onBankAccountCreated={handleBankAccountCreated}
722
- />
723
- </div>
724
- }
725
- />
726
-
727
- <div className="flex flex-col gap-4 sm:flex-row sm:items-center">
728
- <Select
729
- value={contaFilter}
730
- onValueChange={(value) => {
731
- setContaFilter(value);
732
-
733
- const params = new URLSearchParams(searchParams.toString());
734
- params.set('bank_account_id', value);
735
- router.replace(`${pathname}?${params.toString()}`);
736
- }}
737
- >
738
- <SelectTrigger className="w-full sm:w-[280px]">
739
- <SelectValue placeholder={t('filters.selectAccount')} />
740
- </SelectTrigger>
741
- <SelectContent>
742
- {contasBancarias.map((conta) => (
743
- <SelectItem key={conta.id} value={conta.id}>
744
- {conta.banco} - {conta.descricao}
745
- </SelectItem>
746
- ))}
747
- </SelectContent>
748
- </Select>
749
- <div className="flex-1">
750
- <FilterBar
751
- searchPlaceholder={t('filters.searchPlaceholder')}
752
- searchValue={search}
753
- onSearchChange={setSearch}
754
- />
755
- </div>
756
- </div>
757
-
758
- <div className="grid gap-4 md:grid-cols-3">
759
- <Card>
760
- <CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
761
- <CardTitle className="text-sm font-medium">
762
- {t('cards.inflows')}
763
- </CardTitle>
764
- <ArrowUpRight className="h-4 w-4 text-green-500" />
765
- </CardHeader>
766
- <CardContent>
767
- <div className="text-2xl font-bold text-green-600">
768
- <Money value={totalEntradas} />
769
- </div>
770
- </CardContent>
771
- </Card>
772
- <Card>
773
- <CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
774
- <CardTitle className="text-sm font-medium">
775
- {t('cards.outflows')}
776
- </CardTitle>
777
- <ArrowDownRight className="h-4 w-4 text-red-500" />
778
- </CardHeader>
779
- <CardContent>
780
- <div className="text-2xl font-bold text-red-600">
781
- <Money value={totalSaidas} />
782
- </div>
783
- </CardContent>
784
- </Card>
785
- <Card>
786
- <CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
787
- <CardTitle className="text-sm font-medium">
788
- {t('cards.accountBalance')}
789
- </CardTitle>
790
- </CardHeader>
791
- <CardContent>
792
- <div className="text-2xl font-bold">
793
- <Money value={conta?.saldoAtual || 0} />
794
- </div>
795
- <p className="text-xs text-muted-foreground">{conta?.descricao}</p>
796
- </CardContent>
797
- </Card>
798
- </div>
799
-
800
- <Card>
801
- <CardHeader>
802
- <CardTitle>{t('table.title')}</CardTitle>
803
- <CardDescription>
804
- {t('table.foundTransactions', { count: extratos.length })}
805
- </CardDescription>
806
- </CardHeader>
807
- <CardContent>
808
- <div className="overflow-x-auto">
809
- <Table className="min-w-[760px] table-fixed">
810
- <TableHeader>
811
- <TableRow>
812
- <TableHead className="w-[110px]">
813
- {t('table.headers.date')}
814
- </TableHead>
815
- <TableHead>{t('table.headers.description')}</TableHead>
816
- <TableHead className="w-[130px] text-right">
817
- {t('table.headers.value')}
818
- </TableHead>
819
- <TableHead className="w-[110px]">
820
- {t('table.headers.type')}
821
- </TableHead>
822
- <TableHead className="w-[140px]">
823
- {t('table.headers.reconciliation')}
824
- </TableHead>
825
- </TableRow>
826
- </TableHeader>
827
- <TableBody>
828
- {extratos.map((extrato) => (
829
- <TableRow
830
- key={extrato.id}
831
- className="cursor-pointer"
832
- onClick={() => setExtratoSelecionado(extrato)}
833
- onKeyDown={(event) => {
834
- if (event.key === 'Enter' || event.key === ' ') {
835
- event.preventDefault();
836
- setExtratoSelecionado(extrato);
837
- }
838
- }}
839
- role="button"
840
- tabIndex={0}
841
- >
842
- <TableCell>{formatarData(extrato.data)}</TableCell>
843
- <TableCell className="truncate" title={extrato.descricao}>
844
- {extrato.descricao}
845
- </TableCell>
846
- <TableCell className="text-right">
847
- <span
848
- className={
849
- extrato.tipo === 'entrada'
850
- ? 'text-green-600'
851
- : 'text-red-600'
852
- }
853
- >
854
- <Money value={extrato.valor} />
855
- </span>
856
- </TableCell>
857
- <TableCell>
858
- {extrato.tipo === 'entrada' ? (
859
- <span className="flex items-center gap-1 text-green-600">
860
- <ArrowUpRight className="h-4 w-4" />
861
- {t('types.inflow')}
862
- </span>
863
- ) : (
864
- <span className="flex items-center gap-1 text-red-600">
865
- <ArrowDownRight className="h-4 w-4" />
866
- {t('types.outflow')}
867
- </span>
868
- )}
869
- </TableCell>
870
- <TableCell>
871
- <StatusBadge
872
- status={extrato.statusConciliacao}
873
- type="conciliacao"
874
- />
875
- </TableCell>
876
- </TableRow>
877
- ))}
878
- </TableBody>
879
- </Table>
880
- </div>
881
- </CardContent>
882
- </Card>
883
-
884
- <Dialog
885
- open={!!extratoSelecionado}
886
- onOpenChange={(open) => {
887
- if (!open) {
888
- setExtratoSelecionado(null);
889
- }
890
- }}
891
- >
892
- <DialogContent className="sm:max-w-lg">
893
- <DialogHeader>
894
- <DialogTitle>{t('table.title')}</DialogTitle>
895
- <DialogDescription>{t('header.description')}</DialogDescription>
896
- </DialogHeader>
897
-
898
- {extratoSelecionado ? (
899
- <div className="space-y-4 rounded-md border p-4">
900
- <div className="text-center">
901
- <p className="text-sm text-muted-foreground">
902
- {t('table.headers.reconciliation')}
903
- </p>
904
- <div className="mt-1 flex justify-center">
905
- <StatusBadge
906
- status={extratoSelecionado.statusConciliacao}
907
- type="conciliacao"
908
- />
909
- </div>
910
- </div>
911
-
912
- <div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
913
- <div>
914
- <p className="text-xs text-muted-foreground">
915
- {t('table.headers.date')}
916
- </p>
917
- <p className="font-medium">
918
- {formatarData(extratoSelecionado.data)}
919
- </p>
920
- </div>
921
- <div>
922
- <p className="text-xs text-muted-foreground">
923
- {t('table.headers.type')}
924
- </p>
925
- <p className="font-medium">
926
- {extratoSelecionado.tipo === 'entrada'
927
- ? t('types.inflow')
928
- : t('types.outflow')}
929
- </p>
930
- </div>
931
- <div className="sm:col-span-2">
932
- <p className="text-xs text-muted-foreground">
933
- {t('table.headers.description')}
934
- </p>
935
- <p className="font-medium wrap-break-word">
936
- {extratoSelecionado.descricao}
937
- </p>
938
- </div>
939
- <div>
940
- <p className="text-xs text-muted-foreground">
941
- {t('table.headers.value')}
942
- </p>
943
- <p
944
- className={`font-medium ${
945
- extratoSelecionado.tipo === 'entrada'
946
- ? 'text-green-600'
947
- : 'text-red-600'
948
- }`}
949
- >
950
- <Money value={extratoSelecionado.valor} />
951
- </p>
952
- </div>
953
- <div>
954
- <p className="text-xs text-muted-foreground">
955
- {t('importDialog.bankAccount')}
956
- </p>
957
- <p className="font-medium">
958
- {contaExtratoSelecionado
959
- ? `${contaExtratoSelecionado.banco} - ${contaExtratoSelecionado.descricao}`
960
- : '-'}
961
- </p>
962
- </div>
963
- <div className="sm:col-span-2">
964
- <p className="text-xs text-muted-foreground">ID</p>
965
- <p className="font-mono text-xs break-all">
966
- {extratoSelecionado.id}
967
- </p>
968
- </div>
969
- </div>
970
- </div>
971
- ) : null}
972
-
973
- <DialogFooter>
974
- <Button
975
- type="button"
976
- variant="outline"
977
- onClick={() => setExtratoSelecionado(null)}
978
- >
979
- {t('common.cancel')}
980
- </Button>
981
- </DialogFooter>
982
- </DialogContent>
983
- </Dialog>
984
- </Page>
985
- );
986
- }
1
+ 'use client';
2
+
3
+ import { Page, PageHeader } from '@/components/entity-list';
4
+ import { Button } from '@/components/ui/button';
5
+ import {
6
+ Card,
7
+ CardContent,
8
+ CardDescription,
9
+ CardHeader,
10
+ CardTitle,
11
+ } from '@/components/ui/card';
12
+ import {
13
+ Dialog,
14
+ DialogContent,
15
+ DialogDescription,
16
+ DialogFooter,
17
+ DialogHeader,
18
+ DialogTitle,
19
+ } from '@/components/ui/dialog';
20
+ import { FilterBar } from '@/components/ui/filter-bar';
21
+ import {
22
+ Form,
23
+ FormControl,
24
+ FormField,
25
+ FormItem,
26
+ FormLabel,
27
+ FormMessage,
28
+ } from '@/components/ui/form';
29
+ import { Input } from '@/components/ui/input';
30
+ import { InputMoney } from '@/components/ui/input-money';
31
+ import { Money } from '@/components/ui/money';
32
+ import {
33
+ Select,
34
+ SelectContent,
35
+ SelectItem,
36
+ SelectTrigger,
37
+ SelectValue,
38
+ } from '@/components/ui/select';
39
+ import {
40
+ Sheet,
41
+ SheetContent,
42
+ SheetDescription,
43
+ SheetHeader,
44
+ SheetTitle,
45
+ } from '@/components/ui/sheet';
46
+ import { StatusBadge } from '@/components/ui/status-badge';
47
+ import {
48
+ Table,
49
+ TableBody,
50
+ TableCell,
51
+ TableHead,
52
+ TableHeader,
53
+ TableRow,
54
+ } from '@/components/ui/table';
55
+ import { useApp, useQuery } from '@hed-hog/next-app-provider';
56
+ import { zodResolver } from '@hookform/resolvers/zod';
57
+ import {
58
+ ArrowDownRight,
59
+ ArrowUpRight,
60
+ Download,
61
+ Plus,
62
+ Upload,
63
+ } from 'lucide-react';
64
+ import { useTranslations } from 'next-intl';
65
+ import { usePathname, useRouter, useSearchParams } from 'next/navigation';
66
+ import { useEffect, useState } from 'react';
67
+ import { useForm } from 'react-hook-form';
68
+ import { z } from 'zod';
69
+ import { formatarData } from '../../_lib/formatters';
70
+
71
+ type BankAccount = {
72
+ id: string;
73
+ banco: string;
74
+ descricao: string;
75
+ saldoAtual: number;
76
+ };
77
+
78
+ type Statement = {
79
+ id: string;
80
+ contaBancariaId: string;
81
+ data: string;
82
+ descricao: string;
83
+ valor: number;
84
+ tipo: 'entrada' | 'saida';
85
+ statusConciliacao:
86
+ | 'importado'
87
+ | 'pendente'
88
+ | 'conciliado'
89
+ | 'estornado'
90
+ | 'ajustado';
91
+ };
92
+
93
+ const bankAccountFormSchema = z.object({
94
+ banco: z.string().trim().min(1, 'Banco é obrigatório'),
95
+ agencia: z.string().optional(),
96
+ conta: z.string().optional(),
97
+ tipo: z.string().min(1, 'Tipo é obrigatório'),
98
+ descricao: z.string().optional(),
99
+ saldoInicial: z.number().min(0, 'Saldo inicial inválido'),
100
+ });
101
+
102
+ const importStatementSchema = z.object({
103
+ bankAccountId: z.string().trim().min(1, 'Conta bancária é obrigatória'),
104
+ file: z.instanceof(File, { message: 'Arquivo é obrigatório' }).refine(
105
+ (value) => {
106
+ const fileName = value.name.toLowerCase();
107
+ return fileName.endsWith('.csv') || fileName.endsWith('.ofx');
108
+ },
109
+ { message: 'Apenas arquivos CSV ou OFX são permitidos' }
110
+ ),
111
+ });
112
+
113
+ type ImportStatementFormValues = z.infer<typeof importStatementSchema>;
114
+ type BankAccountFormValues = z.infer<typeof bankAccountFormSchema>;
115
+
116
+ function NovaContaBancariaSheet({
117
+ open,
118
+ onOpenChange,
119
+ onCreated,
120
+ }: {
121
+ open: boolean;
122
+ onOpenChange: (open: boolean) => void;
123
+ onCreated: (createdBankAccountId?: string) => Promise<void> | void;
124
+ }) {
125
+ const tBank = useTranslations('finance.BankAccountsPage');
126
+ const { request, showToastHandler } = useApp();
127
+
128
+ const form = useForm<BankAccountFormValues>({
129
+ resolver: zodResolver(bankAccountFormSchema),
130
+ defaultValues: {
131
+ banco: '',
132
+ agencia: '',
133
+ conta: '',
134
+ tipo: '',
135
+ descricao: '',
136
+ saldoInicial: 0,
137
+ },
138
+ });
139
+
140
+ useEffect(() => {
141
+ if (!open) {
142
+ return;
143
+ }
144
+
145
+ form.reset({
146
+ banco: '',
147
+ agencia: '',
148
+ conta: '',
149
+ tipo: '',
150
+ descricao: '',
151
+ saldoInicial: 0,
152
+ });
153
+ }, [form, open]);
154
+
155
+ const handleSubmit = async (values: BankAccountFormValues) => {
156
+ try {
157
+ const response = await request<{ id?: string | number }>({
158
+ url: '/finance/bank-accounts',
159
+ method: 'POST',
160
+ data: {
161
+ bank: values.banco,
162
+ branch: values.agencia || undefined,
163
+ account: values.conta || undefined,
164
+ type: values.tipo,
165
+ description: values.descricao?.trim() || undefined,
166
+ initial_balance: values.saldoInicial,
167
+ },
168
+ });
169
+
170
+ const createdBankAccountId = response?.data?.id;
171
+
172
+ await onCreated(
173
+ createdBankAccountId !== undefined
174
+ ? String(createdBankAccountId)
175
+ : undefined
176
+ );
177
+ onOpenChange(false);
178
+ showToastHandler?.(
179
+ 'success',
180
+ tBank.has('messages.createSuccess')
181
+ ? tBank('messages.createSuccess')
182
+ : 'Conta bancária cadastrada com sucesso'
183
+ );
184
+ } catch {
185
+ showToastHandler?.(
186
+ 'error',
187
+ tBank.has('messages.createError')
188
+ ? tBank('messages.createError')
189
+ : 'Erro ao cadastrar conta bancária'
190
+ );
191
+ }
192
+ };
193
+
194
+ return (
195
+ <Sheet open={open} onOpenChange={onOpenChange}>
196
+ <SheetContent className="w-full sm:max-w-lg overflow-y-auto">
197
+ <SheetHeader>
198
+ <SheetTitle>{tBank('newAccount.title')}</SheetTitle>
199
+ <SheetDescription>{tBank('newAccount.description')}</SheetDescription>
200
+ </SheetHeader>
201
+
202
+ <Form {...form}>
203
+ <form
204
+ className="space-y-4 px-4"
205
+ onSubmit={form.handleSubmit(handleSubmit)}
206
+ >
207
+ <FormField
208
+ control={form.control}
209
+ name="banco"
210
+ render={({ field }) => (
211
+ <FormItem>
212
+ <FormLabel>{tBank('fields.bank')}</FormLabel>
213
+ <FormControl>
214
+ <Input
215
+ placeholder={tBank('fields.bankPlaceholder')}
216
+ {...field}
217
+ />
218
+ </FormControl>
219
+ <FormMessage />
220
+ </FormItem>
221
+ )}
222
+ />
223
+
224
+ <div className="grid grid-cols-2 gap-4">
225
+ <FormField
226
+ control={form.control}
227
+ name="agencia"
228
+ render={({ field }) => (
229
+ <FormItem>
230
+ <FormLabel>{tBank('fields.branch')}</FormLabel>
231
+ <FormControl>
232
+ <Input
233
+ placeholder="0000"
234
+ {...field}
235
+ value={field.value || ''}
236
+ />
237
+ </FormControl>
238
+ <FormMessage />
239
+ </FormItem>
240
+ )}
241
+ />
242
+
243
+ <FormField
244
+ control={form.control}
245
+ name="conta"
246
+ render={({ field }) => (
247
+ <FormItem>
248
+ <FormLabel>{tBank('fields.account')}</FormLabel>
249
+ <FormControl>
250
+ <Input
251
+ placeholder="00000-0"
252
+ {...field}
253
+ value={field.value || ''}
254
+ />
255
+ </FormControl>
256
+ <FormMessage />
257
+ </FormItem>
258
+ )}
259
+ />
260
+ </div>
261
+
262
+ <div className="grid grid-cols-2 gap-4">
263
+ <FormField
264
+ control={form.control}
265
+ name="tipo"
266
+ render={({ field }) => (
267
+ <FormItem>
268
+ <FormLabel>{tBank('fields.type')}</FormLabel>
269
+ <Select value={field.value} onValueChange={field.onChange}>
270
+ <FormControl>
271
+ <SelectTrigger className="w-full">
272
+ <SelectValue placeholder={tBank('common.select')} />
273
+ </SelectTrigger>
274
+ </FormControl>
275
+ <SelectContent>
276
+ <SelectItem value="corrente">
277
+ {tBank('types.corrente')}
278
+ </SelectItem>
279
+ <SelectItem value="poupanca">
280
+ {tBank('types.poupanca')}
281
+ </SelectItem>
282
+ <SelectItem value="investimento">
283
+ {tBank('types.investimento')}
284
+ </SelectItem>
285
+ <SelectItem value="caixa">
286
+ {tBank('types.caixa')}
287
+ </SelectItem>
288
+ </SelectContent>
289
+ </Select>
290
+ <FormMessage />
291
+ </FormItem>
292
+ )}
293
+ />
294
+
295
+ <FormField
296
+ control={form.control}
297
+ name="saldoInicial"
298
+ render={({ field }) => (
299
+ <FormItem>
300
+ <FormLabel>{tBank('fields.initialBalance')}</FormLabel>
301
+ <FormControl>
302
+ <InputMoney
303
+ ref={field.ref}
304
+ name={field.name}
305
+ value={field.value}
306
+ onBlur={field.onBlur}
307
+ onValueChange={(value) => field.onChange(value ?? 0)}
308
+ placeholder="0,00"
309
+ />
310
+ </FormControl>
311
+ <FormMessage />
312
+ </FormItem>
313
+ )}
314
+ />
315
+ </div>
316
+
317
+ <FormField
318
+ control={form.control}
319
+ name="descricao"
320
+ render={({ field }) => (
321
+ <FormItem>
322
+ <FormLabel>{tBank('fields.description')}</FormLabel>
323
+ <FormControl>
324
+ <Input
325
+ placeholder={tBank('fields.descriptionPlaceholder')}
326
+ {...field}
327
+ value={field.value || ''}
328
+ />
329
+ </FormControl>
330
+ <FormMessage />
331
+ </FormItem>
332
+ )}
333
+ />
334
+
335
+ <div className="flex justify-end gap-2 pt-2">
336
+ <Button
337
+ type="button"
338
+ variant="outline"
339
+ onClick={() => onOpenChange(false)}
340
+ >
341
+ {tBank('common.cancel')}
342
+ </Button>
343
+ <Button type="submit" disabled={form.formState.isSubmitting}>
344
+ {tBank('common.save')}
345
+ </Button>
346
+ </div>
347
+ </form>
348
+ </Form>
349
+ </SheetContent>
350
+ </Sheet>
351
+ );
352
+ }
353
+
354
+ function ImportarExtratoSheet({
355
+ contasBancarias,
356
+ t,
357
+ defaultBankAccountId,
358
+ onImported,
359
+ onBankAccountCreated,
360
+ }: {
361
+ contasBancarias: BankAccount[];
362
+ t: ReturnType<typeof useTranslations>;
363
+ defaultBankAccountId?: string;
364
+ onImported: () => Promise<any> | void;
365
+ onBankAccountCreated: (createdBankAccountId?: string) => Promise<void> | void;
366
+ }) {
367
+ const { request, showToastHandler } = useApp();
368
+ const [open, setOpen] = useState(false);
369
+ const [openNovaContaSheet, setOpenNovaContaSheet] = useState(false);
370
+
371
+ const form = useForm<ImportStatementFormValues>({
372
+ resolver: zodResolver(importStatementSchema),
373
+ defaultValues: {
374
+ bankAccountId: defaultBankAccountId || '',
375
+ },
376
+ });
377
+
378
+ useEffect(() => {
379
+ if (!open) {
380
+ return;
381
+ }
382
+
383
+ form.reset({
384
+ bankAccountId: defaultBankAccountId || '',
385
+ file: undefined as unknown as File,
386
+ });
387
+ }, [defaultBankAccountId, form, open]);
388
+
389
+ const handleSubmit = async (values: ImportStatementFormValues) => {
390
+ const formData = new FormData();
391
+ formData.append('bank_account_id', values.bankAccountId);
392
+ formData.append('file', values.file);
393
+
394
+ try {
395
+ await request({
396
+ url: '/finance/statements/import',
397
+ method: 'POST',
398
+ data: formData,
399
+ });
400
+
401
+ await onImported();
402
+ showToastHandler?.('success', 'Extrato importado com sucesso');
403
+ setOpen(false);
404
+ } catch {
405
+ showToastHandler?.('error', 'Não foi possível importar o extrato');
406
+ }
407
+ };
408
+
409
+ return (
410
+ <Sheet open={open} onOpenChange={setOpen}>
411
+ <Button onClick={() => setOpen(true)}>
412
+ <Upload className="mr-2 h-4 w-4" />
413
+ {t('importDialog.action')}
414
+ </Button>
415
+
416
+ <SheetContent className="w-full sm:max-w-lg overflow-y-auto">
417
+ <SheetHeader>
418
+ <SheetTitle>{t('importDialog.title')}</SheetTitle>
419
+ <SheetDescription>{t('importDialog.description')}</SheetDescription>
420
+ </SheetHeader>
421
+
422
+ <Form {...form}>
423
+ <form
424
+ className="space-y-4 px-4"
425
+ onSubmit={form.handleSubmit(handleSubmit)}
426
+ >
427
+ <FormField
428
+ control={form.control}
429
+ name="bankAccountId"
430
+ render={({ field }) => (
431
+ <FormItem>
432
+ <FormLabel>{t('importDialog.bankAccount')}</FormLabel>
433
+ <div className="flex items-center gap-2">
434
+ <div className="flex-1">
435
+ <Select
436
+ value={field.value}
437
+ onValueChange={field.onChange}
438
+ >
439
+ <FormControl>
440
+ <SelectTrigger className="w-full">
441
+ <SelectValue
442
+ placeholder={t('importDialog.selectAccount')}
443
+ />
444
+ </SelectTrigger>
445
+ </FormControl>
446
+ <SelectContent>
447
+ {contasBancarias.map((conta) => (
448
+ <SelectItem key={conta.id} value={conta.id}>
449
+ {conta.banco} - {conta.descricao}
450
+ </SelectItem>
451
+ ))}
452
+ </SelectContent>
453
+ </Select>
454
+ </div>
455
+ <Button
456
+ type="button"
457
+ variant="outline"
458
+ size="icon"
459
+ onClick={() => setOpenNovaContaSheet(true)}
460
+ aria-label="Nova conta bancária"
461
+ >
462
+ <Plus className="h-4 w-4" />
463
+ </Button>
464
+ </div>
465
+ <FormMessage />
466
+ </FormItem>
467
+ )}
468
+ />
469
+
470
+ <NovaContaBancariaSheet
471
+ open={openNovaContaSheet}
472
+ onOpenChange={setOpenNovaContaSheet}
473
+ onCreated={async (createdBankAccountId) => {
474
+ await onBankAccountCreated(createdBankAccountId);
475
+
476
+ if (createdBankAccountId) {
477
+ form.setValue('bankAccountId', createdBankAccountId, {
478
+ shouldDirty: true,
479
+ shouldValidate: true,
480
+ });
481
+ }
482
+ }}
483
+ />
484
+
485
+ <FormField
486
+ control={form.control}
487
+ name="file"
488
+ render={({ field }) => (
489
+ <FormItem>
490
+ <FormLabel>{t('importDialog.file')}</FormLabel>
491
+ <FormControl>
492
+ <Input
493
+ type="file"
494
+ accept=".ofx,.csv"
495
+ onChange={(event) => {
496
+ const selectedFile = event.target.files?.[0];
497
+ field.onChange(selectedFile);
498
+ }}
499
+ />
500
+ </FormControl>
501
+ <p className="text-xs text-muted-foreground">
502
+ {t('importDialog.acceptedFormats')}
503
+ </p>
504
+ <FormMessage />
505
+ </FormItem>
506
+ )}
507
+ />
508
+
509
+ <div className="flex justify-end gap-2 pt-2">
510
+ <Button
511
+ type="button"
512
+ variant="outline"
513
+ onClick={() => setOpen(false)}
514
+ >
515
+ {t('common.cancel')}
516
+ </Button>
517
+ <Button type="submit" disabled={form.formState.isSubmitting}>
518
+ {t('importDialog.submit')}
519
+ </Button>
520
+ </div>
521
+ </form>
522
+ </Form>
523
+ </SheetContent>
524
+ </Sheet>
525
+ );
526
+ }
527
+
528
+ export default function ExtratosPage() {
529
+ const t = useTranslations('finance.StatementsPage');
530
+ const { request, showToastHandler } = useApp();
531
+ const pathname = usePathname();
532
+ const router = useRouter();
533
+ const searchParams = useSearchParams();
534
+ const bankAccountIdFromUrl = searchParams.get('bank_account_id');
535
+
536
+ const [contaFilter, setContaFilter] = useState<string>('');
537
+ const [search, setSearch] = useState('');
538
+ const [debouncedSearch, setDebouncedSearch] = useState('');
539
+ const [extratoSelecionado, setExtratoSelecionado] =
540
+ useState<Statement | null>(null);
541
+
542
+ useEffect(() => {
543
+ const timeoutId = window.setTimeout(() => {
544
+ setDebouncedSearch(search);
545
+ }, 300);
546
+
547
+ return () => {
548
+ window.clearTimeout(timeoutId);
549
+ };
550
+ }, [search]);
551
+
552
+ const { data: contasBancarias = [], refetch: refetchContasBancarias } =
553
+ useQuery<BankAccount[]>({
554
+ queryKey: ['finance-bank-accounts'],
555
+ queryFn: async () => {
556
+ const response = await request({
557
+ url: '/finance/bank-accounts',
558
+ method: 'GET',
559
+ });
560
+
561
+ return (response?.data || []) as BankAccount[];
562
+ },
563
+ });
564
+
565
+ const handleBankAccountCreated = async (createdBankAccountId?: string) => {
566
+ await refetchContasBancarias();
567
+
568
+ if (!createdBankAccountId) {
569
+ return;
570
+ }
571
+
572
+ setContaFilter(createdBankAccountId);
573
+
574
+ const params = new URLSearchParams(searchParams.toString());
575
+ params.set('bank_account_id', createdBankAccountId);
576
+ router.replace(`${pathname}?${params.toString()}`);
577
+ };
578
+
579
+ useEffect(() => {
580
+ const firstAccount = contasBancarias[0];
581
+
582
+ if (!firstAccount) {
583
+ return;
584
+ }
585
+
586
+ const hasAccountFromUrl =
587
+ !!bankAccountIdFromUrl &&
588
+ contasBancarias.some((account) => account.id === bankAccountIdFromUrl);
589
+
590
+ const nextAccountId = hasAccountFromUrl
591
+ ? (bankAccountIdFromUrl as string)
592
+ : firstAccount.id;
593
+
594
+ if (contaFilter !== nextAccountId) {
595
+ setContaFilter(nextAccountId);
596
+ }
597
+
598
+ if (bankAccountIdFromUrl !== nextAccountId) {
599
+ const params = new URLSearchParams(searchParams.toString());
600
+ params.set('bank_account_id', nextAccountId);
601
+ router.replace(`${pathname}?${params.toString()}`);
602
+ }
603
+ }, [
604
+ bankAccountIdFromUrl,
605
+ contaFilter,
606
+ contasBancarias,
607
+ pathname,
608
+ router,
609
+ searchParams,
610
+ ]);
611
+
612
+ const { data: extratos = [], refetch: refetchExtratos } = useQuery<
613
+ Statement[]
614
+ >({
615
+ queryKey: ['finance-statements', contaFilter, debouncedSearch],
616
+ queryFn: async () => {
617
+ if (!contaFilter) {
618
+ return [];
619
+ }
620
+
621
+ const params = new URLSearchParams();
622
+ params.set('bank_account_id', contaFilter);
623
+
624
+ const trimmedSearch = debouncedSearch.trim();
625
+ if (trimmedSearch) {
626
+ params.set('search', trimmedSearch);
627
+ }
628
+
629
+ const response = await request({
630
+ url: `/finance/statements?${params.toString()}`,
631
+ method: 'GET',
632
+ });
633
+
634
+ return (response?.data || []) as Statement[];
635
+ },
636
+ });
637
+
638
+ const conta = contasBancarias.find((item) => item.id === contaFilter);
639
+ const contaExtratoSelecionado = extratoSelecionado
640
+ ? contasBancarias.find(
641
+ (item) => item.id === extratoSelecionado.contaBancariaId
642
+ )
643
+ : undefined;
644
+ const totalEntradas = extratos
645
+ .filter((e) => e.tipo === 'entrada')
646
+ .reduce((acc, e) => acc + e.valor, 0);
647
+ const totalSaidas = extratos
648
+ .filter((e) => e.tipo === 'saida')
649
+ .reduce((acc, e) => acc + e.valor, 0);
650
+
651
+ const handleExport = async () => {
652
+ if (!contaFilter) {
653
+ showToastHandler?.('error', 'Selecione uma conta bancária para exportar');
654
+ return;
655
+ }
656
+
657
+ try {
658
+ const params = new URLSearchParams();
659
+ params.set('bank_account_id', contaFilter);
660
+
661
+ const trimmedSearch = search.trim();
662
+ if (trimmedSearch) {
663
+ params.set('search', trimmedSearch);
664
+ }
665
+
666
+ const response = await request<Blob>({
667
+ url: `/finance/statements/export?${params.toString()}`,
668
+ method: 'GET',
669
+ responseType: 'blob',
670
+ });
671
+
672
+ const contentDisposition =
673
+ response.headers?.['content-disposition'] ||
674
+ response.headers?.['Content-Disposition'];
675
+ const fileNameMatch =
676
+ typeof contentDisposition === 'string'
677
+ ? contentDisposition.match(/filename\*?=(?:UTF-8'')?"?([^";]+)"?/i)
678
+ : null;
679
+ const fileName = fileNameMatch?.[1]
680
+ ? decodeURIComponent(fileNameMatch[1])
681
+ : `extrato-bancario-${contaFilter}.csv`;
682
+
683
+ const blob = new Blob([response.data], {
684
+ type: 'text/csv;charset=utf-8;',
685
+ });
686
+ const url = window.URL.createObjectURL(blob);
687
+ const link = document.createElement('a');
688
+
689
+ link.href = url;
690
+ link.setAttribute('download', fileName);
691
+ document.body.appendChild(link);
692
+ link.click();
693
+ link.remove();
694
+ window.URL.revokeObjectURL(url);
695
+ } catch {
696
+ showToastHandler?.('error', 'Não foi possível exportar o extrato');
697
+ }
698
+ };
699
+
700
+ return (
701
+ <Page>
702
+ <PageHeader
703
+ title={t('header.title')}
704
+ description={t('header.description')}
705
+ breadcrumbs={[
706
+ { label: t('breadcrumbs.home'), href: '/' },
707
+ { label: t('breadcrumbs.finance'), href: '/finance' },
708
+ { label: t('breadcrumbs.current') },
709
+ ]}
710
+ actions={
711
+ <div className="flex gap-2">
712
+ <Button variant="outline" onClick={() => void handleExport()}>
713
+ <Download className="mr-2 h-4 w-4" />
714
+ {t('actions.export')}
715
+ </Button>
716
+ <ImportarExtratoSheet
717
+ contasBancarias={contasBancarias}
718
+ t={t}
719
+ defaultBankAccountId={contaFilter}
720
+ onImported={refetchExtratos}
721
+ onBankAccountCreated={handleBankAccountCreated}
722
+ />
723
+ </div>
724
+ }
725
+ />
726
+
727
+ <div className="flex flex-col gap-4 sm:flex-row sm:items-center">
728
+ <Select
729
+ value={contaFilter}
730
+ onValueChange={(value) => {
731
+ setContaFilter(value);
732
+
733
+ const params = new URLSearchParams(searchParams.toString());
734
+ params.set('bank_account_id', value);
735
+ router.replace(`${pathname}?${params.toString()}`);
736
+ }}
737
+ >
738
+ <SelectTrigger className="w-full sm:w-[280px]">
739
+ <SelectValue placeholder={t('filters.selectAccount')} />
740
+ </SelectTrigger>
741
+ <SelectContent>
742
+ {contasBancarias.map((conta) => (
743
+ <SelectItem key={conta.id} value={conta.id}>
744
+ {conta.banco} - {conta.descricao}
745
+ </SelectItem>
746
+ ))}
747
+ </SelectContent>
748
+ </Select>
749
+ <div className="flex-1">
750
+ <FilterBar
751
+ searchPlaceholder={t('filters.searchPlaceholder')}
752
+ searchValue={search}
753
+ onSearchChange={setSearch}
754
+ />
755
+ </div>
756
+ </div>
757
+
758
+ <div className="grid gap-4 md:grid-cols-3">
759
+ <Card>
760
+ <CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
761
+ <CardTitle className="text-sm font-medium">
762
+ {t('cards.inflows')}
763
+ </CardTitle>
764
+ <ArrowUpRight className="h-4 w-4 text-green-500" />
765
+ </CardHeader>
766
+ <CardContent>
767
+ <div className="text-2xl font-bold text-green-600">
768
+ <Money value={totalEntradas} />
769
+ </div>
770
+ </CardContent>
771
+ </Card>
772
+ <Card>
773
+ <CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
774
+ <CardTitle className="text-sm font-medium">
775
+ {t('cards.outflows')}
776
+ </CardTitle>
777
+ <ArrowDownRight className="h-4 w-4 text-red-500" />
778
+ </CardHeader>
779
+ <CardContent>
780
+ <div className="text-2xl font-bold text-red-600">
781
+ <Money value={totalSaidas} />
782
+ </div>
783
+ </CardContent>
784
+ </Card>
785
+ <Card>
786
+ <CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
787
+ <CardTitle className="text-sm font-medium">
788
+ {t('cards.accountBalance')}
789
+ </CardTitle>
790
+ </CardHeader>
791
+ <CardContent>
792
+ <div className="text-2xl font-bold">
793
+ <Money value={conta?.saldoAtual || 0} />
794
+ </div>
795
+ <p className="text-xs text-muted-foreground">{conta?.descricao}</p>
796
+ </CardContent>
797
+ </Card>
798
+ </div>
799
+
800
+ <Card>
801
+ <CardHeader>
802
+ <CardTitle>{t('table.title')}</CardTitle>
803
+ <CardDescription>
804
+ {t('table.foundTransactions', { count: extratos.length })}
805
+ </CardDescription>
806
+ </CardHeader>
807
+ <CardContent>
808
+ <div className="overflow-x-auto">
809
+ <Table className="min-w-[760px] table-fixed">
810
+ <TableHeader>
811
+ <TableRow>
812
+ <TableHead className="w-[110px]">
813
+ {t('table.headers.date')}
814
+ </TableHead>
815
+ <TableHead>{t('table.headers.description')}</TableHead>
816
+ <TableHead className="w-[130px] text-right">
817
+ {t('table.headers.value')}
818
+ </TableHead>
819
+ <TableHead className="w-[110px]">
820
+ {t('table.headers.type')}
821
+ </TableHead>
822
+ <TableHead className="w-[140px]">
823
+ {t('table.headers.reconciliation')}
824
+ </TableHead>
825
+ </TableRow>
826
+ </TableHeader>
827
+ <TableBody>
828
+ {extratos.map((extrato) => (
829
+ <TableRow
830
+ key={extrato.id}
831
+ className="cursor-pointer"
832
+ onClick={() => setExtratoSelecionado(extrato)}
833
+ onKeyDown={(event) => {
834
+ if (event.key === 'Enter' || event.key === ' ') {
835
+ event.preventDefault();
836
+ setExtratoSelecionado(extrato);
837
+ }
838
+ }}
839
+ role="button"
840
+ tabIndex={0}
841
+ >
842
+ <TableCell>{formatarData(extrato.data)}</TableCell>
843
+ <TableCell className="truncate" title={extrato.descricao}>
844
+ {extrato.descricao}
845
+ </TableCell>
846
+ <TableCell className="text-right">
847
+ <span
848
+ className={
849
+ extrato.tipo === 'entrada'
850
+ ? 'text-green-600'
851
+ : 'text-red-600'
852
+ }
853
+ >
854
+ <Money value={extrato.valor} />
855
+ </span>
856
+ </TableCell>
857
+ <TableCell>
858
+ {extrato.tipo === 'entrada' ? (
859
+ <span className="flex items-center gap-1 text-green-600">
860
+ <ArrowUpRight className="h-4 w-4" />
861
+ {t('types.inflow')}
862
+ </span>
863
+ ) : (
864
+ <span className="flex items-center gap-1 text-red-600">
865
+ <ArrowDownRight className="h-4 w-4" />
866
+ {t('types.outflow')}
867
+ </span>
868
+ )}
869
+ </TableCell>
870
+ <TableCell>
871
+ <StatusBadge
872
+ status={extrato.statusConciliacao}
873
+ type="conciliacao"
874
+ />
875
+ </TableCell>
876
+ </TableRow>
877
+ ))}
878
+ </TableBody>
879
+ </Table>
880
+ </div>
881
+ </CardContent>
882
+ </Card>
883
+
884
+ <Dialog
885
+ open={!!extratoSelecionado}
886
+ onOpenChange={(open) => {
887
+ if (!open) {
888
+ setExtratoSelecionado(null);
889
+ }
890
+ }}
891
+ >
892
+ <DialogContent className="sm:max-w-lg">
893
+ <DialogHeader>
894
+ <DialogTitle>{t('table.title')}</DialogTitle>
895
+ <DialogDescription>{t('header.description')}</DialogDescription>
896
+ </DialogHeader>
897
+
898
+ {extratoSelecionado ? (
899
+ <div className="space-y-4 rounded-md border p-4">
900
+ <div className="text-center">
901
+ <p className="text-sm text-muted-foreground">
902
+ {t('table.headers.reconciliation')}
903
+ </p>
904
+ <div className="mt-1 flex justify-center">
905
+ <StatusBadge
906
+ status={extratoSelecionado.statusConciliacao}
907
+ type="conciliacao"
908
+ />
909
+ </div>
910
+ </div>
911
+
912
+ <div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
913
+ <div>
914
+ <p className="text-xs text-muted-foreground">
915
+ {t('table.headers.date')}
916
+ </p>
917
+ <p className="font-medium">
918
+ {formatarData(extratoSelecionado.data)}
919
+ </p>
920
+ </div>
921
+ <div>
922
+ <p className="text-xs text-muted-foreground">
923
+ {t('table.headers.type')}
924
+ </p>
925
+ <p className="font-medium">
926
+ {extratoSelecionado.tipo === 'entrada'
927
+ ? t('types.inflow')
928
+ : t('types.outflow')}
929
+ </p>
930
+ </div>
931
+ <div className="sm:col-span-2">
932
+ <p className="text-xs text-muted-foreground">
933
+ {t('table.headers.description')}
934
+ </p>
935
+ <p className="font-medium wrap-break-word">
936
+ {extratoSelecionado.descricao}
937
+ </p>
938
+ </div>
939
+ <div>
940
+ <p className="text-xs text-muted-foreground">
941
+ {t('table.headers.value')}
942
+ </p>
943
+ <p
944
+ className={`font-medium ${
945
+ extratoSelecionado.tipo === 'entrada'
946
+ ? 'text-green-600'
947
+ : 'text-red-600'
948
+ }`}
949
+ >
950
+ <Money value={extratoSelecionado.valor} />
951
+ </p>
952
+ </div>
953
+ <div>
954
+ <p className="text-xs text-muted-foreground">
955
+ {t('importDialog.bankAccount')}
956
+ </p>
957
+ <p className="font-medium">
958
+ {contaExtratoSelecionado
959
+ ? `${contaExtratoSelecionado.banco} - ${contaExtratoSelecionado.descricao}`
960
+ : '-'}
961
+ </p>
962
+ </div>
963
+ <div className="sm:col-span-2">
964
+ <p className="text-xs text-muted-foreground">ID</p>
965
+ <p className="font-mono text-xs break-all">
966
+ {extratoSelecionado.id}
967
+ </p>
968
+ </div>
969
+ </div>
970
+ </div>
971
+ ) : null}
972
+
973
+ <DialogFooter>
974
+ <Button
975
+ type="button"
976
+ variant="outline"
977
+ onClick={() => setExtratoSelecionado(null)}
978
+ >
979
+ {t('common.cancel')}
980
+ </Button>
981
+ </DialogFooter>
982
+ </DialogContent>
983
+ </Dialog>
984
+ </Page>
985
+ );
986
+ }