@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.
- package/README.md +1 -22
- package/dist/dto/reject-title.dto.d.ts +4 -0
- package/dist/dto/reject-title.dto.d.ts.map +1 -0
- package/dist/dto/reject-title.dto.js +22 -0
- package/dist/dto/reject-title.dto.js.map +1 -0
- package/dist/dto/reverse-settlement.dto.d.ts +4 -0
- package/dist/dto/reverse-settlement.dto.d.ts.map +1 -0
- package/dist/dto/reverse-settlement.dto.js +22 -0
- package/dist/dto/reverse-settlement.dto.js.map +1 -0
- package/dist/dto/settle-installment.dto.d.ts +12 -0
- package/dist/dto/settle-installment.dto.d.ts.map +1 -0
- package/dist/dto/settle-installment.dto.js +71 -0
- package/dist/dto/settle-installment.dto.js.map +1 -0
- package/dist/finance-data.controller.d.ts +13 -5
- package/dist/finance-data.controller.d.ts.map +1 -1
- package/dist/finance-installments.controller.d.ts +380 -12
- package/dist/finance-installments.controller.d.ts.map +1 -1
- package/dist/finance-installments.controller.js +144 -0
- package/dist/finance-installments.controller.js.map +1 -1
- package/dist/finance-statements.controller.d.ts +8 -0
- package/dist/finance-statements.controller.d.ts.map +1 -1
- package/dist/finance-statements.controller.js +40 -0
- package/dist/finance-statements.controller.js.map +1 -1
- package/dist/finance.module.d.ts.map +1 -1
- package/dist/finance.module.js +1 -0
- package/dist/finance.module.js.map +1 -1
- package/dist/finance.service.d.ts +435 -19
- package/dist/finance.service.d.ts.map +1 -1
- package/dist/finance.service.js +1286 -80
- package/dist/finance.service.js.map +1 -1
- package/hedhog/data/route.yaml +117 -0
- package/hedhog/frontend/app/accounts-payable/approvals/page.tsx.ejs +92 -12
- package/hedhog/frontend/app/accounts-payable/installments/[id]/page.tsx.ejs +434 -7
- package/hedhog/frontend/app/accounts-payable/installments/page.tsx.ejs +1172 -25
- package/hedhog/frontend/app/accounts-receivable/installments/[id]/page.tsx.ejs +396 -49
- package/hedhog/frontend/app/accounts-receivable/installments/page.tsx.ejs +430 -14
- package/hedhog/frontend/app/cash-and-banks/statements/page.tsx.ejs +212 -60
- package/hedhog/frontend/messages/en.json +1 -0
- package/hedhog/frontend/messages/pt.json +1 -0
- package/hedhog/query/0_constraints.sql +2 -0
- package/hedhog/query/constraints.sql +86 -0
- package/hedhog/table/bank_account.yaml +0 -8
- package/hedhog/table/financial_title.yaml +1 -9
- package/hedhog/table/settlement.yaml +0 -8
- package/package.json +6 -6
- package/src/dto/reject-title.dto.ts +7 -0
- package/src/dto/reverse-settlement.dto.ts +7 -0
- package/src/dto/settle-installment.dto.ts +55 -0
- package/src/finance-installments.controller.ts +172 -10
- package/src/finance-statements.controller.ts +61 -2
- package/src/finance.module.ts +2 -1
- package/src/finance.service.ts +1887 -106
- 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 {
|
|
68
|
-
import {
|
|
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
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
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
|
-
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
|
|
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
|
|
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>
|