@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.
- package/README.md +228 -126
- package/dist/dto/create-bank-reconciliation.dto.d.ts +8 -0
- package/dist/dto/create-bank-reconciliation.dto.d.ts.map +1 -0
- package/dist/dto/create-bank-reconciliation.dto.js +43 -0
- package/dist/dto/create-bank-reconciliation.dto.js.map +1 -0
- package/dist/finance-data.controller.d.ts +2 -0
- package/dist/finance-data.controller.d.ts.map +1 -1
- package/dist/finance-statements.controller.d.ts +42 -0
- package/dist/finance-statements.controller.d.ts.map +1 -1
- package/dist/finance-statements.controller.js +13 -0
- package/dist/finance-statements.controller.js.map +1 -1
- package/dist/finance.service.d.ts +44 -0
- package/dist/finance.service.d.ts.map +1 -1
- package/dist/finance.service.js +98 -9
- package/dist/finance.service.js.map +1 -1
- package/hedhog/data/route.yaml +9 -0
- package/hedhog/frontend/app/_components/person-field-with-create.tsx.ejs +126 -126
- package/hedhog/frontend/app/accounts-payable/approvals/page.tsx.ejs +373 -373
- package/hedhog/frontend/app/accounts-payable/installments/[id]/page.tsx.ejs +1270 -1270
- package/hedhog/frontend/app/accounts-receivable/installments/[id]/page.tsx.ejs +982 -982
- package/hedhog/frontend/app/cash-and-banks/bank-accounts/page.tsx.ejs +686 -686
- package/hedhog/frontend/app/cash-and-banks/bank-reconciliation/page.tsx.ejs +152 -32
- package/hedhog/frontend/app/cash-and-banks/statements/page.tsx.ejs +986 -986
- package/hedhog/frontend/app/cash-and-banks/transfers/page.tsx.ejs +492 -492
- package/hedhog/frontend/app/page.tsx.ejs +372 -372
- package/hedhog/frontend/app/planning/cash-flow-forecast/page.tsx.ejs +329 -329
- package/hedhog/frontend/app/planning/receivables-calendar/page.tsx.ejs +227 -227
- package/hedhog/frontend/app/planning/scenarios/page.tsx.ejs +408 -408
- package/hedhog/frontend/messages/en.json +15 -5
- package/hedhog/frontend/messages/pt.json +15 -5
- package/package.json +7 -7
- package/src/dto/create-bank-reconciliation.dto.ts +24 -0
- package/src/finance-statements.controller.ts +14 -0
- package/src/finance.module.ts +43 -43
- package/src/finance.service.ts +118 -0
- package/src/index.ts +14 -14
- package/dist/finance.controller.d.ts +0 -276
- package/dist/finance.controller.d.ts.map +0 -1
- package/dist/finance.controller.js +0 -110
- 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
|
+
}
|