@hed-hog/finance 0.0.235 → 0.0.237

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 (75) hide show
  1. package/dist/dto/create-cost-center.dto.d.ts +4 -0
  2. package/dist/dto/create-cost-center.dto.d.ts.map +1 -0
  3. package/dist/dto/create-cost-center.dto.js +24 -0
  4. package/dist/dto/create-cost-center.dto.js.map +1 -0
  5. package/dist/dto/create-finance-category.dto.d.ts +6 -0
  6. package/dist/dto/create-finance-category.dto.d.ts.map +1 -0
  7. package/dist/dto/create-finance-category.dto.js +37 -0
  8. package/dist/dto/create-finance-category.dto.js.map +1 -0
  9. package/dist/dto/create-period-close.dto.d.ts +7 -0
  10. package/dist/dto/create-period-close.dto.d.ts.map +1 -0
  11. package/dist/dto/create-period-close.dto.js +44 -0
  12. package/dist/dto/create-period-close.dto.js.map +1 -0
  13. package/dist/dto/move-finance-category.dto.d.ts +5 -0
  14. package/dist/dto/move-finance-category.dto.d.ts.map +1 -0
  15. package/dist/dto/move-finance-category.dto.js +32 -0
  16. package/dist/dto/move-finance-category.dto.js.map +1 -0
  17. package/dist/dto/update-cost-center.dto.d.ts +5 -0
  18. package/dist/dto/update-cost-center.dto.d.ts.map +1 -0
  19. package/dist/dto/update-cost-center.dto.js +32 -0
  20. package/dist/dto/update-cost-center.dto.js.map +1 -0
  21. package/dist/dto/update-finance-category.dto.d.ts +7 -0
  22. package/dist/dto/update-finance-category.dto.d.ts.map +1 -0
  23. package/dist/dto/update-finance-category.dto.js +46 -0
  24. package/dist/dto/update-finance-category.dto.js.map +1 -0
  25. package/dist/finance-audit-logs.controller.d.ts +13 -0
  26. package/dist/finance-audit-logs.controller.d.ts.map +1 -0
  27. package/dist/finance-audit-logs.controller.js +54 -0
  28. package/dist/finance-audit-logs.controller.js.map +1 -0
  29. package/dist/finance-categories.controller.d.ts +42 -0
  30. package/dist/finance-categories.controller.d.ts.map +1 -0
  31. package/dist/finance-categories.controller.js +84 -0
  32. package/dist/finance-categories.controller.js.map +1 -0
  33. package/dist/finance-cost-centers.controller.d.ts +32 -0
  34. package/dist/finance-cost-centers.controller.d.ts.map +1 -0
  35. package/dist/finance-cost-centers.controller.js +72 -0
  36. package/dist/finance-cost-centers.controller.js.map +1 -0
  37. package/dist/finance-period-close.controller.d.ts +27 -0
  38. package/dist/finance-period-close.controller.d.ts.map +1 -0
  39. package/dist/finance-period-close.controller.js +64 -0
  40. package/dist/finance-period-close.controller.js.map +1 -0
  41. package/dist/finance.module.d.ts.map +1 -1
  42. package/dist/finance.module.js +8 -0
  43. package/dist/finance.module.js.map +1 -1
  44. package/dist/finance.service.d.ts +111 -0
  45. package/dist/finance.service.d.ts.map +1 -1
  46. package/dist/finance.service.js +446 -17
  47. package/dist/finance.service.js.map +1 -1
  48. package/dist/index.d.ts +4 -0
  49. package/dist/index.d.ts.map +1 -1
  50. package/dist/index.js +4 -0
  51. package/dist/index.js.map +1 -1
  52. package/hedhog/data/route.yaml +108 -0
  53. package/hedhog/frontend/app/_components/person-field-with-create.tsx.ejs +627 -0
  54. package/hedhog/frontend/app/accounts-payable/installments/page.tsx.ejs +865 -883
  55. package/hedhog/frontend/app/accounts-receivable/installments/page.tsx.ejs +838 -861
  56. package/hedhog/frontend/app/administration/audit-logs/page.tsx.ejs +309 -0
  57. package/hedhog/frontend/app/administration/categories/page.tsx.ejs +725 -0
  58. package/hedhog/frontend/app/administration/cost-centers/page.tsx.ejs +378 -0
  59. package/hedhog/frontend/app/administration/period-close/page.tsx.ejs +502 -0
  60. package/hedhog/frontend/messages/en.json +225 -0
  61. package/hedhog/frontend/messages/pt.json +225 -0
  62. package/package.json +5 -5
  63. package/src/dto/create-cost-center.dto.ts +9 -0
  64. package/src/dto/create-finance-category.dto.ts +21 -0
  65. package/src/dto/create-period-close.dto.ts +34 -0
  66. package/src/dto/move-finance-category.dto.ts +18 -0
  67. package/src/dto/update-cost-center.dto.ts +17 -0
  68. package/src/dto/update-finance-category.dto.ts +30 -0
  69. package/src/finance-audit-logs.controller.ts +30 -0
  70. package/src/finance-categories.controller.ts +52 -0
  71. package/src/finance-cost-centers.controller.ts +43 -0
  72. package/src/finance-period-close.controller.ts +34 -0
  73. package/src/finance.module.ts +8 -0
  74. package/src/finance.service.ts +578 -9
  75. package/src/index.ts +4 -0
@@ -1,883 +1,865 @@
1
- 'use client';
2
-
3
- import { Page, PageHeader } from '@/components/entity-list';
4
- import { Button } from '@/components/ui/button';
5
- import {
6
- DropdownMenu,
7
- DropdownMenuContent,
8
- DropdownMenuItem,
9
- DropdownMenuSeparator,
10
- DropdownMenuTrigger,
11
- } from '@/components/ui/dropdown-menu';
12
- import { FilterBar } from '@/components/ui/filter-bar';
13
- import {
14
- Form,
15
- FormControl,
16
- FormField,
17
- FormItem,
18
- FormLabel,
19
- FormMessage,
20
- } from '@/components/ui/form';
21
- import { Input } from '@/components/ui/input';
22
- import { InputMoney } from '@/components/ui/input-money';
23
- import { Money } from '@/components/ui/money';
24
- import { Progress } from '@/components/ui/progress';
25
- import {
26
- Select,
27
- SelectContent,
28
- SelectItem,
29
- SelectTrigger,
30
- SelectValue,
31
- } from '@/components/ui/select';
32
- import {
33
- Sheet,
34
- SheetContent,
35
- SheetDescription,
36
- SheetHeader,
37
- SheetTitle,
38
- SheetTrigger,
39
- } from '@/components/ui/sheet';
40
- import { StatusBadge } from '@/components/ui/status-badge';
41
- import {
42
- Table,
43
- TableBody,
44
- TableCell,
45
- TableHead,
46
- TableHeader,
47
- TableRow,
48
- } from '@/components/ui/table';
49
- import { Textarea } from '@/components/ui/textarea';
50
- import { useApp } from '@hed-hog/next-app-provider';
51
- import { zodResolver } from '@hookform/resolvers/zod';
52
- import {
53
- CheckCircle,
54
- Download,
55
- Edit,
56
- Eye,
57
- Loader2,
58
- MoreHorizontal,
59
- Paperclip,
60
- Plus,
61
- Undo,
62
- XCircle,
63
- } from 'lucide-react';
64
- import { useTranslations } from 'next-intl';
65
- import Link from 'next/link';
66
- import { useState } from 'react';
67
- import { useForm } from 'react-hook-form';
68
- import { z } from 'zod';
69
- import { formatarData } from '../../_lib/formatters';
70
- import { useFinanceData } from '../../_lib/use-finance-data';
71
-
72
- const newTitleFormSchema = z.object({
73
- documento: z.string().trim().min(1, 'Documento é obrigatório'),
74
- fornecedorId: z.string().min(1, 'Fornecedor é obrigatório'),
75
- competencia: z.string().optional(),
76
- vencimento: z.string().min(1, 'Vencimento é obrigatório'),
77
- valor: z.number().min(0.01, 'Valor deve ser maior que zero'),
78
- categoriaId: z.string().optional(),
79
- centroCustoId: z.string().optional(),
80
- metodo: z.string().optional(),
81
- descricao: z.string().optional(),
82
- });
83
-
84
- type NewTitleFormValues = z.infer<typeof newTitleFormSchema>;
85
-
86
- function NovoTituloSheet({
87
- pessoas,
88
- categorias,
89
- centrosCusto,
90
- t,
91
- onCreated,
92
- }: {
93
- pessoas: any[];
94
- categorias: any[];
95
- centrosCusto: any[];
96
- t: ReturnType<typeof useTranslations>;
97
- onCreated: () => Promise<any> | void;
98
- }) {
99
- const { request, showToastHandler } = useApp();
100
- const [open, setOpen] = useState(false);
101
- const [uploadedFileId, setUploadedFileId] = useState<number | null>(null);
102
- const [uploadedFileName, setUploadedFileName] = useState('');
103
- const [isUploadingFile, setIsUploadingFile] = useState(false);
104
- const [isExtractingFileData, setIsExtractingFileData] = useState(false);
105
- const [extractionConfidence, setExtractionConfidence] = useState<
106
- number | null
107
- >(null);
108
- const [extractionWarnings, setExtractionWarnings] = useState<string[]>([]);
109
- const [uploadProgress, setUploadProgress] = useState(0);
110
-
111
- const normalizeFilenameForDisplay = (filename: string) => {
112
- if (!filename) {
113
- return filename;
114
- }
115
-
116
- if (!/Ã.|Â.|â[\u0080-\u00BF]/.test(filename)) {
117
- return filename;
118
- }
119
-
120
- try {
121
- const bytes = Uint8Array.from(filename, (char) => char.charCodeAt(0));
122
- const decoded = new TextDecoder('utf-8').decode(bytes);
123
- return /Ã.|Â.|â[\u0080-\u00BF]/.test(decoded) ? filename : decoded;
124
- } catch {
125
- return filename;
126
- }
127
- };
128
-
129
- const form = useForm<NewTitleFormValues>({
130
- resolver: zodResolver(newTitleFormSchema),
131
- defaultValues: {
132
- documento: '',
133
- fornecedorId: '',
134
- competencia: '',
135
- vencimento: '',
136
- valor: 0,
137
- categoriaId: '',
138
- centroCustoId: '',
139
- metodo: '',
140
- descricao: '',
141
- },
142
- });
143
-
144
- const handleSubmit = async (values: NewTitleFormValues) => {
145
- try {
146
- await request({
147
- url: '/finance/accounts-payable/installments',
148
- method: 'POST',
149
- data: {
150
- document_number: values.documento,
151
- person_id: Number(values.fornecedorId),
152
- competence_date: values.competencia
153
- ? `${values.competencia}-01`
154
- : undefined,
155
- due_date: values.vencimento,
156
- total_amount: values.valor,
157
- finance_category_id: values.categoriaId
158
- ? Number(values.categoriaId)
159
- : undefined,
160
- cost_center_id: values.centroCustoId
161
- ? Number(values.centroCustoId)
162
- : undefined,
163
- payment_channel: values.metodo || undefined,
164
- description: values.descricao?.trim() || undefined,
165
- attachment_file_ids: uploadedFileId ? [uploadedFileId] : undefined,
166
- },
167
- });
168
-
169
- await onCreated();
170
- form.reset();
171
- setUploadedFileId(null);
172
- setUploadedFileName('');
173
- setExtractionConfidence(null);
174
- setExtractionWarnings([]);
175
- setOpen(false);
176
- showToastHandler?.('success', 'Título criado com sucesso');
177
- } catch {
178
- showToastHandler?.('error', 'Erro ao criar título');
179
- }
180
- };
181
-
182
- const handleCancel = () => {
183
- form.reset();
184
- setUploadedFileId(null);
185
- setUploadedFileName('');
186
- setExtractionConfidence(null);
187
- setExtractionWarnings([]);
188
- setUploadProgress(0);
189
- setOpen(false);
190
- };
191
-
192
- const uploadRelatedFile = async (file: File) => {
193
- setIsUploadingFile(true);
194
- setUploadProgress(0);
195
-
196
- try {
197
- const formData = new FormData();
198
- formData.append('file', file);
199
- formData.append('destination', 'finance/titles');
200
-
201
- const { data } = await request<{ id: number; filename: string }>({
202
- url: '/file',
203
- method: 'POST',
204
- data: formData,
205
- headers: {
206
- 'Content-Type': 'multipart/form-data',
207
- },
208
- onUploadProgress: (event) => {
209
- if (!event.total) {
210
- return;
211
- }
212
-
213
- const progress = Math.round((event.loaded * 100) / event.total);
214
- setUploadProgress(progress);
215
- },
216
- });
217
-
218
- if (!data?.id) {
219
- throw new Error('Arquivo inválido');
220
- }
221
-
222
- setUploadedFileId(data.id);
223
- setUploadedFileName(
224
- normalizeFilenameForDisplay(data.filename || file.name)
225
- );
226
- setUploadProgress(100);
227
- showToastHandler?.('success', 'Arquivo relacionado com sucesso');
228
-
229
- setIsExtractingFileData(true);
230
- try {
231
- const extraction = await request<{
232
- documento?: string | null;
233
- fornecedorId?: string;
234
- competencia?: string;
235
- vencimento?: string;
236
- valor?: number | null;
237
- categoriaId?: string;
238
- centroCustoId?: string;
239
- metodo?: string;
240
- descricao?: string | null;
241
- confidence?: number | null;
242
- confidenceLevel?: 'low' | 'high' | null;
243
- warnings?: string[];
244
- }>({
245
- url: '/finance/accounts-payable/installments/extract-from-file',
246
- method: 'POST',
247
- data: {
248
- file_id: data.id,
249
- },
250
- });
251
-
252
- const extracted = extraction.data || {};
253
- setExtractionConfidence(
254
- typeof extracted.confidence === 'number' ? extracted.confidence : null
255
- );
256
- setExtractionWarnings(
257
- Array.isArray(extracted.warnings)
258
- ? extracted.warnings.filter(Boolean)
259
- : []
260
- );
261
-
262
- if (extracted.documento) {
263
- form.setValue('documento', extracted.documento, {
264
- shouldValidate: true,
265
- });
266
- }
267
-
268
- if (extracted.fornecedorId) {
269
- form.setValue('fornecedorId', extracted.fornecedorId, {
270
- shouldValidate: true,
271
- });
272
- }
273
-
274
- if (extracted.competencia) {
275
- form.setValue('competencia', extracted.competencia, {
276
- shouldValidate: true,
277
- });
278
- }
279
-
280
- if (extracted.vencimento) {
281
- form.setValue('vencimento', extracted.vencimento, {
282
- shouldValidate: true,
283
- });
284
- }
285
-
286
- if (typeof extracted.valor === 'number' && extracted.valor > 0) {
287
- form.setValue('valor', extracted.valor, {
288
- shouldValidate: true,
289
- });
290
- }
291
-
292
- if (extracted.categoriaId) {
293
- form.setValue('categoriaId', extracted.categoriaId, {
294
- shouldValidate: true,
295
- });
296
- }
297
-
298
- if (extracted.centroCustoId) {
299
- form.setValue('centroCustoId', extracted.centroCustoId, {
300
- shouldValidate: true,
301
- });
302
- }
303
-
304
- if (extracted.metodo) {
305
- form.setValue('metodo', extracted.metodo, {
306
- shouldValidate: true,
307
- });
308
- }
309
-
310
- if (extracted.descricao) {
311
- form.setValue('descricao', extracted.descricao, {
312
- shouldValidate: true,
313
- });
314
- }
315
-
316
- showToastHandler?.(
317
- 'success',
318
- 'Dados da fatura extraídos e preenchidos automaticamente'
319
- );
320
- } catch {
321
- setExtractionConfidence(null);
322
- setExtractionWarnings([]);
323
- showToastHandler?.(
324
- 'error',
325
- 'Não foi possível extrair os dados automaticamente'
326
- );
327
- } finally {
328
- setIsExtractingFileData(false);
329
- }
330
- } catch {
331
- setUploadedFileId(null);
332
- setUploadedFileName('');
333
- setExtractionConfidence(null);
334
- setExtractionWarnings([]);
335
- setUploadProgress(0);
336
- showToastHandler?.('error', 'Não foi possível enviar o arquivo');
337
- } finally {
338
- setIsUploadingFile(false);
339
- }
340
- };
341
-
342
- const parseDateToIso = (value: string) => {
343
- const sanitized = value.trim();
344
-
345
- if (/^\d{4}-\d{2}-\d{2}$/.test(sanitized)) {
346
- return sanitized;
347
- }
348
-
349
- const br = sanitized.match(/^(\d{2})\/(\d{2})\/(\d{4})$/);
350
- if (br) {
351
- return `${br[3]}-${br[2]}-${br[1]}`;
352
- }
353
-
354
- return '';
355
- };
356
-
357
- const parseMoneyToNumber = (value: string) => {
358
- const normalized = value
359
- .replace(/\s/g, '')
360
- .replace(/\./g, '')
361
- .replace(',', '.');
362
-
363
- const parsed = Number(normalized);
364
- return Number.isFinite(parsed) ? parsed : 0;
365
- };
366
-
367
- return (
368
- <Sheet open={open} onOpenChange={setOpen}>
369
- <SheetTrigger asChild>
370
- <Button>
371
- <Plus className="mr-2 h-4 w-4" />
372
- {t('newTitle.action')}
373
- </Button>
374
- </SheetTrigger>
375
- <SheetContent className="w-full sm:max-w-lg overflow-y-auto">
376
- <SheetHeader>
377
- <SheetTitle>{t('newTitle.title')}</SheetTitle>
378
- <SheetDescription>{t('newTitle.description')}</SheetDescription>
379
- </SheetHeader>
380
- <Form {...form}>
381
- <form className="px-4" onSubmit={form.handleSubmit(handleSubmit)}>
382
- <div className="grid gap-4">
383
- <div className="grid gap-2">
384
- <FormLabel>Arquivo da fatura (opcional)</FormLabel>
385
- <div className="flex flex-col gap-2 sm:flex-row sm:items-center">
386
- <Input
387
- type="file"
388
- accept=".pdf,.png,.jpg,.jpeg,.xml,.txt"
389
- onChange={(event) => {
390
- const file = event.target.files?.[0];
391
- if (!file) {
392
- return;
393
- }
394
-
395
- setUploadedFileId(null);
396
- setUploadedFileName('');
397
- setExtractionConfidence(null);
398
- setExtractionWarnings([]);
399
- setUploadProgress(0);
400
- void uploadRelatedFile(file);
401
- }}
402
- disabled={
403
- isUploadingFile ||
404
- isExtractingFileData ||
405
- form.formState.isSubmitting
406
- }
407
- />
408
- </div>
409
- {isUploadingFile && (
410
- <div className="space-y-1">
411
- <Progress value={uploadProgress} className="h-2" />
412
- <p className="text-xs text-muted-foreground">
413
- Upload em andamento: {uploadProgress}%
414
- </p>
415
- </div>
416
- )}
417
- {uploadedFileId && (
418
- <p className="text-xs text-muted-foreground">
419
- Arquivo relacionado: {uploadedFileName}
420
- </p>
421
- )}
422
- {isExtractingFileData && (
423
- <div className="rounded-md border border-primary/30 bg-primary/5 p-3">
424
- <div className="flex items-center gap-2 text-sm font-medium text-primary">
425
- <Loader2 className="h-4 w-4 animate-spin" />
426
- Analisando documento com IA
427
- </div>
428
- <p className="mt-1 text-xs text-muted-foreground">
429
- Os campos serão preenchidos automaticamente em instantes.
430
- Revise os dados antes de salvar.
431
- </p>
432
- </div>
433
- )}
434
- {!isExtractingFileData &&
435
- extractionConfidence !== null &&
436
- extractionConfidence < 70 && (
437
- <div className="rounded-md border border-destructive/30 bg-destructive/5 p-3">
438
- <div className="text-sm font-medium text-destructive">
439
- Confiança da extração:{' '}
440
- {Math.round(extractionConfidence)}%
441
- </div>
442
- <p className="mt-1 text-xs text-muted-foreground">
443
- Revise principalmente valor e vencimento antes de
444
- salvar.
445
- </p>
446
- {extractionWarnings.length > 0 && (
447
- <p className="mt-1 text-xs text-muted-foreground">
448
- {extractionWarnings[0]}
449
- </p>
450
- )}
451
- </div>
452
- )}
453
- </div>
454
-
455
- <FormField
456
- control={form.control}
457
- name="documento"
458
- render={({ field }) => (
459
- <FormItem>
460
- <FormLabel>{t('fields.document')}</FormLabel>
461
- <FormControl>
462
- <Input placeholder="NF-00000" {...field} />
463
- </FormControl>
464
- <FormMessage />
465
- </FormItem>
466
- )}
467
- />
468
-
469
- <FormField
470
- control={form.control}
471
- name="fornecedorId"
472
- render={({ field }) => (
473
- <FormItem>
474
- <FormLabel>{t('fields.supplier')}</FormLabel>
475
- <Select value={field.value} onValueChange={field.onChange}>
476
- <FormControl>
477
- <SelectTrigger className="w-full">
478
- <SelectValue placeholder={t('common.select')} />
479
- </SelectTrigger>
480
- </FormControl>
481
- <SelectContent>
482
- {pessoas
483
- .filter(
484
- (p) => p.tipo === 'fornecedor' || p.tipo === 'ambos'
485
- )
486
- .map((p) => (
487
- <SelectItem key={p.id} value={String(p.id)}>
488
- {p.nome}
489
- </SelectItem>
490
- ))}
491
- </SelectContent>
492
- </Select>
493
- <FormMessage />
494
- </FormItem>
495
- )}
496
- />
497
-
498
- <div className="grid grid-cols-2 gap-4">
499
- <FormField
500
- control={form.control}
501
- name="competencia"
502
- render={({ field }) => (
503
- <FormItem>
504
- <FormLabel>{t('fields.competency')}</FormLabel>
505
- <FormControl>
506
- <Input
507
- type="month"
508
- {...field}
509
- value={field.value || ''}
510
- />
511
- </FormControl>
512
- <FormMessage />
513
- </FormItem>
514
- )}
515
- />
516
-
517
- <FormField
518
- control={form.control}
519
- name="vencimento"
520
- render={({ field }) => (
521
- <FormItem>
522
- <FormLabel>{t('fields.dueDate')}</FormLabel>
523
- <FormControl>
524
- <Input
525
- type="date"
526
- {...field}
527
- value={field.value || ''}
528
- />
529
- </FormControl>
530
- <FormMessage />
531
- </FormItem>
532
- )}
533
- />
534
- </div>
535
-
536
- <FormField
537
- control={form.control}
538
- name="valor"
539
- render={({ field }) => (
540
- <FormItem>
541
- <FormLabel>{t('fields.totalValue')}</FormLabel>
542
- <FormControl>
543
- <InputMoney
544
- ref={field.ref}
545
- name={field.name}
546
- value={field.value}
547
- onBlur={field.onBlur}
548
- onValueChange={(value) => field.onChange(value ?? 0)}
549
- placeholder="0,00"
550
- />
551
- </FormControl>
552
- <FormMessage />
553
- </FormItem>
554
- )}
555
- />
556
-
557
- <FormField
558
- control={form.control}
559
- name="categoriaId"
560
- render={({ field }) => (
561
- <FormItem>
562
- <FormLabel>{t('fields.category')}</FormLabel>
563
- <Select value={field.value} onValueChange={field.onChange}>
564
- <FormControl>
565
- <SelectTrigger className="w-full">
566
- <SelectValue placeholder={t('common.select')} />
567
- </SelectTrigger>
568
- </FormControl>
569
- <SelectContent>
570
- {categorias
571
- .filter((c) => c.natureza === 'despesa')
572
- .map((c) => (
573
- <SelectItem key={c.id} value={String(c.id)}>
574
- {c.codigo} - {c.nome}
575
- </SelectItem>
576
- ))}
577
- </SelectContent>
578
- </Select>
579
- <FormMessage />
580
- </FormItem>
581
- )}
582
- />
583
-
584
- <FormField
585
- control={form.control}
586
- name="centroCustoId"
587
- render={({ field }) => (
588
- <FormItem>
589
- <FormLabel>{t('fields.costCenter')}</FormLabel>
590
- <Select value={field.value} onValueChange={field.onChange}>
591
- <FormControl>
592
- <SelectTrigger className="w-full">
593
- <SelectValue placeholder={t('common.select')} />
594
- </SelectTrigger>
595
- </FormControl>
596
- <SelectContent>
597
- {centrosCusto.map((c) => (
598
- <SelectItem key={c.id} value={String(c.id)}>
599
- {c.codigo} - {c.nome}
600
- </SelectItem>
601
- ))}
602
- </SelectContent>
603
- </Select>
604
- <FormMessage />
605
- </FormItem>
606
- )}
607
- />
608
-
609
- <FormField
610
- control={form.control}
611
- name="metodo"
612
- render={({ field }) => (
613
- <FormItem>
614
- <FormLabel>{t('fields.paymentMethod')}</FormLabel>
615
- <Select value={field.value} onValueChange={field.onChange}>
616
- <FormControl>
617
- <SelectTrigger className="w-full">
618
- <SelectValue placeholder={t('common.select')} />
619
- </SelectTrigger>
620
- </FormControl>
621
- <SelectContent>
622
- <SelectItem value="boleto">
623
- {t('paymentMethods.boleto')}
624
- </SelectItem>
625
- <SelectItem value="pix">PIX</SelectItem>
626
- <SelectItem value="transferencia">
627
- {t('paymentMethods.transfer')}
628
- </SelectItem>
629
- <SelectItem value="cartao">
630
- {t('paymentMethods.card')}
631
- </SelectItem>
632
- <SelectItem value="dinheiro">
633
- {t('paymentMethods.cash')}
634
- </SelectItem>
635
- <SelectItem value="cheque">
636
- {t('paymentMethods.check')}
637
- </SelectItem>
638
- </SelectContent>
639
- </Select>
640
- <FormMessage />
641
- </FormItem>
642
- )}
643
- />
644
-
645
- <FormField
646
- control={form.control}
647
- name="descricao"
648
- render={({ field }) => (
649
- <FormItem>
650
- <FormLabel>{t('fields.description')}</FormLabel>
651
- <FormControl>
652
- <Textarea
653
- placeholder={t('newTitle.descriptionPlaceholder')}
654
- {...field}
655
- value={field.value || ''}
656
- />
657
- </FormControl>
658
- <FormMessage />
659
- </FormItem>
660
- )}
661
- />
662
- </div>
663
-
664
- <div className="flex justify-end gap-2 pt-4">
665
- <Button type="button" variant="outline" onClick={handleCancel}>
666
- {t('common.cancel')}
667
- </Button>
668
- <Button
669
- type="submit"
670
- disabled={
671
- form.formState.isSubmitting ||
672
- isUploadingFile ||
673
- isExtractingFileData
674
- }
675
- >
676
- {(isUploadingFile || isExtractingFileData) && (
677
- <Loader2 className="mr-2 h-4 w-4 animate-spin" />
678
- )}
679
- {isExtractingFileData
680
- ? 'Preenchendo com IA...'
681
- : isUploadingFile
682
- ? 'Enviando arquivo...'
683
- : t('common.save')}
684
- </Button>
685
- </div>
686
- </form>
687
- </Form>
688
- </SheetContent>
689
- </Sheet>
690
- );
691
- }
692
-
693
- export default function TitulosPagarPage() {
694
- const t = useTranslations('finance.PayableInstallmentsPage');
695
- const { data, refetch } = useFinanceData();
696
- const { titulosPagar, pessoas, categorias, centrosCusto } = data;
697
-
698
- const getPessoaById = (id?: string) => pessoas.find((p) => p.id === id);
699
- const getCategoriaById = (id?: string) => categorias.find((c) => c.id === id);
700
-
701
- const [search, setSearch] = useState('');
702
- const [statusFilter, setStatusFilter] = useState<string>('');
703
-
704
- const filteredTitulos = titulosPagar.filter((titulo) => {
705
- const matchesSearch =
706
- titulo.documento.toLowerCase().includes(search.toLowerCase()) ||
707
- getPessoaById(titulo.fornecedorId)
708
- ?.nome.toLowerCase()
709
- .includes(search.toLowerCase());
710
-
711
- const matchesStatus = !statusFilter || titulo.status === statusFilter;
712
-
713
- return matchesSearch && matchesStatus;
714
- });
715
-
716
- return (
717
- <Page>
718
- <PageHeader
719
- title={t('header.title')}
720
- description={t('header.description')}
721
- breadcrumbs={[
722
- { label: t('breadcrumbs.home'), href: '/' },
723
- { label: t('breadcrumbs.finance'), href: '/finance' },
724
- { label: t('breadcrumbs.current') },
725
- ]}
726
- actions={
727
- <NovoTituloSheet
728
- pessoas={pessoas}
729
- categorias={categorias}
730
- centrosCusto={centrosCusto}
731
- t={t}
732
- onCreated={refetch}
733
- />
734
- }
735
- />
736
-
737
- <FilterBar
738
- searchPlaceholder={t('filters.searchPlaceholder')}
739
- searchValue={search}
740
- onSearchChange={setSearch}
741
- filters={[
742
- {
743
- id: 'status',
744
- label: t('filters.status'),
745
- value: statusFilter,
746
- onChange: setStatusFilter,
747
- options: [
748
- { value: 'all', label: t('statuses.all') },
749
- { value: 'rascunho', label: t('statuses.rascunho') },
750
- { value: 'aprovado', label: t('statuses.aprovado') },
751
- { value: 'aberto', label: t('statuses.aberto') },
752
- { value: 'parcial', label: t('statuses.parcial') },
753
- { value: 'liquidado', label: t('statuses.liquidado') },
754
- { value: 'vencido', label: t('statuses.vencido') },
755
- { value: 'cancelado', label: t('statuses.cancelado') },
756
- ],
757
- },
758
- ]}
759
- activeFilters={statusFilter && statusFilter !== 'all' ? 1 : 0}
760
- onClearFilters={() => setStatusFilter('')}
761
- />
762
-
763
- <div className="rounded-md border">
764
- <Table>
765
- <TableHeader>
766
- <TableRow>
767
- <TableHead>{t('table.headers.document')}</TableHead>
768
- <TableHead>{t('table.headers.supplier')}</TableHead>
769
- <TableHead>{t('table.headers.competency')}</TableHead>
770
- <TableHead>{t('table.headers.dueDate')}</TableHead>
771
- <TableHead className="text-right">
772
- {t('table.headers.value')}
773
- </TableHead>
774
- <TableHead>{t('table.headers.category')}</TableHead>
775
- <TableHead>{t('table.headers.status')}</TableHead>
776
- <TableHead className="w-[50px]" />
777
- </TableRow>
778
- </TableHeader>
779
- <TableBody>
780
- {filteredTitulos.map((titulo) => {
781
- const fornecedor = getPessoaById(titulo.fornecedorId);
782
- const categoria = getCategoriaById(titulo.categoriaId);
783
- const proximaParcela = titulo.parcelas.find(
784
- (p: any) => p.status === 'aberto' || p.status === 'vencido'
785
- );
786
-
787
- return (
788
- <TableRow key={titulo.id}>
789
- <TableCell className="font-medium">
790
- <Link
791
- href={`/finance/accounts-payable/installments/${titulo.id}`}
792
- className="hover:underline"
793
- >
794
- {titulo.documento}
795
- </Link>
796
- {titulo.anexos.length > 0 && (
797
- <Paperclip className="ml-1 inline h-3 w-3 text-muted-foreground" />
798
- )}
799
- </TableCell>
800
- <TableCell>{fornecedor?.nome}</TableCell>
801
- <TableCell>{titulo.competencia}</TableCell>
802
- <TableCell>
803
- {proximaParcela
804
- ? formatarData(proximaParcela.vencimento)
805
- : '-'}
806
- </TableCell>
807
- <TableCell className="text-right">
808
- <Money value={titulo.valorTotal} />
809
- </TableCell>
810
- <TableCell>{categoria?.nome}</TableCell>
811
- <TableCell>
812
- <StatusBadge status={titulo.status} />
813
- </TableCell>
814
- <TableCell>
815
- <DropdownMenu>
816
- <DropdownMenuTrigger asChild>
817
- <Button variant="ghost" size="icon">
818
- <MoreHorizontal className="h-4 w-4" />
819
- <span className="sr-only">
820
- {t('table.actions.srActions')}
821
- </span>
822
- </Button>
823
- </DropdownMenuTrigger>
824
- <DropdownMenuContent align="end">
825
- <DropdownMenuItem asChild>
826
- <Link
827
- href={`/finance/accounts-payable/installments/${titulo.id}`}
828
- >
829
- <Eye className="mr-2 h-4 w-4" />
830
- {t('table.actions.viewDetails')}
831
- </Link>
832
- </DropdownMenuItem>
833
- <DropdownMenuItem>
834
- <Edit className="mr-2 h-4 w-4" />
835
- {t('table.actions.edit')}
836
- </DropdownMenuItem>
837
- <DropdownMenuSeparator />
838
- <DropdownMenuItem>
839
- <CheckCircle className="mr-2 h-4 w-4" />
840
- {t('table.actions.approve')}
841
- </DropdownMenuItem>
842
- <DropdownMenuItem>
843
- <Download className="mr-2 h-4 w-4" />
844
- {t('table.actions.settle')}
845
- </DropdownMenuItem>
846
- <DropdownMenuItem>
847
- <Undo className="mr-2 h-4 w-4" />
848
- {t('table.actions.reverse')}
849
- </DropdownMenuItem>
850
- <DropdownMenuSeparator />
851
- <DropdownMenuItem className="text-destructive">
852
- <XCircle className="mr-2 h-4 w-4" />
853
- {t('table.actions.cancel')}
854
- </DropdownMenuItem>
855
- </DropdownMenuContent>
856
- </DropdownMenu>
857
- </TableCell>
858
- </TableRow>
859
- );
860
- })}
861
- </TableBody>
862
- </Table>
863
- </div>
864
-
865
- <div className="flex items-center justify-between">
866
- <p className="text-sm text-muted-foreground">
867
- {t('footer.showing', {
868
- filtered: filteredTitulos.length,
869
- total: titulosPagar.length,
870
- })}
871
- </p>
872
- <div className="flex items-center gap-2">
873
- <Button variant="outline" size="sm" disabled>
874
- {t('footer.previous')}
875
- </Button>
876
- <Button variant="outline" size="sm" disabled>
877
- {t('footer.next')}
878
- </Button>
879
- </div>
880
- </div>
881
- </Page>
882
- );
883
- }
1
+ 'use client';
2
+
3
+ import { PersonFieldWithCreate } from '@/app/(app)/(libraries)/finance/_components/person-field-with-create';
4
+ import { Page, PageHeader } from '@/components/entity-list';
5
+ import { Button } from '@/components/ui/button';
6
+ import {
7
+ DropdownMenu,
8
+ DropdownMenuContent,
9
+ DropdownMenuItem,
10
+ DropdownMenuSeparator,
11
+ DropdownMenuTrigger,
12
+ } from '@/components/ui/dropdown-menu';
13
+ import { FilterBar } from '@/components/ui/filter-bar';
14
+ import {
15
+ Form,
16
+ FormControl,
17
+ FormField,
18
+ FormItem,
19
+ FormLabel,
20
+ FormMessage,
21
+ } from '@/components/ui/form';
22
+ import { Input } from '@/components/ui/input';
23
+ import { InputMoney } from '@/components/ui/input-money';
24
+ import { Money } from '@/components/ui/money';
25
+ import { Progress } from '@/components/ui/progress';
26
+ import {
27
+ Select,
28
+ SelectContent,
29
+ SelectItem,
30
+ SelectTrigger,
31
+ SelectValue,
32
+ } from '@/components/ui/select';
33
+ import {
34
+ Sheet,
35
+ SheetContent,
36
+ SheetDescription,
37
+ SheetHeader,
38
+ SheetTitle,
39
+ SheetTrigger,
40
+ } from '@/components/ui/sheet';
41
+ import { StatusBadge } from '@/components/ui/status-badge';
42
+ import {
43
+ Table,
44
+ TableBody,
45
+ TableCell,
46
+ TableHead,
47
+ TableHeader,
48
+ TableRow,
49
+ } from '@/components/ui/table';
50
+ import { Textarea } from '@/components/ui/textarea';
51
+ import { useApp, useQuery } from '@hed-hog/next-app-provider';
52
+ import { zodResolver } from '@hookform/resolvers/zod';
53
+ import {
54
+ CheckCircle,
55
+ Download,
56
+ Edit,
57
+ Eye,
58
+ Loader2,
59
+ MoreHorizontal,
60
+ Paperclip,
61
+ Plus,
62
+ Undo,
63
+ XCircle,
64
+ } from 'lucide-react';
65
+ import { useTranslations } from 'next-intl';
66
+ import Link from 'next/link';
67
+ import { useState } from 'react';
68
+ import { useForm } from 'react-hook-form';
69
+ import { z } from 'zod';
70
+ import { formatarData } from '../../_lib/formatters';
71
+ import { useFinanceData } from '../../_lib/use-finance-data';
72
+
73
+ const newTitleFormSchema = z.object({
74
+ documento: z.string().trim().min(1, 'Documento é obrigatório'),
75
+ fornecedorId: z.string().min(1, 'Fornecedor é obrigatório'),
76
+ competencia: z.string().optional(),
77
+ vencimento: z.string().min(1, 'Vencimento é obrigatório'),
78
+ valor: z.number().min(0.01, 'Valor deve ser maior que zero'),
79
+ categoriaId: z.string().optional(),
80
+ centroCustoId: z.string().optional(),
81
+ metodo: z.string().optional(),
82
+ descricao: z.string().optional(),
83
+ });
84
+
85
+ type NewTitleFormValues = z.infer<typeof newTitleFormSchema>;
86
+
87
+ function NovoTituloSheet({
88
+ categorias,
89
+ centrosCusto,
90
+ t,
91
+ onCreated,
92
+ }: {
93
+ categorias: any[];
94
+ centrosCusto: any[];
95
+ t: ReturnType<typeof useTranslations>;
96
+ onCreated: () => Promise<any> | void;
97
+ }) {
98
+ const { request, showToastHandler } = useApp();
99
+ const [open, setOpen] = useState(false);
100
+ const [uploadedFileId, setUploadedFileId] = useState<number | null>(null);
101
+ const [uploadedFileName, setUploadedFileName] = useState('');
102
+ const [isUploadingFile, setIsUploadingFile] = useState(false);
103
+ const [isExtractingFileData, setIsExtractingFileData] = useState(false);
104
+ const [extractionConfidence, setExtractionConfidence] = useState<
105
+ number | null
106
+ >(null);
107
+ const [extractionWarnings, setExtractionWarnings] = useState<string[]>([]);
108
+ const [uploadProgress, setUploadProgress] = useState(0);
109
+
110
+ const normalizeFilenameForDisplay = (filename: string) => {
111
+ if (!filename) {
112
+ return filename;
113
+ }
114
+
115
+ if (!/Ã.|Â.|â[\u0080-\u00BF]/.test(filename)) {
116
+ return filename;
117
+ }
118
+
119
+ try {
120
+ const bytes = Uint8Array.from(filename, (char) => char.charCodeAt(0));
121
+ const decoded = new TextDecoder('utf-8').decode(bytes);
122
+ return /Ã.|Â.|â[\u0080-\u00BF]/.test(decoded) ? filename : decoded;
123
+ } catch {
124
+ return filename;
125
+ }
126
+ };
127
+
128
+ const form = useForm<NewTitleFormValues>({
129
+ resolver: zodResolver(newTitleFormSchema),
130
+ defaultValues: {
131
+ documento: '',
132
+ fornecedorId: '',
133
+ competencia: '',
134
+ vencimento: '',
135
+ valor: 0,
136
+ categoriaId: '',
137
+ centroCustoId: '',
138
+ metodo: '',
139
+ descricao: '',
140
+ },
141
+ });
142
+
143
+ const handleSubmit = async (values: NewTitleFormValues) => {
144
+ try {
145
+ await request({
146
+ url: '/finance/accounts-payable/installments',
147
+ method: 'POST',
148
+ data: {
149
+ document_number: values.documento,
150
+ person_id: Number(values.fornecedorId),
151
+ competence_date: values.competencia
152
+ ? `${values.competencia}-01`
153
+ : undefined,
154
+ due_date: values.vencimento,
155
+ total_amount: values.valor,
156
+ finance_category_id: values.categoriaId
157
+ ? Number(values.categoriaId)
158
+ : undefined,
159
+ cost_center_id: values.centroCustoId
160
+ ? Number(values.centroCustoId)
161
+ : undefined,
162
+ payment_channel: values.metodo || undefined,
163
+ description: values.descricao?.trim() || undefined,
164
+ attachment_file_ids: uploadedFileId ? [uploadedFileId] : undefined,
165
+ },
166
+ });
167
+
168
+ await onCreated();
169
+ form.reset();
170
+ setUploadedFileId(null);
171
+ setUploadedFileName('');
172
+ setExtractionConfidence(null);
173
+ setExtractionWarnings([]);
174
+ setOpen(false);
175
+ showToastHandler?.('success', 'Título criado com sucesso');
176
+ } catch {
177
+ showToastHandler?.('error', 'Erro ao criar título');
178
+ }
179
+ };
180
+
181
+ const handleCancel = () => {
182
+ form.reset();
183
+ setUploadedFileId(null);
184
+ setUploadedFileName('');
185
+ setExtractionConfidence(null);
186
+ setExtractionWarnings([]);
187
+ setUploadProgress(0);
188
+ setOpen(false);
189
+ };
190
+
191
+ const uploadRelatedFile = async (file: File) => {
192
+ setIsUploadingFile(true);
193
+ setUploadProgress(0);
194
+
195
+ try {
196
+ const formData = new FormData();
197
+ formData.append('file', file);
198
+ formData.append('destination', 'finance/titles');
199
+
200
+ const { data } = await request<{ id: number; filename: string }>({
201
+ url: '/file',
202
+ method: 'POST',
203
+ data: formData,
204
+ headers: {
205
+ 'Content-Type': 'multipart/form-data',
206
+ },
207
+ onUploadProgress: (event) => {
208
+ if (!event.total) {
209
+ return;
210
+ }
211
+
212
+ const progress = Math.round((event.loaded * 100) / event.total);
213
+ setUploadProgress(progress);
214
+ },
215
+ });
216
+
217
+ if (!data?.id) {
218
+ throw new Error('Arquivo inválido');
219
+ }
220
+
221
+ setUploadedFileId(data.id);
222
+ setUploadedFileName(
223
+ normalizeFilenameForDisplay(data.filename || file.name)
224
+ );
225
+ setUploadProgress(100);
226
+ showToastHandler?.('success', 'Arquivo relacionado com sucesso');
227
+
228
+ setIsExtractingFileData(true);
229
+ try {
230
+ const extraction = await request<{
231
+ documento?: string | null;
232
+ fornecedorId?: string;
233
+ competencia?: string;
234
+ vencimento?: string;
235
+ valor?: number | null;
236
+ categoriaId?: string;
237
+ centroCustoId?: string;
238
+ metodo?: string;
239
+ descricao?: string | null;
240
+ confidence?: number | null;
241
+ confidenceLevel?: 'low' | 'high' | null;
242
+ warnings?: string[];
243
+ }>({
244
+ url: '/finance/accounts-payable/installments/extract-from-file',
245
+ method: 'POST',
246
+ data: {
247
+ file_id: data.id,
248
+ },
249
+ });
250
+
251
+ const extracted = extraction.data || {};
252
+ setExtractionConfidence(
253
+ typeof extracted.confidence === 'number' ? extracted.confidence : null
254
+ );
255
+ setExtractionWarnings(
256
+ Array.isArray(extracted.warnings)
257
+ ? extracted.warnings.filter(Boolean)
258
+ : []
259
+ );
260
+
261
+ if (extracted.documento) {
262
+ form.setValue('documento', extracted.documento, {
263
+ shouldValidate: true,
264
+ });
265
+ }
266
+
267
+ if (extracted.fornecedorId) {
268
+ form.setValue('fornecedorId', extracted.fornecedorId, {
269
+ shouldValidate: true,
270
+ });
271
+ }
272
+
273
+ if (extracted.competencia) {
274
+ form.setValue('competencia', extracted.competencia, {
275
+ shouldValidate: true,
276
+ });
277
+ }
278
+
279
+ if (extracted.vencimento) {
280
+ form.setValue('vencimento', extracted.vencimento, {
281
+ shouldValidate: true,
282
+ });
283
+ }
284
+
285
+ if (typeof extracted.valor === 'number' && extracted.valor > 0) {
286
+ form.setValue('valor', extracted.valor, {
287
+ shouldValidate: true,
288
+ });
289
+ }
290
+
291
+ if (extracted.categoriaId) {
292
+ form.setValue('categoriaId', extracted.categoriaId, {
293
+ shouldValidate: true,
294
+ });
295
+ }
296
+
297
+ if (extracted.centroCustoId) {
298
+ form.setValue('centroCustoId', extracted.centroCustoId, {
299
+ shouldValidate: true,
300
+ });
301
+ }
302
+
303
+ if (extracted.metodo) {
304
+ form.setValue('metodo', extracted.metodo, {
305
+ shouldValidate: true,
306
+ });
307
+ }
308
+
309
+ if (extracted.descricao) {
310
+ form.setValue('descricao', extracted.descricao, {
311
+ shouldValidate: true,
312
+ });
313
+ }
314
+
315
+ showToastHandler?.(
316
+ 'success',
317
+ 'Dados da fatura extraídos e preenchidos automaticamente'
318
+ );
319
+ } catch {
320
+ setExtractionConfidence(null);
321
+ setExtractionWarnings([]);
322
+ showToastHandler?.(
323
+ 'error',
324
+ 'Não foi possível extrair os dados automaticamente'
325
+ );
326
+ } finally {
327
+ setIsExtractingFileData(false);
328
+ }
329
+ } catch {
330
+ setUploadedFileId(null);
331
+ setUploadedFileName('');
332
+ setExtractionConfidence(null);
333
+ setExtractionWarnings([]);
334
+ setUploadProgress(0);
335
+ showToastHandler?.('error', 'Não foi possível enviar o arquivo');
336
+ } finally {
337
+ setIsUploadingFile(false);
338
+ }
339
+ };
340
+
341
+ return (
342
+ <Sheet open={open} onOpenChange={setOpen}>
343
+ <SheetTrigger asChild>
344
+ <Button>
345
+ <Plus className="mr-2 h-4 w-4" />
346
+ {t('newTitle.action')}
347
+ </Button>
348
+ </SheetTrigger>
349
+ <SheetContent className="w-full sm:max-w-lg overflow-y-auto">
350
+ <SheetHeader>
351
+ <SheetTitle>{t('newTitle.title')}</SheetTitle>
352
+ <SheetDescription>{t('newTitle.description')}</SheetDescription>
353
+ </SheetHeader>
354
+ <Form {...form}>
355
+ <form className="px-4" onSubmit={form.handleSubmit(handleSubmit)}>
356
+ <div className="grid gap-4">
357
+ <div className="grid gap-2">
358
+ <FormLabel>Arquivo da fatura (opcional)</FormLabel>
359
+ <div className="flex flex-col gap-2 sm:flex-row sm:items-center">
360
+ <Input
361
+ type="file"
362
+ accept=".pdf,.png,.jpg,.jpeg,.xml,.txt"
363
+ onChange={(event) => {
364
+ const file = event.target.files?.[0];
365
+ if (!file) {
366
+ return;
367
+ }
368
+
369
+ setUploadedFileId(null);
370
+ setUploadedFileName('');
371
+ setExtractionConfidence(null);
372
+ setExtractionWarnings([]);
373
+ setUploadProgress(0);
374
+ void uploadRelatedFile(file);
375
+ }}
376
+ disabled={
377
+ isUploadingFile ||
378
+ isExtractingFileData ||
379
+ form.formState.isSubmitting
380
+ }
381
+ />
382
+ </div>
383
+ {isUploadingFile && (
384
+ <div className="space-y-1">
385
+ <Progress value={uploadProgress} className="h-2" />
386
+ <p className="text-xs text-muted-foreground">
387
+ Upload em andamento: {uploadProgress}%
388
+ </p>
389
+ </div>
390
+ )}
391
+ {uploadedFileId && (
392
+ <p className="text-xs text-muted-foreground">
393
+ Arquivo relacionado: {uploadedFileName}
394
+ </p>
395
+ )}
396
+ {isExtractingFileData && (
397
+ <div className="rounded-md border border-primary/30 bg-primary/5 p-3">
398
+ <div className="flex items-center gap-2 text-sm font-medium text-primary">
399
+ <Loader2 className="h-4 w-4 animate-spin" />
400
+ Analisando documento com IA
401
+ </div>
402
+ <p className="mt-1 text-xs text-muted-foreground">
403
+ Os campos serão preenchidos automaticamente em instantes.
404
+ Revise os dados antes de salvar.
405
+ </p>
406
+ </div>
407
+ )}
408
+ {!isExtractingFileData &&
409
+ extractionConfidence !== null &&
410
+ extractionConfidence < 70 && (
411
+ <div className="rounded-md border border-destructive/30 bg-destructive/5 p-3">
412
+ <div className="text-sm font-medium text-destructive">
413
+ Confiança da extração:{' '}
414
+ {Math.round(extractionConfidence)}%
415
+ </div>
416
+ <p className="mt-1 text-xs text-muted-foreground">
417
+ Revise principalmente valor e vencimento antes de
418
+ salvar.
419
+ </p>
420
+ {extractionWarnings.length > 0 && (
421
+ <p className="mt-1 text-xs text-muted-foreground">
422
+ {extractionWarnings[0]}
423
+ </p>
424
+ )}
425
+ </div>
426
+ )}
427
+ </div>
428
+
429
+ <FormField
430
+ control={form.control}
431
+ name="documento"
432
+ render={({ field }) => (
433
+ <FormItem>
434
+ <FormLabel>{t('fields.document')}</FormLabel>
435
+ <FormControl>
436
+ <Input placeholder="NF-00000" {...field} />
437
+ </FormControl>
438
+ <FormMessage />
439
+ </FormItem>
440
+ )}
441
+ />
442
+
443
+ <PersonFieldWithCreate
444
+ form={form}
445
+ name="fornecedorId"
446
+ label={t('fields.supplier')}
447
+ entityLabel="fornecedor"
448
+ selectPlaceholder={t('common.select')}
449
+ />
450
+
451
+ <div className="grid grid-cols-2 gap-4">
452
+ <FormField
453
+ control={form.control}
454
+ name="competencia"
455
+ render={({ field }) => (
456
+ <FormItem>
457
+ <FormLabel>{t('fields.competency')}</FormLabel>
458
+ <FormControl>
459
+ <Input
460
+ type="month"
461
+ {...field}
462
+ value={field.value || ''}
463
+ />
464
+ </FormControl>
465
+ <FormMessage />
466
+ </FormItem>
467
+ )}
468
+ />
469
+
470
+ <FormField
471
+ control={form.control}
472
+ name="vencimento"
473
+ render={({ field }) => (
474
+ <FormItem>
475
+ <FormLabel>{t('fields.dueDate')}</FormLabel>
476
+ <FormControl>
477
+ <Input
478
+ type="date"
479
+ {...field}
480
+ value={field.value || ''}
481
+ />
482
+ </FormControl>
483
+ <FormMessage />
484
+ </FormItem>
485
+ )}
486
+ />
487
+ </div>
488
+
489
+ <FormField
490
+ control={form.control}
491
+ name="valor"
492
+ render={({ field }) => (
493
+ <FormItem>
494
+ <FormLabel>{t('fields.totalValue')}</FormLabel>
495
+ <FormControl>
496
+ <InputMoney
497
+ ref={field.ref}
498
+ name={field.name}
499
+ value={field.value}
500
+ onBlur={field.onBlur}
501
+ onValueChange={(value) => field.onChange(value ?? 0)}
502
+ placeholder="0,00"
503
+ />
504
+ </FormControl>
505
+ <FormMessage />
506
+ </FormItem>
507
+ )}
508
+ />
509
+
510
+ <FormField
511
+ control={form.control}
512
+ name="categoriaId"
513
+ render={({ field }) => (
514
+ <FormItem>
515
+ <FormLabel>{t('fields.category')}</FormLabel>
516
+ <Select value={field.value} onValueChange={field.onChange}>
517
+ <FormControl>
518
+ <SelectTrigger className="w-full">
519
+ <SelectValue placeholder={t('common.select')} />
520
+ </SelectTrigger>
521
+ </FormControl>
522
+ <SelectContent>
523
+ {categorias
524
+ .filter((c) => c.natureza === 'despesa')
525
+ .map((c) => (
526
+ <SelectItem key={c.id} value={String(c.id)}>
527
+ {c.codigo} - {c.nome}
528
+ </SelectItem>
529
+ ))}
530
+ </SelectContent>
531
+ </Select>
532
+ <FormMessage />
533
+ </FormItem>
534
+ )}
535
+ />
536
+
537
+ <FormField
538
+ control={form.control}
539
+ name="centroCustoId"
540
+ render={({ field }) => (
541
+ <FormItem>
542
+ <FormLabel>{t('fields.costCenter')}</FormLabel>
543
+ <Select value={field.value} onValueChange={field.onChange}>
544
+ <FormControl>
545
+ <SelectTrigger className="w-full">
546
+ <SelectValue placeholder={t('common.select')} />
547
+ </SelectTrigger>
548
+ </FormControl>
549
+ <SelectContent>
550
+ {centrosCusto.map((c) => (
551
+ <SelectItem key={c.id} value={String(c.id)}>
552
+ {c.codigo} - {c.nome}
553
+ </SelectItem>
554
+ ))}
555
+ </SelectContent>
556
+ </Select>
557
+ <FormMessage />
558
+ </FormItem>
559
+ )}
560
+ />
561
+
562
+ <FormField
563
+ control={form.control}
564
+ name="metodo"
565
+ render={({ field }) => (
566
+ <FormItem>
567
+ <FormLabel>{t('fields.paymentMethod')}</FormLabel>
568
+ <Select value={field.value} onValueChange={field.onChange}>
569
+ <FormControl>
570
+ <SelectTrigger className="w-full">
571
+ <SelectValue placeholder={t('common.select')} />
572
+ </SelectTrigger>
573
+ </FormControl>
574
+ <SelectContent>
575
+ <SelectItem value="boleto">
576
+ {t('paymentMethods.boleto')}
577
+ </SelectItem>
578
+ <SelectItem value="pix">PIX</SelectItem>
579
+ <SelectItem value="transferencia">
580
+ {t('paymentMethods.transfer')}
581
+ </SelectItem>
582
+ <SelectItem value="cartao">
583
+ {t('paymentMethods.card')}
584
+ </SelectItem>
585
+ <SelectItem value="dinheiro">
586
+ {t('paymentMethods.cash')}
587
+ </SelectItem>
588
+ <SelectItem value="cheque">
589
+ {t('paymentMethods.check')}
590
+ </SelectItem>
591
+ </SelectContent>
592
+ </Select>
593
+ <FormMessage />
594
+ </FormItem>
595
+ )}
596
+ />
597
+
598
+ <FormField
599
+ control={form.control}
600
+ name="descricao"
601
+ render={({ field }) => (
602
+ <FormItem>
603
+ <FormLabel>{t('fields.description')}</FormLabel>
604
+ <FormControl>
605
+ <Textarea
606
+ placeholder={t('newTitle.descriptionPlaceholder')}
607
+ {...field}
608
+ value={field.value || ''}
609
+ />
610
+ </FormControl>
611
+ <FormMessage />
612
+ </FormItem>
613
+ )}
614
+ />
615
+ </div>
616
+
617
+ <div className="flex justify-end gap-2 pt-4">
618
+ <Button type="button" variant="outline" onClick={handleCancel}>
619
+ {t('common.cancel')}
620
+ </Button>
621
+ <Button
622
+ type="submit"
623
+ disabled={
624
+ form.formState.isSubmitting ||
625
+ isUploadingFile ||
626
+ isExtractingFileData
627
+ }
628
+ >
629
+ {(isUploadingFile || isExtractingFileData) && (
630
+ <Loader2 className="mr-2 h-4 w-4 animate-spin" />
631
+ )}
632
+ {isExtractingFileData
633
+ ? 'Preenchendo com IA...'
634
+ : isUploadingFile
635
+ ? 'Enviando arquivo...'
636
+ : t('common.save')}
637
+ </Button>
638
+ </div>
639
+ </form>
640
+ </Form>
641
+ </SheetContent>
642
+ </Sheet>
643
+ );
644
+ }
645
+
646
+ export default function TitulosPagarPage() {
647
+ const t = useTranslations('finance.PayableInstallmentsPage');
648
+ const { request } = useApp();
649
+ const { data, refetch } = useFinanceData();
650
+ const { titulosPagar, pessoas } = data;
651
+
652
+ const { data: categoriasData } = useQuery<any[]>({
653
+ queryKey: ['finance-categories-options'],
654
+ queryFn: async () => {
655
+ const response = await request({
656
+ url: '/finance/categories',
657
+ method: 'GET',
658
+ });
659
+
660
+ return (response.data || []) as any[];
661
+ },
662
+ placeholderData: (old) => old,
663
+ });
664
+
665
+ const { data: centrosCustoData } = useQuery<any[]>({
666
+ queryKey: ['finance-cost-centers-options'],
667
+ queryFn: async () => {
668
+ const response = await request({
669
+ url: '/finance/cost-centers',
670
+ method: 'GET',
671
+ });
672
+
673
+ return (response.data || []) as any[];
674
+ },
675
+ placeholderData: (old) => old,
676
+ });
677
+
678
+ const categorias = categoriasData || [];
679
+ const centrosCusto = centrosCustoData || [];
680
+
681
+ const getPessoaById = (id?: string) => pessoas.find((p) => p.id === id);
682
+ const getCategoriaById = (id?: string) => categorias.find((c) => c.id === id);
683
+
684
+ const [search, setSearch] = useState('');
685
+ const [statusFilter, setStatusFilter] = useState<string>('');
686
+
687
+ const filteredTitulos = titulosPagar.filter((titulo) => {
688
+ const matchesSearch =
689
+ titulo.documento.toLowerCase().includes(search.toLowerCase()) ||
690
+ getPessoaById(titulo.fornecedorId)
691
+ ?.nome.toLowerCase()
692
+ .includes(search.toLowerCase());
693
+
694
+ const matchesStatus = !statusFilter || titulo.status === statusFilter;
695
+
696
+ return matchesSearch && matchesStatus;
697
+ });
698
+
699
+ return (
700
+ <Page>
701
+ <PageHeader
702
+ title={t('header.title')}
703
+ description={t('header.description')}
704
+ breadcrumbs={[
705
+ { label: t('breadcrumbs.home'), href: '/' },
706
+ { label: t('breadcrumbs.finance'), href: '/finance' },
707
+ { label: t('breadcrumbs.current') },
708
+ ]}
709
+ actions={
710
+ <NovoTituloSheet
711
+ categorias={categorias}
712
+ centrosCusto={centrosCusto}
713
+ t={t}
714
+ onCreated={refetch}
715
+ />
716
+ }
717
+ />
718
+
719
+ <FilterBar
720
+ searchPlaceholder={t('filters.searchPlaceholder')}
721
+ searchValue={search}
722
+ onSearchChange={setSearch}
723
+ filters={[
724
+ {
725
+ id: 'status',
726
+ label: t('filters.status'),
727
+ value: statusFilter,
728
+ onChange: setStatusFilter,
729
+ options: [
730
+ { value: 'all', label: t('statuses.all') },
731
+ { value: 'rascunho', label: t('statuses.rascunho') },
732
+ { value: 'aprovado', label: t('statuses.aprovado') },
733
+ { value: 'aberto', label: t('statuses.aberto') },
734
+ { value: 'parcial', label: t('statuses.parcial') },
735
+ { value: 'liquidado', label: t('statuses.liquidado') },
736
+ { value: 'vencido', label: t('statuses.vencido') },
737
+ { value: 'cancelado', label: t('statuses.cancelado') },
738
+ ],
739
+ },
740
+ ]}
741
+ activeFilters={statusFilter && statusFilter !== 'all' ? 1 : 0}
742
+ onClearFilters={() => setStatusFilter('')}
743
+ />
744
+
745
+ <div className="rounded-md border">
746
+ <Table>
747
+ <TableHeader>
748
+ <TableRow>
749
+ <TableHead>{t('table.headers.document')}</TableHead>
750
+ <TableHead>{t('table.headers.supplier')}</TableHead>
751
+ <TableHead>{t('table.headers.competency')}</TableHead>
752
+ <TableHead>{t('table.headers.dueDate')}</TableHead>
753
+ <TableHead className="text-right">
754
+ {t('table.headers.value')}
755
+ </TableHead>
756
+ <TableHead>{t('table.headers.category')}</TableHead>
757
+ <TableHead>{t('table.headers.status')}</TableHead>
758
+ <TableHead className="w-[50px]" />
759
+ </TableRow>
760
+ </TableHeader>
761
+ <TableBody>
762
+ {filteredTitulos.map((titulo) => {
763
+ const fornecedor = getPessoaById(titulo.fornecedorId);
764
+ const categoria = getCategoriaById(titulo.categoriaId);
765
+ const proximaParcela = titulo.parcelas.find(
766
+ (p: any) => p.status === 'aberto' || p.status === 'vencido'
767
+ );
768
+
769
+ return (
770
+ <TableRow key={titulo.id}>
771
+ <TableCell className="font-medium">
772
+ <Link
773
+ href={`/finance/accounts-payable/installments/${titulo.id}`}
774
+ className="hover:underline"
775
+ >
776
+ {titulo.documento}
777
+ </Link>
778
+ {titulo.anexos.length > 0 && (
779
+ <Paperclip className="ml-1 inline h-3 w-3 text-muted-foreground" />
780
+ )}
781
+ </TableCell>
782
+ <TableCell>{fornecedor?.nome}</TableCell>
783
+ <TableCell>{titulo.competencia}</TableCell>
784
+ <TableCell>
785
+ {proximaParcela
786
+ ? formatarData(proximaParcela.vencimento)
787
+ : '-'}
788
+ </TableCell>
789
+ <TableCell className="text-right">
790
+ <Money value={titulo.valorTotal} />
791
+ </TableCell>
792
+ <TableCell>{categoria?.nome}</TableCell>
793
+ <TableCell>
794
+ <StatusBadge status={titulo.status} />
795
+ </TableCell>
796
+ <TableCell>
797
+ <DropdownMenu>
798
+ <DropdownMenuTrigger asChild>
799
+ <Button variant="ghost" size="icon">
800
+ <MoreHorizontal className="h-4 w-4" />
801
+ <span className="sr-only">
802
+ {t('table.actions.srActions')}
803
+ </span>
804
+ </Button>
805
+ </DropdownMenuTrigger>
806
+ <DropdownMenuContent align="end">
807
+ <DropdownMenuItem asChild>
808
+ <Link
809
+ href={`/finance/accounts-payable/installments/${titulo.id}`}
810
+ >
811
+ <Eye className="mr-2 h-4 w-4" />
812
+ {t('table.actions.viewDetails')}
813
+ </Link>
814
+ </DropdownMenuItem>
815
+ <DropdownMenuItem>
816
+ <Edit className="mr-2 h-4 w-4" />
817
+ {t('table.actions.edit')}
818
+ </DropdownMenuItem>
819
+ <DropdownMenuSeparator />
820
+ <DropdownMenuItem>
821
+ <CheckCircle className="mr-2 h-4 w-4" />
822
+ {t('table.actions.approve')}
823
+ </DropdownMenuItem>
824
+ <DropdownMenuItem>
825
+ <Download className="mr-2 h-4 w-4" />
826
+ {t('table.actions.settle')}
827
+ </DropdownMenuItem>
828
+ <DropdownMenuItem>
829
+ <Undo className="mr-2 h-4 w-4" />
830
+ {t('table.actions.reverse')}
831
+ </DropdownMenuItem>
832
+ <DropdownMenuSeparator />
833
+ <DropdownMenuItem className="text-destructive">
834
+ <XCircle className="mr-2 h-4 w-4" />
835
+ {t('table.actions.cancel')}
836
+ </DropdownMenuItem>
837
+ </DropdownMenuContent>
838
+ </DropdownMenu>
839
+ </TableCell>
840
+ </TableRow>
841
+ );
842
+ })}
843
+ </TableBody>
844
+ </Table>
845
+ </div>
846
+
847
+ <div className="flex items-center justify-between">
848
+ <p className="text-sm text-muted-foreground">
849
+ {t('footer.showing', {
850
+ filtered: filteredTitulos.length,
851
+ total: titulosPagar.length,
852
+ })}
853
+ </p>
854
+ <div className="flex items-center gap-2">
855
+ <Button variant="outline" size="sm" disabled>
856
+ {t('footer.previous')}
857
+ </Button>
858
+ <Button variant="outline" size="sm" disabled>
859
+ {t('footer.next')}
860
+ </Button>
861
+ </div>
862
+ </div>
863
+ </Page>
864
+ );
865
+ }