@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
@@ -1,6 +1,17 @@
1
1
  'use client';
2
2
 
3
3
  import { Page, PageHeader } from '@/components/entity-list';
4
+ import {
5
+ AlertDialog,
6
+ AlertDialogAction,
7
+ AlertDialogCancel,
8
+ AlertDialogContent,
9
+ AlertDialogDescription,
10
+ AlertDialogFooter,
11
+ AlertDialogHeader,
12
+ AlertDialogTitle,
13
+ AlertDialogTrigger,
14
+ } from '@/components/ui/alert-dialog';
4
15
  import { AuditTimeline } from '@/components/ui/audit-timeline';
5
16
  import { Badge } from '@/components/ui/badge';
6
17
  import { Button } from '@/components/ui/button';
@@ -11,13 +22,38 @@ import {
11
22
  CardHeader,
12
23
  CardTitle,
13
24
  } from '@/components/ui/card';
25
+ import {
26
+ Dialog,
27
+ DialogContent,
28
+ DialogDescription,
29
+ DialogFooter,
30
+ DialogHeader,
31
+ DialogTitle,
32
+ } from '@/components/ui/dialog';
14
33
  import {
15
34
  DropdownMenu,
16
35
  DropdownMenuContent,
17
36
  DropdownMenuItem,
18
37
  DropdownMenuTrigger,
19
38
  } from '@/components/ui/dropdown-menu';
39
+ import {
40
+ Form,
41
+ FormControl,
42
+ FormField,
43
+ FormItem,
44
+ FormLabel,
45
+ FormMessage,
46
+ } from '@/components/ui/form';
47
+ import { Input } from '@/components/ui/input';
48
+ import { InputMoney } from '@/components/ui/input-money';
20
49
  import { Money } from '@/components/ui/money';
50
+ import {
51
+ Select,
52
+ SelectContent,
53
+ SelectItem,
54
+ SelectTrigger,
55
+ SelectValue,
56
+ } from '@/components/ui/select';
21
57
  import { StatusBadge } from '@/components/ui/status-badge';
22
58
  import {
23
59
  Table,
@@ -30,27 +66,39 @@ import {
30
66
  import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
31
67
  import { TagSelectorSheet } from '@/components/ui/tag-selector-sheet';
32
68
  import { useApp } from '@hed-hog/next-app-provider';
69
+ import { zodResolver } from '@hookform/resolvers/zod';
33
70
  import {
34
- ArrowLeft,
71
+ CheckCircle,
35
72
  Download,
36
73
  Edit,
37
74
  FileText,
38
75
  MoreHorizontal,
39
76
  Send,
77
+ Undo,
40
78
  } from 'lucide-react';
41
79
  import { useTranslations } from 'next-intl';
42
80
  import Link from 'next/link';
43
81
  import { useParams } from 'next/navigation';
44
82
  import { useEffect, useState } from 'react';
83
+ import { useForm } from 'react-hook-form';
84
+ import { z } from 'zod';
45
85
  import { formatarData } from '../../../_lib/formatters';
46
86
  import { useFinanceData } from '../../../_lib/use-finance-data';
47
87
 
88
+ const settleSchema = z.object({
89
+ installmentId: z.string().min(1, 'Parcela obrigatória'),
90
+ amount: z.number().min(0.01, 'Valor deve ser maior que zero'),
91
+ description: z.string().optional(),
92
+ });
93
+
94
+ type SettleFormValues = z.infer<typeof settleSchema>;
95
+
48
96
  export default function TituloReceberDetalhePage() {
49
97
  const t = useTranslations('finance.ReceivableInstallmentDetailPage');
50
98
  const { request, showToastHandler } = useApp();
51
99
  const params = useParams<{ id: string }>();
52
100
  const id = params?.id;
53
- const { data } = useFinanceData();
101
+ const { data, refetch } = useFinanceData();
54
102
  const {
55
103
  titulosReceber,
56
104
  pessoas,
@@ -63,9 +111,31 @@ export default function TituloReceberDetalhePage() {
63
111
 
64
112
  const titulo = titulosReceber.find((t) => t.id === id);
65
113
 
114
+ const settleCandidates = (titulo?.parcelas || []).filter(
115
+ (parcela: any) =>
116
+ parcela.status === 'aberto' ||
117
+ parcela.status === 'parcial' ||
118
+ parcela.status === 'vencido'
119
+ );
120
+
121
+ const settleForm = useForm<SettleFormValues>({
122
+ resolver: zodResolver(settleSchema),
123
+ defaultValues: {
124
+ installmentId: settleCandidates[0]?.id || '',
125
+ amount: Number(settleCandidates[0]?.valorAberto || 0),
126
+ description: '',
127
+ },
128
+ });
129
+
66
130
  const [availableTags, setAvailableTags] = useState<any[]>([]);
67
131
  const [selectedTagIds, setSelectedTagIds] = useState<string[]>([]);
68
132
  const [isCreatingTag, setIsCreatingTag] = useState(false);
133
+ const [isApproving, setIsApproving] = useState(false);
134
+ const [isSettling, setIsSettling] = useState(false);
135
+ const [isSettleDialogOpen, setIsSettleDialogOpen] = useState(false);
136
+ const [reversingSettlementId, setReversingSettlementId] = useState<
137
+ string | null
138
+ >(null);
69
139
 
70
140
  useEffect(() => {
71
141
  setAvailableTags(tags || []);
@@ -79,6 +149,27 @@ export default function TituloReceberDetalhePage() {
79
149
  setSelectedTagIds(Array.isArray(titulo.tags) ? titulo.tags : []);
80
150
  }, [titulo]);
81
151
 
152
+ useEffect(() => {
153
+ const firstCandidate = settleCandidates[0];
154
+ const nextInstallmentId = firstCandidate?.id || '';
155
+ const nextAmount = Number(firstCandidate?.valorAberto || 0);
156
+ const currentInstallmentId = settleForm.getValues('installmentId') || '';
157
+ const currentAmount = Number(settleForm.getValues('amount') || 0);
158
+
159
+ if (
160
+ currentInstallmentId === nextInstallmentId &&
161
+ currentAmount === nextAmount
162
+ ) {
163
+ return;
164
+ }
165
+
166
+ settleForm.reset({
167
+ installmentId: nextInstallmentId,
168
+ amount: nextAmount,
169
+ description: '',
170
+ });
171
+ }, [settleCandidates, settleForm]);
172
+
82
173
  const canalBadge = {
83
174
  boleto: {
84
175
  label: t('channels.boleto'),
@@ -286,55 +377,267 @@ export default function TituloReceberDetalhePage() {
286
377
  }
287
378
  };
288
379
 
380
+ const canApprove = titulo.status === 'rascunho';
381
+ const canSettle = ['aprovado', 'aberto', 'parcial'].includes(titulo.status);
382
+
383
+ const getErrorMessage = (error: any, fallback: string) => {
384
+ const message = error?.response?.data?.message;
385
+
386
+ if (Array.isArray(message)) {
387
+ return message.join(', ');
388
+ }
389
+
390
+ if (typeof message === 'string' && message.trim()) {
391
+ return message;
392
+ }
393
+
394
+ return fallback;
395
+ };
396
+
397
+ const handleApprove = async () => {
398
+ if (!canApprove || isApproving) {
399
+ return;
400
+ }
401
+
402
+ setIsApproving(true);
403
+ try {
404
+ await request({
405
+ url: `/finance/accounts-receivable/installments/${titulo.id}/approve`,
406
+ method: 'PATCH',
407
+ });
408
+
409
+ await refetch();
410
+ showToastHandler?.('success', 'Título aprovado com sucesso');
411
+ } catch (error) {
412
+ showToastHandler?.(
413
+ 'error',
414
+ getErrorMessage(error, 'Não foi possível aprovar o título')
415
+ );
416
+ } finally {
417
+ setIsApproving(false);
418
+ }
419
+ };
420
+
421
+ const handleSettle = async (values: SettleFormValues) => {
422
+ if (!canSettle || isSettling) {
423
+ return;
424
+ }
425
+
426
+ setIsSettling(true);
427
+ try {
428
+ await request({
429
+ url: `/finance/accounts-receivable/installments/${titulo.id}/settlements`,
430
+ method: 'POST',
431
+ data: {
432
+ installment_id: Number(values.installmentId),
433
+ amount: values.amount,
434
+ description: values.description?.trim() || undefined,
435
+ },
436
+ });
437
+
438
+ await refetch();
439
+ setIsSettleDialogOpen(false);
440
+ showToastHandler?.('success', 'Recebimento registrado com sucesso');
441
+ } catch (error) {
442
+ showToastHandler?.(
443
+ 'error',
444
+ getErrorMessage(error, 'Não foi possível registrar o recebimento')
445
+ );
446
+ } finally {
447
+ setIsSettling(false);
448
+ }
449
+ };
450
+
451
+ const handleReverseSettlement = async (settlementId: string) => {
452
+ if (!settlementId || reversingSettlementId) {
453
+ return;
454
+ }
455
+
456
+ setReversingSettlementId(settlementId);
457
+ try {
458
+ await request({
459
+ url: `/finance/accounts-receivable/installments/${titulo.id}/settlements/${settlementId}/reverse`,
460
+ method: 'PATCH',
461
+ data: {},
462
+ });
463
+
464
+ await refetch();
465
+ showToastHandler?.('success', 'Estorno realizado com sucesso');
466
+ } catch (error) {
467
+ showToastHandler?.(
468
+ 'error',
469
+ getErrorMessage(error, 'Não foi possível estornar o recebimento')
470
+ );
471
+ } finally {
472
+ setReversingSettlementId(null);
473
+ }
474
+ };
475
+
289
476
  return (
290
477
  <Page>
291
- <div className="flex items-center gap-4">
292
- <Button variant="ghost" size="icon" asChild>
293
- <Link href="/finance/accounts-receivable/installments">
294
- <ArrowLeft className="h-4 w-4" />
295
- </Link>
296
- </Button>
297
- <PageHeader
298
- title={titulo.documento}
299
- description={titulo.descricao}
300
- breadcrumbs={[
301
- { label: t('breadcrumbs.home'), href: '/' },
302
- { label: t('breadcrumbs.finance'), href: '/finance' },
303
- {
304
- label: t('breadcrumbs.receivables'),
305
- href: '/finance/accounts-receivable/installments',
306
- },
307
- { label: titulo.documento },
308
- ]}
309
- actions={
310
- <div className="flex items-center gap-2">
311
- <StatusBadge status={titulo.status} />
312
- <DropdownMenu>
313
- <DropdownMenuTrigger asChild>
314
- <Button variant="outline">
315
- <MoreHorizontal className="mr-2 h-4 w-4" />
316
- {t('actions.title')}
317
- </Button>
318
- </DropdownMenuTrigger>
319
- <DropdownMenuContent align="end">
320
- <DropdownMenuItem>
321
- <Edit className="mr-2 h-4 w-4" />
322
- {t('actions.edit')}
323
- </DropdownMenuItem>
324
- <DropdownMenuItem>
325
- <Download className="mr-2 h-4 w-4" />
326
- {t('actions.registerReceipt')}
327
- </DropdownMenuItem>
328
- <DropdownMenuItem>
329
- <Send className="mr-2 h-4 w-4" />
330
- {t('actions.sendCollection')}
331
- </DropdownMenuItem>
332
- </DropdownMenuContent>
333
- </DropdownMenu>
334
- </div>
335
- }
336
- />
337
- </div>
478
+ <PageHeader
479
+ title={titulo.documento}
480
+ description={titulo.descricao}
481
+ breadcrumbs={[
482
+ { label: t('breadcrumbs.home'), href: '/' },
483
+ { label: t('breadcrumbs.finance'), href: '/finance' },
484
+ {
485
+ label: t('breadcrumbs.receivables'),
486
+ href: '/finance/accounts-receivable/installments',
487
+ },
488
+ { label: titulo.documento },
489
+ ]}
490
+ actions={
491
+ <div className="flex items-center gap-2">
492
+ <DropdownMenu>
493
+ <DropdownMenuTrigger asChild>
494
+ <Button variant="outline">
495
+ <MoreHorizontal className="mr-2 h-4 w-4" />
496
+ {t('actions.title')}
497
+ </Button>
498
+ </DropdownMenuTrigger>
499
+ <DropdownMenuContent align="end">
500
+ <DropdownMenuItem>
501
+ <Edit className="mr-2 h-4 w-4" />
502
+ {t('actions.edit')}
503
+ </DropdownMenuItem>
504
+ <DropdownMenuItem
505
+ disabled={!canApprove || isApproving}
506
+ onClick={() => void handleApprove()}
507
+ >
508
+ <CheckCircle className="mr-2 h-4 w-4" />
509
+ Aprovar
510
+ </DropdownMenuItem>
511
+ <DropdownMenuItem
512
+ disabled={!canSettle || settleCandidates.length === 0}
513
+ onClick={() => setIsSettleDialogOpen(true)}
514
+ >
515
+ <Download className="mr-2 h-4 w-4" />
516
+ {t('actions.registerReceipt')}
517
+ </DropdownMenuItem>
518
+ <DropdownMenuItem>
519
+ <Send className="mr-2 h-4 w-4" />
520
+ {t('actions.sendCollection')}
521
+ </DropdownMenuItem>
522
+ </DropdownMenuContent>
523
+ </DropdownMenu>
524
+
525
+ <Dialog
526
+ open={isSettleDialogOpen}
527
+ onOpenChange={setIsSettleDialogOpen}
528
+ >
529
+ <DialogContent>
530
+ <DialogHeader>
531
+ <DialogTitle>Registrar recebimento</DialogTitle>
532
+ <DialogDescription>
533
+ Informe a parcela e o valor para baixa parcial ou total.
534
+ </DialogDescription>
535
+ </DialogHeader>
536
+
537
+ <Form {...settleForm}>
538
+ <form
539
+ className="space-y-4"
540
+ onSubmit={settleForm.handleSubmit(handleSettle)}
541
+ >
542
+ <FormField
543
+ control={settleForm.control}
544
+ name="installmentId"
545
+ render={({ field }) => (
546
+ <FormItem>
547
+ <FormLabel>Parcela</FormLabel>
548
+ <Select
549
+ value={field.value}
550
+ onValueChange={(value) => {
551
+ field.onChange(value);
552
+
553
+ const selected = settleCandidates.find(
554
+ (parcela: any) => parcela.id === value
555
+ );
556
+
557
+ if (selected) {
558
+ settleForm.setValue(
559
+ 'amount',
560
+ Number(selected.valorAberto || 0),
561
+ { shouldValidate: true }
562
+ );
563
+ }
564
+ }}
565
+ >
566
+ <FormControl>
567
+ <SelectTrigger>
568
+ <SelectValue placeholder="Selecione" />
569
+ </SelectTrigger>
570
+ </FormControl>
571
+ <SelectContent>
572
+ {settleCandidates.map((parcela: any) => (
573
+ <SelectItem key={parcela.id} value={parcela.id}>
574
+ Parcela {parcela.numero} - em aberto:{' '}
575
+ {new Intl.NumberFormat('pt-BR', {
576
+ style: 'currency',
577
+ currency: 'BRL',
578
+ }).format(Number(parcela.valorAberto || 0))}
579
+ </SelectItem>
580
+ ))}
581
+ </SelectContent>
582
+ </Select>
583
+ <FormMessage />
584
+ </FormItem>
585
+ )}
586
+ />
587
+
588
+ <FormField
589
+ control={settleForm.control}
590
+ name="amount"
591
+ render={({ field }) => (
592
+ <FormItem>
593
+ <FormLabel>Valor</FormLabel>
594
+ <FormControl>
595
+ <InputMoney
596
+ value={Number(field.value || 0)}
597
+ onValueChange={(value) => {
598
+ field.onChange(Number(value || 0));
599
+ }}
600
+ />
601
+ </FormControl>
602
+ <FormMessage />
603
+ </FormItem>
604
+ )}
605
+ />
606
+
607
+ <FormField
608
+ control={settleForm.control}
609
+ name="description"
610
+ render={({ field }) => (
611
+ <FormItem>
612
+ <FormLabel>Descrição (opcional)</FormLabel>
613
+ <FormControl>
614
+ <Input {...field} value={field.value || ''} />
615
+ </FormControl>
616
+ <FormMessage />
617
+ </FormItem>
618
+ )}
619
+ />
620
+
621
+ <DialogFooter>
622
+ <Button
623
+ type="button"
624
+ variant="outline"
625
+ disabled={isSettling}
626
+ onClick={() => setIsSettleDialogOpen(false)}
627
+ >
628
+ Cancelar
629
+ </Button>
630
+ <Button type="submit" disabled={isSettling}>
631
+ Confirmar recebimento
632
+ </Button>
633
+ </DialogFooter>
634
+ </form>
635
+ </Form>
636
+ </DialogContent>
637
+ </Dialog>
638
+ </div>
639
+ }
640
+ />
338
641
 
339
642
  <div className="grid gap-6 lg:grid-cols-3">
340
643
  <Card className="lg:col-span-2">
@@ -537,6 +840,7 @@ export default function TituloReceberDetalhePage() {
537
840
  </TableHead>
538
841
  <TableHead>{t('receiptsTable.account')}</TableHead>
539
842
  <TableHead>{t('receiptsTable.method')}</TableHead>
843
+ <TableHead className="text-right">Ações</TableHead>
540
844
  </TableRow>
541
845
  </TableHeader>
542
846
  <TableBody>
@@ -559,6 +863,49 @@ export default function TituloReceberDetalhePage() {
559
863
  <TableCell className="capitalize">
560
864
  {liq.metodo}
561
865
  </TableCell>
866
+ <TableCell className="text-right">
867
+ <AlertDialog>
868
+ <AlertDialogTrigger asChild>
869
+ <Button
870
+ variant="outline"
871
+ size="sm"
872
+ disabled={
873
+ !liq.settlementId ||
874
+ liq.status === 'reversed' ||
875
+ !!reversingSettlementId
876
+ }
877
+ >
878
+ <Undo className="mr-2 h-4 w-4" />
879
+ Estornar
880
+ </Button>
881
+ </AlertDialogTrigger>
882
+ <AlertDialogContent>
883
+ <AlertDialogHeader>
884
+ <AlertDialogTitle>
885
+ Confirmar estorno
886
+ </AlertDialogTitle>
887
+ <AlertDialogDescription>
888
+ Esta ação estorna o recebimento e
889
+ recalcula saldos e status.
890
+ </AlertDialogDescription>
891
+ </AlertDialogHeader>
892
+ <AlertDialogFooter>
893
+ <AlertDialogCancel>
894
+ Cancelar
895
+ </AlertDialogCancel>
896
+ <AlertDialogAction
897
+ onClick={() =>
898
+ void handleReverseSettlement(
899
+ String(liq.settlementId)
900
+ )
901
+ }
902
+ >
903
+ Confirmar estorno
904
+ </AlertDialogAction>
905
+ </AlertDialogFooter>
906
+ </AlertDialogContent>
907
+ </AlertDialog>
908
+ </TableCell>
562
909
  </TableRow>
563
910
  );
564
911
  })