@hed-hog/finance 0.0.238 → 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 (34) hide show
  1. package/dist/dto/reject-title.dto.d.ts +4 -0
  2. package/dist/dto/reject-title.dto.d.ts.map +1 -0
  3. package/dist/dto/reject-title.dto.js +22 -0
  4. package/dist/dto/reject-title.dto.js.map +1 -0
  5. package/dist/dto/reverse-settlement.dto.d.ts +4 -0
  6. package/dist/dto/reverse-settlement.dto.d.ts.map +1 -0
  7. package/dist/dto/reverse-settlement.dto.js +22 -0
  8. package/dist/dto/reverse-settlement.dto.js.map +1 -0
  9. package/dist/dto/settle-installment.dto.d.ts +12 -0
  10. package/dist/dto/settle-installment.dto.d.ts.map +1 -0
  11. package/dist/dto/settle-installment.dto.js +71 -0
  12. package/dist/dto/settle-installment.dto.js.map +1 -0
  13. package/dist/finance-data.controller.d.ts +13 -5
  14. package/dist/finance-data.controller.d.ts.map +1 -1
  15. package/dist/finance-installments.controller.d.ts +248 -12
  16. package/dist/finance-installments.controller.d.ts.map +1 -1
  17. package/dist/finance-installments.controller.js +92 -0
  18. package/dist/finance-installments.controller.js.map +1 -1
  19. package/dist/finance.service.d.ts +275 -17
  20. package/dist/finance.service.d.ts.map +1 -1
  21. package/dist/finance.service.js +666 -78
  22. package/dist/finance.service.js.map +1 -1
  23. package/hedhog/data/route.yaml +63 -0
  24. package/hedhog/frontend/app/accounts-payable/approvals/page.tsx.ejs +92 -12
  25. package/hedhog/frontend/app/accounts-payable/installments/[id]/page.tsx.ejs +355 -4
  26. package/hedhog/frontend/app/accounts-payable/installments/page.tsx.ejs +440 -16
  27. package/hedhog/frontend/app/accounts-receivable/installments/[id]/page.tsx.ejs +396 -49
  28. package/hedhog/frontend/app/accounts-receivable/installments/page.tsx.ejs +432 -14
  29. package/package.json +5 -5
  30. package/src/dto/reject-title.dto.ts +7 -0
  31. package/src/dto/reverse-settlement.dto.ts +7 -0
  32. package/src/dto/settle-installment.dto.ts +55 -0
  33. package/src/finance-installments.controller.ts +102 -0
  34. package/src/finance.service.ts +1007 -82
@@ -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"
@@ -855,15 +1267,27 @@ export default function TitulosPagarPage() {
855
1267
  {t('table.actions.edit')}
856
1268
  </DropdownMenuItem>
857
1269
  <DropdownMenuSeparator />
858
- <DropdownMenuItem>
1270
+ <DropdownMenuItem
1271
+ disabled={titulo.status !== 'rascunho'}
1272
+ >
859
1273
  <CheckCircle className="mr-2 h-4 w-4" />
860
1274
  {t('table.actions.approve')}
861
1275
  </DropdownMenuItem>
862
- <DropdownMenuItem>
1276
+ <DropdownMenuItem
1277
+ disabled={
1278
+ !['aprovado', 'aberto', 'parcial'].includes(
1279
+ titulo.status
1280
+ )
1281
+ }
1282
+ >
863
1283
  <Download className="mr-2 h-4 w-4" />
864
1284
  {t('table.actions.settle')}
865
1285
  </DropdownMenuItem>
866
- <DropdownMenuItem>
1286
+ <DropdownMenuItem
1287
+ disabled={
1288
+ !['parcial', 'liquidado'].includes(titulo.status)
1289
+ }
1290
+ >
867
1291
  <Undo className="mr-2 h-4 w-4" />
868
1292
  {t('table.actions.reverse')}
869
1293
  </DropdownMenuItem>