@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,5 +1,9 @@
1
1
  'use client';
2
2
 
3
+ import {
4
+ CategoryFieldWithCreate,
5
+ CostCenterFieldWithCreate,
6
+ } from '@/app/(app)/(libraries)/finance/_components/finance-entity-field-with-create';
3
7
  import { PersonFieldWithCreate } from '@/app/(app)/(libraries)/finance/_components/person-field-with-create';
4
8
  import { Page, PageHeader } from '@/components/entity-list';
5
9
  import { Badge } from '@/components/ui/badge';
@@ -51,7 +55,12 @@ import {
51
55
  TableRow,
52
56
  } from '@/components/ui/table';
53
57
  import { Textarea } from '@/components/ui/textarea';
54
- import { useApp } from '@hed-hog/next-app-provider';
58
+ import {
59
+ Tooltip,
60
+ TooltipContent,
61
+ TooltipTrigger,
62
+ } from '@/components/ui/tooltip';
63
+ import { useApp, useQuery } from '@hed-hog/next-app-provider';
55
64
  import { zodResolver } from '@hookform/resolvers/zod';
56
65
  import {
57
66
  Download,
@@ -62,10 +71,13 @@ import {
62
71
  Paperclip,
63
72
  Plus,
64
73
  Send,
74
+ Trash2,
75
+ Upload,
65
76
  } from 'lucide-react';
66
77
  import { useTranslations } from 'next-intl';
67
78
  import Link from 'next/link';
68
- import { useEffect, useRef, useState } from 'react';
79
+ import { usePathname, useRouter, useSearchParams } from 'next/navigation';
80
+ import { useEffect, useMemo, useRef, useState } from 'react';
69
81
  import { useFieldArray, useForm } from 'react-hook-form';
70
82
  import { z } from 'zod';
71
83
  import { formatarData } from '../../_lib/formatters';
@@ -168,69 +180,74 @@ const redistributeRemainingInstallments = (
168
180
  });
169
181
  };
170
182
 
171
- const newTitleFormSchema = z
172
- .object({
173
- documento: z.string().trim().min(1, 'Documento é obrigatório'),
174
- clienteId: z.string().min(1, 'Cliente é obrigatório'),
175
- competencia: z.string().optional(),
176
- vencimento: z.string().min(1, 'Vencimento é obrigatório'),
177
- valor: z.number().min(0.01, 'Valor deve ser maior que zero'),
178
- installmentsCount: z.coerce
179
- .number({ invalid_type_error: 'Quantidade de parcelas inválida' })
180
- .int('Quantidade de parcelas inválida')
181
- .min(1, 'Mínimo de 1 parcela')
182
- .max(120, 'Máximo de 120 parcelas'),
183
- installments: z
184
- .array(
185
- z.object({
186
- dueDate: z.string().min(1, 'Vencimento da parcela é obrigatório'),
187
- amount: z
188
- .number()
189
- .min(0.01, 'Valor da parcela deve ser maior que zero'),
190
- })
191
- )
192
- .min(1, 'Informe ao menos uma parcela'),
193
- categoriaId: z.string().optional(),
194
- centroCustoId: z.string().optional(),
195
- canal: z.string().optional(),
196
- descricao: z.string().optional(),
197
- })
198
- .superRefine((values, ctx) => {
199
- if (values.installments.length !== values.installmentsCount) {
200
- ctx.addIssue({
201
- code: z.ZodIssueCode.custom,
202
- path: ['installments'],
203
- message: 'Quantidade de parcelas não confere com o detalhamento',
204
- });
205
- }
183
+ const getNewTitleFormSchema = (t: ReturnType<typeof useTranslations>) =>
184
+ z
185
+ .object({
186
+ documento: z.string().trim().min(1, t('validation.documentRequired')),
187
+ clienteId: z.string().min(1, t('validation.clientRequired')),
188
+ competencia: z.string().optional(),
189
+ vencimento: z.string().min(1, t('validation.dueDateRequired')),
190
+ valor: z.number().min(0.01, t('validation.amountGreaterThanZero')),
191
+ installmentsCount: z.coerce
192
+ .number({ invalid_type_error: t('validation.invalidInstallmentCount') })
193
+ .int(t('validation.invalidInstallmentCount'))
194
+ .min(1, t('validation.installmentsMin'))
195
+ .max(120, t('validation.installmentsMax')),
196
+ installments: z
197
+ .array(
198
+ z.object({
199
+ dueDate: z
200
+ .string()
201
+ .min(1, t('validation.installmentDueDateRequired')),
202
+ amount: z
203
+ .number()
204
+ .min(0.01, t('validation.installmentAmountGreaterThanZero')),
205
+ })
206
+ )
207
+ .min(1, t('validation.installmentsRequired')),
208
+ categoriaId: z.string().optional(),
209
+ centroCustoId: z.string().optional(),
210
+ canal: z.string().optional(),
211
+ descricao: z.string().optional(),
212
+ })
213
+ .superRefine((values, ctx) => {
214
+ if (values.installments.length !== values.installmentsCount) {
215
+ ctx.addIssue({
216
+ code: z.ZodIssueCode.custom,
217
+ path: ['installments'],
218
+ message: t('validation.installmentsCountMismatch'),
219
+ });
220
+ }
206
221
 
207
- const installmentsTotalCents = values.installments.reduce(
208
- (acc, installment) => acc + Math.round((installment.amount || 0) * 100),
209
- 0
210
- );
211
- const totalCents = Math.round((values.valor || 0) * 100);
222
+ const installmentsTotalCents = values.installments.reduce(
223
+ (acc, installment) => acc + Math.round((installment.amount || 0) * 100),
224
+ 0
225
+ );
226
+ const totalCents = Math.round((values.valor || 0) * 100);
212
227
 
213
- if (installmentsTotalCents !== totalCents) {
214
- ctx.addIssue({
215
- code: z.ZodIssueCode.custom,
216
- path: ['installments'],
217
- message: 'A soma das parcelas deve ser igual ao valor total',
218
- });
219
- }
220
- });
228
+ if (installmentsTotalCents !== totalCents) {
229
+ ctx.addIssue({
230
+ code: z.ZodIssueCode.custom,
231
+ path: ['installments'],
232
+ message: t('validation.installmentsSumMismatch'),
233
+ });
234
+ }
235
+ });
221
236
 
222
- type NewTitleFormValues = z.infer<typeof newTitleFormSchema>;
237
+ type NewTitleFormValues = z.infer<ReturnType<typeof getNewTitleFormSchema>>;
223
238
 
224
239
  function NovoTituloSheet({
225
240
  categorias,
226
241
  centrosCusto,
227
242
  t,
228
243
  onCreated,
244
+ onOptionsUpdated,
229
245
  }: {
230
246
  categorias: any[];
231
247
  centrosCusto: any[];
232
248
  t: ReturnType<typeof useTranslations>;
233
249
  onCreated: () => Promise<any> | void;
250
+ onOptionsUpdated?: () => Promise<any> | void;
234
251
  }) {
235
252
  const { request, showToastHandler } = useApp();
236
253
  const [open, setOpen] = useState(false);
@@ -249,6 +266,7 @@ function NovoTituloSheet({
249
266
  >(null);
250
267
  const [extractionWarnings, setExtractionWarnings] = useState<string[]>([]);
251
268
  const [uploadProgress, setUploadProgress] = useState(0);
269
+ const fileInputRef = useRef<HTMLInputElement | null>(null);
252
270
 
253
271
  const normalizeFilenameForDisplay = (filename: string) => {
254
272
  if (!filename) {
@@ -268,6 +286,8 @@ function NovoTituloSheet({
268
286
  }
269
287
  };
270
288
 
289
+ const newTitleFormSchema = useMemo(() => getNewTitleFormSchema(t), [t]);
290
+
271
291
  const form = useForm<NewTitleFormValues>({
272
292
  resolver: zodResolver(newTitleFormSchema),
273
293
  defaultValues: {
@@ -419,9 +439,9 @@ function NovoTituloSheet({
419
439
  setIsInstallmentsEdited(false);
420
440
  setAutoRedistributeInstallments(true);
421
441
  setOpen(false);
422
- showToastHandler?.('success', 'Título criado com sucesso');
442
+ showToastHandler?.('success', t('messages.createSuccess'));
423
443
  } catch {
424
- showToastHandler?.('error', 'Erro ao criar título');
444
+ showToastHandler?.('error', t('messages.createError'));
425
445
  }
426
446
  };
427
447
 
@@ -437,6 +457,25 @@ function NovoTituloSheet({
437
457
  setOpen(false);
438
458
  };
439
459
 
460
+ const clearUploadedFile = () => {
461
+ setUploadedFileId(null);
462
+ setUploadedFileName('');
463
+ setExtractionConfidence(null);
464
+ setExtractionWarnings([]);
465
+ setUploadProgress(0);
466
+
467
+ if (fileInputRef.current) {
468
+ fileInputRef.current.value = '';
469
+ }
470
+ };
471
+
472
+ const handleSelectFile = () => {
473
+ if (fileInputRef.current) {
474
+ fileInputRef.current.value = '';
475
+ fileInputRef.current.click();
476
+ }
477
+ };
478
+
440
479
  const uploadRelatedFile = async (file: File) => {
441
480
  setIsUploadingFile(true);
442
481
  setUploadProgress(0);
@@ -464,7 +503,7 @@ function NovoTituloSheet({
464
503
  });
465
504
 
466
505
  if (!data?.id) {
467
- throw new Error('Arquivo inválido');
506
+ throw new Error(t('messages.invalidFile'));
468
507
  }
469
508
 
470
509
  setUploadedFileId(data.id);
@@ -472,7 +511,7 @@ function NovoTituloSheet({
472
511
  normalizeFilenameForDisplay(data.filename || file.name)
473
512
  );
474
513
  setUploadProgress(100);
475
- showToastHandler?.('success', 'Arquivo relacionado com sucesso');
514
+ showToastHandler?.('success', t('messages.attachSuccess'));
476
515
 
477
516
  setIsExtractingFileData(true);
478
517
  try {
@@ -561,17 +600,11 @@ function NovoTituloSheet({
561
600
  });
562
601
  }
563
602
 
564
- showToastHandler?.(
565
- 'success',
566
- 'Dados da fatura extraídos e preenchidos automaticamente'
567
- );
603
+ showToastHandler?.('success', t('messages.aiExtractSuccess'));
568
604
  } catch {
569
605
  setExtractionConfidence(null);
570
606
  setExtractionWarnings([]);
571
- showToastHandler?.(
572
- 'error',
573
- 'Não foi possível extrair os dados automaticamente'
574
- );
607
+ showToastHandler?.('error', t('messages.aiExtractError'));
575
608
  } finally {
576
609
  setIsExtractingFileData(false);
577
610
  }
@@ -581,7 +614,7 @@ function NovoTituloSheet({
581
614
  setExtractionConfidence(null);
582
615
  setExtractionWarnings([]);
583
616
  setUploadProgress(0);
584
- showToastHandler?.('error', 'Não foi possível enviar o arquivo');
617
+ showToastHandler?.('error', t('messages.uploadError'));
585
618
  } finally {
586
619
  setIsUploadingFile(false);
587
620
  }
@@ -602,11 +635,13 @@ function NovoTituloSheet({
602
635
  </SheetHeader>
603
636
  <Form {...form}>
604
637
  <form className="px-4" onSubmit={form.handleSubmit(handleSubmit)}>
605
- <div className="grid gap-4">
606
- <div className="grid gap-2">
607
- <FormLabel>Arquivo da fatura (opcional)</FormLabel>
608
- <div className="flex flex-col gap-2 sm:flex-row sm:items-center">
638
+ <div className="grid gap-3">
639
+ <div className="grid grid-cols-1 items-start gap-4 sm:grid-cols-2">
640
+ <div className="grid gap-2">
641
+ <FormLabel>{t('common.upload.label')}</FormLabel>
609
642
  <Input
643
+ ref={fileInputRef}
644
+ className="hidden"
610
645
  type="file"
611
646
  accept=".pdf,.png,.jpg,.jpeg,.xml,.txt"
612
647
  onChange={(event) => {
@@ -615,11 +650,7 @@ function NovoTituloSheet({
615
650
  return;
616
651
  }
617
652
 
618
- setUploadedFileId(null);
619
- setUploadedFileName('');
620
- setExtractionConfidence(null);
621
- setExtractionWarnings([]);
622
- setUploadProgress(0);
653
+ clearUploadedFile();
623
654
  void uploadRelatedFile(file);
624
655
  }}
625
656
  disabled={
@@ -628,67 +659,128 @@ function NovoTituloSheet({
628
659
  form.formState.isSubmitting
629
660
  }
630
661
  />
631
- </div>
632
- {isUploadingFile && (
633
- <div className="space-y-1">
634
- <Progress value={uploadProgress} className="h-2" />
635
- <p className="text-xs text-muted-foreground">
636
- Upload em andamento: {uploadProgress}%
637
- </p>
638
- </div>
639
- )}
640
- {uploadedFileId && (
641
- <p className="text-xs text-muted-foreground">
642
- Arquivo relacionado: {uploadedFileName}
643
- </p>
644
- )}
645
- {isExtractingFileData && (
646
- <div className="rounded-md border border-primary/30 bg-primary/5 p-3">
647
- <div className="flex items-center gap-2 text-sm font-medium text-primary">
648
- <Loader2 className="h-4 w-4 animate-spin" />
649
- Analisando documento com IA
650
- </div>
651
- <p className="mt-1 text-xs text-muted-foreground">
652
- Os campos serão preenchidos automaticamente em instantes.
653
- Revise os dados antes de salvar.
654
- </p>
662
+
663
+ <div className="grid w-full grid-cols-2 gap-2">
664
+ <Tooltip>
665
+ <TooltipTrigger asChild>
666
+ <Button
667
+ type="button"
668
+ variant="outline"
669
+ className={
670
+ uploadedFileId ? 'w-full' : 'col-span-2 w-full'
671
+ }
672
+ onClick={handleSelectFile}
673
+ aria-label={
674
+ uploadedFileId
675
+ ? t('common.upload.change')
676
+ : t('common.upload.upload')
677
+ }
678
+ disabled={
679
+ isUploadingFile ||
680
+ isExtractingFileData ||
681
+ form.formState.isSubmitting
682
+ }
683
+ >
684
+ {uploadedFileId ? (
685
+ <Upload className="h-4 w-4" />
686
+ ) : (
687
+ <>
688
+ <Upload className="mr-2 h-4 w-4" />
689
+ {t('common.upload.upload')}
690
+ </>
691
+ )}
692
+ </Button>
693
+ </TooltipTrigger>
694
+ <TooltipContent>
695
+ {uploadedFileId
696
+ ? t('common.upload.change')
697
+ : t('common.upload.upload')}
698
+ </TooltipContent>
699
+ </Tooltip>
700
+
701
+ {uploadedFileId && (
702
+ <Tooltip>
703
+ <TooltipTrigger asChild>
704
+ <Button
705
+ type="button"
706
+ variant="outline"
707
+ className="w-full"
708
+ onClick={clearUploadedFile}
709
+ aria-label={t('common.upload.remove')}
710
+ disabled={
711
+ isUploadingFile ||
712
+ isExtractingFileData ||
713
+ form.formState.isSubmitting
714
+ }
715
+ >
716
+ <Trash2 className="h-4 w-4" />
717
+ </Button>
718
+ </TooltipTrigger>
719
+ <TooltipContent>
720
+ {t('common.upload.remove')}
721
+ </TooltipContent>
722
+ </Tooltip>
723
+ )}
655
724
  </div>
656
- )}
657
- {!isExtractingFileData &&
658
- extractionConfidence !== null &&
659
- extractionConfidence < 70 && (
660
- <div className="rounded-md border border-destructive/30 bg-destructive/5 p-3">
661
- <div className="text-sm font-medium text-destructive">
662
- Confiança da extração:{' '}
663
- {Math.round(extractionConfidence)}%
725
+
726
+ <div className="space-y-1">
727
+ {uploadedFileId && (
728
+ <p className="truncate text-xs text-muted-foreground">
729
+ {t('common.upload.selectedPrefix')} {uploadedFileName}
730
+ </p>
731
+ )}
732
+
733
+ {isUploadingFile && !isExtractingFileData && (
734
+ <div className="space-y-1">
735
+ <Progress value={uploadProgress} className="h-2" />
736
+ <p className="text-xs text-muted-foreground">
737
+ {t('common.upload.uploadingProgress', {
738
+ progress: uploadProgress,
739
+ })}
740
+ </p>
664
741
  </div>
665
- <p className="mt-1 text-xs text-muted-foreground">
666
- Revise principalmente valor e vencimento antes de
667
- salvar.
742
+ )}
743
+
744
+ {isExtractingFileData && (
745
+ <p className="flex items-center gap-2 text-xs text-primary">
746
+ <Loader2 className="h-4 w-4 animate-spin" />
747
+ {t('common.upload.processingAi')}
668
748
  </p>
669
- {extractionWarnings.length > 0 && (
670
- <p className="mt-1 text-xs text-muted-foreground">
671
- {extractionWarnings[0]}
749
+ )}
750
+
751
+ {!isExtractingFileData &&
752
+ extractionConfidence !== null &&
753
+ extractionConfidence < 70 && (
754
+ <p className="text-xs text-destructive">
755
+ {t('common.upload.lowConfidence', {
756
+ confidence: Math.round(extractionConfidence),
757
+ })}
672
758
  </p>
673
759
  )}
674
- </div>
760
+
761
+ {!isExtractingFileData && extractionWarnings.length > 0 && (
762
+ <p className="truncate text-xs text-muted-foreground">
763
+ {extractionWarnings[0]}
764
+ </p>
765
+ )}
766
+ </div>
767
+ </div>
768
+
769
+ <FormField
770
+ control={form.control}
771
+ name="documento"
772
+ render={({ field }) => (
773
+ <FormItem>
774
+ <FormLabel>{t('fields.document')}</FormLabel>
775
+ <FormControl>
776
+ <Input placeholder="FAT-00000" {...field} />
777
+ </FormControl>
778
+ <FormMessage />
779
+ </FormItem>
675
780
  )}
781
+ />
676
782
  </div>
677
783
 
678
- <FormField
679
- control={form.control}
680
- name="documento"
681
- render={({ field }) => (
682
- <FormItem>
683
- <FormLabel>{t('fields.document')}</FormLabel>
684
- <FormControl>
685
- <Input placeholder="FAT-00000" {...field} />
686
- </FormControl>
687
- <FormMessage />
688
- </FormItem>
689
- )}
690
- />
691
-
692
784
  <PersonFieldWithCreate
693
785
  form={form}
694
786
  name="clienteId"
@@ -697,7 +789,7 @@ function NovoTituloSheet({
697
789
  selectPlaceholder={t('common.select')}
698
790
  />
699
791
 
700
- <div className="grid grid-cols-2 gap-4">
792
+ <div className="grid grid-cols-2 items-start gap-3">
701
793
  <FormField
702
794
  control={form.control}
703
795
  name="competencia"
@@ -735,55 +827,61 @@ function NovoTituloSheet({
735
827
  />
736
828
  </div>
737
829
 
738
- <FormField
739
- control={form.control}
740
- name="valor"
741
- render={({ field }) => (
742
- <FormItem>
743
- <FormLabel>{t('fields.totalValue')}</FormLabel>
744
- <FormControl>
745
- <InputMoney
746
- ref={field.ref}
747
- name={field.name}
748
- value={field.value}
749
- onBlur={field.onBlur}
750
- onValueChange={(value) => field.onChange(value ?? 0)}
751
- placeholder="0,00"
752
- />
753
- </FormControl>
754
- <FormMessage />
755
- </FormItem>
756
- )}
757
- />
830
+ <div className="grid grid-cols-1 items-start gap-3 sm:grid-cols-2">
831
+ <FormField
832
+ control={form.control}
833
+ name="valor"
834
+ render={({ field }) => (
835
+ <FormItem>
836
+ <FormLabel>{t('fields.totalValue')}</FormLabel>
837
+ <FormControl>
838
+ <InputMoney
839
+ ref={field.ref}
840
+ name={field.name}
841
+ value={field.value}
842
+ onBlur={field.onBlur}
843
+ onValueChange={(value) => field.onChange(value ?? 0)}
844
+ placeholder="0,00"
845
+ />
846
+ </FormControl>
847
+ <FormMessage />
848
+ </FormItem>
849
+ )}
850
+ />
758
851
 
759
- <FormField
760
- control={form.control}
761
- name="installmentsCount"
762
- render={({ field }) => (
763
- <FormItem>
764
- <FormLabel>Quantidade de Parcelas</FormLabel>
765
- <FormControl>
766
- <Input
767
- type="number"
768
- min={1}
769
- max={120}
770
- value={field.value}
771
- onChange={(event) => {
772
- const nextValue = Number(event.target.value || 1);
773
- field.onChange(
774
- Number.isNaN(nextValue) ? 1 : nextValue
775
- );
776
- }}
777
- />
778
- </FormControl>
779
- <FormMessage />
780
- </FormItem>
781
- )}
782
- />
852
+ <FormField
853
+ control={form.control}
854
+ name="installmentsCount"
855
+ render={({ field }) => (
856
+ <FormItem>
857
+ <FormLabel>
858
+ {t('installmentsEditor.countLabel')}
859
+ </FormLabel>
860
+ <FormControl>
861
+ <Input
862
+ type="number"
863
+ min={1}
864
+ max={120}
865
+ value={field.value}
866
+ onChange={(event) => {
867
+ const nextValue = Number(event.target.value || 1);
868
+ field.onChange(
869
+ Number.isNaN(nextValue) ? 1 : nextValue
870
+ );
871
+ }}
872
+ />
873
+ </FormControl>
874
+ <FormMessage />
875
+ </FormItem>
876
+ )}
877
+ />
878
+ </div>
783
879
 
784
880
  <div className="space-y-3 rounded-md border p-3">
785
881
  <div className="flex items-center justify-between gap-2">
786
- <p className="text-sm font-medium">Parcelas</p>
882
+ <p className="text-sm font-medium">
883
+ {t('installmentsEditor.title')}
884
+ </p>
787
885
  <Button
788
886
  type="button"
789
887
  variant="outline"
@@ -799,7 +897,7 @@ function NovoTituloSheet({
799
897
  );
800
898
  }}
801
899
  >
802
- Recalcular automaticamente
900
+ {t('installmentsEditor.recalculate')}
803
901
  </Button>
804
902
  </div>
805
903
 
@@ -815,7 +913,7 @@ function NovoTituloSheet({
815
913
  htmlFor="auto-redistribute-installments-receivable"
816
914
  className="text-xs text-muted-foreground"
817
915
  >
818
- Redistribuir automaticamente o restante ao editar parcela
916
+ {t('installmentsEditor.autoRedistributeLabel')}
819
917
  </Label>
820
918
  </div>
821
919
  {autoRedistributeInstallments && (
@@ -829,7 +927,7 @@ function NovoTituloSheet({
829
927
  {installmentFields.map((installment, index) => (
830
928
  <div
831
929
  key={installment.id}
832
- className="grid grid-cols-1 gap-2 rounded-md border p-2 sm:grid-cols-[96px_1fr_160px]"
930
+ className="grid grid-cols-1 items-start gap-2 rounded-md border p-2 sm:grid-cols-[96px_1fr_160px]"
833
931
  >
834
932
  <div className="flex items-center text-sm text-muted-foreground">
835
933
  #{index + 1}
@@ -841,7 +939,7 @@ function NovoTituloSheet({
841
939
  render={({ field }) => (
842
940
  <FormItem>
843
941
  <FormLabel className="text-xs">
844
- Vencimento
942
+ {t('installmentsEditor.dueDateLabel')}
845
943
  </FormLabel>
846
944
  <FormControl>
847
945
  <Input
@@ -864,7 +962,9 @@ function NovoTituloSheet({
864
962
  name={`installments.${index}.amount` as const}
865
963
  render={({ field }) => (
866
964
  <FormItem>
867
- <FormLabel className="text-xs">Valor</FormLabel>
965
+ <FormLabel className="text-xs">
966
+ {t('installmentsEditor.amountLabel')}
967
+ </FormLabel>
868
968
  <FormControl>
869
969
  <InputMoney
870
970
  ref={field.ref}
@@ -908,8 +1008,11 @@ function NovoTituloSheet({
908
1008
  : 'text-destructive'
909
1009
  }`}
910
1010
  >
911
- Soma das parcelas: {installmentsTotal.toFixed(2)}
912
- {installmentsDiffCents > 0 && ' (ajuste necessário)'}
1011
+ {t('installmentsEditor.totalPrefix', {
1012
+ total: installmentsTotal.toFixed(2),
1013
+ })}
1014
+ {installmentsDiffCents > 0 &&
1015
+ ` ${t('installmentsEditor.adjustmentNeeded')}`}
913
1016
  </p>
914
1017
  {form.formState.errors.installments?.message && (
915
1018
  <p className="text-xs text-destructive">
@@ -918,56 +1021,23 @@ function NovoTituloSheet({
918
1021
  )}
919
1022
  </div>
920
1023
 
921
- <FormField
922
- control={form.control}
1024
+ <CategoryFieldWithCreate
1025
+ form={form}
923
1026
  name="categoriaId"
924
- render={({ field }) => (
925
- <FormItem>
926
- <FormLabel>{t('fields.category')}</FormLabel>
927
- <Select value={field.value} onValueChange={field.onChange}>
928
- <FormControl>
929
- <SelectTrigger>
930
- <SelectValue placeholder={t('common.select')} />
931
- </SelectTrigger>
932
- </FormControl>
933
- <SelectContent>
934
- {categorias
935
- .filter((c) => c.natureza === 'receita')
936
- .map((c) => (
937
- <SelectItem key={c.id} value={String(c.id)}>
938
- {c.codigo} - {c.nome}
939
- </SelectItem>
940
- ))}
941
- </SelectContent>
942
- </Select>
943
- <FormMessage />
944
- </FormItem>
945
- )}
1027
+ label={t('fields.category')}
1028
+ selectPlaceholder={t('common.select')}
1029
+ categories={categorias}
1030
+ categoryKind="receita"
1031
+ onCreated={onOptionsUpdated}
946
1032
  />
947
1033
 
948
- <FormField
949
- control={form.control}
1034
+ <CostCenterFieldWithCreate
1035
+ form={form}
950
1036
  name="centroCustoId"
951
- render={({ field }) => (
952
- <FormItem>
953
- <FormLabel>{t('fields.costCenter')}</FormLabel>
954
- <Select value={field.value} onValueChange={field.onChange}>
955
- <FormControl>
956
- <SelectTrigger>
957
- <SelectValue placeholder={t('common.select')} />
958
- </SelectTrigger>
959
- </FormControl>
960
- <SelectContent>
961
- {centrosCusto.map((c) => (
962
- <SelectItem key={c.id} value={String(c.id)}>
963
- {c.codigo} - {c.nome}
964
- </SelectItem>
965
- ))}
966
- </SelectContent>
967
- </Select>
968
- <FormMessage />
969
- </FormItem>
970
- )}
1037
+ label={t('fields.costCenter')}
1038
+ selectPlaceholder={t('common.select')}
1039
+ costCenters={centrosCusto}
1040
+ onCreated={onOptionsUpdated}
971
1041
  />
972
1042
 
973
1043
  <FormField
@@ -978,7 +1048,7 @@ function NovoTituloSheet({
978
1048
  <FormLabel>{t('fields.channel')}</FormLabel>
979
1049
  <Select value={field.value} onValueChange={field.onChange}>
980
1050
  <FormControl>
981
- <SelectTrigger>
1051
+ <SelectTrigger className="w-full">
982
1052
  <SelectValue placeholder={t('common.select')} />
983
1053
  </SelectTrigger>
984
1054
  </FormControl>
@@ -1019,10 +1089,7 @@ function NovoTituloSheet({
1019
1089
  />
1020
1090
  </div>
1021
1091
 
1022
- <div className="flex justify-end gap-2 pt-4">
1023
- <Button type="button" variant="outline" onClick={handleCancel}>
1024
- {t('common.cancel')}
1025
- </Button>
1092
+ <div className="flex flex-col gap-2 py-4">
1026
1093
  <Button
1027
1094
  type="submit"
1028
1095
  disabled={
@@ -1035,9 +1102,9 @@ function NovoTituloSheet({
1035
1102
  <Loader2 className="mr-2 h-4 w-4 animate-spin" />
1036
1103
  )}
1037
1104
  {isExtractingFileData
1038
- ? 'Preenchendo com IA...'
1105
+ ? t('common.upload.fillingWithAi')
1039
1106
  : isUploadingFile
1040
- ? 'Enviando arquivo...'
1107
+ ? t('common.upload.uploadingFile')
1041
1108
  : t('common.save')}
1042
1109
  </Button>
1043
1110
  </div>
@@ -1048,87 +1115,1154 @@ function NovoTituloSheet({
1048
1115
  );
1049
1116
  }
1050
1117
 
1051
- export default function TitulosReceberPage() {
1052
- const t = useTranslations('finance.ReceivableInstallmentsPage');
1118
+ function EditarTituloSheet({
1119
+ open,
1120
+ onOpenChange,
1121
+ titulo,
1122
+ categorias,
1123
+ centrosCusto,
1124
+ t,
1125
+ onUpdated,
1126
+ onOptionsUpdated,
1127
+ }: {
1128
+ open: boolean;
1129
+ onOpenChange: (open: boolean) => void;
1130
+ titulo?: any;
1131
+ categorias: any[];
1132
+ centrosCusto: any[];
1133
+ t: ReturnType<typeof useTranslations>;
1134
+ onUpdated: () => Promise<any> | void;
1135
+ onOptionsUpdated?: () => Promise<any> | void;
1136
+ }) {
1053
1137
  const { request, showToastHandler } = useApp();
1054
- const { data, refetch } = useFinanceData();
1055
- const { titulosReceber, pessoas, categorias, centrosCusto } = data;
1056
-
1057
- const getPessoaById = (id?: string) => pessoas.find((p) => p.id === id);
1058
-
1059
- const [search, setSearch] = useState('');
1060
- const [statusFilter, setStatusFilter] = useState<string>('');
1061
-
1062
- const canalBadge = {
1063
- boleto: {
1064
- label: t('channels.boleto'),
1065
- className: 'bg-blue-100 text-blue-700',
1066
- },
1067
- pix: { label: 'PIX', className: 'bg-green-100 text-green-700' },
1068
- cartao: {
1069
- label: t('channels.card'),
1070
- className: 'bg-purple-100 text-purple-700',
1071
- },
1072
- transferencia: {
1073
- label: t('channels.transfer'),
1074
- className: 'bg-orange-100 text-orange-700',
1075
- },
1076
- };
1077
-
1078
- const filteredTitulos = titulosReceber.filter((titulo) => {
1079
- const matchesSearch =
1080
- titulo.documento.toLowerCase().includes(search.toLowerCase()) ||
1081
- getPessoaById(titulo.clienteId)
1082
- ?.nome.toLowerCase()
1083
- .includes(search.toLowerCase());
1084
-
1085
- const matchesStatus = !statusFilter || titulo.status === statusFilter;
1138
+ const [uploadedFileId, setUploadedFileId] = useState<number | null>(null);
1139
+ const [uploadedFileName, setUploadedFileName] = useState('');
1140
+ const [isUploadingFile, setIsUploadingFile] = useState(false);
1141
+ const [isExtractingFileData, setIsExtractingFileData] = useState(false);
1142
+ const [isInstallmentsEdited, setIsInstallmentsEdited] = useState(false);
1143
+ const [autoRedistributeInstallments, setAutoRedistributeInstallments] =
1144
+ useState(true);
1145
+ const [extractionConfidence, setExtractionConfidence] = useState<
1146
+ number | null
1147
+ >(null);
1148
+ const [extractionWarnings, setExtractionWarnings] = useState<string[]>([]);
1149
+ const [uploadProgress, setUploadProgress] = useState(0);
1150
+ const fileInputRef = useRef<HTMLInputElement | null>(null);
1151
+ const redistributionTimeoutRef = useRef<
1152
+ Record<number, ReturnType<typeof setTimeout>>
1153
+ >({});
1086
1154
 
1087
- return matchesSearch && matchesStatus;
1088
- });
1155
+ const normalizeFilenameForDisplay = (filename: string) => {
1156
+ if (!filename) {
1157
+ return filename;
1158
+ }
1089
1159
 
1090
- const handleOpenAttachment = async (fileId?: string) => {
1091
- if (!fileId) {
1092
- return;
1160
+ if (!/Ã.|Â.|â[\u0080-\u00BF]/.test(filename)) {
1161
+ return filename;
1093
1162
  }
1094
1163
 
1095
1164
  try {
1096
- const response = await request<{ url?: string }>({
1097
- url: `/file/open/${fileId}`,
1098
- method: 'PUT',
1099
- });
1100
-
1101
- const url = response?.data?.url;
1102
- if (!url) {
1103
- showToastHandler?.('error', 'Não foi possível abrir o anexo');
1104
- return;
1105
- }
1106
-
1107
- window.open(url, '_blank', 'noopener,noreferrer');
1165
+ const bytes = Uint8Array.from(filename, (char) => char.charCodeAt(0));
1166
+ const decoded = new TextDecoder('utf-8').decode(bytes);
1167
+ return /Ã.|Â.|â[\u0080-\u00BF]/.test(decoded) ? filename : decoded;
1108
1168
  } catch {
1109
- showToastHandler?.('error', 'Não foi possível abrir o anexo');
1169
+ return filename;
1110
1170
  }
1111
1171
  };
1112
1172
 
1113
- return (
1114
- <Page>
1115
- <PageHeader
1116
- title={t('header.title')}
1117
- description={t('header.description')}
1118
- breadcrumbs={[
1119
- { label: t('breadcrumbs.home'), href: '/' },
1120
- { label: t('breadcrumbs.finance'), href: '/finance' },
1121
- { label: t('breadcrumbs.current') },
1122
- ]}
1123
- actions={
1124
- <NovoTituloSheet
1125
- categorias={categorias}
1126
- centrosCusto={centrosCusto}
1127
- t={t}
1128
- onCreated={refetch}
1129
- />
1130
- }
1131
- />
1173
+ const newTitleFormSchema = useMemo(() => getNewTitleFormSchema(t), [t]);
1174
+
1175
+ const form = useForm<NewTitleFormValues>({
1176
+ resolver: zodResolver(newTitleFormSchema),
1177
+ defaultValues: {
1178
+ documento: '',
1179
+ clienteId: '',
1180
+ competencia: '',
1181
+ vencimento: '',
1182
+ valor: 0,
1183
+ installmentsCount: 1,
1184
+ installments: [{ dueDate: '', amount: 0 }],
1185
+ categoriaId: '',
1186
+ centroCustoId: '',
1187
+ canal: '',
1188
+ descricao: '',
1189
+ },
1190
+ });
1191
+
1192
+ const { fields: installmentFields, replace: replaceInstallments } =
1193
+ useFieldArray({
1194
+ control: form.control,
1195
+ name: 'installments',
1196
+ });
1197
+
1198
+ const watchedInstallmentsCount = form.watch('installmentsCount');
1199
+ const watchedTotalValue = form.watch('valor');
1200
+ const watchedDueDate = form.watch('vencimento');
1201
+ const watchedInstallments = form.watch('installments');
1202
+
1203
+ const toDateInput = (value?: string) => {
1204
+ if (!value) {
1205
+ return '';
1206
+ }
1207
+
1208
+ if (/^\d{4}-\d{2}-\d{2}$/.test(value)) {
1209
+ return value;
1210
+ }
1211
+
1212
+ const parsed = new Date(value);
1213
+ if (Number.isNaN(parsed.getTime())) {
1214
+ return '';
1215
+ }
1216
+
1217
+ return parsed.toISOString().slice(0, 10);
1218
+ };
1219
+
1220
+ useEffect(() => {
1221
+ if (!open || !titulo) {
1222
+ return;
1223
+ }
1224
+
1225
+ const installments = Array.isArray(titulo.parcelas) ? titulo.parcelas : [];
1226
+ const normalizedInstallments =
1227
+ installments.length > 0
1228
+ ? installments.map((installment: any) => ({
1229
+ dueDate: toDateInput(installment.vencimento),
1230
+ amount: Number(installment.valor || 0),
1231
+ }))
1232
+ : [
1233
+ {
1234
+ dueDate: toDateInput(titulo?.vencimento),
1235
+ amount: Number(titulo?.valorTotal || 0),
1236
+ },
1237
+ ];
1238
+
1239
+ form.reset({
1240
+ documento: titulo.documento || '',
1241
+ clienteId: titulo.clienteId || '',
1242
+ competencia: titulo.competencia || '',
1243
+ vencimento:
1244
+ normalizedInstallments[0]?.dueDate || toDateInput(titulo?.vencimento),
1245
+ valor: Number(titulo.valorTotal || 0),
1246
+ installmentsCount: normalizedInstallments.length,
1247
+ installments: normalizedInstallments,
1248
+ categoriaId: titulo.categoriaId || '',
1249
+ centroCustoId: titulo.centroCustoId || '',
1250
+ canal: installments[0]?.canal || '',
1251
+ descricao: titulo.descricao || '',
1252
+ });
1253
+
1254
+ const attachmentSource = Array.isArray(titulo.anexosDetalhes)
1255
+ ? titulo.anexosDetalhes
1256
+ : Array.isArray(titulo.anexos)
1257
+ ? titulo.anexos
1258
+ : [];
1259
+ const firstAttachment = attachmentSource[0] as any;
1260
+ const firstAttachmentIdRaw =
1261
+ firstAttachment && typeof firstAttachment === 'object'
1262
+ ? (firstAttachment.id ??
1263
+ firstAttachment.file_id ??
1264
+ firstAttachment.fileId)
1265
+ : undefined;
1266
+ const parsedAttachmentId = Number(firstAttachmentIdRaw);
1267
+
1268
+ setUploadedFileId(
1269
+ Number.isFinite(parsedAttachmentId) ? parsedAttachmentId : null
1270
+ );
1271
+ setUploadedFileName(
1272
+ normalizeFilenameForDisplay(
1273
+ typeof firstAttachment === 'string'
1274
+ ? firstAttachment
1275
+ : firstAttachment?.filename ||
1276
+ firstAttachment?.originalname ||
1277
+ firstAttachment?.name ||
1278
+ firstAttachment?.nome ||
1279
+ firstAttachment?.file_name ||
1280
+ firstAttachment?.fileName ||
1281
+ ''
1282
+ )
1283
+ );
1284
+ setExtractionConfidence(null);
1285
+ setExtractionWarnings([]);
1286
+ setUploadProgress(0);
1287
+
1288
+ setIsInstallmentsEdited(true);
1289
+ }, [form, open, titulo]);
1290
+
1291
+ useEffect(() => {
1292
+ if (isInstallmentsEdited || !open) {
1293
+ return;
1294
+ }
1295
+
1296
+ replaceInstallments(
1297
+ buildEqualInstallments(
1298
+ watchedInstallmentsCount,
1299
+ watchedTotalValue,
1300
+ watchedDueDate
1301
+ )
1302
+ );
1303
+ }, [
1304
+ isInstallmentsEdited,
1305
+ open,
1306
+ replaceInstallments,
1307
+ watchedDueDate,
1308
+ watchedInstallmentsCount,
1309
+ watchedTotalValue,
1310
+ ]);
1311
+
1312
+ const installmentsTotal = (watchedInstallments || []).reduce(
1313
+ (acc, installment) => acc + Number(installment?.amount || 0),
1314
+ 0
1315
+ );
1316
+ const installmentsDiffCents = Math.abs(
1317
+ Math.round(installmentsTotal * 100) -
1318
+ Math.round((watchedTotalValue || 0) * 100)
1319
+ );
1320
+
1321
+ const clearScheduledRedistribution = (index: number) => {
1322
+ const timeout = redistributionTimeoutRef.current[index];
1323
+ if (!timeout) {
1324
+ return;
1325
+ }
1326
+
1327
+ clearTimeout(timeout);
1328
+ delete redistributionTimeoutRef.current[index];
1329
+ };
1330
+
1331
+ const runInstallmentRedistribution = (index: number) => {
1332
+ if (!autoRedistributeInstallments) {
1333
+ return;
1334
+ }
1335
+
1336
+ const currentInstallments = form.getValues('installments');
1337
+ const editedInstallmentAmount = Number(
1338
+ currentInstallments[index]?.amount || 0
1339
+ );
1340
+
1341
+ const redistributedInstallments = redistributeRemainingInstallments(
1342
+ currentInstallments,
1343
+ index,
1344
+ editedInstallmentAmount,
1345
+ form.getValues('valor')
1346
+ );
1347
+
1348
+ replaceInstallments(redistributedInstallments);
1349
+ };
1350
+
1351
+ const scheduleInstallmentRedistribution = (index: number) => {
1352
+ clearScheduledRedistribution(index);
1353
+
1354
+ redistributionTimeoutRef.current[index] = setTimeout(() => {
1355
+ runInstallmentRedistribution(index);
1356
+ delete redistributionTimeoutRef.current[index];
1357
+ }, INSTALLMENT_REDISTRIBUTION_DEBOUNCE_MS);
1358
+ };
1359
+
1360
+ useEffect(() => {
1361
+ if (autoRedistributeInstallments) {
1362
+ return;
1363
+ }
1364
+
1365
+ Object.values(redistributionTimeoutRef.current).forEach(clearTimeout);
1366
+ redistributionTimeoutRef.current = {};
1367
+ }, [autoRedistributeInstallments]);
1368
+
1369
+ useEffect(() => {
1370
+ return () => {
1371
+ Object.values(redistributionTimeoutRef.current).forEach(clearTimeout);
1372
+ redistributionTimeoutRef.current = {};
1373
+ };
1374
+ }, []);
1375
+
1376
+ const clearUploadedFile = () => {
1377
+ setUploadedFileId(null);
1378
+ setUploadedFileName('');
1379
+ setExtractionConfidence(null);
1380
+ setExtractionWarnings([]);
1381
+ setUploadProgress(0);
1382
+
1383
+ if (fileInputRef.current) {
1384
+ fileInputRef.current.value = '';
1385
+ }
1386
+ };
1387
+
1388
+ const handleSelectFile = () => {
1389
+ if (fileInputRef.current) {
1390
+ fileInputRef.current.value = '';
1391
+ fileInputRef.current.click();
1392
+ }
1393
+ };
1394
+
1395
+ const uploadRelatedFile = async (file: File) => {
1396
+ setIsUploadingFile(true);
1397
+ setUploadProgress(0);
1398
+
1399
+ try {
1400
+ const formData = new FormData();
1401
+ formData.append('file', file);
1402
+ formData.append('destination', 'finance/titles');
1403
+
1404
+ const { data } = await request<{ id: number; filename: string }>({
1405
+ url: '/file',
1406
+ method: 'POST',
1407
+ data: formData,
1408
+ headers: {
1409
+ 'Content-Type': 'multipart/form-data',
1410
+ },
1411
+ onUploadProgress: (event) => {
1412
+ if (!event.total) {
1413
+ return;
1414
+ }
1415
+
1416
+ const progress = Math.round((event.loaded * 100) / event.total);
1417
+ setUploadProgress(progress);
1418
+ },
1419
+ });
1420
+
1421
+ if (!data?.id) {
1422
+ throw new Error(t('messages.invalidFile'));
1423
+ }
1424
+
1425
+ setUploadedFileId(data.id);
1426
+ setUploadedFileName(
1427
+ normalizeFilenameForDisplay(data.filename || file.name)
1428
+ );
1429
+ setUploadProgress(100);
1430
+ showToastHandler?.('success', t('messages.attachSuccess'));
1431
+
1432
+ setIsExtractingFileData(true);
1433
+ try {
1434
+ const extraction = await request<{
1435
+ documento?: string | null;
1436
+ clienteId?: string;
1437
+ competencia?: string;
1438
+ vencimento?: string;
1439
+ valor?: number | null;
1440
+ categoriaId?: string;
1441
+ centroCustoId?: string;
1442
+ canal?: string;
1443
+ descricao?: string | null;
1444
+ confidence?: number | null;
1445
+ confidenceLevel?: 'low' | 'high' | null;
1446
+ warnings?: string[];
1447
+ }>({
1448
+ url: '/finance/accounts-receivable/installments/extract-from-file',
1449
+ method: 'POST',
1450
+ data: {
1451
+ file_id: data.id,
1452
+ },
1453
+ });
1454
+
1455
+ const extracted = extraction.data || {};
1456
+ setExtractionConfidence(
1457
+ typeof extracted.confidence === 'number' ? extracted.confidence : null
1458
+ );
1459
+ setExtractionWarnings(
1460
+ Array.isArray(extracted.warnings)
1461
+ ? extracted.warnings.filter(Boolean)
1462
+ : []
1463
+ );
1464
+
1465
+ if (extracted.documento) {
1466
+ form.setValue('documento', extracted.documento, {
1467
+ shouldValidate: true,
1468
+ });
1469
+ }
1470
+
1471
+ if (extracted.clienteId) {
1472
+ form.setValue('clienteId', extracted.clienteId, {
1473
+ shouldValidate: true,
1474
+ });
1475
+ }
1476
+
1477
+ if (extracted.competencia) {
1478
+ form.setValue('competencia', extracted.competencia, {
1479
+ shouldValidate: true,
1480
+ });
1481
+ }
1482
+
1483
+ if (extracted.vencimento) {
1484
+ form.setValue('vencimento', extracted.vencimento, {
1485
+ shouldValidate: true,
1486
+ });
1487
+ }
1488
+
1489
+ if (typeof extracted.valor === 'number' && extracted.valor > 0) {
1490
+ form.setValue('valor', extracted.valor, {
1491
+ shouldValidate: true,
1492
+ });
1493
+ }
1494
+
1495
+ if (extracted.categoriaId) {
1496
+ form.setValue('categoriaId', extracted.categoriaId, {
1497
+ shouldValidate: true,
1498
+ });
1499
+ }
1500
+
1501
+ if (extracted.centroCustoId) {
1502
+ form.setValue('centroCustoId', extracted.centroCustoId, {
1503
+ shouldValidate: true,
1504
+ });
1505
+ }
1506
+
1507
+ if (extracted.canal) {
1508
+ form.setValue('canal', extracted.canal, {
1509
+ shouldValidate: true,
1510
+ });
1511
+ }
1512
+
1513
+ if (extracted.descricao) {
1514
+ form.setValue('descricao', extracted.descricao, {
1515
+ shouldValidate: true,
1516
+ });
1517
+ }
1518
+
1519
+ showToastHandler?.('success', t('messages.aiExtractSuccess'));
1520
+ } catch {
1521
+ setExtractionConfidence(null);
1522
+ setExtractionWarnings([]);
1523
+ showToastHandler?.('error', t('messages.aiExtractError'));
1524
+ } finally {
1525
+ setIsExtractingFileData(false);
1526
+ }
1527
+ } catch {
1528
+ setUploadedFileId(null);
1529
+ setUploadedFileName('');
1530
+ setExtractionConfidence(null);
1531
+ setExtractionWarnings([]);
1532
+ setUploadProgress(0);
1533
+ showToastHandler?.('error', t('messages.uploadError'));
1534
+ } finally {
1535
+ setIsUploadingFile(false);
1536
+ }
1537
+ };
1538
+
1539
+ const handleSubmit = async (values: NewTitleFormValues) => {
1540
+ if (!titulo?.id) {
1541
+ showToastHandler?.('error', t('messages.invalidTitleForEdit'));
1542
+ return;
1543
+ }
1544
+
1545
+ try {
1546
+ await request({
1547
+ url: `/finance/accounts-receivable/installments/${titulo.id}`,
1548
+ method: 'PATCH',
1549
+ data: {
1550
+ document_number: values.documento,
1551
+ person_id: Number(values.clienteId),
1552
+ competence_date: values.competencia
1553
+ ? `${values.competencia}-01`
1554
+ : undefined,
1555
+ due_date: values.vencimento,
1556
+ total_amount: values.valor,
1557
+ finance_category_id: values.categoriaId
1558
+ ? Number(values.categoriaId)
1559
+ : undefined,
1560
+ cost_center_id: values.centroCustoId
1561
+ ? Number(values.centroCustoId)
1562
+ : undefined,
1563
+ payment_channel: values.canal || undefined,
1564
+ description: values.descricao?.trim() || undefined,
1565
+ installments: values.installments.map((installment, index) => ({
1566
+ installment_number: index + 1,
1567
+ due_date: installment.dueDate || values.vencimento,
1568
+ amount: installment.amount,
1569
+ })),
1570
+ attachment_file_ids: uploadedFileId ? [uploadedFileId] : undefined,
1571
+ },
1572
+ });
1573
+
1574
+ await onUpdated();
1575
+ showToastHandler?.('success', t('messages.updateSuccess'));
1576
+ onOpenChange(false);
1577
+ } catch {
1578
+ showToastHandler?.('error', t('messages.updateError'));
1579
+ }
1580
+ };
1581
+
1582
+ return (
1583
+ <Sheet open={open} onOpenChange={onOpenChange}>
1584
+ <SheetContent className="w-full sm:max-w-lg overflow-y-auto">
1585
+ <SheetHeader>
1586
+ <SheetTitle>{t('table.actions.edit')}</SheetTitle>
1587
+ <SheetDescription>{t('editTitle.description')}</SheetDescription>
1588
+ </SheetHeader>
1589
+ <Form {...form}>
1590
+ <form className="px-4" onSubmit={form.handleSubmit(handleSubmit)}>
1591
+ <div className="grid gap-3">
1592
+ <div className="grid grid-cols-1 items-start gap-4 sm:grid-cols-2">
1593
+ <div className="grid gap-2">
1594
+ <FormLabel>{t('common.upload.label')}</FormLabel>
1595
+ <Input
1596
+ ref={fileInputRef}
1597
+ className="hidden"
1598
+ type="file"
1599
+ accept=".pdf,.png,.jpg,.jpeg,.xml,.txt"
1600
+ onChange={(event) => {
1601
+ const file = event.target.files?.[0];
1602
+ if (!file) {
1603
+ return;
1604
+ }
1605
+
1606
+ clearUploadedFile();
1607
+ void uploadRelatedFile(file);
1608
+ }}
1609
+ disabled={
1610
+ isUploadingFile ||
1611
+ isExtractingFileData ||
1612
+ form.formState.isSubmitting
1613
+ }
1614
+ />
1615
+
1616
+ <div className="grid w-full grid-cols-2 gap-2">
1617
+ <Tooltip>
1618
+ <TooltipTrigger asChild>
1619
+ <Button
1620
+ type="button"
1621
+ variant="outline"
1622
+ className={
1623
+ uploadedFileId ? 'w-full' : 'col-span-2 w-full'
1624
+ }
1625
+ onClick={handleSelectFile}
1626
+ aria-label={
1627
+ uploadedFileId
1628
+ ? t('common.upload.change')
1629
+ : t('common.upload.upload')
1630
+ }
1631
+ disabled={
1632
+ isUploadingFile ||
1633
+ isExtractingFileData ||
1634
+ form.formState.isSubmitting
1635
+ }
1636
+ >
1637
+ {uploadedFileId ? (
1638
+ <Upload className="h-4 w-4" />
1639
+ ) : (
1640
+ <>
1641
+ <Upload className="mr-2 h-4 w-4" />
1642
+ {t('common.upload.upload')}
1643
+ </>
1644
+ )}
1645
+ </Button>
1646
+ </TooltipTrigger>
1647
+ <TooltipContent>
1648
+ {uploadedFileId
1649
+ ? t('common.upload.change')
1650
+ : t('common.upload.upload')}
1651
+ </TooltipContent>
1652
+ </Tooltip>
1653
+
1654
+ {uploadedFileId && (
1655
+ <Tooltip>
1656
+ <TooltipTrigger asChild>
1657
+ <Button
1658
+ type="button"
1659
+ variant="outline"
1660
+ className="w-full"
1661
+ onClick={clearUploadedFile}
1662
+ aria-label={t('common.upload.remove')}
1663
+ disabled={
1664
+ isUploadingFile ||
1665
+ isExtractingFileData ||
1666
+ form.formState.isSubmitting
1667
+ }
1668
+ >
1669
+ <Trash2 className="h-4 w-4" />
1670
+ </Button>
1671
+ </TooltipTrigger>
1672
+ <TooltipContent>
1673
+ {t('common.upload.remove')}
1674
+ </TooltipContent>
1675
+ </Tooltip>
1676
+ )}
1677
+ </div>
1678
+
1679
+ <div className="space-y-1">
1680
+ {(uploadedFileId || uploadedFileName) && (
1681
+ <p className="truncate text-xs text-muted-foreground">
1682
+ {t('common.upload.selectedPrefix')} {uploadedFileName}
1683
+ </p>
1684
+ )}
1685
+
1686
+ {isUploadingFile && !isExtractingFileData && (
1687
+ <div className="space-y-1">
1688
+ <Progress value={uploadProgress} className="h-2" />
1689
+ <p className="text-xs text-muted-foreground">
1690
+ {t('common.upload.uploadingProgress', {
1691
+ progress: uploadProgress,
1692
+ })}
1693
+ </p>
1694
+ </div>
1695
+ )}
1696
+
1697
+ {isExtractingFileData && (
1698
+ <p className="flex items-center gap-2 text-xs text-primary">
1699
+ <Loader2 className="h-4 w-4 animate-spin" />
1700
+ {t('common.upload.processingAi')}
1701
+ </p>
1702
+ )}
1703
+
1704
+ {!isExtractingFileData &&
1705
+ extractionConfidence !== null &&
1706
+ extractionConfidence < 70 && (
1707
+ <p className="text-xs text-destructive">
1708
+ {t('common.upload.lowConfidence', {
1709
+ confidence: Math.round(extractionConfidence),
1710
+ })}
1711
+ </p>
1712
+ )}
1713
+
1714
+ {!isExtractingFileData && extractionWarnings.length > 0 && (
1715
+ <p className="truncate text-xs text-muted-foreground">
1716
+ {extractionWarnings[0]}
1717
+ </p>
1718
+ )}
1719
+ </div>
1720
+ </div>
1721
+
1722
+ <FormField
1723
+ control={form.control}
1724
+ name="documento"
1725
+ render={({ field }) => (
1726
+ <FormItem>
1727
+ <FormLabel>{t('fields.document')}</FormLabel>
1728
+ <FormControl>
1729
+ <Input placeholder="FAT-00000" {...field} />
1730
+ </FormControl>
1731
+ <FormMessage />
1732
+ </FormItem>
1733
+ )}
1734
+ />
1735
+ </div>
1736
+
1737
+ <PersonFieldWithCreate
1738
+ form={form}
1739
+ name="clienteId"
1740
+ label={t('fields.client')}
1741
+ entityLabel="cliente"
1742
+ selectPlaceholder={t('common.select')}
1743
+ />
1744
+
1745
+ <div className="grid grid-cols-2 items-start gap-3">
1746
+ <FormField
1747
+ control={form.control}
1748
+ name="competencia"
1749
+ render={({ field }) => (
1750
+ <FormItem>
1751
+ <FormLabel>{t('fields.competency')}</FormLabel>
1752
+ <FormControl>
1753
+ <Input
1754
+ type="month"
1755
+ {...field}
1756
+ value={field.value || ''}
1757
+ />
1758
+ </FormControl>
1759
+ <FormMessage />
1760
+ </FormItem>
1761
+ )}
1762
+ />
1763
+
1764
+ <FormField
1765
+ control={form.control}
1766
+ name="vencimento"
1767
+ render={({ field }) => (
1768
+ <FormItem>
1769
+ <FormLabel>{t('fields.dueDate')}</FormLabel>
1770
+ <FormControl>
1771
+ <Input
1772
+ type="date"
1773
+ {...field}
1774
+ value={field.value || ''}
1775
+ />
1776
+ </FormControl>
1777
+ <FormMessage />
1778
+ </FormItem>
1779
+ )}
1780
+ />
1781
+ </div>
1782
+
1783
+ <div className="grid grid-cols-1 items-start gap-3 sm:grid-cols-2">
1784
+ <FormField
1785
+ control={form.control}
1786
+ name="valor"
1787
+ render={({ field }) => (
1788
+ <FormItem>
1789
+ <FormLabel>{t('fields.totalValue')}</FormLabel>
1790
+ <FormControl>
1791
+ <InputMoney
1792
+ ref={field.ref}
1793
+ name={field.name}
1794
+ value={field.value}
1795
+ onBlur={field.onBlur}
1796
+ onValueChange={(value) => field.onChange(value ?? 0)}
1797
+ placeholder="0,00"
1798
+ />
1799
+ </FormControl>
1800
+ <FormMessage />
1801
+ </FormItem>
1802
+ )}
1803
+ />
1804
+
1805
+ <FormField
1806
+ control={form.control}
1807
+ name="installmentsCount"
1808
+ render={({ field }) => (
1809
+ <FormItem>
1810
+ <FormLabel>
1811
+ {t('installmentsEditor.countLabel')}
1812
+ </FormLabel>
1813
+ <FormControl>
1814
+ <Input
1815
+ type="number"
1816
+ min={1}
1817
+ max={120}
1818
+ value={field.value}
1819
+ onChange={(event) => {
1820
+ const nextValue = Number(event.target.value || 1);
1821
+ field.onChange(
1822
+ Number.isNaN(nextValue) ? 1 : nextValue
1823
+ );
1824
+ setIsInstallmentsEdited(false);
1825
+ }}
1826
+ />
1827
+ </FormControl>
1828
+ <FormMessage />
1829
+ </FormItem>
1830
+ )}
1831
+ />
1832
+ </div>
1833
+
1834
+ <div className="space-y-3 rounded-md border p-3">
1835
+ <div className="flex items-center justify-between gap-2">
1836
+ <p className="text-sm font-medium">
1837
+ {t('installmentsEditor.title')}
1838
+ </p>
1839
+ <Button
1840
+ type="button"
1841
+ variant="outline"
1842
+ size="sm"
1843
+ onClick={() => {
1844
+ setIsInstallmentsEdited(false);
1845
+ replaceInstallments(
1846
+ buildEqualInstallments(
1847
+ form.getValues('installmentsCount'),
1848
+ form.getValues('valor'),
1849
+ form.getValues('vencimento')
1850
+ )
1851
+ );
1852
+ }}
1853
+ >
1854
+ {t('installmentsEditor.recalculate')}
1855
+ </Button>
1856
+ </div>
1857
+
1858
+ <div className="flex items-center gap-2">
1859
+ <Checkbox
1860
+ id="auto-redistribute-installments-edit-receivable"
1861
+ checked={autoRedistributeInstallments}
1862
+ onCheckedChange={(checked) =>
1863
+ setAutoRedistributeInstallments(checked === true)
1864
+ }
1865
+ />
1866
+ <Label
1867
+ htmlFor="auto-redistribute-installments-edit-receivable"
1868
+ className="text-xs text-muted-foreground"
1869
+ >
1870
+ {t('installmentsEditor.autoRedistributeLabel')}
1871
+ </Label>
1872
+ </div>
1873
+
1874
+ <div className="space-y-2">
1875
+ {installmentFields.map((installment, index) => (
1876
+ <div
1877
+ key={installment.id}
1878
+ className="grid grid-cols-1 items-start gap-2 rounded-md border p-2 sm:grid-cols-[96px_1fr_160px]"
1879
+ >
1880
+ <div className="flex items-center text-sm text-muted-foreground">
1881
+ #{index + 1}
1882
+ </div>
1883
+
1884
+ <FormField
1885
+ control={form.control}
1886
+ name={`installments.${index}.dueDate` as const}
1887
+ render={({ field }) => (
1888
+ <FormItem>
1889
+ <FormLabel className="text-xs">
1890
+ {t('installmentsEditor.dueDateLabel')}
1891
+ </FormLabel>
1892
+ <FormControl>
1893
+ <Input
1894
+ type="date"
1895
+ {...field}
1896
+ value={field.value || ''}
1897
+ onChange={(event) => {
1898
+ setIsInstallmentsEdited(true);
1899
+ field.onChange(event);
1900
+ }}
1901
+ />
1902
+ </FormControl>
1903
+ <FormMessage />
1904
+ </FormItem>
1905
+ )}
1906
+ />
1907
+
1908
+ <FormField
1909
+ control={form.control}
1910
+ name={`installments.${index}.amount` as const}
1911
+ render={({ field }) => (
1912
+ <FormItem>
1913
+ <FormLabel className="text-xs">
1914
+ {t('installmentsEditor.amountLabel')}
1915
+ </FormLabel>
1916
+ <FormControl>
1917
+ <InputMoney
1918
+ ref={field.ref}
1919
+ name={field.name}
1920
+ value={field.value}
1921
+ onBlur={() => {
1922
+ field.onBlur();
1923
+
1924
+ if (!autoRedistributeInstallments) {
1925
+ return;
1926
+ }
1927
+
1928
+ clearScheduledRedistribution(index);
1929
+ runInstallmentRedistribution(index);
1930
+ }}
1931
+ onValueChange={(value) => {
1932
+ setIsInstallmentsEdited(true);
1933
+ field.onChange(value ?? 0);
1934
+
1935
+ if (!autoRedistributeInstallments) {
1936
+ return;
1937
+ }
1938
+
1939
+ scheduleInstallmentRedistribution(index);
1940
+ }}
1941
+ placeholder="0,00"
1942
+ />
1943
+ </FormControl>
1944
+ <FormMessage />
1945
+ </FormItem>
1946
+ )}
1947
+ />
1948
+ </div>
1949
+ ))}
1950
+ </div>
1951
+
1952
+ <p
1953
+ className={`text-xs ${
1954
+ installmentsDiffCents === 0
1955
+ ? 'text-muted-foreground'
1956
+ : 'text-destructive'
1957
+ }`}
1958
+ >
1959
+ {t('installmentsEditor.totalPrefix', {
1960
+ total: installmentsTotal.toFixed(2),
1961
+ })}
1962
+ {installmentsDiffCents > 0 &&
1963
+ ` ${t('installmentsEditor.adjustmentNeeded')}`}
1964
+ </p>
1965
+ {form.formState.errors.installments?.message && (
1966
+ <p className="text-xs text-destructive">
1967
+ {form.formState.errors.installments.message}
1968
+ </p>
1969
+ )}
1970
+ </div>
1971
+
1972
+ <CategoryFieldWithCreate
1973
+ form={form}
1974
+ name="categoriaId"
1975
+ label={t('fields.category')}
1976
+ selectPlaceholder={t('common.select')}
1977
+ categories={categorias}
1978
+ categoryKind="receita"
1979
+ onCreated={onOptionsUpdated}
1980
+ />
1981
+
1982
+ <CostCenterFieldWithCreate
1983
+ form={form}
1984
+ name="centroCustoId"
1985
+ label={t('fields.costCenter')}
1986
+ selectPlaceholder={t('common.select')}
1987
+ costCenters={centrosCusto}
1988
+ onCreated={onOptionsUpdated}
1989
+ />
1990
+
1991
+ <FormField
1992
+ control={form.control}
1993
+ name="canal"
1994
+ render={({ field }) => (
1995
+ <FormItem>
1996
+ <FormLabel>{t('fields.channel')}</FormLabel>
1997
+ <Select value={field.value} onValueChange={field.onChange}>
1998
+ <FormControl>
1999
+ <SelectTrigger className="w-full">
2000
+ <SelectValue placeholder={t('common.select')} />
2001
+ </SelectTrigger>
2002
+ </FormControl>
2003
+ <SelectContent>
2004
+ <SelectItem value="boleto">
2005
+ {t('channels.boleto')}
2006
+ </SelectItem>
2007
+ <SelectItem value="pix">PIX</SelectItem>
2008
+ <SelectItem value="cartao">
2009
+ {t('channels.card')}
2010
+ </SelectItem>
2011
+ <SelectItem value="transferencia">
2012
+ {t('channels.transfer')}
2013
+ </SelectItem>
2014
+ </SelectContent>
2015
+ </Select>
2016
+ <FormMessage />
2017
+ </FormItem>
2018
+ )}
2019
+ />
2020
+
2021
+ <FormField
2022
+ control={form.control}
2023
+ name="descricao"
2024
+ render={({ field }) => (
2025
+ <FormItem>
2026
+ <FormLabel>{t('fields.description')}</FormLabel>
2027
+ <FormControl>
2028
+ <Textarea
2029
+ placeholder={t('newTitle.descriptionPlaceholder')}
2030
+ {...field}
2031
+ value={field.value || ''}
2032
+ />
2033
+ </FormControl>
2034
+ <FormMessage />
2035
+ </FormItem>
2036
+ )}
2037
+ />
2038
+ </div>
2039
+
2040
+ <div className="flex flex-col gap-2 py-4">
2041
+ <Button
2042
+ type="submit"
2043
+ disabled={
2044
+ form.formState.isSubmitting ||
2045
+ isUploadingFile ||
2046
+ isExtractingFileData
2047
+ }
2048
+ >
2049
+ {(isUploadingFile || isExtractingFileData) && (
2050
+ <Loader2 className="mr-2 h-4 w-4 animate-spin" />
2051
+ )}
2052
+ {isExtractingFileData
2053
+ ? t('common.upload.fillingWithAi')
2054
+ : isUploadingFile
2055
+ ? t('common.upload.uploadingFile')
2056
+ : t('common.save')}
2057
+ </Button>
2058
+ </div>
2059
+ </form>
2060
+ </Form>
2061
+ </SheetContent>
2062
+ </Sheet>
2063
+ );
2064
+ }
2065
+
2066
+ export default function TitulosReceberPage() {
2067
+ const t = useTranslations('finance.ReceivableInstallmentsPage');
2068
+ const { request, showToastHandler } = useApp();
2069
+ const pathname = usePathname();
2070
+ const router = useRouter();
2071
+ const searchParams = useSearchParams();
2072
+ const { data, refetch: refetchFinanceData } = useFinanceData();
2073
+ const {
2074
+ titulosReceber: allTitulosReceber,
2075
+ pessoas,
2076
+ categorias,
2077
+ centrosCusto,
2078
+ } = data;
2079
+
2080
+ const getPessoaById = (id?: string) => pessoas.find((p) => p.id === id);
2081
+
2082
+ const [search, setSearch] = useState('');
2083
+ const [statusFilter, setStatusFilter] = useState<string>('');
2084
+ const [page, setPage] = useState(1);
2085
+ const pageSize = 10;
2086
+
2087
+ const normalizedStatusFilter =
2088
+ statusFilter && statusFilter !== 'all' ? statusFilter : undefined;
2089
+
2090
+ const {
2091
+ data: paginatedTitlesResponse,
2092
+ refetch: refetchTitles,
2093
+ isFetching: isFetchingTitles,
2094
+ } = useQuery<{
2095
+ data: any[];
2096
+ total: number;
2097
+ page: number;
2098
+ pageSize: number;
2099
+ prev: number | null;
2100
+ next: number | null;
2101
+ lastPage: number;
2102
+ }>({
2103
+ queryKey: [
2104
+ 'finance-receivable-installments-list',
2105
+ search,
2106
+ normalizedStatusFilter,
2107
+ page,
2108
+ pageSize,
2109
+ ],
2110
+ queryFn: async () => {
2111
+ const response = await request({
2112
+ url: '/finance/accounts-receivable/installments',
2113
+ method: 'GET',
2114
+ params: {
2115
+ page,
2116
+ pageSize,
2117
+ search: search.trim() || undefined,
2118
+ status: normalizedStatusFilter,
2119
+ },
2120
+ });
2121
+
2122
+ return response.data as {
2123
+ data: any[];
2124
+ total: number;
2125
+ page: number;
2126
+ pageSize: number;
2127
+ prev: number | null;
2128
+ next: number | null;
2129
+ lastPage: number;
2130
+ };
2131
+ },
2132
+ placeholderData: (old) => old,
2133
+ });
2134
+
2135
+ const titulosReceber = paginatedTitlesResponse?.data || [];
2136
+ const [editingTitleId, setEditingTitleId] = useState<string | null>(null);
2137
+
2138
+ const editingTitle = useMemo(
2139
+ () =>
2140
+ titulosReceber.find((item) => item.id === editingTitleId) ||
2141
+ allTitulosReceber.find((item) => item.id === editingTitleId),
2142
+ [allTitulosReceber, editingTitleId, titulosReceber]
2143
+ );
2144
+
2145
+ useEffect(() => {
2146
+ const editId = searchParams.get('editId');
2147
+ if (!editId || editingTitleId) {
2148
+ return;
2149
+ }
2150
+
2151
+ const foundTitle =
2152
+ titulosReceber.find((item) => item.id === editId) ||
2153
+ allTitulosReceber.find((item) => item.id === editId);
2154
+ if (!foundTitle) {
2155
+ return;
2156
+ }
2157
+
2158
+ if (foundTitle.status !== 'rascunho') {
2159
+ showToastHandler?.('error', t('messages.editDraftOnly'));
2160
+ router.replace(pathname, { scroll: false });
2161
+ return;
2162
+ }
2163
+
2164
+ setEditingTitleId(editId);
2165
+ }, [
2166
+ allTitulosReceber,
2167
+ editingTitleId,
2168
+ pathname,
2169
+ router,
2170
+ searchParams,
2171
+ showToastHandler,
2172
+ titulosReceber,
2173
+ ]);
2174
+
2175
+ const closeEditSheet = (isOpen: boolean) => {
2176
+ if (isOpen) {
2177
+ return;
2178
+ }
2179
+
2180
+ setEditingTitleId(null);
2181
+
2182
+ if (searchParams.get('editId')) {
2183
+ router.replace(pathname, { scroll: false });
2184
+ }
2185
+ };
2186
+
2187
+ useEffect(() => {
2188
+ setPage(1);
2189
+ }, [search, normalizedStatusFilter]);
2190
+
2191
+ const canalBadge = {
2192
+ boleto: {
2193
+ label: t('channels.boleto'),
2194
+ className: 'bg-blue-100 text-blue-700',
2195
+ },
2196
+ pix: { label: 'PIX', className: 'bg-green-100 text-green-700' },
2197
+ cartao: {
2198
+ label: t('channels.card'),
2199
+ className: 'bg-purple-100 text-purple-700',
2200
+ },
2201
+ transferencia: {
2202
+ label: t('channels.transfer'),
2203
+ className: 'bg-orange-100 text-orange-700',
2204
+ },
2205
+ };
2206
+
2207
+ const handleOpenAttachment = async (fileId?: string) => {
2208
+ if (!fileId) {
2209
+ return;
2210
+ }
2211
+
2212
+ try {
2213
+ const response = await request<{ url?: string }>({
2214
+ url: `/file/open/${fileId}`,
2215
+ method: 'PUT',
2216
+ });
2217
+
2218
+ const url = response?.data?.url;
2219
+ if (!url) {
2220
+ showToastHandler?.('error', t('messages.openAttachmentError'));
2221
+ return;
2222
+ }
2223
+
2224
+ window.open(url, '_blank', 'noopener,noreferrer');
2225
+ } catch {
2226
+ showToastHandler?.('error', t('messages.openAttachmentError'));
2227
+ }
2228
+ };
2229
+
2230
+ return (
2231
+ <Page>
2232
+ <PageHeader
2233
+ title={t('header.title')}
2234
+ description={t('header.description')}
2235
+ breadcrumbs={[
2236
+ { label: t('breadcrumbs.home'), href: '/' },
2237
+ { label: t('breadcrumbs.finance'), href: '/finance' },
2238
+ { label: t('breadcrumbs.current') },
2239
+ ]}
2240
+ actions={
2241
+ <>
2242
+ <NovoTituloSheet
2243
+ categorias={categorias}
2244
+ centrosCusto={centrosCusto}
2245
+ t={t}
2246
+ onCreated={async () => {
2247
+ await Promise.all([refetchTitles(), refetchFinanceData()]);
2248
+ }}
2249
+ onOptionsUpdated={refetchFinanceData}
2250
+ />
2251
+ <EditarTituloSheet
2252
+ open={!!editingTitleId && !!editingTitle}
2253
+ onOpenChange={closeEditSheet}
2254
+ titulo={editingTitle}
2255
+ categorias={categorias}
2256
+ centrosCusto={centrosCusto}
2257
+ t={t}
2258
+ onUpdated={async () => {
2259
+ await Promise.all([refetchTitles(), refetchFinanceData()]);
2260
+ }}
2261
+ onOptionsUpdated={refetchFinanceData}
2262
+ />
2263
+ </>
2264
+ }
2265
+ />
1132
2266
 
1133
2267
  <FilterBar
1134
2268
  searchPlaceholder={t('filters.searchPlaceholder')}
@@ -1150,8 +2284,8 @@ export default function TitulosReceberPage() {
1150
2284
  ],
1151
2285
  },
1152
2286
  ]}
1153
- activeFilters={statusFilter && statusFilter !== 'all' ? 1 : 0}
1154
- onClearFilters={() => setStatusFilter('')}
2287
+ activeFilters={normalizedStatusFilter ? 1 : 0}
2288
+ onClearFilters={() => setStatusFilter('all')}
1155
2289
  />
1156
2290
 
1157
2291
  <div className="rounded-md border">
@@ -1171,7 +2305,7 @@ export default function TitulosReceberPage() {
1171
2305
  </TableRow>
1172
2306
  </TableHeader>
1173
2307
  <TableBody>
1174
- {filteredTitulos.map((titulo) => {
2308
+ {titulosReceber.map((titulo) => {
1175
2309
  const cliente = getPessoaById(titulo.clienteId);
1176
2310
  const canal =
1177
2311
  canalBadge[titulo.canal as keyof typeof canalBadge] ||
@@ -1202,7 +2336,7 @@ export default function TitulosReceberPage() {
1202
2336
  titulo.anexosDetalhes?.[0]?.id;
1203
2337
  void handleOpenAttachment(firstAttachmentId);
1204
2338
  }}
1205
- aria-label="Abrir anexo"
2339
+ aria-label={t('table.actions.openAttachment')}
1206
2340
  >
1207
2341
  <Paperclip className="h-3 w-3" />
1208
2342
  </Button>
@@ -1245,7 +2379,10 @@ export default function TitulosReceberPage() {
1245
2379
  {t('table.actions.viewDetails')}
1246
2380
  </Link>
1247
2381
  </DropdownMenuItem>
1248
- <DropdownMenuItem>
2382
+ <DropdownMenuItem
2383
+ disabled={titulo.status !== 'rascunho'}
2384
+ onClick={() => setEditingTitleId(titulo.id)}
2385
+ >
1249
2386
  <Edit className="mr-2 h-4 w-4" />
1250
2387
  {t('table.actions.edit')}
1251
2388
  </DropdownMenuItem>
@@ -1275,15 +2412,25 @@ export default function TitulosReceberPage() {
1275
2412
  <div className="flex items-center justify-between">
1276
2413
  <p className="text-sm text-muted-foreground">
1277
2414
  {t('footer.showing', {
1278
- filtered: filteredTitulos.length,
1279
- total: titulosReceber.length,
2415
+ filtered: titulosReceber.length,
2416
+ total: paginatedTitlesResponse?.total || 0,
1280
2417
  })}
1281
2418
  </p>
1282
2419
  <div className="flex items-center gap-2">
1283
- <Button variant="outline" size="sm" disabled>
2420
+ <Button
2421
+ variant="outline"
2422
+ size="sm"
2423
+ disabled={!paginatedTitlesResponse?.prev || isFetchingTitles}
2424
+ onClick={() => setPage((current) => Math.max(1, current - 1))}
2425
+ >
1284
2426
  {t('footer.previous')}
1285
2427
  </Button>
1286
- <Button variant="outline" size="sm" disabled>
2428
+ <Button
2429
+ variant="outline"
2430
+ size="sm"
2431
+ disabled={!paginatedTitlesResponse?.next || isFetchingTitles}
2432
+ onClick={() => setPage((current) => current + 1)}
2433
+ >
1287
2434
  {t('footer.next')}
1288
2435
  </Button>
1289
2436
  </div>