@hed-hog/finance 0.0.237 → 0.0.239

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 (47) hide show
  1. package/dist/dto/create-finance-tag.dto.d.ts +5 -0
  2. package/dist/dto/create-finance-tag.dto.d.ts.map +1 -0
  3. package/dist/dto/create-finance-tag.dto.js +29 -0
  4. package/dist/dto/create-finance-tag.dto.js.map +1 -0
  5. package/dist/dto/reject-title.dto.d.ts +4 -0
  6. package/dist/dto/reject-title.dto.d.ts.map +1 -0
  7. package/dist/dto/reject-title.dto.js +22 -0
  8. package/dist/dto/reject-title.dto.js.map +1 -0
  9. package/dist/dto/reverse-settlement.dto.d.ts +4 -0
  10. package/dist/dto/reverse-settlement.dto.d.ts.map +1 -0
  11. package/dist/dto/reverse-settlement.dto.js +22 -0
  12. package/dist/dto/reverse-settlement.dto.js.map +1 -0
  13. package/dist/dto/settle-installment.dto.d.ts +12 -0
  14. package/dist/dto/settle-installment.dto.d.ts.map +1 -0
  15. package/dist/dto/settle-installment.dto.js +71 -0
  16. package/dist/dto/settle-installment.dto.js.map +1 -0
  17. package/dist/dto/update-installment-tags.dto.d.ts +4 -0
  18. package/dist/dto/update-installment-tags.dto.d.ts.map +1 -0
  19. package/dist/dto/update-installment-tags.dto.js +27 -0
  20. package/dist/dto/update-installment-tags.dto.js.map +1 -0
  21. package/dist/finance-data.controller.d.ts +17 -5
  22. package/dist/finance-data.controller.d.ts.map +1 -1
  23. package/dist/finance-installments.controller.d.ts +325 -8
  24. package/dist/finance-installments.controller.d.ts.map +1 -1
  25. package/dist/finance-installments.controller.js +128 -0
  26. package/dist/finance-installments.controller.js.map +1 -1
  27. package/dist/finance.service.d.ts +357 -13
  28. package/dist/finance.service.d.ts.map +1 -1
  29. package/dist/finance.service.js +835 -64
  30. package/dist/finance.service.js.map +1 -1
  31. package/hedhog/data/route.yaml +90 -0
  32. package/hedhog/frontend/app/_lib/use-finance-data.ts.ejs +2 -0
  33. package/hedhog/frontend/app/accounts-payable/approvals/page.tsx.ejs +92 -12
  34. package/hedhog/frontend/app/accounts-payable/installments/[id]/page.tsx.ejs +601 -79
  35. package/hedhog/frontend/app/accounts-payable/installments/page.tsx.ejs +481 -19
  36. package/hedhog/frontend/app/accounts-receivable/installments/[id]/page.tsx.ejs +598 -69
  37. package/hedhog/frontend/app/accounts-receivable/installments/page.tsx.ejs +472 -15
  38. package/hedhog/frontend/messages/en.json +38 -0
  39. package/hedhog/frontend/messages/pt.json +38 -0
  40. package/package.json +5 -5
  41. package/src/dto/create-finance-tag.dto.ts +15 -0
  42. package/src/dto/reject-title.dto.ts +7 -0
  43. package/src/dto/reverse-settlement.dto.ts +7 -0
  44. package/src/dto/settle-installment.dto.ts +55 -0
  45. package/src/dto/update-installment-tags.dto.ts +12 -0
  46. package/src/finance-installments.controller.ts +145 -9
  47. package/src/finance.service.ts +1333 -165
@@ -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,159 @@ 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 { useEffect, useRef, useState } from 'react';
70
+ import { useFieldArray, useForm } from 'react-hook-form';
69
71
  import { z } from 'zod';
70
72
  import { formatarData } from '../../_lib/formatters';
71
73
  import { useFinanceData } from '../../_lib/use-finance-data';
72
74
 
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
- });
75
+ const INSTALLMENT_REDISTRIBUTION_DEBOUNCE_MS = 300;
76
+
77
+ const addMonthsToDate = (date: string, monthsToAdd: number) => {
78
+ if (!date) {
79
+ return '';
80
+ }
81
+
82
+ const baseDate = new Date(`${date}T00:00:00`);
83
+ if (Number.isNaN(baseDate.getTime())) {
84
+ return date;
85
+ }
86
+
87
+ const nextDate = new Date(baseDate);
88
+ nextDate.setMonth(nextDate.getMonth() + monthsToAdd);
89
+ return nextDate.toISOString().slice(0, 10);
90
+ };
91
+
92
+ const buildEqualInstallments = (
93
+ installmentsCount: number,
94
+ totalAmount: number,
95
+ firstDueDate: string
96
+ ) => {
97
+ const safeInstallmentsCount = Math.max(1, Math.floor(installmentsCount || 1));
98
+ const totalCents = Math.max(0, Math.round((totalAmount || 0) * 100));
99
+ const baseCents = Math.floor(totalCents / safeInstallmentsCount);
100
+ const remainder = totalCents % safeInstallmentsCount;
101
+
102
+ return Array.from({ length: safeInstallmentsCount }, (_, index) => ({
103
+ dueDate: addMonthsToDate(firstDueDate, index),
104
+ amount: (baseCents + (index < remainder ? 1 : 0)) / 100,
105
+ }));
106
+ };
107
+
108
+ const redistributeRemainingInstallments = (
109
+ installments: Array<{ dueDate: string; amount: number }>,
110
+ editedIndex: number,
111
+ editedAmount: number,
112
+ totalAmount: number
113
+ ) => {
114
+ if (installments.length === 0) {
115
+ return installments;
116
+ }
117
+
118
+ const totalCents = Math.max(0, Math.round((totalAmount || 0) * 100));
119
+
120
+ if (installments.length === 1) {
121
+ return installments.map((installment, index) =>
122
+ index === editedIndex
123
+ ? {
124
+ ...installment,
125
+ amount: totalCents / 100,
126
+ }
127
+ : installment
128
+ );
129
+ }
130
+
131
+ const minimumPerInstallmentCents = totalCents >= installments.length ? 1 : 0;
132
+ const remainingInstallmentsCount = installments.length - 1;
133
+
134
+ let editedCents = Math.round((editedAmount || 0) * 100);
135
+ const minEditedCents = minimumPerInstallmentCents;
136
+ const maxEditedCents =
137
+ totalCents - remainingInstallmentsCount * minimumPerInstallmentCents;
138
+
139
+ if (maxEditedCents < minEditedCents) {
140
+ editedCents = Math.min(Math.max(editedCents, 0), totalCents);
141
+ } else {
142
+ editedCents = Math.min(
143
+ Math.max(editedCents, minEditedCents),
144
+ maxEditedCents
145
+ );
146
+ }
147
+
148
+ const remainingCents = totalCents - editedCents;
149
+ const baseCents = Math.floor(remainingCents / remainingInstallmentsCount);
150
+ let remainderCents = remainingCents % remainingInstallmentsCount;
151
+ let processedInstallments = 0;
152
+
153
+ return installments.map((installment, index) => {
154
+ if (index === editedIndex) {
155
+ return {
156
+ ...installment,
157
+ amount: editedCents / 100,
158
+ };
159
+ }
160
+
161
+ const amountCents =
162
+ baseCents + (processedInstallments < remainderCents ? 1 : 0);
163
+ processedInstallments += 1;
164
+
165
+ return {
166
+ ...installment,
167
+ amount: amountCents / 100,
168
+ };
169
+ });
170
+ };
171
+
172
+ const newTitleFormSchema = z
173
+ .object({
174
+ documento: z.string().trim().min(1, 'Documento é obrigatório'),
175
+ fornecedorId: z.string().min(1, 'Fornecedor é obrigatório'),
176
+ competencia: z.string().optional(),
177
+ vencimento: z.string().min(1, 'Vencimento é obrigatório'),
178
+ valor: z.number().min(0.01, 'Valor deve ser maior que zero'),
179
+ installmentsCount: z.coerce
180
+ .number({ invalid_type_error: 'Quantidade de parcelas inválida' })
181
+ .int('Quantidade de parcelas inválida')
182
+ .min(1, 'Mínimo de 1 parcela')
183
+ .max(120, 'Máximo de 120 parcelas'),
184
+ installments: z
185
+ .array(
186
+ z.object({
187
+ dueDate: z.string().min(1, 'Vencimento da parcela é obrigatório'),
188
+ amount: z
189
+ .number()
190
+ .min(0.01, 'Valor da parcela deve ser maior que zero'),
191
+ })
192
+ )
193
+ .min(1, 'Informe ao menos uma parcela'),
194
+ categoriaId: z.string().optional(),
195
+ centroCustoId: z.string().optional(),
196
+ metodo: z.string().optional(),
197
+ descricao: z.string().optional(),
198
+ })
199
+ .superRefine((values, ctx) => {
200
+ if (values.installments.length !== values.installmentsCount) {
201
+ ctx.addIssue({
202
+ code: z.ZodIssueCode.custom,
203
+ path: ['installments'],
204
+ message: 'Quantidade de parcelas não confere com o detalhamento',
205
+ });
206
+ }
207
+
208
+ const installmentsTotalCents = values.installments.reduce(
209
+ (acc, installment) => acc + Math.round((installment.amount || 0) * 100),
210
+ 0
211
+ );
212
+ const totalCents = Math.round((values.valor || 0) * 100);
213
+
214
+ if (installmentsTotalCents !== totalCents) {
215
+ ctx.addIssue({
216
+ code: z.ZodIssueCode.custom,
217
+ path: ['installments'],
218
+ message: 'A soma das parcelas deve ser igual ao valor total',
219
+ });
220
+ }
221
+ });
84
222
 
85
223
  type NewTitleFormValues = z.infer<typeof newTitleFormSchema>;
86
224
 
@@ -101,6 +239,12 @@ function NovoTituloSheet({
101
239
  const [uploadedFileName, setUploadedFileName] = useState('');
102
240
  const [isUploadingFile, setIsUploadingFile] = useState(false);
103
241
  const [isExtractingFileData, setIsExtractingFileData] = useState(false);
242
+ const [isInstallmentsEdited, setIsInstallmentsEdited] = useState(false);
243
+ const [autoRedistributeInstallments, setAutoRedistributeInstallments] =
244
+ useState(true);
245
+ const redistributionTimeoutRef = useRef<
246
+ Record<number, ReturnType<typeof setTimeout>>
247
+ >({});
104
248
  const [extractionConfidence, setExtractionConfidence] = useState<
105
249
  number | null
106
250
  >(null);
@@ -133,6 +277,8 @@ function NovoTituloSheet({
133
277
  competencia: '',
134
278
  vencimento: '',
135
279
  valor: 0,
280
+ installmentsCount: 1,
281
+ installments: [{ dueDate: '', amount: 0 }],
136
282
  categoriaId: '',
137
283
  centroCustoId: '',
138
284
  metodo: '',
@@ -140,6 +286,101 @@ function NovoTituloSheet({
140
286
  },
141
287
  });
142
288
 
289
+ const { fields: installmentFields, replace: replaceInstallments } =
290
+ useFieldArray({
291
+ control: form.control,
292
+ name: 'installments',
293
+ });
294
+
295
+ const watchedInstallmentsCount = form.watch('installmentsCount');
296
+ const watchedTotalValue = form.watch('valor');
297
+ const watchedDueDate = form.watch('vencimento');
298
+ const watchedInstallments = form.watch('installments');
299
+
300
+ useEffect(() => {
301
+ if (isInstallmentsEdited) {
302
+ return;
303
+ }
304
+
305
+ replaceInstallments(
306
+ buildEqualInstallments(
307
+ watchedInstallmentsCount,
308
+ watchedTotalValue,
309
+ watchedDueDate
310
+ )
311
+ );
312
+ }, [
313
+ isInstallmentsEdited,
314
+ replaceInstallments,
315
+ watchedDueDate,
316
+ watchedInstallmentsCount,
317
+ watchedTotalValue,
318
+ ]);
319
+
320
+ const installmentsTotal = (watchedInstallments || []).reduce(
321
+ (acc, installment) => acc + Number(installment?.amount || 0),
322
+ 0
323
+ );
324
+ const installmentsDiffCents = Math.abs(
325
+ Math.round(installmentsTotal * 100) -
326
+ Math.round((watchedTotalValue || 0) * 100)
327
+ );
328
+
329
+ const clearScheduledRedistribution = (index: number) => {
330
+ const timeout = redistributionTimeoutRef.current[index];
331
+ if (!timeout) {
332
+ return;
333
+ }
334
+
335
+ clearTimeout(timeout);
336
+ delete redistributionTimeoutRef.current[index];
337
+ };
338
+
339
+ const runInstallmentRedistribution = (index: number) => {
340
+ if (!autoRedistributeInstallments) {
341
+ return;
342
+ }
343
+
344
+ const currentInstallments = form.getValues('installments');
345
+ const editedInstallmentAmount = Number(
346
+ currentInstallments[index]?.amount || 0
347
+ );
348
+
349
+ const redistributedInstallments = redistributeRemainingInstallments(
350
+ currentInstallments,
351
+ index,
352
+ editedInstallmentAmount,
353
+ form.getValues('valor')
354
+ );
355
+
356
+ replaceInstallments(redistributedInstallments);
357
+ };
358
+
359
+ const scheduleInstallmentRedistribution = (index: number) => {
360
+ clearScheduledRedistribution(index);
361
+
362
+ redistributionTimeoutRef.current[index] = setTimeout(() => {
363
+ runInstallmentRedistribution(index);
364
+ delete redistributionTimeoutRef.current[index];
365
+ }, INSTALLMENT_REDISTRIBUTION_DEBOUNCE_MS);
366
+ };
367
+
368
+ useEffect(() => {
369
+ if (autoRedistributeInstallments) {
370
+ return;
371
+ }
372
+
373
+ Object.values(redistributionTimeoutRef.current).forEach(clearTimeout);
374
+ redistributionTimeoutRef.current = {};
375
+ }, [autoRedistributeInstallments]);
376
+
377
+ useEffect(() => {
378
+ return () => {
379
+ Object.values(redistributionTimeoutRef.current).forEach(clearTimeout);
380
+ redistributionTimeoutRef.current = {};
381
+ };
382
+ }, []);
383
+
143
384
  const handleSubmit = async (values: NewTitleFormValues) => {
144
385
  try {
145
386
  await request({
@@ -161,6 +402,11 @@ function NovoTituloSheet({
161
402
  : undefined,
162
403
  payment_channel: values.metodo || undefined,
163
404
  description: values.descricao?.trim() || undefined,
405
+ installments: values.installments.map((installment, index) => ({
406
+ installment_number: index + 1,
407
+ due_date: installment.dueDate || values.vencimento,
408
+ amount: installment.amount,
409
+ })),
164
410
  attachment_file_ids: uploadedFileId ? [uploadedFileId] : undefined,
165
411
  },
166
412
  });
@@ -171,6 +417,8 @@ function NovoTituloSheet({
171
417
  setUploadedFileName('');
172
418
  setExtractionConfidence(null);
173
419
  setExtractionWarnings([]);
420
+ setIsInstallmentsEdited(false);
421
+ setAutoRedistributeInstallments(true);
174
422
  setOpen(false);
175
423
  showToastHandler?.('success', 'Título criado com sucesso');
176
424
  } catch {
@@ -185,6 +433,8 @@ function NovoTituloSheet({
185
433
  setExtractionConfidence(null);
186
434
  setExtractionWarnings([]);
187
435
  setUploadProgress(0);
436
+ setIsInstallmentsEdited(false);
437
+ setAutoRedistributeInstallments(true);
188
438
  setOpen(false);
189
439
  };
190
440
 
@@ -507,6 +757,168 @@ function NovoTituloSheet({
507
757
  )}
508
758
  />
509
759
 
760
+ <FormField
761
+ control={form.control}
762
+ name="installmentsCount"
763
+ render={({ field }) => (
764
+ <FormItem>
765
+ <FormLabel>Quantidade de Parcelas</FormLabel>
766
+ <FormControl>
767
+ <Input
768
+ type="number"
769
+ min={1}
770
+ max={120}
771
+ value={field.value}
772
+ onChange={(event) => {
773
+ const nextValue = Number(event.target.value || 1);
774
+ field.onChange(
775
+ Number.isNaN(nextValue) ? 1 : nextValue
776
+ );
777
+ }}
778
+ />
779
+ </FormControl>
780
+ <FormMessage />
781
+ </FormItem>
782
+ )}
783
+ />
784
+
785
+ <div className="space-y-3 rounded-md border p-3">
786
+ <div className="flex items-center justify-between gap-2">
787
+ <p className="text-sm font-medium">Parcelas</p>
788
+ <Button
789
+ type="button"
790
+ variant="outline"
791
+ size="sm"
792
+ onClick={() => {
793
+ setIsInstallmentsEdited(false);
794
+ replaceInstallments(
795
+ buildEqualInstallments(
796
+ form.getValues('installmentsCount'),
797
+ form.getValues('valor'),
798
+ form.getValues('vencimento')
799
+ )
800
+ );
801
+ }}
802
+ >
803
+ Recalcular automaticamente
804
+ </Button>
805
+ </div>
806
+
807
+ <div className="flex items-center gap-2">
808
+ <Checkbox
809
+ id="auto-redistribute-installments-payable"
810
+ checked={autoRedistributeInstallments}
811
+ onCheckedChange={(checked) =>
812
+ setAutoRedistributeInstallments(checked === true)
813
+ }
814
+ />
815
+ <Label
816
+ htmlFor="auto-redistribute-installments-payable"
817
+ className="text-xs text-muted-foreground"
818
+ >
819
+ Redistribuir automaticamente o restante ao editar parcela
820
+ </Label>
821
+ </div>
822
+ {autoRedistributeInstallments && (
823
+ <p className="text-xs text-muted-foreground">
824
+ A redistribuição ocorre ao parar de digitar e ao sair do
825
+ campo.
826
+ </p>
827
+ )}
828
+
829
+ <div className="space-y-2">
830
+ {installmentFields.map((installment, index) => (
831
+ <div
832
+ key={installment.id}
833
+ className="grid grid-cols-1 gap-2 rounded-md border p-2 sm:grid-cols-[96px_1fr_160px]"
834
+ >
835
+ <div className="flex items-center text-sm text-muted-foreground">
836
+ #{index + 1}
837
+ </div>
838
+
839
+ <FormField
840
+ control={form.control}
841
+ name={`installments.${index}.dueDate` as const}
842
+ render={({ field }) => (
843
+ <FormItem>
844
+ <FormLabel className="text-xs">
845
+ Vencimento
846
+ </FormLabel>
847
+ <FormControl>
848
+ <Input
849
+ type="date"
850
+ {...field}
851
+ value={field.value || ''}
852
+ onChange={(event) => {
853
+ setIsInstallmentsEdited(true);
854
+ field.onChange(event);
855
+ }}
856
+ />
857
+ </FormControl>
858
+ <FormMessage />
859
+ </FormItem>
860
+ )}
861
+ />
862
+
863
+ <FormField
864
+ control={form.control}
865
+ name={`installments.${index}.amount` as const}
866
+ render={({ field }) => (
867
+ <FormItem>
868
+ <FormLabel className="text-xs">Valor</FormLabel>
869
+ <FormControl>
870
+ <InputMoney
871
+ ref={field.ref}
872
+ name={field.name}
873
+ value={field.value}
874
+ onBlur={() => {
875
+ field.onBlur();
876
+
877
+ if (!autoRedistributeInstallments) {
878
+ return;
879
+ }
880
+
881
+ clearScheduledRedistribution(index);
882
+ runInstallmentRedistribution(index);
883
+ }}
884
+ onValueChange={(value) => {
885
+ setIsInstallmentsEdited(true);
886
+ field.onChange(value ?? 0);
887
+
888
+ if (!autoRedistributeInstallments) {
889
+ return;
890
+ }
891
+
892
+ scheduleInstallmentRedistribution(index);
893
+ }}
894
+ placeholder="0,00"
895
+ />
896
+ </FormControl>
897
+ <FormMessage />
898
+ </FormItem>
899
+ )}
900
+ />
901
+ </div>
902
+ ))}
903
+ </div>
904
+
905
+ <p
906
+ className={`text-xs ${
907
+ installmentsDiffCents === 0
908
+ ? 'text-muted-foreground'
909
+ : 'text-destructive'
910
+ }`}
911
+ >
912
+ Soma das parcelas: {installmentsTotal.toFixed(2)}
913
+ {installmentsDiffCents > 0 && ' (ajuste necessário)'}
914
+ </p>
915
+ {form.formState.errors.installments?.message && (
916
+ <p className="text-xs text-destructive">
917
+ {form.formState.errors.installments.message}
918
+ </p>
919
+ )}
920
+ </div>
921
+
510
922
  <FormField
511
923
  control={form.control}
512
924
  name="categoriaId"
@@ -645,12 +1057,12 @@ function NovoTituloSheet({
645
1057
 
646
1058
  export default function TitulosPagarPage() {
647
1059
  const t = useTranslations('finance.PayableInstallmentsPage');
648
- const { request } = useApp();
1060
+ const { request, currentLocaleCode, showToastHandler } = useApp();
649
1061
  const { data, refetch } = useFinanceData();
650
1062
  const { titulosPagar, pessoas } = data;
651
1063
 
652
1064
  const { data: categoriasData } = useQuery<any[]>({
653
- queryKey: ['finance-categories-options'],
1065
+ queryKey: ['finance-categories-options', currentLocaleCode],
654
1066
  queryFn: async () => {
655
1067
  const response = await request({
656
1068
  url: '/finance/categories',
@@ -696,6 +1108,29 @@ export default function TitulosPagarPage() {
696
1108
  return matchesSearch && matchesStatus;
697
1109
  });
698
1110
 
1111
+ const handleOpenAttachment = async (fileId?: string) => {
1112
+ if (!fileId) {
1113
+ return;
1114
+ }
1115
+
1116
+ try {
1117
+ const response = await request<{ url?: string }>({
1118
+ url: `/file/open/${fileId}`,
1119
+ method: 'PUT',
1120
+ });
1121
+
1122
+ const url = response?.data?.url;
1123
+ if (!url) {
1124
+ showToastHandler?.('error', 'Não foi possível abrir o anexo');
1125
+ return;
1126
+ }
1127
+
1128
+ window.open(url, '_blank', 'noopener,noreferrer');
1129
+ } catch {
1130
+ showToastHandler?.('error', 'Não foi possível abrir o anexo');
1131
+ }
1132
+ };
1133
+
699
1134
  return (
700
1135
  <Page>
701
1136
  <PageHeader
@@ -776,7 +1211,22 @@ export default function TitulosPagarPage() {
776
1211
  {titulo.documento}
777
1212
  </Link>
778
1213
  {titulo.anexos.length > 0 && (
779
- <Paperclip className="ml-1 inline h-3 w-3 text-muted-foreground" />
1214
+ <Button
1215
+ type="button"
1216
+ variant="ghost"
1217
+ size="icon"
1218
+ className="ml-1 inline-flex h-5 w-5 align-middle text-muted-foreground"
1219
+ onClick={(event) => {
1220
+ event.preventDefault();
1221
+ event.stopPropagation();
1222
+ const firstAttachmentId =
1223
+ titulo.anexosDetalhes?.[0]?.id;
1224
+ void handleOpenAttachment(firstAttachmentId);
1225
+ }}
1226
+ aria-label="Abrir anexo"
1227
+ >
1228
+ <Paperclip className="h-3 w-3" />
1229
+ </Button>
780
1230
  )}
781
1231
  </TableCell>
782
1232
  <TableCell>{fornecedor?.nome}</TableCell>
@@ -817,15 +1267,27 @@ export default function TitulosPagarPage() {
817
1267
  {t('table.actions.edit')}
818
1268
  </DropdownMenuItem>
819
1269
  <DropdownMenuSeparator />
820
- <DropdownMenuItem>
1270
+ <DropdownMenuItem
1271
+ disabled={titulo.status !== 'rascunho'}
1272
+ >
821
1273
  <CheckCircle className="mr-2 h-4 w-4" />
822
1274
  {t('table.actions.approve')}
823
1275
  </DropdownMenuItem>
824
- <DropdownMenuItem>
1276
+ <DropdownMenuItem
1277
+ disabled={
1278
+ !['aprovado', 'aberto', 'parcial'].includes(
1279
+ titulo.status
1280
+ )
1281
+ }
1282
+ >
825
1283
  <Download className="mr-2 h-4 w-4" />
826
1284
  {t('table.actions.settle')}
827
1285
  </DropdownMenuItem>
828
- <DropdownMenuItem>
1286
+ <DropdownMenuItem
1287
+ disabled={
1288
+ !['parcial', 'liquidado'].includes(titulo.status)
1289
+ }
1290
+ >
829
1291
  <Undo className="mr-2 h-4 w-4" />
830
1292
  {t('table.actions.reverse')}
831
1293
  </DropdownMenuItem>