@hed-hog/finance 0.0.252 → 0.0.256

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 (32) hide show
  1. package/dist/dto/reverse-settlement.dto.d.ts +1 -0
  2. package/dist/dto/reverse-settlement.dto.d.ts.map +1 -1
  3. package/dist/dto/reverse-settlement.dto.js +5 -0
  4. package/dist/dto/reverse-settlement.dto.js.map +1 -1
  5. package/dist/finance-installments.controller.d.ts +106 -4
  6. package/dist/finance-installments.controller.d.ts.map +1 -1
  7. package/dist/finance-installments.controller.js +38 -2
  8. package/dist/finance-installments.controller.js.map +1 -1
  9. package/dist/finance.service.d.ts +104 -2
  10. package/dist/finance.service.d.ts.map +1 -1
  11. package/dist/finance.service.js +366 -121
  12. package/dist/finance.service.js.map +1 -1
  13. package/hedhog/data/route.yaml +27 -0
  14. package/hedhog/frontend/app/_components/finance-entity-field-with-create.tsx.ejs +572 -0
  15. package/hedhog/frontend/app/_components/finance-title-actions-menu.tsx.ejs +244 -0
  16. package/hedhog/frontend/app/_components/person-field-with-create.tsx.ejs +143 -51
  17. package/hedhog/frontend/app/_lib/title-action-rules.ts.ejs +36 -0
  18. package/hedhog/frontend/app/accounts-payable/installments/[id]/page.tsx.ejs +449 -293
  19. package/hedhog/frontend/app/accounts-payable/installments/page.tsx.ejs +1189 -545
  20. package/hedhog/frontend/app/accounts-receivable/installments/[id]/page.tsx.ejs +176 -133
  21. package/hedhog/frontend/app/accounts-receivable/installments/page.tsx.ejs +1459 -312
  22. package/hedhog/frontend/app/page.tsx.ejs +15 -4
  23. package/hedhog/frontend/messages/en.json +294 -5
  24. package/hedhog/frontend/messages/pt.json +294 -5
  25. package/hedhog/query/settlement-auditability.sql +175 -0
  26. package/hedhog/table/bank_reconciliation.yaml +11 -0
  27. package/hedhog/table/settlement.yaml +17 -1
  28. package/hedhog/table/settlement_allocation.yaml +3 -0
  29. package/package.json +7 -7
  30. package/src/dto/reverse-settlement.dto.ts +4 -0
  31. package/src/finance-installments.controller.ts +45 -12
  32. package/src/finance.service.ts +521 -146
@@ -1,17 +1,7 @@
1
1
  'use client';
2
2
 
3
+ import { FinanceTitleActionsMenu } from '@/app/(app)/(libraries)/finance/_components/finance-title-actions-menu';
3
4
  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';
15
5
  import { AuditTimeline } from '@/components/ui/audit-timeline';
16
6
  import { Button } from '@/components/ui/button';
17
7
  import {
@@ -21,13 +11,6 @@ import {
21
11
  CardHeader,
22
12
  CardTitle,
23
13
  } from '@/components/ui/card';
24
- import {
25
- DropdownMenu,
26
- DropdownMenuContent,
27
- DropdownMenuItem,
28
- DropdownMenuSeparator,
29
- DropdownMenuTrigger,
30
- } from '@/components/ui/dropdown-menu';
31
14
  import {
32
15
  Form,
33
16
  FormControl,
@@ -38,6 +21,7 @@ import {
38
21
  } from '@/components/ui/form';
39
22
  import { Input } from '@/components/ui/input';
40
23
  import { InputMoney } from '@/components/ui/input-money';
24
+ import { Label } from '@/components/ui/label';
41
25
  import { Money } from '@/components/ui/money';
42
26
  import {
43
27
  Select,
@@ -64,24 +48,23 @@ import {
64
48
  } from '@/components/ui/table';
65
49
  import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
66
50
  import { TagSelectorSheet } from '@/components/ui/tag-selector-sheet';
67
- import { useApp } from '@hed-hog/next-app-provider';
51
+ import { useApp, useQuery } from '@hed-hog/next-app-provider';
68
52
  import { zodResolver } from '@hookform/resolvers/zod';
69
- import {
70
- CheckCircle,
71
- Download,
72
- Edit,
73
- FileText,
74
- MoreHorizontal,
75
- Undo,
76
- XCircle,
77
- } from 'lucide-react';
53
+ import { ChevronDown, ChevronUp, FileText, Loader2, Undo } from 'lucide-react';
78
54
  import { useTranslations } from 'next-intl';
79
55
  import Link from 'next/link';
80
56
  import { useParams, useRouter, useSearchParams } from 'next/navigation';
81
- import { useEffect, useMemo, useState } from 'react';
57
+ import { Fragment, useEffect, useMemo, useState } from 'react';
82
58
  import { useForm } from 'react-hook-form';
83
59
  import { z } from 'zod';
84
60
  import { formatarData } from '../../../_lib/formatters';
61
+ import {
62
+ canApproveTitle,
63
+ canCancelTitle,
64
+ canEditTitle,
65
+ canReverseTitle,
66
+ canSettleTitle,
67
+ } from '../../../_lib/title-action-rules';
85
68
  import { useFinanceData } from '../../../_lib/use-finance-data';
86
69
 
87
70
  type SettleFormValues = {
@@ -90,6 +73,40 @@ type SettleFormValues = {
90
73
  description?: string;
91
74
  };
92
75
 
76
+ type SettlementHistoryAllocation = {
77
+ installmentId: string;
78
+ installmentSeq: number;
79
+ amountCents: number;
80
+ };
81
+
82
+ type SettlementHistoryItem = {
83
+ normal: {
84
+ id: string;
85
+ paidAt: string | null;
86
+ amountCents: number;
87
+ type: 'NORMAL';
88
+ method?: string | null;
89
+ account?: string | null;
90
+ accountId?: string | null;
91
+ createdAt?: string | null;
92
+ createdBy?: string | null;
93
+ memo?: string | null;
94
+ reconciled: boolean;
95
+ reconciliationId?: string | null;
96
+ };
97
+ reversal?: {
98
+ id: string;
99
+ paidAt: string | null;
100
+ amountCents: number;
101
+ type: 'REVERSAL';
102
+ createdAt?: string | null;
103
+ createdBy?: string | null;
104
+ memo?: string | null;
105
+ } | null;
106
+ allocations: SettlementHistoryAllocation[];
107
+ statusLabel: 'ATIVO' | 'ESTORNADO';
108
+ };
109
+
93
110
  export default function TituloDetalhePage() {
94
111
  const t = useTranslations('finance.PayableInstallmentDetailPage');
95
112
  const settleSchema = useMemo(
@@ -112,15 +129,31 @@ export default function TituloDetalhePage() {
112
129
  pessoas,
113
130
  categorias,
114
131
  centrosCusto,
115
- contasBancarias,
116
132
  logsAuditoria,
117
133
  tags,
118
134
  } = data;
119
135
 
120
136
  const titulo = titulosPagar.find((t) => t.id === id);
121
- const canSettle = ['aberto', 'parcial', 'vencido'].includes(
122
- titulo?.status || ''
123
- );
137
+
138
+ const {
139
+ data: settlementsHistory = [],
140
+ refetch: refetchSettlementsHistory,
141
+ isFetching: isFetchingSettlementsHistory,
142
+ } = useQuery<SettlementHistoryItem[]>({
143
+ queryKey: ['finance-title-settlements-history', id],
144
+ enabled: !!id,
145
+ queryFn: async () => {
146
+ const response = await request<SettlementHistoryItem[]>({
147
+ url: `/finance/titles/${id}/settlements-history`,
148
+ method: 'GET',
149
+ });
150
+
151
+ return (response.data || []) as SettlementHistoryItem[];
152
+ },
153
+ placeholderData: (old) => old,
154
+ });
155
+
156
+ const canSettle = canSettleTitle(titulo?.status);
124
157
 
125
158
  const settleCandidates = (titulo?.parcelas || []).filter(
126
159
  (parcela: any) =>
@@ -145,13 +178,17 @@ export default function TituloDetalhePage() {
145
178
  const [isSettling, setIsSettling] = useState(false);
146
179
  const [isCanceling, setIsCanceling] = useState(false);
147
180
  const [isSettleDialogOpen, setIsSettleDialogOpen] = useState(false);
148
- const [isCancelDialogOpen, setIsCancelDialogOpen] = useState(false);
149
181
  const [isReverseDialogOpen, setIsReverseDialogOpen] = useState(false);
150
182
  const [selectedSettlementIdToReverse, setSelectedSettlementIdToReverse] =
151
183
  useState<string | null>(null);
184
+ const [reverseReason, setReverseReason] = useState('');
152
185
  const [reversingSettlementId, setReversingSettlementId] = useState<
153
186
  string | null
154
187
  >(null);
188
+ const [unreconcilingId, setUnreconcilingId] = useState<string | null>(null);
189
+ const [expandedSettlementRows, setExpandedSettlementRows] = useState<
190
+ Record<string, boolean>
191
+ >({});
155
192
 
156
193
  useEffect(() => {
157
194
  setAvailableTags(tags || []);
@@ -225,8 +262,6 @@ export default function TituloDetalhePage() {
225
262
  categorias.find((c) => c.id === categoryId);
226
263
  const getCentroCustoById = (costCenterId?: string) =>
227
264
  centrosCusto.find((c) => c.id === costCenterId);
228
- const getContaBancariaById = (bankId?: string) =>
229
- contasBancarias.find((c) => c.id === bankId);
230
265
 
231
266
  const fornecedor = getPessoaById(titulo.fornecedorId);
232
267
  const categoria = getCategoriaById(titulo.categoriaId);
@@ -282,11 +317,6 @@ export default function TituloDetalhePage() {
282
317
  window.open(url, '_blank', 'noopener,noreferrer');
283
318
  };
284
319
 
285
- const tTagSelector = (key: string, fallback: string) => {
286
- const fullKey = `tagSelector.${key}`;
287
- return t.has(fullKey) ? t(fullKey) : fallback;
288
- };
289
-
290
320
  const toTagSlug = (value: string) => {
291
321
  return value
292
322
  .normalize('NFD')
@@ -334,10 +364,7 @@ export default function TituloDetalhePage() {
334
364
  return [...current, newTag];
335
365
  });
336
366
 
337
- showToastHandler?.(
338
- 'success',
339
- tTagSelector('messages.createSuccess', 'Tag criada com sucesso')
340
- );
367
+ showToastHandler?.('success', t('tagSelector.messages.createSuccess'));
341
368
 
342
369
  return {
343
370
  id: newTag.id,
@@ -345,10 +372,7 @@ export default function TituloDetalhePage() {
345
372
  color: newTag.cor,
346
373
  };
347
374
  } catch {
348
- showToastHandler?.(
349
- 'error',
350
- tTagSelector('messages.createError', 'Não foi possível criar a tag')
351
- );
375
+ showToastHandler?.('error', t('tagSelector.messages.createError'));
352
376
  return null;
353
377
  } finally {
354
378
  setIsCreatingTag(false);
@@ -375,29 +399,21 @@ export default function TituloDetalhePage() {
375
399
  setSelectedTagIds(nextTagIds);
376
400
  }
377
401
  } catch {
378
- showToastHandler?.(
379
- 'error',
380
- tTagSelector(
381
- 'messages.updateError',
382
- 'Não foi possível atualizar as tags'
383
- )
384
- );
402
+ showToastHandler?.('error', t('tagSelector.messages.updateError'));
385
403
  }
386
404
  };
387
405
 
388
- const canApprove = titulo.status === 'rascunho';
389
- const canEdit = titulo.status === 'rascunho';
390
- const canCancel = !['cancelado', 'liquidado'].includes(titulo.status);
391
- const reversibleSettlements = titulo.parcelas
392
- .flatMap((parcela: any) => parcela.liquidacoes || [])
393
- .filter((liquidacao: any) => {
394
- return (
395
- !!liquidacao?.settlementId &&
396
- liquidacao?.status !== 'reversed' &&
397
- liquidacao?.status !== 'estornado'
398
- );
399
- });
400
- const canReverse = reversibleSettlements.length > 0;
406
+ const canApprove = canApproveTitle(titulo.status);
407
+ const canEdit = canEditTitle(titulo.status);
408
+ const canCancel = canCancelTitle(titulo.status);
409
+ const reversibleSettlements = settlementsHistory.filter(
410
+ (group) =>
411
+ group.normal?.id &&
412
+ group.statusLabel === 'ATIVO' &&
413
+ !group.normal?.reconciled
414
+ );
415
+ const canReverse =
416
+ canReverseTitle(titulo.status) && reversibleSettlements.length > 0;
401
417
 
402
418
  const getErrorMessage = (error: any, fallback: string) => {
403
419
  const message = error?.response?.data?.message;
@@ -454,7 +470,7 @@ export default function TituloDetalhePage() {
454
470
  },
455
471
  });
456
472
 
457
- await refetch();
473
+ await Promise.all([refetch(), refetchSettlementsHistory()]);
458
474
  setIsSettleDialogOpen(false);
459
475
  showToastHandler?.('success', t('messages.settleSuccess'));
460
476
  } catch (error) {
@@ -467,7 +483,10 @@ export default function TituloDetalhePage() {
467
483
  }
468
484
  };
469
485
 
470
- const handleReverseSettlement = async (settlementId: string) => {
486
+ const handleReverseSettlement = async (
487
+ settlementId: string,
488
+ reasonOverride?: string
489
+ ) => {
471
490
  if (!settlementId || reversingSettlementId) {
472
491
  return;
473
492
  }
@@ -475,12 +494,16 @@ export default function TituloDetalhePage() {
475
494
  setReversingSettlementId(settlementId);
476
495
  try {
477
496
  await request({
478
- url: `/finance/accounts-payable/installments/${titulo.id}/settlements/${settlementId}/reverse`,
479
- method: 'PATCH',
480
- data: {},
497
+ url: `/finance/settlements/${settlementId}/reverse`,
498
+ method: 'POST',
499
+ data: {
500
+ reason: reasonOverride?.trim() || reverseReason?.trim() || undefined,
501
+ memo: reasonOverride?.trim() || reverseReason?.trim() || undefined,
502
+ },
481
503
  });
482
504
 
483
- await refetch();
505
+ await Promise.all([refetch(), refetchSettlementsHistory()]);
506
+ setReverseReason('');
484
507
  showToastHandler?.('success', t('messages.reverseSuccess'));
485
508
  } catch (error) {
486
509
  showToastHandler?.(
@@ -492,6 +515,37 @@ export default function TituloDetalhePage() {
492
515
  }
493
516
  };
494
517
 
518
+ const handleUnreconcileSettlement = async (reconciliationId: string) => {
519
+ if (!reconciliationId || unreconcilingId) {
520
+ return;
521
+ }
522
+
523
+ setUnreconcilingId(reconciliationId);
524
+ try {
525
+ await request({
526
+ url: `/finance/bank-reconciliations/${reconciliationId}`,
527
+ method: 'DELETE',
528
+ });
529
+
530
+ await Promise.all([refetch(), refetchSettlementsHistory()]);
531
+ showToastHandler?.('success', t('messages.unreconcileSuccess'));
532
+ } catch (error) {
533
+ showToastHandler?.(
534
+ 'error',
535
+ getErrorMessage(error, t('messages.unreconcileError'))
536
+ );
537
+ } finally {
538
+ setUnreconcilingId(null);
539
+ }
540
+ };
541
+
542
+ const toggleSettlementExpanded = (settlementId: string) => {
543
+ setExpandedSettlementRows((current) => ({
544
+ ...current,
545
+ [settlementId]: !current[settlementId],
546
+ }));
547
+ };
548
+
495
549
  const handleCancel = async () => {
496
550
  if (!canCancel || isCanceling) {
497
551
  return;
@@ -506,7 +560,6 @@ export default function TituloDetalhePage() {
506
560
  });
507
561
 
508
562
  await refetch();
509
- setIsCancelDialogOpen(false);
510
563
  showToastHandler?.('success', t('messages.cancelSuccess'));
511
564
  } catch (error) {
512
565
  showToastHandler?.(
@@ -534,140 +587,129 @@ export default function TituloDetalhePage() {
534
587
  ]}
535
588
  actions={
536
589
  <div className="flex items-center gap-2">
537
- <DropdownMenu>
538
- <DropdownMenuTrigger asChild>
539
- <Button variant="outline">
540
- <MoreHorizontal className="mr-2 h-4 w-4" />
541
- {t('actions.title')}
542
- </Button>
543
- </DropdownMenuTrigger>
544
- <DropdownMenuContent align="end">
545
- <DropdownMenuItem
546
- disabled={!canEdit}
547
- onClick={() =>
548
- router.push(
549
- `/finance/accounts-payable/installments?editId=${titulo.id}`
550
- )
551
- }
552
- >
553
- <Edit className="mr-2 h-4 w-4" />
554
- {t('actions.edit')}
555
- </DropdownMenuItem>
556
- <DropdownMenuItem
557
- disabled={!canApprove || isApproving}
558
- onClick={() => void handleApprove()}
559
- >
560
- <CheckCircle className="mr-2 h-4 w-4" />
561
- {t('actions.approve')}
562
- </DropdownMenuItem>
563
- <DropdownMenuItem
564
- disabled={!canSettle || settleCandidates.length === 0}
565
- onClick={() => setIsSettleDialogOpen(true)}
566
- >
567
- <Download className="mr-2 h-4 w-4" />
568
- {t('actions.settle')}
569
- </DropdownMenuItem>
570
- <DropdownMenuItem
571
- disabled={!canReverse || !!reversingSettlementId}
572
- onClick={() => {
573
- const latestSettlement = reversibleSettlements.at(-1);
574
- if (!latestSettlement?.settlementId) {
575
- return;
576
- }
577
-
578
- setSelectedSettlementIdToReverse(
579
- String(latestSettlement.settlementId)
580
- );
581
- setIsReverseDialogOpen(true);
582
- }}
583
- >
584
- <Undo className="mr-2 h-4 w-4" />
585
- {t('actions.reverse')}
586
- </DropdownMenuItem>
587
- <DropdownMenuSeparator />
588
- <DropdownMenuItem
589
- className="text-destructive"
590
- disabled={!canCancel || isCanceling}
591
- onClick={() => setIsCancelDialogOpen(true)}
592
- >
593
- <XCircle className="mr-2 h-4 w-4" />
594
- {t('actions.cancel')}
595
- </DropdownMenuItem>
596
- </DropdownMenuContent>
597
- </DropdownMenu>
598
-
599
- <AlertDialog
600
- open={isCancelDialogOpen}
601
- onOpenChange={setIsCancelDialogOpen}
602
- >
603
- <AlertDialogContent>
604
- <AlertDialogHeader>
605
- <AlertDialogTitle>
606
- {t('dialogs.cancel.title')}
607
- </AlertDialogTitle>
608
- <AlertDialogDescription>
609
- {t('dialogs.cancel.description')}
610
- </AlertDialogDescription>
611
- </AlertDialogHeader>
612
- <AlertDialogFooter>
613
- <AlertDialogCancel disabled={isCanceling}>
614
- {t('dialogs.cancel.cancel')}
615
- </AlertDialogCancel>
616
- <AlertDialogAction
617
- disabled={isCanceling}
618
- onClick={() => void handleCancel()}
619
- >
620
- {t('dialogs.cancel.confirm')}
621
- </AlertDialogAction>
622
- </AlertDialogFooter>
623
- </AlertDialogContent>
624
- </AlertDialog>
590
+ <FinanceTitleActionsMenu
591
+ triggerVariant="outline"
592
+ canEdit={canEdit}
593
+ canApprove={canApprove}
594
+ canSettle={canSettle && settleCandidates.length > 0}
595
+ canReverse={canReverse}
596
+ canCancel={canCancel}
597
+ isApproving={isApproving}
598
+ isReversing={!!reversingSettlementId}
599
+ isCanceling={isCanceling}
600
+ labels={{
601
+ menu: t('actions.title'),
602
+ srActions: t('actions.title'),
603
+ edit: t('actions.edit'),
604
+ approve: t('actions.approve'),
605
+ settle: t('actions.settle'),
606
+ reverse: t('actions.reverse'),
607
+ cancel: t('actions.cancel'),
608
+ }}
609
+ dialogs={{
610
+ cancelTitle: t('dialogs.cancel.title'),
611
+ cancelDescription: t('dialogs.cancel.description'),
612
+ cancelButton: t('dialogs.cancel.cancel'),
613
+ confirmCancelButton: t('dialogs.cancel.confirm'),
614
+ reverseTitle: t('dialogs.reverse.title'),
615
+ reverseDescription: t('dialogs.reverse.description'),
616
+ reverseReasonLabel: t('dialogs.reverse.reasonLabel'),
617
+ reverseReasonPlaceholder: t(
618
+ 'dialogs.reverse.reasonPlaceholder'
619
+ ),
620
+ reverseButton: t('dialogs.reverse.cancel'),
621
+ confirmReverseButton: t('dialogs.reverse.confirm'),
622
+ }}
623
+ onEdit={() =>
624
+ router.push(
625
+ `/finance/accounts-payable/installments?editId=${titulo.id}`
626
+ )
627
+ }
628
+ onApprove={() => void handleApprove()}
629
+ onSettle={() => setIsSettleDialogOpen(true)}
630
+ onReverse={(reason) => {
631
+ const latestSettlementId = reversibleSettlements[0]?.normal?.id;
632
+
633
+ if (!latestSettlementId) {
634
+ return;
635
+ }
636
+
637
+ return handleReverseSettlement(
638
+ String(latestSettlementId),
639
+ reason
640
+ );
641
+ }}
642
+ onCancel={() => handleCancel()}
643
+ />
625
644
 
626
- <AlertDialog
645
+ <Sheet
627
646
  open={isReverseDialogOpen}
628
647
  onOpenChange={(open) => {
629
648
  setIsReverseDialogOpen(open);
630
649
 
631
650
  if (!open) {
632
651
  setSelectedSettlementIdToReverse(null);
652
+ setReverseReason('');
633
653
  }
634
654
  }}
635
655
  >
636
- <AlertDialogContent>
637
- <AlertDialogHeader>
638
- <AlertDialogTitle>
639
- {t('dialogs.reverse.title')}
640
- </AlertDialogTitle>
641
- <AlertDialogDescription>
656
+ <SheetContent className="w-full sm:max-w-lg overflow-y-auto">
657
+ <SheetHeader>
658
+ <SheetTitle>{t('dialogs.reverse.title')}</SheetTitle>
659
+ <SheetDescription>
642
660
  {t('dialogs.reverse.description')}
643
- </AlertDialogDescription>
644
- </AlertDialogHeader>
645
- <AlertDialogFooter>
646
- <AlertDialogCancel disabled={!!reversingSettlementId}>
647
- {t('dialogs.reverse.cancel')}
648
- </AlertDialogCancel>
649
- <AlertDialogAction
650
- disabled={
651
- !!reversingSettlementId || !selectedSettlementIdToReverse
652
- }
653
- onClick={() => {
654
- if (!selectedSettlementIdToReverse) {
655
- return;
656
- }
661
+ </SheetDescription>
662
+ </SheetHeader>
663
+
664
+ <div className="space-y-4 px-4">
665
+ <div className="space-y-2">
666
+ <Label>{t('dialogs.reverse.reasonLabel')}</Label>
667
+ <Input
668
+ value={reverseReason}
669
+ onChange={(event) => setReverseReason(event.target.value)}
670
+ placeholder={t('dialogs.reverse.reasonPlaceholder')}
671
+ maxLength={255}
672
+ disabled={!!reversingSettlementId}
673
+ />
674
+ </div>
657
675
 
658
- void handleReverseSettlement(
659
- selectedSettlementIdToReverse
660
- ).finally(() => {
676
+ <div className="flex flex-col gap-2">
677
+ <Button
678
+ disabled={
679
+ !!reversingSettlementId ||
680
+ !selectedSettlementIdToReverse
681
+ }
682
+ onClick={() => {
683
+ if (!selectedSettlementIdToReverse) {
684
+ return;
685
+ }
686
+
687
+ void handleReverseSettlement(
688
+ selectedSettlementIdToReverse
689
+ ).finally(() => {
690
+ setIsReverseDialogOpen(false);
691
+ setSelectedSettlementIdToReverse(null);
692
+ setReverseReason('');
693
+ });
694
+ }}
695
+ >
696
+ {t('dialogs.reverse.confirm')}
697
+ </Button>
698
+ <Button
699
+ variant="outline"
700
+ disabled={!!reversingSettlementId}
701
+ onClick={() => {
661
702
  setIsReverseDialogOpen(false);
662
703
  setSelectedSettlementIdToReverse(null);
663
- });
664
- }}
665
- >
666
- {t('dialogs.reverse.confirm')}
667
- </AlertDialogAction>
668
- </AlertDialogFooter>
669
- </AlertDialogContent>
670
- </AlertDialog>
704
+ setReverseReason('');
705
+ }}
706
+ >
707
+ {t('dialogs.reverse.cancel')}
708
+ </Button>
709
+ </div>
710
+ </div>
711
+ </SheetContent>
712
+ </Sheet>
671
713
 
672
714
  <Sheet
673
715
  open={isSettleDialogOpen}
@@ -869,35 +911,21 @@ export default function TituloDetalhePage() {
869
911
  onChange={handleChangeTags}
870
912
  onCreateTag={handleCreateTag}
871
913
  disabled={isCreatingTag}
872
- emptyText={tTagSelector('noTags', 'Sem tags')}
914
+ emptyText={t('tagSelector.noTags')}
873
915
  labels={{
874
- addTag: tTagSelector('addTag', 'Adicionar tag'),
875
- sheetTitle: tTagSelector('sheetTitle', 'Gerenciar tags'),
876
- sheetDescription: tTagSelector(
877
- 'sheetDescription',
878
- 'Selecione tags existentes ou crie uma nova.'
879
- ),
880
- createLabel: tTagSelector('createLabel', 'Nova tag'),
881
- createPlaceholder: tTagSelector(
882
- 'createPlaceholder',
883
- 'Digite o nome da tag'
884
- ),
885
- createAction: tTagSelector('createAction', 'Criar tag'),
886
- popularTitle: tTagSelector(
887
- 'popularTitle',
888
- 'Tags mais usadas'
889
- ),
890
- selectedTitle: tTagSelector(
891
- 'selectedTitle',
892
- 'Tags selecionadas'
893
- ),
894
- noTags: tTagSelector('noTags', 'Sem tags'),
895
- cancel: tTagSelector('cancel', 'Cancelar'),
896
- apply: tTagSelector('apply', 'Aplicar'),
916
+ addTag: t('tagSelector.addTag'),
917
+ sheetTitle: t('tagSelector.sheetTitle'),
918
+ sheetDescription: t('tagSelector.sheetDescription'),
919
+ createLabel: t('tagSelector.createLabel'),
920
+ createPlaceholder: t('tagSelector.createPlaceholder'),
921
+ createAction: t('tagSelector.createAction'),
922
+ popularTitle: t('tagSelector.popularTitle'),
923
+ selectedTitle: t('tagSelector.selectedTitle'),
924
+ noTags: t('tagSelector.noTags'),
925
+ cancel: t('tagSelector.cancel'),
926
+ apply: t('tagSelector.apply'),
897
927
  removeTagAria: (tagName: string) =>
898
- t.has('tagSelector.removeTagAria')
899
- ? t('tagSelector.removeTagAria', { tag: tagName })
900
- : `Remover tag ${tagName}`,
928
+ t('tagSelector.removeTagAria', { tag: tagName }),
901
929
  }}
902
930
  />
903
931
  </dd>
@@ -985,105 +1013,233 @@ export default function TituloDetalhePage() {
985
1013
  <TabsContent value="liquidacoes" className="mt-4">
986
1014
  <Card>
987
1015
  <CardContent className="pt-6">
988
- {titulo.parcelas.some((p: any) => p.liquidacoes.length > 0) ? (
1016
+ {isFetchingSettlementsHistory ? (
1017
+ <div className="flex items-center justify-center py-8 text-muted-foreground">
1018
+ <Loader2 className="mr-2 h-4 w-4 animate-spin" />
1019
+ {t('settlementsHistory.loading')}
1020
+ </div>
1021
+ ) : settlementsHistory.length > 0 ? (
989
1022
  <Table>
990
1023
  <TableHeader>
991
1024
  <TableRow>
992
- <TableHead>{t('settlementsTable.date')}</TableHead>
993
- <TableHead className="text-right">
994
- {t('settlementsTable.value')}
1025
+ <TableHead>
1026
+ {t('settlementsHistory.headers.date')}
995
1027
  </TableHead>
996
- <TableHead className="text-right">
997
- {t('settlementsTable.interest')}
1028
+ <TableHead>
1029
+ {t('settlementsHistory.headers.type')}
998
1030
  </TableHead>
999
- <TableHead className="text-right">
1000
- {t('settlementsTable.discount')}
1031
+ <TableHead>
1032
+ {t('settlementsHistory.headers.status')}
1001
1033
  </TableHead>
1002
1034
  <TableHead className="text-right">
1003
- {t('settlementsTable.fine')}
1035
+ {t('settlementsHistory.headers.netAmount')}
1036
+ </TableHead>
1037
+ <TableHead>
1038
+ {t('settlementsHistory.headers.method')}
1039
+ </TableHead>
1040
+ <TableHead>
1041
+ {t('settlementsHistory.headers.account')}
1042
+ </TableHead>
1043
+ <TableHead>
1044
+ {t('settlementsHistory.headers.reconciled')}
1004
1045
  </TableHead>
1005
- <TableHead>{t('settlementsTable.account')}</TableHead>
1006
- <TableHead>{t('settlementsTable.method')}</TableHead>
1007
1046
  <TableHead className="text-right">
1008
- {t('settlementsTable.actions')}
1047
+ {t('settlementsHistory.headers.actions')}
1009
1048
  </TableHead>
1010
1049
  </TableRow>
1011
1050
  </TableHeader>
1012
1051
  <TableBody>
1013
- {titulo.parcelas.flatMap((parcela: any) =>
1014
- parcela.liquidacoes.map((liq: any) => {
1015
- const conta = getContaBancariaById(liq.contaBancariaId);
1016
- return (
1017
- <TableRow key={liq.id}>
1018
- <TableCell>{formatarData(liq.data)}</TableCell>
1019
- <TableCell className="text-right">
1020
- <Money value={liq.valor} />
1052
+ {settlementsHistory.map((group) => {
1053
+ const normalId = group.normal.id;
1054
+ const isExpanded = !!expandedSettlementRows[normalId];
1055
+ const canReverseNormal =
1056
+ group.statusLabel === 'ATIVO' &&
1057
+ !group.normal.reconciled &&
1058
+ !reversingSettlementId;
1059
+
1060
+ return (
1061
+ <Fragment key={normalId}>
1062
+ <TableRow>
1063
+ <TableCell>
1064
+ {formatarData(group.normal.paidAt)}
1021
1065
  </TableCell>
1022
- <TableCell className="text-right">
1023
- <Money value={liq.juros} />
1066
+ <TableCell>
1067
+ <span className="rounded-md border px-2 py-1 text-xs font-medium">
1068
+ {t('settlementsHistory.normalType')}
1069
+ </span>
1024
1070
  </TableCell>
1025
- <TableCell className="text-right">
1026
- <Money value={liq.desconto} />
1071
+ <TableCell>
1072
+ <span
1073
+ className={`rounded-md px-2 py-1 text-xs font-medium ${
1074
+ group.statusLabel === 'ESTORNADO'
1075
+ ? 'bg-muted text-muted-foreground'
1076
+ : 'bg-primary/10 text-primary'
1077
+ }`}
1078
+ >
1079
+ {group.statusLabel}
1080
+ </span>
1027
1081
  </TableCell>
1028
- <TableCell className="text-right">
1029
- <Money value={liq.multa} />
1082
+ <TableCell className="text-right font-medium">
1083
+ +
1084
+ <Money
1085
+ value={
1086
+ Math.abs(group.normal.amountCents || 0) / 100
1087
+ }
1088
+ />
1030
1089
  </TableCell>
1031
- <TableCell>{conta?.descricao}</TableCell>
1032
1090
  <TableCell className="capitalize">
1033
- {liq.metodo}
1091
+ {group.normal.method || '-'}
1092
+ </TableCell>
1093
+ <TableCell>{group.normal.account || '-'}</TableCell>
1094
+ <TableCell>
1095
+ <span
1096
+ className={`rounded-md px-2 py-1 text-xs font-medium ${
1097
+ group.normal.reconciled
1098
+ ? 'bg-primary/10 text-primary'
1099
+ : 'bg-muted text-muted-foreground'
1100
+ }`}
1101
+ >
1102
+ {group.normal.reconciled
1103
+ ? t('settlementsHistory.reconciled.yes')
1104
+ : t('settlementsHistory.reconciled.no')}
1105
+ </span>
1034
1106
  </TableCell>
1035
1107
  <TableCell className="text-right">
1036
- <AlertDialog>
1037
- <AlertDialogTrigger asChild>
1108
+ <div className="flex items-center justify-end gap-2">
1109
+ {group.normal.reconciled &&
1110
+ group.normal.reconciliationId ? (
1038
1111
  <Button
1039
1112
  variant="outline"
1040
1113
  size="sm"
1041
1114
  disabled={
1042
- !liq.settlementId ||
1043
- liq.status === 'reversed' ||
1044
- !!reversingSettlementId
1115
+ unreconcilingId ===
1116
+ group.normal.reconciliationId
1117
+ }
1118
+ onClick={() =>
1119
+ void handleUnreconcileSettlement(
1120
+ String(group.normal.reconciliationId)
1121
+ )
1045
1122
  }
1046
1123
  >
1047
- <Undo className="mr-2 h-4 w-4" />
1048
- {t('settlementsTable.reverseButton')}
1049
- </Button>
1050
- </AlertDialogTrigger>
1051
- <AlertDialogContent>
1052
- <AlertDialogHeader>
1053
- <AlertDialogTitle>
1054
- {t('settlementsTable.reverseDialogTitle')}
1055
- </AlertDialogTitle>
1056
- <AlertDialogDescription>
1057
- {t(
1058
- 'settlementsTable.reverseDialogDescription'
1059
- )}
1060
- </AlertDialogDescription>
1061
- </AlertDialogHeader>
1062
- <AlertDialogFooter>
1063
- <AlertDialogCancel>
1064
- {t(
1065
- 'settlementsTable.reverseDialogCancel'
1066
- )}
1067
- </AlertDialogCancel>
1068
- <AlertDialogAction
1069
- onClick={() =>
1070
- void handleReverseSettlement(
1071
- String(liq.settlementId)
1124
+ {unreconcilingId ===
1125
+ group.normal.reconciliationId
1126
+ ? t(
1127
+ 'settlementsHistory.unreconcileLoading'
1072
1128
  )
1073
- }
1074
- >
1075
- {t(
1076
- 'settlementsTable.reverseDialogConfirm'
1077
- )}
1078
- </AlertDialogAction>
1079
- </AlertDialogFooter>
1080
- </AlertDialogContent>
1081
- </AlertDialog>
1129
+ : t('settlementsHistory.unreconcile')}
1130
+ </Button>
1131
+ ) : null}
1132
+
1133
+ <Button
1134
+ variant="outline"
1135
+ size="sm"
1136
+ title={
1137
+ group.normal.reconciled
1138
+ ? t('settlementsHistory.unreconcileFirst')
1139
+ : undefined
1140
+ }
1141
+ disabled={!canReverseNormal}
1142
+ onClick={() => {
1143
+ setSelectedSettlementIdToReverse(normalId);
1144
+ setReverseReason('');
1145
+ setIsReverseDialogOpen(true);
1146
+ }}
1147
+ >
1148
+ <Undo className="mr-2 h-4 w-4" />
1149
+ {t('settlementsHistory.reverse')}
1150
+ </Button>
1151
+
1152
+ <Button
1153
+ variant="ghost"
1154
+ size="sm"
1155
+ onClick={() =>
1156
+ toggleSettlementExpanded(normalId)
1157
+ }
1158
+ >
1159
+ {isExpanded ? (
1160
+ <ChevronUp className="h-4 w-4" />
1161
+ ) : (
1162
+ <ChevronDown className="h-4 w-4" />
1163
+ )}
1164
+ </Button>
1165
+ </div>
1082
1166
  </TableCell>
1083
1167
  </TableRow>
1084
- );
1085
- })
1086
- )}
1168
+
1169
+ {group.reversal ? (
1170
+ <TableRow>
1171
+ <TableCell className="pl-8">
1172
+ {formatarData(group.reversal.paidAt)}
1173
+ </TableCell>
1174
+ <TableCell>
1175
+ <span className="rounded-md border px-2 py-1 text-xs font-medium">
1176
+ {t('settlementsHistory.reversalType')}
1177
+ </span>
1178
+ </TableCell>
1179
+ <TableCell>
1180
+ <span className="rounded-md bg-muted px-2 py-1 text-xs font-medium text-muted-foreground">
1181
+ {t('settlementsHistory.reversedStatus')}
1182
+ </span>
1183
+ </TableCell>
1184
+ <TableCell className="text-right font-medium text-destructive">
1185
+ -
1186
+ <Money
1187
+ value={
1188
+ Math.abs(group.reversal.amountCents || 0) /
1189
+ 100
1190
+ }
1191
+ />
1192
+ </TableCell>
1193
+ <TableCell>-</TableCell>
1194
+ <TableCell>-</TableCell>
1195
+ <TableCell>-</TableCell>
1196
+ <TableCell className="text-right text-xs text-muted-foreground">
1197
+ {group.reversal.memo || '-'}
1198
+ </TableCell>
1199
+ </TableRow>
1200
+ ) : null}
1201
+
1202
+ {isExpanded ? (
1203
+ <TableRow>
1204
+ <TableCell colSpan={8}>
1205
+ <div className="rounded-md border bg-muted/20 p-3">
1206
+ <p className="mb-2 text-xs font-medium text-muted-foreground">
1207
+ {t('settlementsHistory.allocationsTitle')}
1208
+ </p>
1209
+ <div className="space-y-1">
1210
+ {group.allocations.map((allocation) => (
1211
+ <div
1212
+ key={`${normalId}-${allocation.installmentId}`}
1213
+ className="flex items-center justify-between text-sm"
1214
+ >
1215
+ <span>
1216
+ {t(
1217
+ 'settlementsHistory.allocationInstallment'
1218
+ )}{' '}
1219
+ #{allocation.installmentSeq}
1220
+ </span>
1221
+ <span className="font-medium">
1222
+ +
1223
+ <Money
1224
+ value={
1225
+ Math.abs(
1226
+ Number(
1227
+ allocation.amountCents || 0
1228
+ )
1229
+ ) / 100
1230
+ }
1231
+ />
1232
+ </span>
1233
+ </div>
1234
+ ))}
1235
+ </div>
1236
+ </div>
1237
+ </TableCell>
1238
+ </TableRow>
1239
+ ) : null}
1240
+ </Fragment>
1241
+ );
1242
+ })}
1087
1243
  </TableBody>
1088
1244
  </Table>
1089
1245
  ) : (