@hed-hog/finance 0.0.252 → 0.0.256

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 (32) hide show
  1. package/dist/dto/reverse-settlement.dto.d.ts +1 -0
  2. package/dist/dto/reverse-settlement.dto.d.ts.map +1 -1
  3. package/dist/dto/reverse-settlement.dto.js +5 -0
  4. package/dist/dto/reverse-settlement.dto.js.map +1 -1
  5. package/dist/finance-installments.controller.d.ts +106 -4
  6. package/dist/finance-installments.controller.d.ts.map +1 -1
  7. package/dist/finance-installments.controller.js +38 -2
  8. package/dist/finance-installments.controller.js.map +1 -1
  9. package/dist/finance.service.d.ts +104 -2
  10. package/dist/finance.service.d.ts.map +1 -1
  11. package/dist/finance.service.js +366 -121
  12. package/dist/finance.service.js.map +1 -1
  13. package/hedhog/data/route.yaml +27 -0
  14. package/hedhog/frontend/app/_components/finance-entity-field-with-create.tsx.ejs +572 -0
  15. package/hedhog/frontend/app/_components/finance-title-actions-menu.tsx.ejs +244 -0
  16. package/hedhog/frontend/app/_components/person-field-with-create.tsx.ejs +143 -51
  17. package/hedhog/frontend/app/_lib/title-action-rules.ts.ejs +36 -0
  18. package/hedhog/frontend/app/accounts-payable/installments/[id]/page.tsx.ejs +449 -293
  19. package/hedhog/frontend/app/accounts-payable/installments/page.tsx.ejs +1189 -545
  20. package/hedhog/frontend/app/accounts-receivable/installments/[id]/page.tsx.ejs +176 -133
  21. package/hedhog/frontend/app/accounts-receivable/installments/page.tsx.ejs +1459 -312
  22. package/hedhog/frontend/app/page.tsx.ejs +15 -4
  23. package/hedhog/frontend/messages/en.json +294 -5
  24. package/hedhog/frontend/messages/pt.json +294 -5
  25. package/hedhog/query/settlement-auditability.sql +175 -0
  26. package/hedhog/table/bank_reconciliation.yaml +11 -0
  27. package/hedhog/table/settlement.yaml +17 -1
  28. package/hedhog/table/settlement_allocation.yaml +3 -0
  29. package/package.json +7 -7
  30. package/src/dto/reverse-settlement.dto.ts +4 -0
  31. package/src/finance-installments.controller.ts +45 -12
  32. package/src/finance.service.ts +521 -146
@@ -1,16 +1,14 @@
1
1
  'use client';
2
2
 
3
+ import {
4
+ CategoryFieldWithCreate,
5
+ CostCenterFieldWithCreate,
6
+ } from '@/app/(app)/(libraries)/finance/_components/finance-entity-field-with-create';
7
+ import { FinanceTitleActionsMenu } from '@/app/(app)/(libraries)/finance/_components/finance-title-actions-menu';
3
8
  import { PersonFieldWithCreate } from '@/app/(app)/(libraries)/finance/_components/person-field-with-create';
4
9
  import { Page, PageHeader } from '@/components/entity-list';
5
10
  import { Button } from '@/components/ui/button';
6
11
  import { Checkbox } from '@/components/ui/checkbox';
7
- import {
8
- DropdownMenu,
9
- DropdownMenuContent,
10
- DropdownMenuItem,
11
- DropdownMenuSeparator,
12
- DropdownMenuTrigger,
13
- } from '@/components/ui/dropdown-menu';
14
12
  import { FilterBar } from '@/components/ui/filter-bar';
15
13
  import {
16
14
  Form,
@@ -50,20 +48,14 @@ import {
50
48
  TableRow,
51
49
  } from '@/components/ui/table';
52
50
  import { Textarea } from '@/components/ui/textarea';
51
+ import {
52
+ Tooltip,
53
+ TooltipContent,
54
+ TooltipTrigger,
55
+ } from '@/components/ui/tooltip';
53
56
  import { useApp, useQuery } from '@hed-hog/next-app-provider';
54
57
  import { zodResolver } from '@hookform/resolvers/zod';
55
- import {
56
- CheckCircle,
57
- Download,
58
- Edit,
59
- Eye,
60
- Loader2,
61
- MoreHorizontal,
62
- Paperclip,
63
- Plus,
64
- Undo,
65
- XCircle,
66
- } from 'lucide-react';
58
+ import { Loader2, Paperclip, Plus, Trash2, Upload } from 'lucide-react';
67
59
  import { useTranslations } from 'next-intl';
68
60
  import Link from 'next/link';
69
61
  import { usePathname, useRouter, useSearchParams } from 'next/navigation';
@@ -71,6 +63,14 @@ import { useEffect, useMemo, useRef, useState } from 'react';
71
63
  import { useFieldArray, useForm } from 'react-hook-form';
72
64
  import { z } from 'zod';
73
65
  import { formatarData } from '../../_lib/formatters';
66
+ import {
67
+ canApproveTitle,
68
+ canCancelTitle,
69
+ canEditTitle,
70
+ canReverseTitle,
71
+ canSettleTitle,
72
+ getFirstActiveSettlementId,
73
+ } from '../../_lib/title-action-rules';
74
74
  import { useFinanceData } from '../../_lib/use-finance-data';
75
75
 
76
76
  const INSTALLMENT_REDISTRIBUTION_DEBOUNCE_MS = 300;
@@ -170,69 +170,87 @@ const redistributeRemainingInstallments = (
170
170
  });
171
171
  };
172
172
 
173
- const newTitleFormSchema = z
174
- .object({
175
- documento: z.string().trim().min(1, 'Documento é obrigatório'),
176
- fornecedorId: z.string().min(1, 'Fornecedor é obrigatório'),
177
- competencia: z.string().optional(),
178
- vencimento: z.string().min(1, 'Vencimento é obrigatório'),
179
- valor: z.number().min(0.01, 'Valor deve ser maior que zero'),
180
- installmentsCount: z.coerce
181
- .number({ invalid_type_error: 'Quantidade de parcelas inválida' })
182
- .int('Quantidade de parcelas inválida')
183
- .min(1, 'Mínimo de 1 parcela')
184
- .max(120, 'Máximo de 120 parcelas'),
185
- installments: z
186
- .array(
187
- z.object({
188
- dueDate: z.string().min(1, 'Vencimento da parcela é obrigatório'),
189
- amount: z
190
- .number()
191
- .min(0.01, 'Valor da parcela deve ser maior que zero'),
192
- })
193
- )
194
- .min(1, 'Informe ao menos uma parcela'),
195
- categoriaId: z.string().optional(),
196
- centroCustoId: z.string().optional(),
197
- metodo: z.string().optional(),
198
- descricao: z.string().optional(),
199
- })
200
- .superRefine((values, ctx) => {
201
- if (values.installments.length !== values.installmentsCount) {
202
- ctx.addIssue({
203
- code: z.ZodIssueCode.custom,
204
- path: ['installments'],
205
- message: 'Quantidade de parcelas não confere com o detalhamento',
206
- });
207
- }
173
+ const getNewTitleFormSchema = (t: ReturnType<typeof useTranslations>) =>
174
+ z
175
+ .object({
176
+ documento: z.string().trim().min(1, t('validation.documentRequired')),
177
+ fornecedorId: z.string().min(1, t('validation.supplierRequired')),
178
+ competencia: z.string().optional(),
179
+ vencimento: z.string().min(1, t('validation.dueDateRequired')),
180
+ valor: z.number().min(0.01, t('validation.amountGreaterThanZero')),
181
+ installmentsCount: z.coerce
182
+ .number({ invalid_type_error: t('validation.invalidInstallmentCount') })
183
+ .int(t('validation.invalidInstallmentCount'))
184
+ .min(1, t('validation.installmentsMin'))
185
+ .max(120, t('validation.installmentsMax')),
186
+ installments: z
187
+ .array(
188
+ z.object({
189
+ dueDate: z
190
+ .string()
191
+ .min(1, t('validation.installmentDueDateRequired')),
192
+ amount: z
193
+ .number()
194
+ .min(0.01, t('validation.installmentAmountGreaterThanZero')),
195
+ })
196
+ )
197
+ .min(1, t('validation.installmentsRequired')),
198
+ categoriaId: z.string().optional(),
199
+ centroCustoId: z.string().optional(),
200
+ metodo: z.string().optional(),
201
+ descricao: z.string().optional(),
202
+ })
203
+ .superRefine((values, ctx) => {
204
+ if (values.installments.length !== values.installmentsCount) {
205
+ ctx.addIssue({
206
+ code: z.ZodIssueCode.custom,
207
+ path: ['installments'],
208
+ message: t('validation.installmentsCountMismatch'),
209
+ });
210
+ }
208
211
 
209
- const installmentsTotalCents = values.installments.reduce(
210
- (acc, installment) => acc + Math.round((installment.amount || 0) * 100),
211
- 0
212
- );
213
- const totalCents = Math.round((values.valor || 0) * 100);
212
+ const installmentsTotalCents = values.installments.reduce(
213
+ (acc, installment) => acc + Math.round((installment.amount || 0) * 100),
214
+ 0
215
+ );
216
+ const totalCents = Math.round((values.valor || 0) * 100);
214
217
 
215
- if (installmentsTotalCents !== totalCents) {
216
- ctx.addIssue({
217
- code: z.ZodIssueCode.custom,
218
- path: ['installments'],
219
- message: 'A soma das parcelas deve ser igual ao valor total',
220
- });
221
- }
218
+ if (installmentsTotalCents !== totalCents) {
219
+ ctx.addIssue({
220
+ code: z.ZodIssueCode.custom,
221
+ path: ['installments'],
222
+ message: t('validation.installmentsSumMismatch'),
223
+ });
224
+ }
225
+ });
226
+
227
+ type NewTitleFormValues = z.infer<ReturnType<typeof getNewTitleFormSchema>>;
228
+
229
+ const getSettleTitleFormSchema = (t: ReturnType<typeof useTranslations>) =>
230
+ z.object({
231
+ installmentId: z.string().min(1, t('validation.installmentRequired')),
232
+ amount: z.number().min(0.01, t('validation.amountGreaterThanZero')),
233
+ description: z.string().optional(),
222
234
  });
223
235
 
224
- type NewTitleFormValues = z.infer<typeof newTitleFormSchema>;
236
+ type SettleTitleFormValues = z.infer<
237
+ ReturnType<typeof getSettleTitleFormSchema>
238
+ >;
225
239
 
226
240
  function NovoTituloSheet({
227
241
  categorias,
228
242
  centrosCusto,
229
243
  t,
230
244
  onCreated,
245
+ onCategoriesUpdated,
246
+ onCostCentersUpdated,
231
247
  }: {
232
248
  categorias: any[];
233
249
  centrosCusto: any[];
234
250
  t: ReturnType<typeof useTranslations>;
235
251
  onCreated: () => Promise<any> | void;
252
+ onCategoriesUpdated?: () => Promise<any> | void;
253
+ onCostCentersUpdated?: () => Promise<any> | void;
236
254
  }) {
237
255
  const { request, showToastHandler } = useApp();
238
256
  const [open, setOpen] = useState(false);
@@ -251,6 +269,7 @@ function NovoTituloSheet({
251
269
  >(null);
252
270
  const [extractionWarnings, setExtractionWarnings] = useState<string[]>([]);
253
271
  const [uploadProgress, setUploadProgress] = useState(0);
272
+ const fileInputRef = useRef<HTMLInputElement | null>(null);
254
273
 
255
274
  const normalizeFilenameForDisplay = (filename: string) => {
256
275
  if (!filename) {
@@ -270,6 +289,8 @@ function NovoTituloSheet({
270
289
  }
271
290
  };
272
291
 
292
+ const newTitleFormSchema = useMemo(() => getNewTitleFormSchema(t), [t]);
293
+
273
294
  const form = useForm<NewTitleFormValues>({
274
295
  resolver: zodResolver(newTitleFormSchema),
275
296
  defaultValues: {
@@ -421,9 +442,9 @@ function NovoTituloSheet({
421
442
  setIsInstallmentsEdited(false);
422
443
  setAutoRedistributeInstallments(true);
423
444
  setOpen(false);
424
- showToastHandler?.('success', 'Título criado com sucesso');
445
+ showToastHandler?.('success', t('messages.createSuccess'));
425
446
  } catch {
426
- showToastHandler?.('error', 'Erro ao criar título');
447
+ showToastHandler?.('error', t('messages.createError'));
427
448
  }
428
449
  };
429
450
 
@@ -439,6 +460,25 @@ function NovoTituloSheet({
439
460
  setOpen(false);
440
461
  };
441
462
 
463
+ const clearUploadedFile = () => {
464
+ setUploadedFileId(null);
465
+ setUploadedFileName('');
466
+ setExtractionConfidence(null);
467
+ setExtractionWarnings([]);
468
+ setUploadProgress(0);
469
+
470
+ if (fileInputRef.current) {
471
+ fileInputRef.current.value = '';
472
+ }
473
+ };
474
+
475
+ const handleSelectFile = () => {
476
+ if (fileInputRef.current) {
477
+ fileInputRef.current.value = '';
478
+ fileInputRef.current.click();
479
+ }
480
+ };
481
+
442
482
  const uploadRelatedFile = async (file: File) => {
443
483
  setIsUploadingFile(true);
444
484
  setUploadProgress(0);
@@ -466,7 +506,7 @@ function NovoTituloSheet({
466
506
  });
467
507
 
468
508
  if (!data?.id) {
469
- throw new Error('Arquivo inválido');
509
+ throw new Error(t('messages.invalidFile'));
470
510
  }
471
511
 
472
512
  setUploadedFileId(data.id);
@@ -474,7 +514,7 @@ function NovoTituloSheet({
474
514
  normalizeFilenameForDisplay(data.filename || file.name)
475
515
  );
476
516
  setUploadProgress(100);
477
- showToastHandler?.('success', 'Arquivo relacionado com sucesso');
517
+ showToastHandler?.('success', t('messages.attachSuccess'));
478
518
 
479
519
  setIsExtractingFileData(true);
480
520
  try {
@@ -563,17 +603,11 @@ function NovoTituloSheet({
563
603
  });
564
604
  }
565
605
 
566
- showToastHandler?.(
567
- 'success',
568
- 'Dados da fatura extraídos e preenchidos automaticamente'
569
- );
606
+ showToastHandler?.('success', t('messages.aiExtractSuccess'));
570
607
  } catch {
571
608
  setExtractionConfidence(null);
572
609
  setExtractionWarnings([]);
573
- showToastHandler?.(
574
- 'error',
575
- 'Não foi possível extrair os dados automaticamente'
576
- );
610
+ showToastHandler?.('error', t('messages.aiExtractError'));
577
611
  } finally {
578
612
  setIsExtractingFileData(false);
579
613
  }
@@ -583,7 +617,7 @@ function NovoTituloSheet({
583
617
  setExtractionConfidence(null);
584
618
  setExtractionWarnings([]);
585
619
  setUploadProgress(0);
586
- showToastHandler?.('error', 'Não foi possível enviar o arquivo');
620
+ showToastHandler?.('error', t('messages.uploadError'));
587
621
  } finally {
588
622
  setIsUploadingFile(false);
589
623
  }
@@ -604,11 +638,13 @@ function NovoTituloSheet({
604
638
  </SheetHeader>
605
639
  <Form {...form}>
606
640
  <form className="px-4" onSubmit={form.handleSubmit(handleSubmit)}>
607
- <div className="grid gap-4">
608
- <div className="grid gap-2">
609
- <FormLabel>Arquivo da fatura (opcional)</FormLabel>
610
- <div className="flex flex-col gap-2 sm:flex-row sm:items-center">
641
+ <div className="grid gap-3">
642
+ <div className="grid grid-cols-1 items-start gap-4 sm:grid-cols-2">
643
+ <div className="grid gap-2">
644
+ <FormLabel>{t('common.upload.label')}</FormLabel>
611
645
  <Input
646
+ ref={fileInputRef}
647
+ className="hidden"
612
648
  type="file"
613
649
  accept=".pdf,.png,.jpg,.jpeg,.xml,.txt"
614
650
  onChange={(event) => {
@@ -617,11 +653,7 @@ function NovoTituloSheet({
617
653
  return;
618
654
  }
619
655
 
620
- setUploadedFileId(null);
621
- setUploadedFileName('');
622
- setExtractionConfidence(null);
623
- setExtractionWarnings([]);
624
- setUploadProgress(0);
656
+ clearUploadedFile();
625
657
  void uploadRelatedFile(file);
626
658
  }}
627
659
  disabled={
@@ -630,67 +662,128 @@ function NovoTituloSheet({
630
662
  form.formState.isSubmitting
631
663
  }
632
664
  />
633
- </div>
634
- {isUploadingFile && (
635
- <div className="space-y-1">
636
- <Progress value={uploadProgress} className="h-2" />
637
- <p className="text-xs text-muted-foreground">
638
- Upload em andamento: {uploadProgress}%
639
- </p>
640
- </div>
641
- )}
642
- {uploadedFileId && (
643
- <p className="text-xs text-muted-foreground">
644
- Arquivo relacionado: {uploadedFileName}
645
- </p>
646
- )}
647
- {isExtractingFileData && (
648
- <div className="rounded-md border border-primary/30 bg-primary/5 p-3">
649
- <div className="flex items-center gap-2 text-sm font-medium text-primary">
650
- <Loader2 className="h-4 w-4 animate-spin" />
651
- Analisando documento com IA
652
- </div>
653
- <p className="mt-1 text-xs text-muted-foreground">
654
- Os campos serão preenchidos automaticamente em instantes.
655
- Revise os dados antes de salvar.
656
- </p>
665
+
666
+ <div className="grid w-full grid-cols-2 gap-2">
667
+ <Tooltip>
668
+ <TooltipTrigger asChild>
669
+ <Button
670
+ type="button"
671
+ variant="outline"
672
+ className={
673
+ uploadedFileId ? 'w-full' : 'col-span-2 w-full'
674
+ }
675
+ onClick={handleSelectFile}
676
+ aria-label={
677
+ uploadedFileId
678
+ ? t('common.upload.change')
679
+ : t('common.upload.upload')
680
+ }
681
+ disabled={
682
+ isUploadingFile ||
683
+ isExtractingFileData ||
684
+ form.formState.isSubmitting
685
+ }
686
+ >
687
+ {uploadedFileId ? (
688
+ <Upload className="h-4 w-4" />
689
+ ) : (
690
+ <>
691
+ <Upload className="mr-2 h-4 w-4" />
692
+ {t('common.upload.upload')}
693
+ </>
694
+ )}
695
+ </Button>
696
+ </TooltipTrigger>
697
+ <TooltipContent>
698
+ {uploadedFileId
699
+ ? t('common.upload.change')
700
+ : t('common.upload.upload')}
701
+ </TooltipContent>
702
+ </Tooltip>
703
+
704
+ {uploadedFileId && (
705
+ <Tooltip>
706
+ <TooltipTrigger asChild>
707
+ <Button
708
+ type="button"
709
+ variant="outline"
710
+ className="w-full"
711
+ onClick={clearUploadedFile}
712
+ aria-label={t('common.upload.remove')}
713
+ disabled={
714
+ isUploadingFile ||
715
+ isExtractingFileData ||
716
+ form.formState.isSubmitting
717
+ }
718
+ >
719
+ <Trash2 className="h-4 w-4" />
720
+ </Button>
721
+ </TooltipTrigger>
722
+ <TooltipContent>
723
+ {t('common.upload.remove')}
724
+ </TooltipContent>
725
+ </Tooltip>
726
+ )}
657
727
  </div>
658
- )}
659
- {!isExtractingFileData &&
660
- extractionConfidence !== null &&
661
- extractionConfidence < 70 && (
662
- <div className="rounded-md border border-destructive/30 bg-destructive/5 p-3">
663
- <div className="text-sm font-medium text-destructive">
664
- Confiança da extração:{' '}
665
- {Math.round(extractionConfidence)}%
728
+
729
+ <div className="space-y-1">
730
+ {uploadedFileId && (
731
+ <p className="truncate text-xs text-muted-foreground">
732
+ {t('common.upload.selectedPrefix')} {uploadedFileName}
733
+ </p>
734
+ )}
735
+
736
+ {isUploadingFile && !isExtractingFileData && (
737
+ <div className="space-y-1">
738
+ <Progress value={uploadProgress} className="h-2" />
739
+ <p className="text-xs text-muted-foreground">
740
+ {t('common.upload.uploadingProgress', {
741
+ progress: uploadProgress,
742
+ })}
743
+ </p>
666
744
  </div>
667
- <p className="mt-1 text-xs text-muted-foreground">
668
- Revise principalmente valor e vencimento antes de
669
- salvar.
745
+ )}
746
+
747
+ {isExtractingFileData && (
748
+ <p className="flex items-center gap-2 text-xs text-primary">
749
+ <Loader2 className="h-4 w-4 animate-spin" />
750
+ {t('common.upload.processingAi')}
670
751
  </p>
671
- {extractionWarnings.length > 0 && (
672
- <p className="mt-1 text-xs text-muted-foreground">
673
- {extractionWarnings[0]}
752
+ )}
753
+
754
+ {!isExtractingFileData &&
755
+ extractionConfidence !== null &&
756
+ extractionConfidence < 70 && (
757
+ <p className="text-xs text-destructive">
758
+ {t('common.upload.lowConfidence', {
759
+ confidence: Math.round(extractionConfidence),
760
+ })}
674
761
  </p>
675
762
  )}
676
- </div>
763
+
764
+ {!isExtractingFileData && extractionWarnings.length > 0 && (
765
+ <p className="truncate text-xs text-muted-foreground">
766
+ {extractionWarnings[0]}
767
+ </p>
768
+ )}
769
+ </div>
770
+ </div>
771
+
772
+ <FormField
773
+ control={form.control}
774
+ name="documento"
775
+ render={({ field }) => (
776
+ <FormItem>
777
+ <FormLabel>{t('fields.document')}</FormLabel>
778
+ <FormControl>
779
+ <Input placeholder="NF-00000" {...field} />
780
+ </FormControl>
781
+ <FormMessage />
782
+ </FormItem>
677
783
  )}
784
+ />
678
785
  </div>
679
786
 
680
- <FormField
681
- control={form.control}
682
- name="documento"
683
- render={({ field }) => (
684
- <FormItem>
685
- <FormLabel>{t('fields.document')}</FormLabel>
686
- <FormControl>
687
- <Input placeholder="NF-00000" {...field} />
688
- </FormControl>
689
- <FormMessage />
690
- </FormItem>
691
- )}
692
- />
693
-
694
787
  <PersonFieldWithCreate
695
788
  form={form}
696
789
  name="fornecedorId"
@@ -699,7 +792,7 @@ function NovoTituloSheet({
699
792
  selectPlaceholder={t('common.select')}
700
793
  />
701
794
 
702
- <div className="grid grid-cols-2 gap-4">
795
+ <div className="grid grid-cols-2 items-start gap-3">
703
796
  <FormField
704
797
  control={form.control}
705
798
  name="competencia"
@@ -737,55 +830,61 @@ function NovoTituloSheet({
737
830
  />
738
831
  </div>
739
832
 
740
- <FormField
741
- control={form.control}
742
- name="valor"
743
- render={({ field }) => (
744
- <FormItem>
745
- <FormLabel>{t('fields.totalValue')}</FormLabel>
746
- <FormControl>
747
- <InputMoney
748
- ref={field.ref}
749
- name={field.name}
750
- value={field.value}
751
- onBlur={field.onBlur}
752
- onValueChange={(value) => field.onChange(value ?? 0)}
753
- placeholder="0,00"
754
- />
755
- </FormControl>
756
- <FormMessage />
757
- </FormItem>
758
- )}
759
- />
833
+ <div className="grid grid-cols-1 items-start gap-3 sm:grid-cols-2">
834
+ <FormField
835
+ control={form.control}
836
+ name="valor"
837
+ render={({ field }) => (
838
+ <FormItem>
839
+ <FormLabel>{t('fields.totalValue')}</FormLabel>
840
+ <FormControl>
841
+ <InputMoney
842
+ ref={field.ref}
843
+ name={field.name}
844
+ value={field.value}
845
+ onBlur={field.onBlur}
846
+ onValueChange={(value) => field.onChange(value ?? 0)}
847
+ placeholder="0,00"
848
+ />
849
+ </FormControl>
850
+ <FormMessage />
851
+ </FormItem>
852
+ )}
853
+ />
760
854
 
761
- <FormField
762
- control={form.control}
763
- name="installmentsCount"
764
- render={({ field }) => (
765
- <FormItem>
766
- <FormLabel>Quantidade de Parcelas</FormLabel>
767
- <FormControl>
768
- <Input
769
- type="number"
770
- min={1}
771
- max={120}
772
- value={field.value}
773
- onChange={(event) => {
774
- const nextValue = Number(event.target.value || 1);
775
- field.onChange(
776
- Number.isNaN(nextValue) ? 1 : nextValue
777
- );
778
- }}
779
- />
780
- </FormControl>
781
- <FormMessage />
782
- </FormItem>
783
- )}
784
- />
855
+ <FormField
856
+ control={form.control}
857
+ name="installmentsCount"
858
+ render={({ field }) => (
859
+ <FormItem>
860
+ <FormLabel>
861
+ {t('installmentsEditor.countLabel')}
862
+ </FormLabel>
863
+ <FormControl>
864
+ <Input
865
+ type="number"
866
+ min={1}
867
+ max={120}
868
+ value={field.value}
869
+ onChange={(event) => {
870
+ const nextValue = Number(event.target.value || 1);
871
+ field.onChange(
872
+ Number.isNaN(nextValue) ? 1 : nextValue
873
+ );
874
+ }}
875
+ />
876
+ </FormControl>
877
+ <FormMessage />
878
+ </FormItem>
879
+ )}
880
+ />
881
+ </div>
785
882
 
786
883
  <div className="space-y-3 rounded-md border p-3">
787
884
  <div className="flex items-center justify-between gap-2">
788
- <p className="text-sm font-medium">Parcelas</p>
885
+ <p className="text-sm font-medium">
886
+ {t('installmentsEditor.title')}
887
+ </p>
789
888
  <Button
790
889
  type="button"
791
890
  variant="outline"
@@ -801,7 +900,7 @@ function NovoTituloSheet({
801
900
  );
802
901
  }}
803
902
  >
804
- Recalcular automaticamente
903
+ {t('installmentsEditor.recalculate')}
805
904
  </Button>
806
905
  </div>
807
906
 
@@ -817,13 +916,12 @@ function NovoTituloSheet({
817
916
  htmlFor="auto-redistribute-installments-payable"
818
917
  className="text-xs text-muted-foreground"
819
918
  >
820
- Redistribuir automaticamente o restante ao editar parcela
919
+ {t('installmentsEditor.autoRedistributeLabel')}
821
920
  </Label>
822
921
  </div>
823
922
  {autoRedistributeInstallments && (
824
923
  <p className="text-xs text-muted-foreground">
825
- A redistribuição ocorre ao parar de digitar e ao sair do
826
- campo.
924
+ {t('installmentsEditor.autoRedistributeHint')}
827
925
  </p>
828
926
  )}
829
927
 
@@ -831,7 +929,7 @@ function NovoTituloSheet({
831
929
  {installmentFields.map((installment, index) => (
832
930
  <div
833
931
  key={installment.id}
834
- className="grid grid-cols-1 gap-2 rounded-md border p-2 sm:grid-cols-[96px_1fr_160px]"
932
+ className="grid grid-cols-1 items-start gap-2 rounded-md border p-2 sm:grid-cols-[96px_1fr_160px]"
835
933
  >
836
934
  <div className="flex items-center text-sm text-muted-foreground">
837
935
  #{index + 1}
@@ -843,7 +941,7 @@ function NovoTituloSheet({
843
941
  render={({ field }) => (
844
942
  <FormItem>
845
943
  <FormLabel className="text-xs">
846
- Vencimento
944
+ {t('installmentsEditor.dueDateLabel')}
847
945
  </FormLabel>
848
946
  <FormControl>
849
947
  <Input
@@ -866,7 +964,9 @@ function NovoTituloSheet({
866
964
  name={`installments.${index}.amount` as const}
867
965
  render={({ field }) => (
868
966
  <FormItem>
869
- <FormLabel className="text-xs">Valor</FormLabel>
967
+ <FormLabel className="text-xs">
968
+ {t('installmentsEditor.amountLabel')}
969
+ </FormLabel>
870
970
  <FormControl>
871
971
  <InputMoney
872
972
  ref={field.ref}
@@ -910,8 +1010,11 @@ function NovoTituloSheet({
910
1010
  : 'text-destructive'
911
1011
  }`}
912
1012
  >
913
- Soma das parcelas: {installmentsTotal.toFixed(2)}
914
- {installmentsDiffCents > 0 && ' (ajuste necessário)'}
1013
+ {t('installmentsEditor.totalPrefix', {
1014
+ total: installmentsTotal.toFixed(2),
1015
+ })}
1016
+ {installmentsDiffCents > 0 &&
1017
+ ` ${t('installmentsEditor.adjustmentNeeded')}`}
915
1018
  </p>
916
1019
  {form.formState.errors.installments?.message && (
917
1020
  <p className="text-xs text-destructive">
@@ -920,56 +1023,23 @@ function NovoTituloSheet({
920
1023
  )}
921
1024
  </div>
922
1025
 
923
- <FormField
924
- control={form.control}
1026
+ <CategoryFieldWithCreate
1027
+ form={form}
925
1028
  name="categoriaId"
926
- render={({ field }) => (
927
- <FormItem>
928
- <FormLabel>{t('fields.category')}</FormLabel>
929
- <Select value={field.value} onValueChange={field.onChange}>
930
- <FormControl>
931
- <SelectTrigger className="w-full">
932
- <SelectValue placeholder={t('common.select')} />
933
- </SelectTrigger>
934
- </FormControl>
935
- <SelectContent>
936
- {categorias
937
- .filter((c) => c.natureza === 'despesa')
938
- .map((c) => (
939
- <SelectItem key={c.id} value={String(c.id)}>
940
- {c.codigo} - {c.nome}
941
- </SelectItem>
942
- ))}
943
- </SelectContent>
944
- </Select>
945
- <FormMessage />
946
- </FormItem>
947
- )}
1029
+ label={t('fields.category')}
1030
+ selectPlaceholder={t('common.select')}
1031
+ categories={categorias}
1032
+ categoryKind="despesa"
1033
+ onCreated={onCategoriesUpdated}
948
1034
  />
949
1035
 
950
- <FormField
951
- control={form.control}
1036
+ <CostCenterFieldWithCreate
1037
+ form={form}
952
1038
  name="centroCustoId"
953
- render={({ field }) => (
954
- <FormItem>
955
- <FormLabel>{t('fields.costCenter')}</FormLabel>
956
- <Select value={field.value} onValueChange={field.onChange}>
957
- <FormControl>
958
- <SelectTrigger className="w-full">
959
- <SelectValue placeholder={t('common.select')} />
960
- </SelectTrigger>
961
- </FormControl>
962
- <SelectContent>
963
- {centrosCusto.map((c) => (
964
- <SelectItem key={c.id} value={String(c.id)}>
965
- {c.codigo} - {c.nome}
966
- </SelectItem>
967
- ))}
968
- </SelectContent>
969
- </Select>
970
- <FormMessage />
971
- </FormItem>
972
- )}
1039
+ label={t('fields.costCenter')}
1040
+ selectPlaceholder={t('common.select')}
1041
+ costCenters={centrosCusto}
1042
+ onCreated={onCostCentersUpdated}
973
1043
  />
974
1044
 
975
1045
  <FormField
@@ -1027,10 +1097,7 @@ function NovoTituloSheet({
1027
1097
  />
1028
1098
  </div>
1029
1099
 
1030
- <div className="flex justify-end gap-2 pt-4">
1031
- <Button type="button" variant="outline" onClick={handleCancel}>
1032
- {t('common.cancel')}
1033
- </Button>
1100
+ <div className="flex flex-col gap-2 py-4">
1034
1101
  <Button
1035
1102
  type="submit"
1036
1103
  disabled={
@@ -1043,9 +1110,9 @@ function NovoTituloSheet({
1043
1110
  <Loader2 className="mr-2 h-4 w-4 animate-spin" />
1044
1111
  )}
1045
1112
  {isExtractingFileData
1046
- ? 'Preenchendo com IA...'
1113
+ ? t('common.upload.fillingWithAi')
1047
1114
  : isUploadingFile
1048
- ? 'Enviando arquivo...'
1115
+ ? t('common.upload.uploadingFile')
1049
1116
  : t('common.save')}
1050
1117
  </Button>
1051
1118
  </div>
@@ -1060,29 +1127,61 @@ function EditarTituloSheet({
1060
1127
  open,
1061
1128
  onOpenChange,
1062
1129
  titulo,
1063
- pessoas,
1064
1130
  categorias,
1065
1131
  centrosCusto,
1066
1132
  t,
1067
1133
  onUpdated,
1134
+ onCategoriesUpdated,
1135
+ onCostCentersUpdated,
1068
1136
  }: {
1069
1137
  open: boolean;
1070
1138
  onOpenChange: (open: boolean) => void;
1071
1139
  titulo?: any;
1072
- pessoas: any[];
1073
1140
  categorias: any[];
1074
1141
  centrosCusto: any[];
1075
1142
  t: ReturnType<typeof useTranslations>;
1076
1143
  onUpdated: () => Promise<any> | void;
1144
+ onCategoriesUpdated?: () => Promise<any> | void;
1145
+ onCostCentersUpdated?: () => Promise<any> | void;
1077
1146
  }) {
1078
1147
  const { request, showToastHandler } = useApp();
1148
+ const [uploadedFileId, setUploadedFileId] = useState<number | null>(null);
1149
+ const [uploadedFileName, setUploadedFileName] = useState('');
1150
+ const [isUploadingFile, setIsUploadingFile] = useState(false);
1151
+ const [isExtractingFileData, setIsExtractingFileData] = useState(false);
1079
1152
  const [isInstallmentsEdited, setIsInstallmentsEdited] = useState(false);
1080
1153
  const [autoRedistributeInstallments, setAutoRedistributeInstallments] =
1081
1154
  useState(true);
1155
+ const [extractionConfidence, setExtractionConfidence] = useState<
1156
+ number | null
1157
+ >(null);
1158
+ const [extractionWarnings, setExtractionWarnings] = useState<string[]>([]);
1159
+ const [uploadProgress, setUploadProgress] = useState(0);
1160
+ const fileInputRef = useRef<HTMLInputElement | null>(null);
1082
1161
  const redistributionTimeoutRef = useRef<
1083
1162
  Record<number, ReturnType<typeof setTimeout>>
1084
1163
  >({});
1085
1164
 
1165
+ const normalizeFilenameForDisplay = (filename: string) => {
1166
+ if (!filename) {
1167
+ return filename;
1168
+ }
1169
+
1170
+ if (!/Ã.|Â.|â[\u0080-\u00BF]/.test(filename)) {
1171
+ return filename;
1172
+ }
1173
+
1174
+ try {
1175
+ const bytes = Uint8Array.from(filename, (char) => char.charCodeAt(0));
1176
+ const decoded = new TextDecoder('utf-8').decode(bytes);
1177
+ return /Ã.|Â.|â[\u0080-\u00BF]/.test(decoded) ? filename : decoded;
1178
+ } catch {
1179
+ return filename;
1180
+ }
1181
+ };
1182
+
1183
+ const newTitleFormSchema = useMemo(() => getNewTitleFormSchema(t), [t]);
1184
+
1086
1185
  const form = useForm<NewTitleFormValues>({
1087
1186
  resolver: zodResolver(newTitleFormSchema),
1088
1187
  defaultValues: {
@@ -1162,6 +1261,40 @@ function EditarTituloSheet({
1162
1261
  descricao: titulo.descricao || '',
1163
1262
  });
1164
1263
 
1264
+ const attachmentSource = Array.isArray(titulo.anexosDetalhes)
1265
+ ? titulo.anexosDetalhes
1266
+ : Array.isArray(titulo.anexos)
1267
+ ? titulo.anexos
1268
+ : [];
1269
+ const firstAttachment = attachmentSource[0] as any;
1270
+ const firstAttachmentIdRaw =
1271
+ firstAttachment && typeof firstAttachment === 'object'
1272
+ ? (firstAttachment.id ??
1273
+ firstAttachment.file_id ??
1274
+ firstAttachment.fileId)
1275
+ : undefined;
1276
+ const parsedAttachmentId = Number(firstAttachmentIdRaw);
1277
+
1278
+ setUploadedFileId(
1279
+ Number.isFinite(parsedAttachmentId) ? parsedAttachmentId : null
1280
+ );
1281
+ setUploadedFileName(
1282
+ normalizeFilenameForDisplay(
1283
+ typeof firstAttachment === 'string'
1284
+ ? firstAttachment
1285
+ : firstAttachment?.filename ||
1286
+ firstAttachment?.originalname ||
1287
+ firstAttachment?.name ||
1288
+ firstAttachment?.nome ||
1289
+ firstAttachment?.file_name ||
1290
+ firstAttachment?.fileName ||
1291
+ ''
1292
+ )
1293
+ );
1294
+ setExtractionConfidence(null);
1295
+ setExtractionWarnings([]);
1296
+ setUploadProgress(0);
1297
+
1165
1298
  setIsInstallmentsEdited(true);
1166
1299
  }, [form, open, titulo]);
1167
1300
 
@@ -1252,7 +1385,7 @@ function EditarTituloSheet({
1252
1385
 
1253
1386
  const handleSubmit = async (values: NewTitleFormValues) => {
1254
1387
  if (!titulo?.id) {
1255
- showToastHandler?.('error', 'Título inválido para edição');
1388
+ showToastHandler?.('error', t('messages.invalidTitleForEdit'));
1256
1389
  return;
1257
1390
  }
1258
1391
 
@@ -1281,69 +1414,345 @@ function EditarTituloSheet({
1281
1414
  due_date: installment.dueDate || values.vencimento,
1282
1415
  amount: installment.amount,
1283
1416
  })),
1417
+ attachment_file_ids: uploadedFileId ? [uploadedFileId] : undefined,
1284
1418
  },
1285
1419
  });
1286
1420
 
1287
1421
  await onUpdated();
1288
- showToastHandler?.('success', 'Título atualizado com sucesso');
1422
+ showToastHandler?.('success', t('messages.updateSuccess'));
1289
1423
  onOpenChange(false);
1290
1424
  } catch {
1291
- showToastHandler?.('error', 'Não foi possível atualizar o título');
1425
+ showToastHandler?.('error', t('messages.updateError'));
1292
1426
  }
1293
1427
  };
1294
1428
 
1295
- return (
1296
- <Sheet open={open} onOpenChange={onOpenChange}>
1297
- <SheetContent className="w-full sm:max-w-lg overflow-y-auto">
1298
- <SheetHeader>
1299
- <SheetTitle>{t('table.actions.edit')}</SheetTitle>
1300
- <SheetDescription>
1301
- Edite os dados do título enquanto estiver em rascunho.
1302
- </SheetDescription>
1303
- </SheetHeader>
1304
- <Form {...form}>
1305
- <form className="px-4" onSubmit={form.handleSubmit(handleSubmit)}>
1306
- <div className="grid gap-4">
1307
- <FormField
1308
- control={form.control}
1309
- name="documento"
1310
- render={({ field }) => (
1311
- <FormItem>
1312
- <FormLabel>{t('fields.document')}</FormLabel>
1313
- <FormControl>
1314
- <Input placeholder="NF-00000" {...field} />
1315
- </FormControl>
1316
- <FormMessage />
1317
- </FormItem>
1318
- )}
1319
- />
1429
+ const clearUploadedFile = () => {
1430
+ setUploadedFileId(null);
1431
+ setUploadedFileName('');
1432
+ setExtractionConfidence(null);
1433
+ setExtractionWarnings([]);
1434
+ setUploadProgress(0);
1320
1435
 
1321
- <FormField
1322
- control={form.control}
1323
- name="fornecedorId"
1324
- render={({ field }) => (
1325
- <FormItem>
1326
- <FormLabel>{t('fields.supplier')}</FormLabel>
1327
- <Select value={field.value} onValueChange={field.onChange}>
1328
- <FormControl>
1329
- <SelectTrigger className="w-full">
1330
- <SelectValue placeholder={t('common.select')} />
1331
- </SelectTrigger>
1332
- </FormControl>
1333
- <SelectContent>
1334
- {pessoas.map((person) => (
1335
- <SelectItem key={person.id} value={String(person.id)}>
1336
- {person.nome}
1337
- </SelectItem>
1338
- ))}
1339
- </SelectContent>
1340
- </Select>
1341
- <FormMessage />
1342
- </FormItem>
1343
- )}
1436
+ if (fileInputRef.current) {
1437
+ fileInputRef.current.value = '';
1438
+ }
1439
+ };
1440
+
1441
+ const handleSelectFile = () => {
1442
+ if (fileInputRef.current) {
1443
+ fileInputRef.current.value = '';
1444
+ fileInputRef.current.click();
1445
+ }
1446
+ };
1447
+
1448
+ const uploadRelatedFile = async (file: File) => {
1449
+ setIsUploadingFile(true);
1450
+ setUploadProgress(0);
1451
+
1452
+ try {
1453
+ const formData = new FormData();
1454
+ formData.append('file', file);
1455
+ formData.append('destination', 'finance/titles');
1456
+
1457
+ const { data } = await request<{ id: number; filename: string }>({
1458
+ url: '/file',
1459
+ method: 'POST',
1460
+ data: formData,
1461
+ headers: {
1462
+ 'Content-Type': 'multipart/form-data',
1463
+ },
1464
+ onUploadProgress: (event) => {
1465
+ if (!event.total) {
1466
+ return;
1467
+ }
1468
+
1469
+ const progress = Math.round((event.loaded * 100) / event.total);
1470
+ setUploadProgress(progress);
1471
+ },
1472
+ });
1473
+
1474
+ if (!data?.id) {
1475
+ throw new Error(t('messages.invalidFile'));
1476
+ }
1477
+
1478
+ setUploadedFileId(data.id);
1479
+ setUploadedFileName(
1480
+ normalizeFilenameForDisplay(data.filename || file.name)
1481
+ );
1482
+ setUploadProgress(100);
1483
+ showToastHandler?.('success', t('messages.attachSuccess'));
1484
+
1485
+ setIsExtractingFileData(true);
1486
+ try {
1487
+ const extraction = await request<{
1488
+ documento?: string | null;
1489
+ fornecedorId?: string;
1490
+ competencia?: string;
1491
+ vencimento?: string;
1492
+ valor?: number | null;
1493
+ categoriaId?: string;
1494
+ centroCustoId?: string;
1495
+ metodo?: string;
1496
+ descricao?: string | null;
1497
+ confidence?: number | null;
1498
+ confidenceLevel?: 'low' | 'high' | null;
1499
+ warnings?: string[];
1500
+ }>({
1501
+ url: '/finance/accounts-payable/installments/extract-from-file',
1502
+ method: 'POST',
1503
+ data: {
1504
+ file_id: data.id,
1505
+ },
1506
+ });
1507
+
1508
+ const extracted = extraction.data || {};
1509
+ setExtractionConfidence(
1510
+ typeof extracted.confidence === 'number' ? extracted.confidence : null
1511
+ );
1512
+ setExtractionWarnings(
1513
+ Array.isArray(extracted.warnings)
1514
+ ? extracted.warnings.filter(Boolean)
1515
+ : []
1516
+ );
1517
+
1518
+ if (extracted.documento) {
1519
+ form.setValue('documento', extracted.documento, {
1520
+ shouldValidate: true,
1521
+ });
1522
+ }
1523
+
1524
+ if (extracted.fornecedorId) {
1525
+ form.setValue('fornecedorId', extracted.fornecedorId, {
1526
+ shouldValidate: true,
1527
+ });
1528
+ }
1529
+
1530
+ if (extracted.competencia) {
1531
+ form.setValue('competencia', extracted.competencia, {
1532
+ shouldValidate: true,
1533
+ });
1534
+ }
1535
+
1536
+ if (extracted.vencimento) {
1537
+ form.setValue('vencimento', extracted.vencimento, {
1538
+ shouldValidate: true,
1539
+ });
1540
+ }
1541
+
1542
+ if (typeof extracted.valor === 'number' && extracted.valor > 0) {
1543
+ form.setValue('valor', extracted.valor, {
1544
+ shouldValidate: true,
1545
+ });
1546
+ }
1547
+
1548
+ if (extracted.categoriaId) {
1549
+ form.setValue('categoriaId', extracted.categoriaId, {
1550
+ shouldValidate: true,
1551
+ });
1552
+ }
1553
+
1554
+ if (extracted.centroCustoId) {
1555
+ form.setValue('centroCustoId', extracted.centroCustoId, {
1556
+ shouldValidate: true,
1557
+ });
1558
+ }
1559
+
1560
+ if (extracted.metodo) {
1561
+ form.setValue('metodo', extracted.metodo, {
1562
+ shouldValidate: true,
1563
+ });
1564
+ }
1565
+
1566
+ if (extracted.descricao) {
1567
+ form.setValue('descricao', extracted.descricao, {
1568
+ shouldValidate: true,
1569
+ });
1570
+ }
1571
+
1572
+ showToastHandler?.('success', t('messages.aiExtractSuccess'));
1573
+ } catch {
1574
+ setExtractionConfidence(null);
1575
+ setExtractionWarnings([]);
1576
+ showToastHandler?.('error', t('messages.aiExtractError'));
1577
+ } finally {
1578
+ setIsExtractingFileData(false);
1579
+ }
1580
+ } catch {
1581
+ setUploadedFileId(null);
1582
+ setUploadedFileName('');
1583
+ setExtractionConfidence(null);
1584
+ setExtractionWarnings([]);
1585
+ setUploadProgress(0);
1586
+ showToastHandler?.('error', t('messages.uploadError'));
1587
+ } finally {
1588
+ setIsUploadingFile(false);
1589
+ }
1590
+ };
1591
+
1592
+ return (
1593
+ <Sheet open={open} onOpenChange={onOpenChange}>
1594
+ <SheetContent className="w-full sm:max-w-lg overflow-y-auto">
1595
+ <SheetHeader>
1596
+ <SheetTitle>{t('table.actions.edit')}</SheetTitle>
1597
+ <SheetDescription>{t('editTitle.description')}</SheetDescription>
1598
+ </SheetHeader>
1599
+ <Form {...form}>
1600
+ <form className="px-4" onSubmit={form.handleSubmit(handleSubmit)}>
1601
+ <div className="grid gap-3">
1602
+ <div className="grid grid-cols-1 items-start gap-4 sm:grid-cols-2">
1603
+ <div className="grid gap-2">
1604
+ <FormLabel>{t('common.upload.label')}</FormLabel>
1605
+ <Input
1606
+ ref={fileInputRef}
1607
+ className="hidden"
1608
+ type="file"
1609
+ accept=".pdf,.png,.jpg,.jpeg,.xml,.txt"
1610
+ onChange={(event) => {
1611
+ const file = event.target.files?.[0];
1612
+ if (!file) {
1613
+ return;
1614
+ }
1615
+
1616
+ clearUploadedFile();
1617
+ void uploadRelatedFile(file);
1618
+ }}
1619
+ disabled={
1620
+ isUploadingFile ||
1621
+ isExtractingFileData ||
1622
+ form.formState.isSubmitting
1623
+ }
1624
+ />
1625
+
1626
+ <div className="grid w-full grid-cols-2 gap-2">
1627
+ <Tooltip>
1628
+ <TooltipTrigger asChild>
1629
+ <Button
1630
+ type="button"
1631
+ variant="outline"
1632
+ className={
1633
+ uploadedFileId ? 'w-full' : 'col-span-2 w-full'
1634
+ }
1635
+ onClick={handleSelectFile}
1636
+ aria-label={
1637
+ uploadedFileId
1638
+ ? t('common.upload.change')
1639
+ : t('common.upload.upload')
1640
+ }
1641
+ disabled={
1642
+ isUploadingFile ||
1643
+ isExtractingFileData ||
1644
+ form.formState.isSubmitting
1645
+ }
1646
+ >
1647
+ {uploadedFileId ? (
1648
+ <Upload className="h-4 w-4" />
1649
+ ) : (
1650
+ <>
1651
+ <Upload className="mr-2 h-4 w-4" />
1652
+ {t('common.upload.upload')}
1653
+ </>
1654
+ )}
1655
+ </Button>
1656
+ </TooltipTrigger>
1657
+ <TooltipContent>
1658
+ {uploadedFileId
1659
+ ? t('common.upload.change')
1660
+ : t('common.upload.upload')}
1661
+ </TooltipContent>
1662
+ </Tooltip>
1663
+
1664
+ {uploadedFileId && (
1665
+ <Tooltip>
1666
+ <TooltipTrigger asChild>
1667
+ <Button
1668
+ type="button"
1669
+ variant="outline"
1670
+ className="w-full"
1671
+ onClick={clearUploadedFile}
1672
+ aria-label={t('common.upload.remove')}
1673
+ disabled={
1674
+ isUploadingFile ||
1675
+ isExtractingFileData ||
1676
+ form.formState.isSubmitting
1677
+ }
1678
+ >
1679
+ <Trash2 className="h-4 w-4" />
1680
+ </Button>
1681
+ </TooltipTrigger>
1682
+ <TooltipContent>
1683
+ {t('common.upload.remove')}
1684
+ </TooltipContent>
1685
+ </Tooltip>
1686
+ )}
1687
+ </div>
1688
+
1689
+ <div className="space-y-1">
1690
+ {(uploadedFileId || uploadedFileName) && (
1691
+ <p className="truncate text-xs text-muted-foreground">
1692
+ {t('common.upload.selectedPrefix')} {uploadedFileName}
1693
+ </p>
1694
+ )}
1695
+
1696
+ {isUploadingFile && !isExtractingFileData && (
1697
+ <div className="space-y-1">
1698
+ <Progress value={uploadProgress} className="h-2" />
1699
+ <p className="text-xs text-muted-foreground">
1700
+ {t('common.upload.uploadingProgress', {
1701
+ progress: uploadProgress,
1702
+ })}
1703
+ </p>
1704
+ </div>
1705
+ )}
1706
+
1707
+ {isExtractingFileData && (
1708
+ <p className="flex items-center gap-2 text-xs text-primary">
1709
+ <Loader2 className="h-4 w-4 animate-spin" />
1710
+ {t('common.upload.processingAi')}
1711
+ </p>
1712
+ )}
1713
+
1714
+ {!isExtractingFileData &&
1715
+ extractionConfidence !== null &&
1716
+ extractionConfidence < 70 && (
1717
+ <p className="text-xs text-destructive">
1718
+ {t('common.upload.lowConfidence', {
1719
+ confidence: Math.round(extractionConfidence),
1720
+ })}
1721
+ </p>
1722
+ )}
1723
+
1724
+ {!isExtractingFileData && extractionWarnings.length > 0 && (
1725
+ <p className="truncate text-xs text-muted-foreground">
1726
+ {extractionWarnings[0]}
1727
+ </p>
1728
+ )}
1729
+ </div>
1730
+ </div>
1731
+
1732
+ <FormField
1733
+ control={form.control}
1734
+ name="documento"
1735
+ render={({ field }) => (
1736
+ <FormItem>
1737
+ <FormLabel>{t('fields.document')}</FormLabel>
1738
+ <FormControl>
1739
+ <Input placeholder="NF-00000" {...field} />
1740
+ </FormControl>
1741
+ <FormMessage />
1742
+ </FormItem>
1743
+ )}
1744
+ />
1745
+ </div>
1746
+
1747
+ <PersonFieldWithCreate
1748
+ form={form}
1749
+ name="fornecedorId"
1750
+ label={t('fields.supplier')}
1751
+ entityLabel="fornecedor"
1752
+ selectPlaceholder={t('common.select')}
1344
1753
  />
1345
1754
 
1346
- <div className="grid grid-cols-2 gap-4">
1755
+ <div className="grid grid-cols-2 items-start gap-3">
1347
1756
  <FormField
1348
1757
  control={form.control}
1349
1758
  name="competencia"
@@ -1381,56 +1790,62 @@ function EditarTituloSheet({
1381
1790
  />
1382
1791
  </div>
1383
1792
 
1384
- <FormField
1385
- control={form.control}
1386
- name="valor"
1387
- render={({ field }) => (
1388
- <FormItem>
1389
- <FormLabel>{t('fields.totalValue')}</FormLabel>
1390
- <FormControl>
1391
- <InputMoney
1392
- ref={field.ref}
1393
- name={field.name}
1394
- value={field.value}
1395
- onBlur={field.onBlur}
1396
- onValueChange={(value) => field.onChange(value ?? 0)}
1397
- placeholder="0,00"
1398
- />
1399
- </FormControl>
1400
- <FormMessage />
1401
- </FormItem>
1402
- )}
1403
- />
1793
+ <div className="grid grid-cols-1 items-start gap-3 sm:grid-cols-2">
1794
+ <FormField
1795
+ control={form.control}
1796
+ name="valor"
1797
+ render={({ field }) => (
1798
+ <FormItem>
1799
+ <FormLabel>{t('fields.totalValue')}</FormLabel>
1800
+ <FormControl>
1801
+ <InputMoney
1802
+ ref={field.ref}
1803
+ name={field.name}
1804
+ value={field.value}
1805
+ onBlur={field.onBlur}
1806
+ onValueChange={(value) => field.onChange(value ?? 0)}
1807
+ placeholder="0,00"
1808
+ />
1809
+ </FormControl>
1810
+ <FormMessage />
1811
+ </FormItem>
1812
+ )}
1813
+ />
1404
1814
 
1405
- <FormField
1406
- control={form.control}
1407
- name="installmentsCount"
1408
- render={({ field }) => (
1409
- <FormItem>
1410
- <FormLabel>Quantidade de Parcelas</FormLabel>
1411
- <FormControl>
1412
- <Input
1413
- type="number"
1414
- min={1}
1415
- max={120}
1416
- value={field.value}
1417
- onChange={(event) => {
1418
- const nextValue = Number(event.target.value || 1);
1419
- field.onChange(
1420
- Number.isNaN(nextValue) ? 1 : nextValue
1421
- );
1422
- setIsInstallmentsEdited(false);
1423
- }}
1424
- />
1425
- </FormControl>
1426
- <FormMessage />
1427
- </FormItem>
1428
- )}
1429
- />
1815
+ <FormField
1816
+ control={form.control}
1817
+ name="installmentsCount"
1818
+ render={({ field }) => (
1819
+ <FormItem>
1820
+ <FormLabel>
1821
+ {t('installmentsEditor.countLabel')}
1822
+ </FormLabel>
1823
+ <FormControl>
1824
+ <Input
1825
+ type="number"
1826
+ min={1}
1827
+ max={120}
1828
+ value={field.value}
1829
+ onChange={(event) => {
1830
+ const nextValue = Number(event.target.value || 1);
1831
+ field.onChange(
1832
+ Number.isNaN(nextValue) ? 1 : nextValue
1833
+ );
1834
+ setIsInstallmentsEdited(false);
1835
+ }}
1836
+ />
1837
+ </FormControl>
1838
+ <FormMessage />
1839
+ </FormItem>
1840
+ )}
1841
+ />
1842
+ </div>
1430
1843
 
1431
1844
  <div className="space-y-3 rounded-md border p-3">
1432
1845
  <div className="flex items-center justify-between gap-2">
1433
- <p className="text-sm font-medium">Parcelas</p>
1846
+ <p className="text-sm font-medium">
1847
+ {t('installmentsEditor.title')}
1848
+ </p>
1434
1849
  <Button
1435
1850
  type="button"
1436
1851
  variant="outline"
@@ -1446,7 +1861,7 @@ function EditarTituloSheet({
1446
1861
  );
1447
1862
  }}
1448
1863
  >
1449
- Recalcular automaticamente
1864
+ {t('installmentsEditor.recalculate')}
1450
1865
  </Button>
1451
1866
  </div>
1452
1867
 
@@ -1462,7 +1877,7 @@ function EditarTituloSheet({
1462
1877
  htmlFor="auto-redistribute-installments-edit-payable"
1463
1878
  className="text-xs text-muted-foreground"
1464
1879
  >
1465
- Redistribuir automaticamente o restante ao editar parcela
1880
+ {t('installmentsEditor.autoRedistributeLabel')}
1466
1881
  </Label>
1467
1882
  </div>
1468
1883
 
@@ -1470,7 +1885,7 @@ function EditarTituloSheet({
1470
1885
  {installmentFields.map((installment, index) => (
1471
1886
  <div
1472
1887
  key={installment.id}
1473
- className="grid grid-cols-1 gap-2 rounded-md border p-2 sm:grid-cols-[96px_1fr_160px]"
1888
+ className="grid grid-cols-1 items-start gap-2 rounded-md border p-2 sm:grid-cols-[96px_1fr_160px]"
1474
1889
  >
1475
1890
  <div className="flex items-center text-sm text-muted-foreground">
1476
1891
  #{index + 1}
@@ -1482,7 +1897,7 @@ function EditarTituloSheet({
1482
1897
  render={({ field }) => (
1483
1898
  <FormItem>
1484
1899
  <FormLabel className="text-xs">
1485
- Vencimento
1900
+ {t('installmentsEditor.dueDateLabel')}
1486
1901
  </FormLabel>
1487
1902
  <FormControl>
1488
1903
  <Input
@@ -1505,7 +1920,9 @@ function EditarTituloSheet({
1505
1920
  name={`installments.${index}.amount` as const}
1506
1921
  render={({ field }) => (
1507
1922
  <FormItem>
1508
- <FormLabel className="text-xs">Valor</FormLabel>
1923
+ <FormLabel className="text-xs">
1924
+ {t('installmentsEditor.amountLabel')}
1925
+ </FormLabel>
1509
1926
  <FormControl>
1510
1927
  <InputMoney
1511
1928
  ref={field.ref}
@@ -1549,8 +1966,11 @@ function EditarTituloSheet({
1549
1966
  : 'text-destructive'
1550
1967
  }`}
1551
1968
  >
1552
- Soma das parcelas: {installmentsTotal.toFixed(2)}
1553
- {installmentsDiffCents > 0 && ' (ajuste necessário)'}
1969
+ {t('installmentsEditor.totalPrefix', {
1970
+ total: installmentsTotal.toFixed(2),
1971
+ })}
1972
+ {installmentsDiffCents > 0 &&
1973
+ ` ${t('installmentsEditor.adjustmentNeeded')}`}
1554
1974
  </p>
1555
1975
  {form.formState.errors.installments?.message && (
1556
1976
  <p className="text-xs text-destructive">
@@ -1559,56 +1979,23 @@ function EditarTituloSheet({
1559
1979
  )}
1560
1980
  </div>
1561
1981
 
1562
- <FormField
1563
- control={form.control}
1982
+ <CategoryFieldWithCreate
1983
+ form={form}
1564
1984
  name="categoriaId"
1565
- render={({ field }) => (
1566
- <FormItem>
1567
- <FormLabel>{t('fields.category')}</FormLabel>
1568
- <Select value={field.value} onValueChange={field.onChange}>
1569
- <FormControl>
1570
- <SelectTrigger className="w-full">
1571
- <SelectValue placeholder={t('common.select')} />
1572
- </SelectTrigger>
1573
- </FormControl>
1574
- <SelectContent>
1575
- {categorias
1576
- .filter((c) => c.natureza === 'despesa')
1577
- .map((c) => (
1578
- <SelectItem key={c.id} value={String(c.id)}>
1579
- {c.codigo} - {c.nome}
1580
- </SelectItem>
1581
- ))}
1582
- </SelectContent>
1583
- </Select>
1584
- <FormMessage />
1585
- </FormItem>
1586
- )}
1985
+ label={t('fields.category')}
1986
+ selectPlaceholder={t('common.select')}
1987
+ categories={categorias}
1988
+ categoryKind="despesa"
1989
+ onCreated={onCategoriesUpdated}
1587
1990
  />
1588
1991
 
1589
- <FormField
1590
- control={form.control}
1992
+ <CostCenterFieldWithCreate
1993
+ form={form}
1591
1994
  name="centroCustoId"
1592
- render={({ field }) => (
1593
- <FormItem>
1594
- <FormLabel>{t('fields.costCenter')}</FormLabel>
1595
- <Select value={field.value} onValueChange={field.onChange}>
1596
- <FormControl>
1597
- <SelectTrigger className="w-full">
1598
- <SelectValue placeholder={t('common.select')} />
1599
- </SelectTrigger>
1600
- </FormControl>
1601
- <SelectContent>
1602
- {centrosCusto.map((c) => (
1603
- <SelectItem key={c.id} value={String(c.id)}>
1604
- {c.codigo} - {c.nome}
1605
- </SelectItem>
1606
- ))}
1607
- </SelectContent>
1608
- </Select>
1609
- <FormMessage />
1610
- </FormItem>
1611
- )}
1995
+ label={t('fields.costCenter')}
1996
+ selectPlaceholder={t('common.select')}
1997
+ costCenters={centrosCusto}
1998
+ onCreated={onCostCentersUpdated}
1612
1999
  />
1613
2000
 
1614
2001
  <FormField
@@ -1666,16 +2053,23 @@ function EditarTituloSheet({
1666
2053
  />
1667
2054
  </div>
1668
2055
 
1669
- <div className="flex justify-end gap-2 pt-4">
2056
+ <div className="flex flex-col gap-2 py-4">
1670
2057
  <Button
1671
- type="button"
1672
- variant="outline"
1673
- onClick={() => onOpenChange(false)}
2058
+ type="submit"
2059
+ disabled={
2060
+ form.formState.isSubmitting ||
2061
+ isUploadingFile ||
2062
+ isExtractingFileData
2063
+ }
1674
2064
  >
1675
- {t('common.cancel')}
1676
- </Button>
1677
- <Button type="submit" disabled={form.formState.isSubmitting}>
1678
- {t('common.save')}
2065
+ {(isUploadingFile || isExtractingFileData) && (
2066
+ <Loader2 className="mr-2 h-4 w-4 animate-spin" />
2067
+ )}
2068
+ {isExtractingFileData
2069
+ ? t('common.upload.fillingWithAi')
2070
+ : isUploadingFile
2071
+ ? t('common.upload.uploadingFile')
2072
+ : t('common.save')}
1679
2073
  </Button>
1680
2074
  </div>
1681
2075
  </form>
@@ -1691,10 +2085,10 @@ export default function TitulosPagarPage() {
1691
2085
  const pathname = usePathname();
1692
2086
  const router = useRouter();
1693
2087
  const searchParams = useSearchParams();
1694
- const { data, refetch } = useFinanceData();
1695
- const { titulosPagar, pessoas } = data;
2088
+ const { data, refetch: refetchFinanceData } = useFinanceData();
2089
+ const { titulosPagar: allTitulosPagar, pessoas } = data;
1696
2090
 
1697
- const { data: categoriasData } = useQuery<any[]>({
2091
+ const { data: categoriasData, refetch: refetchCategorias } = useQuery<any[]>({
1698
2092
  queryKey: ['finance-categories-options', currentLocaleCode],
1699
2093
  queryFn: async () => {
1700
2094
  const response = await request({
@@ -1707,7 +2101,9 @@ export default function TitulosPagarPage() {
1707
2101
  placeholderData: (old) => old,
1708
2102
  });
1709
2103
 
1710
- const { data: centrosCustoData } = useQuery<any[]>({
2104
+ const { data: centrosCustoData, refetch: refetchCentrosCusto } = useQuery<
2105
+ any[]
2106
+ >({
1711
2107
  queryKey: ['finance-cost-centers-options'],
1712
2108
  queryFn: async () => {
1713
2109
  const response = await request({
@@ -1728,14 +2124,107 @@ export default function TitulosPagarPage() {
1728
2124
 
1729
2125
  const [search, setSearch] = useState('');
1730
2126
  const [statusFilter, setStatusFilter] = useState<string>('');
2127
+ const [page, setPage] = useState(1);
2128
+ const pageSize = 10;
1731
2129
  const [editingTitleId, setEditingTitleId] = useState<string | null>(null);
1732
2130
  const [approvingTitleId, setApprovingTitleId] = useState<string | null>(null);
1733
2131
  const [reversingTitleId, setReversingTitleId] = useState<string | null>(null);
1734
2132
  const [cancelingTitleId, setCancelingTitleId] = useState<string | null>(null);
2133
+ const [titleToSettle, setTitleToSettle] = useState<any | null>(null);
2134
+ const [isSettleSheetOpen, setIsSettleSheetOpen] = useState(false);
2135
+ const [isSettlingTitle, setIsSettlingTitle] = useState(false);
2136
+
2137
+ const settleTitleFormSchema = useMemo(() => getSettleTitleFormSchema(t), [t]);
2138
+
2139
+ const settleTitleForm = useForm<SettleTitleFormValues>({
2140
+ resolver: zodResolver(settleTitleFormSchema),
2141
+ defaultValues: {
2142
+ installmentId: '',
2143
+ amount: 0,
2144
+ description: '',
2145
+ },
2146
+ });
2147
+
2148
+ const settleCandidates = useMemo(
2149
+ () =>
2150
+ (titleToSettle?.parcelas || []).filter(
2151
+ (installment: any) =>
2152
+ installment.status === 'aberto' ||
2153
+ installment.status === 'parcial' ||
2154
+ installment.status === 'vencido'
2155
+ ),
2156
+ [titleToSettle]
2157
+ );
2158
+
2159
+ const normalizedStatusFilter =
2160
+ statusFilter && statusFilter !== 'all' ? statusFilter : undefined;
2161
+
2162
+ const {
2163
+ data: paginatedTitlesResponse,
2164
+ refetch: refetchTitles,
2165
+ isFetching: isFetchingTitles,
2166
+ } = useQuery<{
2167
+ data: any[];
2168
+ total: number;
2169
+ page: number;
2170
+ pageSize: number;
2171
+ prev: number | null;
2172
+ next: number | null;
2173
+ lastPage: number;
2174
+ }>({
2175
+ queryKey: [
2176
+ 'finance-payable-installments-list',
2177
+ search,
2178
+ normalizedStatusFilter,
2179
+ page,
2180
+ pageSize,
2181
+ ],
2182
+ queryFn: async () => {
2183
+ const response = await request({
2184
+ url: '/finance/accounts-payable/installments',
2185
+ method: 'GET',
2186
+ params: {
2187
+ page,
2188
+ pageSize,
2189
+ search: search.trim() || undefined,
2190
+ status: normalizedStatusFilter,
2191
+ },
2192
+ });
2193
+
2194
+ return response.data as {
2195
+ data: any[];
2196
+ total: number;
2197
+ page: number;
2198
+ pageSize: number;
2199
+ prev: number | null;
2200
+ next: number | null;
2201
+ lastPage: number;
2202
+ };
2203
+ },
2204
+ placeholderData: (old) => old,
2205
+ });
2206
+
2207
+ const titulosPagar = paginatedTitlesResponse?.data || [];
2208
+
2209
+ useEffect(() => {
2210
+ const firstCandidate = settleCandidates[0];
2211
+
2212
+ settleTitleForm.reset({
2213
+ installmentId: firstCandidate?.id || '',
2214
+ amount: Number(firstCandidate?.valorAberto || 0),
2215
+ description: '',
2216
+ });
2217
+ }, [settleCandidates, settleTitleForm]);
2218
+
2219
+ useEffect(() => {
2220
+ setPage(1);
2221
+ }, [search, normalizedStatusFilter]);
1735
2222
 
1736
2223
  const editingTitle = useMemo(
1737
- () => titulosPagar.find((item) => item.id === editingTitleId),
1738
- [editingTitleId, titulosPagar]
2224
+ () =>
2225
+ titulosPagar.find((item) => item.id === editingTitleId) ||
2226
+ allTitulosPagar.find((item) => item.id === editingTitleId),
2227
+ [allTitulosPagar, editingTitleId, titulosPagar]
1739
2228
  );
1740
2229
 
1741
2230
  useEffect(() => {
@@ -1744,16 +2233,15 @@ export default function TitulosPagarPage() {
1744
2233
  return;
1745
2234
  }
1746
2235
 
1747
- const foundTitle = titulosPagar.find((item) => item.id === editId);
2236
+ const foundTitle =
2237
+ titulosPagar.find((item) => item.id === editId) ||
2238
+ allTitulosPagar.find((item) => item.id === editId);
1748
2239
  if (!foundTitle) {
1749
2240
  return;
1750
2241
  }
1751
2242
 
1752
2243
  if (foundTitle.status !== 'rascunho') {
1753
- showToastHandler?.(
1754
- 'error',
1755
- 'Apenas títulos em rascunho podem ser editados'
1756
- );
2244
+ showToastHandler?.('error', t('messages.editDraftOnly'));
1757
2245
  router.replace(pathname, { scroll: false });
1758
2246
  return;
1759
2247
  }
@@ -1765,6 +2253,7 @@ export default function TitulosPagarPage() {
1765
2253
  router,
1766
2254
  searchParams,
1767
2255
  showToastHandler,
2256
+ allTitulosPagar,
1768
2257
  titulosPagar,
1769
2258
  ]);
1770
2259
 
@@ -1792,15 +2281,15 @@ export default function TitulosPagarPage() {
1792
2281
  data: {},
1793
2282
  });
1794
2283
 
1795
- await refetch();
1796
- showToastHandler?.('success', 'Título cancelado com sucesso');
2284
+ await Promise.all([refetchTitles(), refetchFinanceData()]);
2285
+ showToastHandler?.('success', t('messages.cancelSuccess'));
1797
2286
  } catch (error: any) {
1798
2287
  const message = error?.response?.data?.message;
1799
2288
  showToastHandler?.(
1800
2289
  'error',
1801
2290
  typeof message === 'string' && message.trim()
1802
2291
  ? message
1803
- : 'Não foi possível cancelar o título'
2292
+ : t('messages.cancelError')
1804
2293
  );
1805
2294
  } finally {
1806
2295
  setCancelingTitleId(null);
@@ -1819,84 +2308,123 @@ export default function TitulosPagarPage() {
1819
2308
  method: 'PATCH',
1820
2309
  });
1821
2310
 
1822
- await refetch();
1823
- showToastHandler?.('success', 'Título aprovado com sucesso');
2311
+ await Promise.all([refetchTitles(), refetchFinanceData()]);
2312
+ showToastHandler?.('success', t('messages.approveSuccess'));
1824
2313
  } catch (error: any) {
1825
2314
  const message = error?.response?.data?.message;
1826
2315
  showToastHandler?.(
1827
2316
  'error',
1828
2317
  typeof message === 'string' && message.trim()
1829
2318
  ? message
1830
- : 'Não foi possível aprovar o título'
2319
+ : t('messages.approveError')
1831
2320
  );
1832
2321
  } finally {
1833
2322
  setApprovingTitleId(null);
1834
2323
  }
1835
2324
  };
1836
2325
 
1837
- const handleSettleTitle = (titleId: string) => {
1838
- router.push(
1839
- `/finance/accounts-payable/installments/${titleId}?action=settle`
2326
+ const handleSettleTitle = (title: any) => {
2327
+ const titleId = title?.id;
2328
+
2329
+ if (!titleId) {
2330
+ return;
2331
+ }
2332
+
2333
+ const availableInstallments = (title?.parcelas || []).filter(
2334
+ (installment: any) =>
2335
+ installment.status === 'aberto' ||
2336
+ installment.status === 'parcial' ||
2337
+ installment.status === 'vencido'
1840
2338
  );
2339
+
2340
+ if (availableInstallments.length === 0) {
2341
+ showToastHandler?.('error', t('messages.noInstallmentForSettle'));
2342
+ return;
2343
+ }
2344
+
2345
+ setTitleToSettle(title);
2346
+ setIsSettleSheetOpen(true);
2347
+ };
2348
+
2349
+ const handleSubmitSettleTitle = async (values: SettleTitleFormValues) => {
2350
+ if (!titleToSettle?.id || isSettlingTitle) {
2351
+ return;
2352
+ }
2353
+
2354
+ setIsSettlingTitle(true);
2355
+ try {
2356
+ await request({
2357
+ url: `/finance/accounts-payable/installments/${titleToSettle.id}/settlements`,
2358
+ method: 'POST',
2359
+ data: {
2360
+ installment_id: Number(values.installmentId),
2361
+ amount: values.amount,
2362
+ description: values.description?.trim() || undefined,
2363
+ },
2364
+ });
2365
+
2366
+ await Promise.all([refetchTitles(), refetchFinanceData()]);
2367
+ setIsSettleSheetOpen(false);
2368
+ setTitleToSettle(null);
2369
+ settleTitleForm.reset({
2370
+ installmentId: '',
2371
+ amount: 0,
2372
+ description: '',
2373
+ });
2374
+ showToastHandler?.('success', t('messages.settleSuccess'));
2375
+ } catch (error: any) {
2376
+ const message = error?.response?.data?.message;
2377
+ showToastHandler?.(
2378
+ 'error',
2379
+ typeof message === 'string' && message.trim()
2380
+ ? message
2381
+ : t('messages.settleError')
2382
+ );
2383
+ } finally {
2384
+ setIsSettlingTitle(false);
2385
+ }
1841
2386
  };
1842
2387
 
1843
- const handleReverseTitle = async (title: any) => {
2388
+ const handleReverseTitle = async (title: any, reason?: string) => {
1844
2389
  const titleId = title?.id;
1845
2390
 
1846
2391
  if (!titleId || reversingTitleId) {
1847
2392
  return;
1848
2393
  }
1849
2394
 
1850
- const settlementToReverse = (title?.parcelas || [])
1851
- .flatMap((parcela: any) => parcela?.liquidacoes || [])
1852
- .find((liquidacao: any) => {
1853
- return (
1854
- !!liquidacao?.settlementId &&
1855
- liquidacao?.status !== 'reversed' &&
1856
- liquidacao?.status !== 'estornado'
1857
- );
1858
- });
2395
+ const settlementId = getFirstActiveSettlementId(title);
1859
2396
 
1860
- if (!settlementToReverse?.settlementId) {
1861
- showToastHandler?.('error', 'Nenhuma liquidação ativa para estornar');
2397
+ if (!settlementId) {
2398
+ showToastHandler?.('error', t('messages.noActiveSettlementToReverse'));
1862
2399
  return;
1863
2400
  }
1864
2401
 
1865
2402
  setReversingTitleId(titleId);
1866
2403
  try {
1867
2404
  await request({
1868
- url: `/finance/accounts-payable/installments/${titleId}/settlements/${settlementToReverse.settlementId}/reverse`,
1869
- method: 'PATCH',
1870
- data: {},
2405
+ url: `/finance/settlements/${settlementId}/reverse`,
2406
+ method: 'POST',
2407
+ data: {
2408
+ reason: reason?.trim() || t('messages.reverseDefaultReason'),
2409
+ memo: reason?.trim() || t('messages.reverseDefaultReason'),
2410
+ },
1871
2411
  });
1872
2412
 
1873
- await refetch();
1874
- showToastHandler?.('success', 'Estorno realizado com sucesso');
2413
+ await Promise.all([refetchTitles(), refetchFinanceData()]);
2414
+ showToastHandler?.('success', t('messages.reverseSuccess'));
1875
2415
  } catch (error: any) {
1876
2416
  const message = error?.response?.data?.message;
1877
2417
  showToastHandler?.(
1878
2418
  'error',
1879
2419
  typeof message === 'string' && message.trim()
1880
2420
  ? message
1881
- : 'Não foi possível estornar a liquidação'
2421
+ : t('messages.reverseError')
1882
2422
  );
1883
2423
  } finally {
1884
2424
  setReversingTitleId(null);
1885
2425
  }
1886
2426
  };
1887
2427
 
1888
- const filteredTitulos = titulosPagar.filter((titulo) => {
1889
- const matchesSearch =
1890
- titulo.documento.toLowerCase().includes(search.toLowerCase()) ||
1891
- getPessoaById(titulo.fornecedorId)
1892
- ?.nome.toLowerCase()
1893
- .includes(search.toLowerCase());
1894
-
1895
- const matchesStatus = !statusFilter || titulo.status === statusFilter;
1896
-
1897
- return matchesSearch && matchesStatus;
1898
- });
1899
-
1900
2428
  const handleOpenAttachment = async (fileId?: string) => {
1901
2429
  if (!fileId) {
1902
2430
  return;
@@ -1910,13 +2438,13 @@ export default function TitulosPagarPage() {
1910
2438
 
1911
2439
  const url = response?.data?.url;
1912
2440
  if (!url) {
1913
- showToastHandler?.('error', 'Não foi possível abrir o anexo');
2441
+ showToastHandler?.('error', t('messages.openAttachmentError'));
1914
2442
  return;
1915
2443
  }
1916
2444
 
1917
2445
  window.open(url, '_blank', 'noopener,noreferrer');
1918
2446
  } catch {
1919
- showToastHandler?.('error', 'Não foi possível abrir o anexo');
2447
+ showToastHandler?.('error', t('messages.openAttachmentError'));
1920
2448
  }
1921
2449
  };
1922
2450
 
@@ -1936,17 +2464,24 @@ export default function TitulosPagarPage() {
1936
2464
  categorias={categorias}
1937
2465
  centrosCusto={centrosCusto}
1938
2466
  t={t}
1939
- onCreated={refetch}
2467
+ onCreated={async () => {
2468
+ await Promise.all([refetchTitles(), refetchFinanceData()]);
2469
+ }}
2470
+ onCategoriesUpdated={refetchCategorias}
2471
+ onCostCentersUpdated={refetchCentrosCusto}
1940
2472
  />
1941
2473
  <EditarTituloSheet
1942
2474
  open={!!editingTitleId && !!editingTitle}
1943
2475
  onOpenChange={closeEditSheet}
1944
2476
  titulo={editingTitle}
1945
- pessoas={pessoas}
1946
2477
  categorias={categorias}
1947
2478
  centrosCusto={centrosCusto}
1948
2479
  t={t}
1949
- onUpdated={refetch}
2480
+ onUpdated={async () => {
2481
+ await Promise.all([refetchTitles(), refetchFinanceData()]);
2482
+ }}
2483
+ onCategoriesUpdated={refetchCategorias}
2484
+ onCostCentersUpdated={refetchCentrosCusto}
1950
2485
  />
1951
2486
  </>
1952
2487
  }
@@ -1973,10 +2508,135 @@ export default function TitulosPagarPage() {
1973
2508
  ],
1974
2509
  },
1975
2510
  ]}
1976
- activeFilters={statusFilter && statusFilter !== 'all' ? 1 : 0}
1977
- onClearFilters={() => setStatusFilter('')}
2511
+ activeFilters={normalizedStatusFilter ? 1 : 0}
2512
+ onClearFilters={() => setStatusFilter('all')}
1978
2513
  />
1979
2514
 
2515
+ <Sheet
2516
+ open={isSettleSheetOpen}
2517
+ onOpenChange={(open) => {
2518
+ setIsSettleSheetOpen(open);
2519
+
2520
+ if (!open) {
2521
+ setTitleToSettle(null);
2522
+ }
2523
+ }}
2524
+ >
2525
+ <SheetContent className="w-full sm:max-w-lg overflow-y-auto">
2526
+ <SheetHeader>
2527
+ <SheetTitle>{t('settleSheet.title')}</SheetTitle>
2528
+ <SheetDescription>
2529
+ {t('settleSheet.description', {
2530
+ document: titleToSettle?.documento || '-',
2531
+ })}
2532
+ </SheetDescription>
2533
+ </SheetHeader>
2534
+
2535
+ <Form {...settleTitleForm}>
2536
+ <form
2537
+ className="space-y-4 px-4"
2538
+ onSubmit={settleTitleForm.handleSubmit(handleSubmitSettleTitle)}
2539
+ >
2540
+ <FormField
2541
+ control={settleTitleForm.control}
2542
+ name="installmentId"
2543
+ render={({ field }) => (
2544
+ <FormItem>
2545
+ <FormLabel>{t('settleSheet.installmentLabel')}</FormLabel>
2546
+ <Select
2547
+ value={field.value}
2548
+ onValueChange={(value) => {
2549
+ field.onChange(value);
2550
+
2551
+ const selected = settleCandidates.find(
2552
+ (installment: any) => installment.id === value
2553
+ );
2554
+
2555
+ if (selected) {
2556
+ settleTitleForm.setValue(
2557
+ 'amount',
2558
+ Number(selected.valorAberto || 0),
2559
+ { shouldValidate: true }
2560
+ );
2561
+ }
2562
+ }}
2563
+ >
2564
+ <FormControl>
2565
+ <SelectTrigger className="w-full">
2566
+ <SelectValue
2567
+ placeholder={t(
2568
+ 'settleSheet.installmentPlaceholder'
2569
+ )}
2570
+ />
2571
+ </SelectTrigger>
2572
+ </FormControl>
2573
+ <SelectContent>
2574
+ {settleCandidates.map((installment: any) => (
2575
+ <SelectItem
2576
+ key={installment.id}
2577
+ value={installment.id}
2578
+ >
2579
+ {t('settleSheet.installmentOption', {
2580
+ number: installment.numero,
2581
+ amount: new Intl.NumberFormat('pt-BR', {
2582
+ style: 'currency',
2583
+ currency: 'BRL',
2584
+ }).format(Number(installment.valorAberto || 0)),
2585
+ })}
2586
+ </SelectItem>
2587
+ ))}
2588
+ </SelectContent>
2589
+ </Select>
2590
+ <FormMessage />
2591
+ </FormItem>
2592
+ )}
2593
+ />
2594
+
2595
+ <FormField
2596
+ control={settleTitleForm.control}
2597
+ name="amount"
2598
+ render={({ field }) => (
2599
+ <FormItem>
2600
+ <FormLabel>{t('settleSheet.amountLabel')}</FormLabel>
2601
+ <FormControl>
2602
+ <InputMoney
2603
+ value={Number(field.value || 0)}
2604
+ onValueChange={(value) => {
2605
+ field.onChange(Number(value || 0));
2606
+ }}
2607
+ />
2608
+ </FormControl>
2609
+ <FormMessage />
2610
+ </FormItem>
2611
+ )}
2612
+ />
2613
+
2614
+ <FormField
2615
+ control={settleTitleForm.control}
2616
+ name="description"
2617
+ render={({ field }) => (
2618
+ <FormItem>
2619
+ <FormLabel>{t('settleSheet.descriptionLabel')}</FormLabel>
2620
+ <FormControl>
2621
+ <Input {...field} value={field.value || ''} />
2622
+ </FormControl>
2623
+ <FormMessage />
2624
+ </FormItem>
2625
+ )}
2626
+ />
2627
+
2628
+ <Button
2629
+ className="w-full"
2630
+ type="submit"
2631
+ disabled={isSettlingTitle}
2632
+ >
2633
+ {t('settleSheet.confirm')}
2634
+ </Button>
2635
+ </form>
2636
+ </Form>
2637
+ </SheetContent>
2638
+ </Sheet>
2639
+
1980
2640
  <div className="rounded-md border">
1981
2641
  <Table>
1982
2642
  <TableHeader>
@@ -1994,7 +2654,7 @@ export default function TitulosPagarPage() {
1994
2654
  </TableRow>
1995
2655
  </TableHeader>
1996
2656
  <TableBody>
1997
- {filteredTitulos.map((titulo) => {
2657
+ {titulosPagar.map((titulo) => {
1998
2658
  const fornecedor = getPessoaById(titulo.fornecedorId);
1999
2659
  const categoria = getCategoriaById(titulo.categoriaId);
2000
2660
  const proximaParcela = titulo.parcelas.find(
@@ -2023,7 +2683,7 @@ export default function TitulosPagarPage() {
2023
2683
  titulo.anexosDetalhes?.[0]?.id;
2024
2684
  void handleOpenAttachment(firstAttachmentId);
2025
2685
  }}
2026
- aria-label="Abrir anexo"
2686
+ aria-label={t('table.actions.openAttachment')}
2027
2687
  >
2028
2688
  <Paperclip className="h-3 w-3" />
2029
2689
  </Button>
@@ -2044,78 +2704,52 @@ export default function TitulosPagarPage() {
2044
2704
  <StatusBadge status={titulo.status} />
2045
2705
  </TableCell>
2046
2706
  <TableCell>
2047
- <DropdownMenu>
2048
- <DropdownMenuTrigger asChild>
2049
- <Button variant="ghost" size="icon">
2050
- <MoreHorizontal className="h-4 w-4" />
2051
- <span className="sr-only">
2052
- {t('table.actions.srActions')}
2053
- </span>
2054
- </Button>
2055
- </DropdownMenuTrigger>
2056
- <DropdownMenuContent align="end">
2057
- <DropdownMenuItem asChild>
2058
- <Link
2059
- href={`/finance/accounts-payable/installments/${titulo.id}`}
2060
- >
2061
- <Eye className="mr-2 h-4 w-4" />
2062
- {t('table.actions.viewDetails')}
2063
- </Link>
2064
- </DropdownMenuItem>
2065
- <DropdownMenuItem
2066
- disabled={titulo.status !== 'rascunho'}
2067
- onClick={() => setEditingTitleId(titulo.id)}
2068
- >
2069
- <Edit className="mr-2 h-4 w-4" />
2070
- {t('table.actions.edit')}
2071
- </DropdownMenuItem>
2072
- <DropdownMenuSeparator />
2073
- <DropdownMenuItem
2074
- disabled={
2075
- titulo.status !== 'rascunho' ||
2076
- approvingTitleId === titulo.id
2077
- }
2078
- onClick={() => void handleApproveTitle(titulo.id)}
2079
- >
2080
- <CheckCircle className="mr-2 h-4 w-4" />
2081
- {t('table.actions.approve')}
2082
- </DropdownMenuItem>
2083
- <DropdownMenuItem
2084
- disabled={
2085
- !['aberto', 'parcial', 'vencido'].includes(
2086
- titulo.status
2087
- )
2088
- }
2089
- onClick={() => handleSettleTitle(titulo.id)}
2090
- >
2091
- <Download className="mr-2 h-4 w-4" />
2092
- {t('table.actions.settle')}
2093
- </DropdownMenuItem>
2094
- <DropdownMenuItem
2095
- disabled={
2096
- !['parcial', 'liquidado'].includes(titulo.status) ||
2097
- reversingTitleId === titulo.id
2098
- }
2099
- onClick={() => void handleReverseTitle(titulo)}
2100
- >
2101
- <Undo className="mr-2 h-4 w-4" />
2102
- {t('table.actions.reverse')}
2103
- </DropdownMenuItem>
2104
- <DropdownMenuSeparator />
2105
- <DropdownMenuItem
2106
- className="text-destructive"
2107
- disabled={
2108
- ['cancelado', 'liquidado'].includes(
2109
- titulo.status
2110
- ) || cancelingTitleId === titulo.id
2111
- }
2112
- onClick={() => void handleCancelTitle(titulo.id)}
2113
- >
2114
- <XCircle className="mr-2 h-4 w-4" />
2115
- {t('table.actions.cancel')}
2116
- </DropdownMenuItem>
2117
- </DropdownMenuContent>
2118
- </DropdownMenu>
2707
+ <FinanceTitleActionsMenu
2708
+ triggerVariant="ghost"
2709
+ detailHref={`/finance/accounts-payable/installments/${titulo.id}`}
2710
+ canEdit={canEditTitle(titulo.status)}
2711
+ canApprove={canApproveTitle(titulo.status)}
2712
+ canSettle={canSettleTitle(titulo.status)}
2713
+ canReverse={
2714
+ canReverseTitle(titulo.status) &&
2715
+ !!getFirstActiveSettlementId(titulo)
2716
+ }
2717
+ canCancel={canCancelTitle(titulo.status)}
2718
+ isApproving={approvingTitleId === titulo.id}
2719
+ isReversing={reversingTitleId === titulo.id}
2720
+ isCanceling={cancelingTitleId === titulo.id}
2721
+ labels={{
2722
+ menu: t.has('actions.title')
2723
+ ? t('actions.title')
2724
+ : t('table.actions.srActions'),
2725
+ srActions: t('table.actions.srActions'),
2726
+ viewDetails: t('table.actions.viewDetails'),
2727
+ edit: t('table.actions.edit'),
2728
+ approve: t('table.actions.approve'),
2729
+ settle: t('table.actions.settle'),
2730
+ reverse: t('table.actions.reverse'),
2731
+ cancel: t('table.actions.cancel'),
2732
+ }}
2733
+ dialogs={{
2734
+ cancelTitle: t('dialogs.cancel.title'),
2735
+ cancelDescription: t('dialogs.cancel.description'),
2736
+ cancelButton: t('dialogs.cancel.cancel'),
2737
+ confirmCancelButton: t('dialogs.cancel.confirm'),
2738
+ reverseTitle: t('dialogs.reverse.title'),
2739
+ reverseDescription: t('dialogs.reverse.description'),
2740
+ reverseReasonLabel: t('dialogs.reverse.reasonLabel'),
2741
+ reverseReasonPlaceholder: t(
2742
+ 'dialogs.reverse.reasonPlaceholder'
2743
+ ),
2744
+ reverseButton: t('dialogs.reverse.cancel'),
2745
+ confirmReverseButton: t('dialogs.reverse.confirm'),
2746
+ }}
2747
+ onEdit={() => setEditingTitleId(titulo.id)}
2748
+ onApprove={() => void handleApproveTitle(titulo.id)}
2749
+ onSettle={() => handleSettleTitle(titulo)}
2750
+ onReverse={(reason) => handleReverseTitle(titulo, reason)}
2751
+ onCancel={() => handleCancelTitle(titulo.id)}
2752
+ />
2119
2753
  </TableCell>
2120
2754
  </TableRow>
2121
2755
  );
@@ -2127,15 +2761,25 @@ export default function TitulosPagarPage() {
2127
2761
  <div className="flex items-center justify-between">
2128
2762
  <p className="text-sm text-muted-foreground">
2129
2763
  {t('footer.showing', {
2130
- filtered: filteredTitulos.length,
2131
- total: titulosPagar.length,
2764
+ filtered: titulosPagar.length,
2765
+ total: paginatedTitlesResponse?.total || 0,
2132
2766
  })}
2133
2767
  </p>
2134
2768
  <div className="flex items-center gap-2">
2135
- <Button variant="outline" size="sm" disabled>
2769
+ <Button
2770
+ variant="outline"
2771
+ size="sm"
2772
+ disabled={!paginatedTitlesResponse?.prev || isFetchingTitles}
2773
+ onClick={() => setPage((current) => Math.max(1, current - 1))}
2774
+ >
2136
2775
  {t('footer.previous')}
2137
2776
  </Button>
2138
- <Button variant="outline" size="sm" disabled>
2777
+ <Button
2778
+ variant="outline"
2779
+ size="sm"
2780
+ disabled={!paginatedTitlesResponse?.next || isFetchingTitles}
2781
+ onClick={() => setPage((current) => current + 1)}
2782
+ >
2139
2783
  {t('footer.next')}
2140
2784
  </Button>
2141
2785
  </div>