@hed-hog/finance 0.0.238 → 0.0.240

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 (53) hide show
  1. package/README.md +1 -22
  2. package/dist/dto/reject-title.dto.d.ts +4 -0
  3. package/dist/dto/reject-title.dto.d.ts.map +1 -0
  4. package/dist/dto/reject-title.dto.js +22 -0
  5. package/dist/dto/reject-title.dto.js.map +1 -0
  6. package/dist/dto/reverse-settlement.dto.d.ts +4 -0
  7. package/dist/dto/reverse-settlement.dto.d.ts.map +1 -0
  8. package/dist/dto/reverse-settlement.dto.js +22 -0
  9. package/dist/dto/reverse-settlement.dto.js.map +1 -0
  10. package/dist/dto/settle-installment.dto.d.ts +12 -0
  11. package/dist/dto/settle-installment.dto.d.ts.map +1 -0
  12. package/dist/dto/settle-installment.dto.js +71 -0
  13. package/dist/dto/settle-installment.dto.js.map +1 -0
  14. package/dist/finance-data.controller.d.ts +13 -5
  15. package/dist/finance-data.controller.d.ts.map +1 -1
  16. package/dist/finance-installments.controller.d.ts +380 -12
  17. package/dist/finance-installments.controller.d.ts.map +1 -1
  18. package/dist/finance-installments.controller.js +144 -0
  19. package/dist/finance-installments.controller.js.map +1 -1
  20. package/dist/finance-statements.controller.d.ts +8 -0
  21. package/dist/finance-statements.controller.d.ts.map +1 -1
  22. package/dist/finance-statements.controller.js +40 -0
  23. package/dist/finance-statements.controller.js.map +1 -1
  24. package/dist/finance.module.d.ts.map +1 -1
  25. package/dist/finance.module.js +1 -0
  26. package/dist/finance.module.js.map +1 -1
  27. package/dist/finance.service.d.ts +435 -19
  28. package/dist/finance.service.d.ts.map +1 -1
  29. package/dist/finance.service.js +1286 -80
  30. package/dist/finance.service.js.map +1 -1
  31. package/hedhog/data/route.yaml +117 -0
  32. package/hedhog/frontend/app/accounts-payable/approvals/page.tsx.ejs +92 -12
  33. package/hedhog/frontend/app/accounts-payable/installments/[id]/page.tsx.ejs +434 -7
  34. package/hedhog/frontend/app/accounts-payable/installments/page.tsx.ejs +1172 -25
  35. package/hedhog/frontend/app/accounts-receivable/installments/[id]/page.tsx.ejs +396 -49
  36. package/hedhog/frontend/app/accounts-receivable/installments/page.tsx.ejs +430 -14
  37. package/hedhog/frontend/app/cash-and-banks/statements/page.tsx.ejs +212 -60
  38. package/hedhog/frontend/messages/en.json +1 -0
  39. package/hedhog/frontend/messages/pt.json +1 -0
  40. package/hedhog/query/0_constraints.sql +2 -0
  41. package/hedhog/query/constraints.sql +86 -0
  42. package/hedhog/table/bank_account.yaml +0 -8
  43. package/hedhog/table/financial_title.yaml +1 -9
  44. package/hedhog/table/settlement.yaml +0 -8
  45. package/package.json +6 -6
  46. package/src/dto/reject-title.dto.ts +7 -0
  47. package/src/dto/reverse-settlement.dto.ts +7 -0
  48. package/src/dto/settle-installment.dto.ts +55 -0
  49. package/src/finance-installments.controller.ts +172 -10
  50. package/src/finance-statements.controller.ts +61 -2
  51. package/src/finance.module.ts +2 -1
  52. package/src/finance.service.ts +1887 -106
  53. package/hedhog/table/branch.yaml +0 -18
@@ -3,6 +3,7 @@
3
3
  import { PersonFieldWithCreate } from '@/app/(app)/(libraries)/finance/_components/person-field-with-create';
4
4
  import { Page, PageHeader } from '@/components/entity-list';
5
5
  import { Button } from '@/components/ui/button';
6
+ import { Checkbox } from '@/components/ui/checkbox';
6
7
  import {
7
8
  DropdownMenu,
8
9
  DropdownMenuContent,
@@ -21,6 +22,7 @@ import {
21
22
  } from '@/components/ui/form';
22
23
  import { Input } from '@/components/ui/input';
23
24
  import { InputMoney } from '@/components/ui/input-money';
25
+ import { Label } from '@/components/ui/label';
24
26
  import { Money } from '@/components/ui/money';
25
27
  import { Progress } from '@/components/ui/progress';
26
28
  import {
@@ -64,23 +66,160 @@ import {
64
66
  } from 'lucide-react';
65
67
  import { useTranslations } from 'next-intl';
66
68
  import Link from 'next/link';
67
- import { useState } from 'react';
68
- import { useForm } from 'react-hook-form';
69
+ import { usePathname, useRouter, useSearchParams } from 'next/navigation';
70
+ import { useEffect, useMemo, useRef, useState } from 'react';
71
+ import { useFieldArray, useForm } from 'react-hook-form';
69
72
  import { z } from 'zod';
70
73
  import { formatarData } from '../../_lib/formatters';
71
74
  import { useFinanceData } from '../../_lib/use-finance-data';
72
75
 
73
- const newTitleFormSchema = z.object({
74
- documento: z.string().trim().min(1, 'Documento é obrigatório'),
75
- fornecedorId: z.string().min(1, 'Fornecedor é obrigatório'),
76
- competencia: z.string().optional(),
77
- vencimento: z.string().min(1, 'Vencimento é obrigatório'),
78
- valor: z.number().min(0.01, 'Valor deve ser maior que zero'),
79
- categoriaId: z.string().optional(),
80
- centroCustoId: z.string().optional(),
81
- metodo: z.string().optional(),
82
- descricao: z.string().optional(),
83
- });
76
+ const INSTALLMENT_REDISTRIBUTION_DEBOUNCE_MS = 300;
77
+
78
+ const addMonthsToDate = (date: string, monthsToAdd: number) => {
79
+ if (!date) {
80
+ return '';
81
+ }
82
+
83
+ const baseDate = new Date(`${date}T00:00:00`);
84
+ if (Number.isNaN(baseDate.getTime())) {
85
+ return date;
86
+ }
87
+
88
+ const nextDate = new Date(baseDate);
89
+ nextDate.setMonth(nextDate.getMonth() + monthsToAdd);
90
+ return nextDate.toISOString().slice(0, 10);
91
+ };
92
+
93
+ const buildEqualInstallments = (
94
+ installmentsCount: number,
95
+ totalAmount: number,
96
+ firstDueDate: string
97
+ ) => {
98
+ const safeInstallmentsCount = Math.max(1, Math.floor(installmentsCount || 1));
99
+ const totalCents = Math.max(0, Math.round((totalAmount || 0) * 100));
100
+ const baseCents = Math.floor(totalCents / safeInstallmentsCount);
101
+ const remainder = totalCents % safeInstallmentsCount;
102
+
103
+ return Array.from({ length: safeInstallmentsCount }, (_, index) => ({
104
+ dueDate: addMonthsToDate(firstDueDate, index),
105
+ amount: (baseCents + (index < remainder ? 1 : 0)) / 100,
106
+ }));
107
+ };
108
+
109
+ const redistributeRemainingInstallments = (
110
+ installments: Array<{ dueDate: string; amount: number }>,
111
+ editedIndex: number,
112
+ editedAmount: number,
113
+ totalAmount: number
114
+ ) => {
115
+ if (installments.length === 0) {
116
+ return installments;
117
+ }
118
+
119
+ const totalCents = Math.max(0, Math.round((totalAmount || 0) * 100));
120
+
121
+ if (installments.length === 1) {
122
+ return installments.map((installment, index) =>
123
+ index === editedIndex
124
+ ? {
125
+ ...installment,
126
+ amount: totalCents / 100,
127
+ }
128
+ : installment
129
+ );
130
+ }
131
+
132
+ const minimumPerInstallmentCents = totalCents >= installments.length ? 1 : 0;
133
+ const remainingInstallmentsCount = installments.length - 1;
134
+
135
+ let editedCents = Math.round((editedAmount || 0) * 100);
136
+ const minEditedCents = minimumPerInstallmentCents;
137
+ const maxEditedCents =
138
+ totalCents - remainingInstallmentsCount * minimumPerInstallmentCents;
139
+
140
+ if (maxEditedCents < minEditedCents) {
141
+ editedCents = Math.min(Math.max(editedCents, 0), totalCents);
142
+ } else {
143
+ editedCents = Math.min(
144
+ Math.max(editedCents, minEditedCents),
145
+ maxEditedCents
146
+ );
147
+ }
148
+
149
+ const remainingCents = totalCents - editedCents;
150
+ const baseCents = Math.floor(remainingCents / remainingInstallmentsCount);
151
+ let remainderCents = remainingCents % remainingInstallmentsCount;
152
+ let processedInstallments = 0;
153
+
154
+ return installments.map((installment, index) => {
155
+ if (index === editedIndex) {
156
+ return {
157
+ ...installment,
158
+ amount: editedCents / 100,
159
+ };
160
+ }
161
+
162
+ const amountCents =
163
+ baseCents + (processedInstallments < remainderCents ? 1 : 0);
164
+ processedInstallments += 1;
165
+
166
+ return {
167
+ ...installment,
168
+ amount: amountCents / 100,
169
+ };
170
+ });
171
+ };
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
+ }
208
+
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);
214
+
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
+ }
222
+ });
84
223
 
85
224
  type NewTitleFormValues = z.infer<typeof newTitleFormSchema>;
86
225
 
@@ -101,6 +240,12 @@ function NovoTituloSheet({
101
240
  const [uploadedFileName, setUploadedFileName] = useState('');
102
241
  const [isUploadingFile, setIsUploadingFile] = useState(false);
103
242
  const [isExtractingFileData, setIsExtractingFileData] = useState(false);
243
+ const [isInstallmentsEdited, setIsInstallmentsEdited] = useState(false);
244
+ const [autoRedistributeInstallments, setAutoRedistributeInstallments] =
245
+ useState(true);
246
+ const redistributionTimeoutRef = useRef<
247
+ Record<number, ReturnType<typeof setTimeout>>
248
+ >({});
104
249
  const [extractionConfidence, setExtractionConfidence] = useState<
105
250
  number | null
106
251
  >(null);
@@ -133,6 +278,8 @@ function NovoTituloSheet({
133
278
  competencia: '',
134
279
  vencimento: '',
135
280
  valor: 0,
281
+ installmentsCount: 1,
282
+ installments: [{ dueDate: '', amount: 0 }],
136
283
  categoriaId: '',
137
284
  centroCustoId: '',
138
285
  metodo: '',
@@ -140,6 +287,101 @@ function NovoTituloSheet({
140
287
  },
141
288
  });
142
289
 
290
+ const { fields: installmentFields, replace: replaceInstallments } =
291
+ useFieldArray({
292
+ control: form.control,
293
+ name: 'installments',
294
+ });
295
+
296
+ const watchedInstallmentsCount = form.watch('installmentsCount');
297
+ const watchedTotalValue = form.watch('valor');
298
+ const watchedDueDate = form.watch('vencimento');
299
+ const watchedInstallments = form.watch('installments');
300
+
301
+ useEffect(() => {
302
+ if (isInstallmentsEdited) {
303
+ return;
304
+ }
305
+
306
+ replaceInstallments(
307
+ buildEqualInstallments(
308
+ watchedInstallmentsCount,
309
+ watchedTotalValue,
310
+ watchedDueDate
311
+ )
312
+ );
313
+ }, [
314
+ isInstallmentsEdited,
315
+ replaceInstallments,
316
+ watchedDueDate,
317
+ watchedInstallmentsCount,
318
+ watchedTotalValue,
319
+ ]);
320
+
321
+ const installmentsTotal = (watchedInstallments || []).reduce(
322
+ (acc, installment) => acc + Number(installment?.amount || 0),
323
+ 0
324
+ );
325
+ const installmentsDiffCents = Math.abs(
326
+ Math.round(installmentsTotal * 100) -
327
+ Math.round((watchedTotalValue || 0) * 100)
328
+ );
329
+
330
+ const clearScheduledRedistribution = (index: number) => {
331
+ const timeout = redistributionTimeoutRef.current[index];
332
+ if (!timeout) {
333
+ return;
334
+ }
335
+
336
+ clearTimeout(timeout);
337
+ delete redistributionTimeoutRef.current[index];
338
+ };
339
+
340
+ const runInstallmentRedistribution = (index: number) => {
341
+ if (!autoRedistributeInstallments) {
342
+ return;
343
+ }
344
+
345
+ const currentInstallments = form.getValues('installments');
346
+ const editedInstallmentAmount = Number(
347
+ currentInstallments[index]?.amount || 0
348
+ );
349
+
350
+ const redistributedInstallments = redistributeRemainingInstallments(
351
+ currentInstallments,
352
+ index,
353
+ editedInstallmentAmount,
354
+ form.getValues('valor')
355
+ );
356
+
357
+ replaceInstallments(redistributedInstallments);
358
+ };
359
+
360
+ const scheduleInstallmentRedistribution = (index: number) => {
361
+ clearScheduledRedistribution(index);
362
+
363
+ redistributionTimeoutRef.current[index] = setTimeout(() => {
364
+ runInstallmentRedistribution(index);
365
+ delete redistributionTimeoutRef.current[index];
366
+ }, INSTALLMENT_REDISTRIBUTION_DEBOUNCE_MS);
367
+ };
368
+
369
+ useEffect(() => {
370
+ if (autoRedistributeInstallments) {
371
+ return;
372
+ }
373
+
374
+ Object.values(redistributionTimeoutRef.current).forEach(clearTimeout);
375
+ redistributionTimeoutRef.current = {};
376
+ }, [autoRedistributeInstallments]);
377
+
378
+ useEffect(() => {
379
+ return () => {
380
+ Object.values(redistributionTimeoutRef.current).forEach(clearTimeout);
381
+ redistributionTimeoutRef.current = {};
382
+ };
383
+ }, []);
384
+
143
385
  const handleSubmit = async (values: NewTitleFormValues) => {
144
386
  try {
145
387
  await request({
@@ -161,6 +403,11 @@ function NovoTituloSheet({
161
403
  : undefined,
162
404
  payment_channel: values.metodo || undefined,
163
405
  description: values.descricao?.trim() || undefined,
406
+ installments: values.installments.map((installment, index) => ({
407
+ installment_number: index + 1,
408
+ due_date: installment.dueDate || values.vencimento,
409
+ amount: installment.amount,
410
+ })),
164
411
  attachment_file_ids: uploadedFileId ? [uploadedFileId] : undefined,
165
412
  },
166
413
  });
@@ -171,6 +418,8 @@ function NovoTituloSheet({
171
418
  setUploadedFileName('');
172
419
  setExtractionConfidence(null);
173
420
  setExtractionWarnings([]);
421
+ setIsInstallmentsEdited(false);
422
+ setAutoRedistributeInstallments(true);
174
423
  setOpen(false);
175
424
  showToastHandler?.('success', 'Título criado com sucesso');
176
425
  } catch {
@@ -185,6 +434,8 @@ function NovoTituloSheet({
185
434
  setExtractionConfidence(null);
186
435
  setExtractionWarnings([]);
187
436
  setUploadProgress(0);
437
+ setIsInstallmentsEdited(false);
438
+ setAutoRedistributeInstallments(true);
188
439
  setOpen(false);
189
440
  };
190
441
 
@@ -507,6 +758,168 @@ function NovoTituloSheet({
507
758
  )}
508
759
  />
509
760
 
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
+ />
785
+
786
+ <div className="space-y-3 rounded-md border p-3">
787
+ <div className="flex items-center justify-between gap-2">
788
+ <p className="text-sm font-medium">Parcelas</p>
789
+ <Button
790
+ type="button"
791
+ variant="outline"
792
+ size="sm"
793
+ onClick={() => {
794
+ setIsInstallmentsEdited(false);
795
+ replaceInstallments(
796
+ buildEqualInstallments(
797
+ form.getValues('installmentsCount'),
798
+ form.getValues('valor'),
799
+ form.getValues('vencimento')
800
+ )
801
+ );
802
+ }}
803
+ >
804
+ Recalcular automaticamente
805
+ </Button>
806
+ </div>
807
+
808
+ <div className="flex items-center gap-2">
809
+ <Checkbox
810
+ id="auto-redistribute-installments-payable"
811
+ checked={autoRedistributeInstallments}
812
+ onCheckedChange={(checked) =>
813
+ setAutoRedistributeInstallments(checked === true)
814
+ }
815
+ />
816
+ <Label
817
+ htmlFor="auto-redistribute-installments-payable"
818
+ className="text-xs text-muted-foreground"
819
+ >
820
+ Redistribuir automaticamente o restante ao editar parcela
821
+ </Label>
822
+ </div>
823
+ {autoRedistributeInstallments && (
824
+ <p className="text-xs text-muted-foreground">
825
+ A redistribuição ocorre ao parar de digitar e ao sair do
826
+ campo.
827
+ </p>
828
+ )}
829
+
830
+ <div className="space-y-2">
831
+ {installmentFields.map((installment, index) => (
832
+ <div
833
+ key={installment.id}
834
+ className="grid grid-cols-1 gap-2 rounded-md border p-2 sm:grid-cols-[96px_1fr_160px]"
835
+ >
836
+ <div className="flex items-center text-sm text-muted-foreground">
837
+ #{index + 1}
838
+ </div>
839
+
840
+ <FormField
841
+ control={form.control}
842
+ name={`installments.${index}.dueDate` as const}
843
+ render={({ field }) => (
844
+ <FormItem>
845
+ <FormLabel className="text-xs">
846
+ Vencimento
847
+ </FormLabel>
848
+ <FormControl>
849
+ <Input
850
+ type="date"
851
+ {...field}
852
+ value={field.value || ''}
853
+ onChange={(event) => {
854
+ setIsInstallmentsEdited(true);
855
+ field.onChange(event);
856
+ }}
857
+ />
858
+ </FormControl>
859
+ <FormMessage />
860
+ </FormItem>
861
+ )}
862
+ />
863
+
864
+ <FormField
865
+ control={form.control}
866
+ name={`installments.${index}.amount` as const}
867
+ render={({ field }) => (
868
+ <FormItem>
869
+ <FormLabel className="text-xs">Valor</FormLabel>
870
+ <FormControl>
871
+ <InputMoney
872
+ ref={field.ref}
873
+ name={field.name}
874
+ value={field.value}
875
+ onBlur={() => {
876
+ field.onBlur();
877
+
878
+ if (!autoRedistributeInstallments) {
879
+ return;
880
+ }
881
+
882
+ clearScheduledRedistribution(index);
883
+ runInstallmentRedistribution(index);
884
+ }}
885
+ onValueChange={(value) => {
886
+ setIsInstallmentsEdited(true);
887
+ field.onChange(value ?? 0);
888
+
889
+ if (!autoRedistributeInstallments) {
890
+ return;
891
+ }
892
+
893
+ scheduleInstallmentRedistribution(index);
894
+ }}
895
+ placeholder="0,00"
896
+ />
897
+ </FormControl>
898
+ <FormMessage />
899
+ </FormItem>
900
+ )}
901
+ />
902
+ </div>
903
+ ))}
904
+ </div>
905
+
906
+ <p
907
+ className={`text-xs ${
908
+ installmentsDiffCents === 0
909
+ ? 'text-muted-foreground'
910
+ : 'text-destructive'
911
+ }`}
912
+ >
913
+ Soma das parcelas: {installmentsTotal.toFixed(2)}
914
+ {installmentsDiffCents > 0 && ' (ajuste necessário)'}
915
+ </p>
916
+ {form.formState.errors.installments?.message && (
917
+ <p className="text-xs text-destructive">
918
+ {form.formState.errors.installments.message}
919
+ </p>
920
+ )}
921
+ </div>
922
+
510
923
  <FormField
511
924
  control={form.control}
512
925
  name="categoriaId"
@@ -643,9 +1056,641 @@ function NovoTituloSheet({
643
1056
  );
644
1057
  }
645
1058
 
1059
+ function EditarTituloSheet({
1060
+ open,
1061
+ onOpenChange,
1062
+ titulo,
1063
+ pessoas,
1064
+ categorias,
1065
+ centrosCusto,
1066
+ t,
1067
+ onUpdated,
1068
+ }: {
1069
+ open: boolean;
1070
+ onOpenChange: (open: boolean) => void;
1071
+ titulo?: any;
1072
+ pessoas: any[];
1073
+ categorias: any[];
1074
+ centrosCusto: any[];
1075
+ t: ReturnType<typeof useTranslations>;
1076
+ onUpdated: () => Promise<any> | void;
1077
+ }) {
1078
+ const { request, showToastHandler } = useApp();
1079
+ const [isInstallmentsEdited, setIsInstallmentsEdited] = useState(false);
1080
+ const [autoRedistributeInstallments, setAutoRedistributeInstallments] =
1081
+ useState(true);
1082
+ const redistributionTimeoutRef = useRef<
1083
+ Record<number, ReturnType<typeof setTimeout>>
1084
+ >({});
1085
+
1086
+ const form = useForm<NewTitleFormValues>({
1087
+ resolver: zodResolver(newTitleFormSchema),
1088
+ defaultValues: {
1089
+ documento: '',
1090
+ fornecedorId: '',
1091
+ competencia: '',
1092
+ vencimento: '',
1093
+ valor: 0,
1094
+ installmentsCount: 1,
1095
+ installments: [{ dueDate: '', amount: 0 }],
1096
+ categoriaId: '',
1097
+ centroCustoId: '',
1098
+ metodo: '',
1099
+ descricao: '',
1100
+ },
1101
+ });
1102
+
1103
+ const { fields: installmentFields, replace: replaceInstallments } =
1104
+ useFieldArray({
1105
+ control: form.control,
1106
+ name: 'installments',
1107
+ });
1108
+
1109
+ const watchedInstallmentsCount = form.watch('installmentsCount');
1110
+ const watchedTotalValue = form.watch('valor');
1111
+ const watchedDueDate = form.watch('vencimento');
1112
+ const watchedInstallments = form.watch('installments');
1113
+
1114
+ const toDateInput = (value?: string) => {
1115
+ if (!value) {
1116
+ return '';
1117
+ }
1118
+
1119
+ if (/^\d{4}-\d{2}-\d{2}$/.test(value)) {
1120
+ return value;
1121
+ }
1122
+
1123
+ const parsed = new Date(value);
1124
+ if (Number.isNaN(parsed.getTime())) {
1125
+ return '';
1126
+ }
1127
+
1128
+ return parsed.toISOString().slice(0, 10);
1129
+ };
1130
+
1131
+ useEffect(() => {
1132
+ if (!open || !titulo) {
1133
+ return;
1134
+ }
1135
+
1136
+ const installments = Array.isArray(titulo.parcelas) ? titulo.parcelas : [];
1137
+ const normalizedInstallments =
1138
+ installments.length > 0
1139
+ ? installments.map((installment: any) => ({
1140
+ dueDate: toDateInput(installment.vencimento),
1141
+ amount: Number(installment.valor || 0),
1142
+ }))
1143
+ : [
1144
+ {
1145
+ dueDate: toDateInput(titulo?.vencimento),
1146
+ amount: Number(titulo?.valorTotal || 0),
1147
+ },
1148
+ ];
1149
+
1150
+ form.reset({
1151
+ documento: titulo.documento || '',
1152
+ fornecedorId: titulo.fornecedorId || '',
1153
+ competencia: titulo.competencia || '',
1154
+ vencimento:
1155
+ normalizedInstallments[0]?.dueDate || toDateInput(titulo?.vencimento),
1156
+ valor: Number(titulo.valorTotal || 0),
1157
+ installmentsCount: normalizedInstallments.length,
1158
+ installments: normalizedInstallments,
1159
+ categoriaId: titulo.categoriaId || '',
1160
+ centroCustoId: titulo.centroCustoId || '',
1161
+ metodo: installments[0]?.metodoPagamento || '',
1162
+ descricao: titulo.descricao || '',
1163
+ });
1164
+
1165
+ setIsInstallmentsEdited(true);
1166
+ }, [form, open, titulo]);
1167
+
1168
+ useEffect(() => {
1169
+ if (isInstallmentsEdited || !open) {
1170
+ return;
1171
+ }
1172
+
1173
+ replaceInstallments(
1174
+ buildEqualInstallments(
1175
+ watchedInstallmentsCount,
1176
+ watchedTotalValue,
1177
+ watchedDueDate
1178
+ )
1179
+ );
1180
+ }, [
1181
+ isInstallmentsEdited,
1182
+ open,
1183
+ replaceInstallments,
1184
+ watchedDueDate,
1185
+ watchedInstallmentsCount,
1186
+ watchedTotalValue,
1187
+ ]);
1188
+
1189
+ const installmentsTotal = (watchedInstallments || []).reduce(
1190
+ (acc, installment) => acc + Number(installment?.amount || 0),
1191
+ 0
1192
+ );
1193
+ const installmentsDiffCents = Math.abs(
1194
+ Math.round(installmentsTotal * 100) -
1195
+ Math.round((watchedTotalValue || 0) * 100)
1196
+ );
1197
+
1198
+ const clearScheduledRedistribution = (index: number) => {
1199
+ const timeout = redistributionTimeoutRef.current[index];
1200
+ if (!timeout) {
1201
+ return;
1202
+ }
1203
+
1204
+ clearTimeout(timeout);
1205
+ delete redistributionTimeoutRef.current[index];
1206
+ };
1207
+
1208
+ const runInstallmentRedistribution = (index: number) => {
1209
+ if (!autoRedistributeInstallments) {
1210
+ return;
1211
+ }
1212
+
1213
+ const currentInstallments = form.getValues('installments');
1214
+ const editedInstallmentAmount = Number(
1215
+ currentInstallments[index]?.amount || 0
1216
+ );
1217
+
1218
+ const redistributedInstallments = redistributeRemainingInstallments(
1219
+ currentInstallments,
1220
+ index,
1221
+ editedInstallmentAmount,
1222
+ form.getValues('valor')
1223
+ );
1224
+
1225
+ replaceInstallments(redistributedInstallments);
1226
+ };
1227
+
1228
+ const scheduleInstallmentRedistribution = (index: number) => {
1229
+ clearScheduledRedistribution(index);
1230
+
1231
+ redistributionTimeoutRef.current[index] = setTimeout(() => {
1232
+ runInstallmentRedistribution(index);
1233
+ delete redistributionTimeoutRef.current[index];
1234
+ }, INSTALLMENT_REDISTRIBUTION_DEBOUNCE_MS);
1235
+ };
1236
+
1237
+ useEffect(() => {
1238
+ if (autoRedistributeInstallments) {
1239
+ return;
1240
+ }
1241
+
1242
+ Object.values(redistributionTimeoutRef.current).forEach(clearTimeout);
1243
+ redistributionTimeoutRef.current = {};
1244
+ }, [autoRedistributeInstallments]);
1245
+
1246
+ useEffect(() => {
1247
+ return () => {
1248
+ Object.values(redistributionTimeoutRef.current).forEach(clearTimeout);
1249
+ redistributionTimeoutRef.current = {};
1250
+ };
1251
+ }, []);
1252
+
1253
+ const handleSubmit = async (values: NewTitleFormValues) => {
1254
+ if (!titulo?.id) {
1255
+ showToastHandler?.('error', 'Título inválido para edição');
1256
+ return;
1257
+ }
1258
+
1259
+ try {
1260
+ await request({
1261
+ url: `/finance/accounts-payable/installments/${titulo.id}`,
1262
+ method: 'PATCH',
1263
+ data: {
1264
+ document_number: values.documento,
1265
+ person_id: Number(values.fornecedorId),
1266
+ competence_date: values.competencia
1267
+ ? `${values.competencia}-01`
1268
+ : undefined,
1269
+ due_date: values.vencimento,
1270
+ total_amount: values.valor,
1271
+ finance_category_id: values.categoriaId
1272
+ ? Number(values.categoriaId)
1273
+ : undefined,
1274
+ cost_center_id: values.centroCustoId
1275
+ ? Number(values.centroCustoId)
1276
+ : undefined,
1277
+ payment_channel: values.metodo || undefined,
1278
+ description: values.descricao?.trim() || undefined,
1279
+ installments: values.installments.map((installment, index) => ({
1280
+ installment_number: index + 1,
1281
+ due_date: installment.dueDate || values.vencimento,
1282
+ amount: installment.amount,
1283
+ })),
1284
+ },
1285
+ });
1286
+
1287
+ await onUpdated();
1288
+ showToastHandler?.('success', 'Título atualizado com sucesso');
1289
+ onOpenChange(false);
1290
+ } catch {
1291
+ showToastHandler?.('error', 'Não foi possível atualizar o título');
1292
+ }
1293
+ };
1294
+
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
+ />
1320
+
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
+ )}
1344
+ />
1345
+
1346
+ <div className="grid grid-cols-2 gap-4">
1347
+ <FormField
1348
+ control={form.control}
1349
+ name="competencia"
1350
+ render={({ field }) => (
1351
+ <FormItem>
1352
+ <FormLabel>{t('fields.competency')}</FormLabel>
1353
+ <FormControl>
1354
+ <Input
1355
+ type="month"
1356
+ {...field}
1357
+ value={field.value || ''}
1358
+ />
1359
+ </FormControl>
1360
+ <FormMessage />
1361
+ </FormItem>
1362
+ )}
1363
+ />
1364
+
1365
+ <FormField
1366
+ control={form.control}
1367
+ name="vencimento"
1368
+ render={({ field }) => (
1369
+ <FormItem>
1370
+ <FormLabel>{t('fields.dueDate')}</FormLabel>
1371
+ <FormControl>
1372
+ <Input
1373
+ type="date"
1374
+ {...field}
1375
+ value={field.value || ''}
1376
+ />
1377
+ </FormControl>
1378
+ <FormMessage />
1379
+ </FormItem>
1380
+ )}
1381
+ />
1382
+ </div>
1383
+
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
+ />
1404
+
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
+ />
1430
+
1431
+ <div className="space-y-3 rounded-md border p-3">
1432
+ <div className="flex items-center justify-between gap-2">
1433
+ <p className="text-sm font-medium">Parcelas</p>
1434
+ <Button
1435
+ type="button"
1436
+ variant="outline"
1437
+ size="sm"
1438
+ onClick={() => {
1439
+ setIsInstallmentsEdited(false);
1440
+ replaceInstallments(
1441
+ buildEqualInstallments(
1442
+ form.getValues('installmentsCount'),
1443
+ form.getValues('valor'),
1444
+ form.getValues('vencimento')
1445
+ )
1446
+ );
1447
+ }}
1448
+ >
1449
+ Recalcular automaticamente
1450
+ </Button>
1451
+ </div>
1452
+
1453
+ <div className="flex items-center gap-2">
1454
+ <Checkbox
1455
+ id="auto-redistribute-installments-edit-payable"
1456
+ checked={autoRedistributeInstallments}
1457
+ onCheckedChange={(checked) =>
1458
+ setAutoRedistributeInstallments(checked === true)
1459
+ }
1460
+ />
1461
+ <Label
1462
+ htmlFor="auto-redistribute-installments-edit-payable"
1463
+ className="text-xs text-muted-foreground"
1464
+ >
1465
+ Redistribuir automaticamente o restante ao editar parcela
1466
+ </Label>
1467
+ </div>
1468
+
1469
+ <div className="space-y-2">
1470
+ {installmentFields.map((installment, index) => (
1471
+ <div
1472
+ key={installment.id}
1473
+ className="grid grid-cols-1 gap-2 rounded-md border p-2 sm:grid-cols-[96px_1fr_160px]"
1474
+ >
1475
+ <div className="flex items-center text-sm text-muted-foreground">
1476
+ #{index + 1}
1477
+ </div>
1478
+
1479
+ <FormField
1480
+ control={form.control}
1481
+ name={`installments.${index}.dueDate` as const}
1482
+ render={({ field }) => (
1483
+ <FormItem>
1484
+ <FormLabel className="text-xs">
1485
+ Vencimento
1486
+ </FormLabel>
1487
+ <FormControl>
1488
+ <Input
1489
+ type="date"
1490
+ {...field}
1491
+ value={field.value || ''}
1492
+ onChange={(event) => {
1493
+ setIsInstallmentsEdited(true);
1494
+ field.onChange(event);
1495
+ }}
1496
+ />
1497
+ </FormControl>
1498
+ <FormMessage />
1499
+ </FormItem>
1500
+ )}
1501
+ />
1502
+
1503
+ <FormField
1504
+ control={form.control}
1505
+ name={`installments.${index}.amount` as const}
1506
+ render={({ field }) => (
1507
+ <FormItem>
1508
+ <FormLabel className="text-xs">Valor</FormLabel>
1509
+ <FormControl>
1510
+ <InputMoney
1511
+ ref={field.ref}
1512
+ name={field.name}
1513
+ value={field.value}
1514
+ onBlur={() => {
1515
+ field.onBlur();
1516
+
1517
+ if (!autoRedistributeInstallments) {
1518
+ return;
1519
+ }
1520
+
1521
+ clearScheduledRedistribution(index);
1522
+ runInstallmentRedistribution(index);
1523
+ }}
1524
+ onValueChange={(value) => {
1525
+ setIsInstallmentsEdited(true);
1526
+ field.onChange(value ?? 0);
1527
+
1528
+ if (!autoRedistributeInstallments) {
1529
+ return;
1530
+ }
1531
+
1532
+ scheduleInstallmentRedistribution(index);
1533
+ }}
1534
+ placeholder="0,00"
1535
+ />
1536
+ </FormControl>
1537
+ <FormMessage />
1538
+ </FormItem>
1539
+ )}
1540
+ />
1541
+ </div>
1542
+ ))}
1543
+ </div>
1544
+
1545
+ <p
1546
+ className={`text-xs ${
1547
+ installmentsDiffCents === 0
1548
+ ? 'text-muted-foreground'
1549
+ : 'text-destructive'
1550
+ }`}
1551
+ >
1552
+ Soma das parcelas: {installmentsTotal.toFixed(2)}
1553
+ {installmentsDiffCents > 0 && ' (ajuste necessário)'}
1554
+ </p>
1555
+ {form.formState.errors.installments?.message && (
1556
+ <p className="text-xs text-destructive">
1557
+ {form.formState.errors.installments.message}
1558
+ </p>
1559
+ )}
1560
+ </div>
1561
+
1562
+ <FormField
1563
+ control={form.control}
1564
+ 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
+ )}
1587
+ />
1588
+
1589
+ <FormField
1590
+ control={form.control}
1591
+ 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
+ )}
1612
+ />
1613
+
1614
+ <FormField
1615
+ control={form.control}
1616
+ name="metodo"
1617
+ render={({ field }) => (
1618
+ <FormItem>
1619
+ <FormLabel>{t('fields.paymentMethod')}</FormLabel>
1620
+ <Select value={field.value} onValueChange={field.onChange}>
1621
+ <FormControl>
1622
+ <SelectTrigger className="w-full">
1623
+ <SelectValue placeholder={t('common.select')} />
1624
+ </SelectTrigger>
1625
+ </FormControl>
1626
+ <SelectContent>
1627
+ <SelectItem value="boleto">
1628
+ {t('paymentMethods.boleto')}
1629
+ </SelectItem>
1630
+ <SelectItem value="pix">PIX</SelectItem>
1631
+ <SelectItem value="transferencia">
1632
+ {t('paymentMethods.transfer')}
1633
+ </SelectItem>
1634
+ <SelectItem value="cartao">
1635
+ {t('paymentMethods.card')}
1636
+ </SelectItem>
1637
+ <SelectItem value="dinheiro">
1638
+ {t('paymentMethods.cash')}
1639
+ </SelectItem>
1640
+ <SelectItem value="cheque">
1641
+ {t('paymentMethods.check')}
1642
+ </SelectItem>
1643
+ </SelectContent>
1644
+ </Select>
1645
+ <FormMessage />
1646
+ </FormItem>
1647
+ )}
1648
+ />
1649
+
1650
+ <FormField
1651
+ control={form.control}
1652
+ name="descricao"
1653
+ render={({ field }) => (
1654
+ <FormItem>
1655
+ <FormLabel>{t('fields.description')}</FormLabel>
1656
+ <FormControl>
1657
+ <Textarea
1658
+ placeholder={t('newTitle.descriptionPlaceholder')}
1659
+ {...field}
1660
+ value={field.value || ''}
1661
+ />
1662
+ </FormControl>
1663
+ <FormMessage />
1664
+ </FormItem>
1665
+ )}
1666
+ />
1667
+ </div>
1668
+
1669
+ <div className="flex justify-end gap-2 pt-4">
1670
+ <Button
1671
+ type="button"
1672
+ variant="outline"
1673
+ onClick={() => onOpenChange(false)}
1674
+ >
1675
+ {t('common.cancel')}
1676
+ </Button>
1677
+ <Button type="submit" disabled={form.formState.isSubmitting}>
1678
+ {t('common.save')}
1679
+ </Button>
1680
+ </div>
1681
+ </form>
1682
+ </Form>
1683
+ </SheetContent>
1684
+ </Sheet>
1685
+ );
1686
+ }
1687
+
646
1688
  export default function TitulosPagarPage() {
647
1689
  const t = useTranslations('finance.PayableInstallmentsPage');
648
1690
  const { request, currentLocaleCode, showToastHandler } = useApp();
1691
+ const pathname = usePathname();
1692
+ const router = useRouter();
1693
+ const searchParams = useSearchParams();
649
1694
  const { data, refetch } = useFinanceData();
650
1695
  const { titulosPagar, pessoas } = data;
651
1696
 
@@ -683,6 +1728,76 @@ export default function TitulosPagarPage() {
683
1728
 
684
1729
  const [search, setSearch] = useState('');
685
1730
  const [statusFilter, setStatusFilter] = useState<string>('');
1731
+ const [editingTitleId, setEditingTitleId] = useState<string | null>(null);
1732
+ const [cancelingTitleId, setCancelingTitleId] = useState<string | null>(null);
1733
+
1734
+ const editingTitle = useMemo(
1735
+ () => titulosPagar.find((item) => item.id === editingTitleId),
1736
+ [editingTitleId, titulosPagar]
1737
+ );
1738
+
1739
+ useEffect(() => {
1740
+ const editId = searchParams.get('editId');
1741
+ if (!editId || editingTitleId) {
1742
+ return;
1743
+ }
1744
+
1745
+ const foundTitle = titulosPagar.find((item) => item.id === editId);
1746
+ if (!foundTitle) {
1747
+ return;
1748
+ }
1749
+
1750
+ if (foundTitle.status !== 'rascunho') {
1751
+ showToastHandler?.(
1752
+ 'error',
1753
+ 'Apenas títulos em rascunho podem ser editados'
1754
+ );
1755
+ router.replace(pathname, { scroll: false });
1756
+ return;
1757
+ }
1758
+
1759
+ setEditingTitleId(editId);
1760
+ }, [
1761
+ editingTitleId,
1762
+ pathname,
1763
+ router,
1764
+ searchParams,
1765
+ showToastHandler,
1766
+ titulosPagar,
1767
+ ]);
1768
+
1769
+ const closeEditSheet = (open: boolean) => {
1770
+ if (open) {
1771
+ return;
1772
+ }
1773
+
1774
+ setEditingTitleId(null);
1775
+ if (searchParams.get('editId')) {
1776
+ router.replace(pathname, { scroll: false });
1777
+ }
1778
+ };
1779
+
1780
+ const handleCancelTitle = async (titleId: string) => {
1781
+ if (!titleId || cancelingTitleId) {
1782
+ return;
1783
+ }
1784
+
1785
+ setCancelingTitleId(titleId);
1786
+ try {
1787
+ await request({
1788
+ url: `/finance/accounts-payable/installments/${titleId}/cancel`,
1789
+ method: 'PATCH',
1790
+ data: {},
1791
+ });
1792
+
1793
+ await refetch();
1794
+ showToastHandler?.('success', 'Título cancelado com sucesso');
1795
+ } catch {
1796
+ showToastHandler?.('error', 'Não foi possível cancelar o título');
1797
+ } finally {
1798
+ setCancelingTitleId(null);
1799
+ }
1800
+ };
686
1801
 
687
1802
  const filteredTitulos = titulosPagar.filter((titulo) => {
688
1803
  const matchesSearch =
@@ -730,12 +1845,24 @@ export default function TitulosPagarPage() {
730
1845
  { label: t('breadcrumbs.current') },
731
1846
  ]}
732
1847
  actions={
733
- <NovoTituloSheet
734
- categorias={categorias}
735
- centrosCusto={centrosCusto}
736
- t={t}
737
- onCreated={refetch}
738
- />
1848
+ <>
1849
+ <NovoTituloSheet
1850
+ categorias={categorias}
1851
+ centrosCusto={centrosCusto}
1852
+ t={t}
1853
+ onCreated={refetch}
1854
+ />
1855
+ <EditarTituloSheet
1856
+ open={!!editingTitleId && !!editingTitle}
1857
+ onOpenChange={closeEditSheet}
1858
+ titulo={editingTitle}
1859
+ pessoas={pessoas}
1860
+ categorias={categorias}
1861
+ centrosCusto={centrosCusto}
1862
+ t={t}
1863
+ onUpdated={refetch}
1864
+ />
1865
+ </>
739
1866
  }
740
1867
  />
741
1868
 
@@ -752,7 +1879,6 @@ export default function TitulosPagarPage() {
752
1879
  options: [
753
1880
  { value: 'all', label: t('statuses.all') },
754
1881
  { value: 'rascunho', label: t('statuses.rascunho') },
755
- { value: 'aprovado', label: t('statuses.aprovado') },
756
1882
  { value: 'aberto', label: t('statuses.aberto') },
757
1883
  { value: 'parcial', label: t('statuses.parcial') },
758
1884
  { value: 'liquidado', label: t('statuses.liquidado') },
@@ -850,25 +1976,46 @@ export default function TitulosPagarPage() {
850
1976
  {t('table.actions.viewDetails')}
851
1977
  </Link>
852
1978
  </DropdownMenuItem>
853
- <DropdownMenuItem>
1979
+ <DropdownMenuItem
1980
+ disabled={titulo.status !== 'rascunho'}
1981
+ onClick={() => setEditingTitleId(titulo.id)}
1982
+ >
854
1983
  <Edit className="mr-2 h-4 w-4" />
855
1984
  {t('table.actions.edit')}
856
1985
  </DropdownMenuItem>
857
1986
  <DropdownMenuSeparator />
858
- <DropdownMenuItem>
1987
+ <DropdownMenuItem
1988
+ disabled={titulo.status !== 'rascunho'}
1989
+ >
859
1990
  <CheckCircle className="mr-2 h-4 w-4" />
860
1991
  {t('table.actions.approve')}
861
1992
  </DropdownMenuItem>
862
- <DropdownMenuItem>
1993
+ <DropdownMenuItem
1994
+ disabled={
1995
+ !['aberto', 'parcial'].includes(titulo.status)
1996
+ }
1997
+ >
863
1998
  <Download className="mr-2 h-4 w-4" />
864
1999
  {t('table.actions.settle')}
865
2000
  </DropdownMenuItem>
866
- <DropdownMenuItem>
2001
+ <DropdownMenuItem
2002
+ disabled={
2003
+ !['parcial', 'liquidado'].includes(titulo.status)
2004
+ }
2005
+ >
867
2006
  <Undo className="mr-2 h-4 w-4" />
868
2007
  {t('table.actions.reverse')}
869
2008
  </DropdownMenuItem>
870
2009
  <DropdownMenuSeparator />
871
- <DropdownMenuItem className="text-destructive">
2010
+ <DropdownMenuItem
2011
+ className="text-destructive"
2012
+ disabled={
2013
+ ['cancelado', 'liquidado'].includes(
2014
+ titulo.status
2015
+ ) || cancelingTitleId === titulo.id
2016
+ }
2017
+ onClick={() => void handleCancelTitle(titulo.id)}
2018
+ >
872
2019
  <XCircle className="mr-2 h-4 w-4" />
873
2020
  {t('table.actions.cancel')}
874
2021
  </DropdownMenuItem>