@hed-hog/finance 0.0.252 → 0.0.253

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.
@@ -10,7 +10,6 @@ import {
10
10
  AlertDialogFooter,
11
11
  AlertDialogHeader,
12
12
  AlertDialogTitle,
13
- AlertDialogTrigger,
14
13
  } from '@/components/ui/alert-dialog';
15
14
  import { AuditTimeline } from '@/components/ui/audit-timeline';
16
15
  import { Button } from '@/components/ui/button';
@@ -64,13 +63,16 @@ import {
64
63
  } from '@/components/ui/table';
65
64
  import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
66
65
  import { TagSelectorSheet } from '@/components/ui/tag-selector-sheet';
67
- import { useApp } from '@hed-hog/next-app-provider';
66
+ import { useApp, useQuery } from '@hed-hog/next-app-provider';
68
67
  import { zodResolver } from '@hookform/resolvers/zod';
69
68
  import {
70
69
  CheckCircle,
70
+ ChevronDown,
71
+ ChevronUp,
71
72
  Download,
72
73
  Edit,
73
74
  FileText,
75
+ Loader2,
74
76
  MoreHorizontal,
75
77
  Undo,
76
78
  XCircle,
@@ -78,7 +80,7 @@ import {
78
80
  import { useTranslations } from 'next-intl';
79
81
  import Link from 'next/link';
80
82
  import { useParams, useRouter, useSearchParams } from 'next/navigation';
81
- import { useEffect, useMemo, useState } from 'react';
83
+ import { Fragment, useEffect, useMemo, useState } from 'react';
82
84
  import { useForm } from 'react-hook-form';
83
85
  import { z } from 'zod';
84
86
  import { formatarData } from '../../../_lib/formatters';
@@ -90,6 +92,40 @@ type SettleFormValues = {
90
92
  description?: string;
91
93
  };
92
94
 
95
+ type SettlementHistoryAllocation = {
96
+ installmentId: string;
97
+ installmentSeq: number;
98
+ amountCents: number;
99
+ };
100
+
101
+ type SettlementHistoryItem = {
102
+ normal: {
103
+ id: string;
104
+ paidAt: string | null;
105
+ amountCents: number;
106
+ type: 'NORMAL';
107
+ method?: string | null;
108
+ account?: string | null;
109
+ accountId?: string | null;
110
+ createdAt?: string | null;
111
+ createdBy?: string | null;
112
+ memo?: string | null;
113
+ reconciled: boolean;
114
+ reconciliationId?: string | null;
115
+ };
116
+ reversal?: {
117
+ id: string;
118
+ paidAt: string | null;
119
+ amountCents: number;
120
+ type: 'REVERSAL';
121
+ createdAt?: string | null;
122
+ createdBy?: string | null;
123
+ memo?: string | null;
124
+ } | null;
125
+ allocations: SettlementHistoryAllocation[];
126
+ statusLabel: 'ATIVO' | 'ESTORNADO';
127
+ };
128
+
93
129
  export default function TituloDetalhePage() {
94
130
  const t = useTranslations('finance.PayableInstallmentDetailPage');
95
131
  const settleSchema = useMemo(
@@ -112,12 +148,30 @@ export default function TituloDetalhePage() {
112
148
  pessoas,
113
149
  categorias,
114
150
  centrosCusto,
115
- contasBancarias,
116
151
  logsAuditoria,
117
152
  tags,
118
153
  } = data;
119
154
 
120
155
  const titulo = titulosPagar.find((t) => t.id === id);
156
+
157
+ const {
158
+ data: settlementsHistory = [],
159
+ refetch: refetchSettlementsHistory,
160
+ isFetching: isFetchingSettlementsHistory,
161
+ } = useQuery<SettlementHistoryItem[]>({
162
+ queryKey: ['finance-title-settlements-history', id],
163
+ enabled: !!id,
164
+ queryFn: async () => {
165
+ const response = await request<SettlementHistoryItem[]>({
166
+ url: `/finance/titles/${id}/settlements-history`,
167
+ method: 'GET',
168
+ });
169
+
170
+ return (response.data || []) as SettlementHistoryItem[];
171
+ },
172
+ placeholderData: (old) => old,
173
+ });
174
+
121
175
  const canSettle = ['aberto', 'parcial', 'vencido'].includes(
122
176
  titulo?.status || ''
123
177
  );
@@ -149,9 +203,14 @@ export default function TituloDetalhePage() {
149
203
  const [isReverseDialogOpen, setIsReverseDialogOpen] = useState(false);
150
204
  const [selectedSettlementIdToReverse, setSelectedSettlementIdToReverse] =
151
205
  useState<string | null>(null);
206
+ const [reverseReason, setReverseReason] = useState('');
152
207
  const [reversingSettlementId, setReversingSettlementId] = useState<
153
208
  string | null
154
209
  >(null);
210
+ const [unreconcilingId, setUnreconcilingId] = useState<string | null>(null);
211
+ const [expandedSettlementRows, setExpandedSettlementRows] = useState<
212
+ Record<string, boolean>
213
+ >({});
155
214
 
156
215
  useEffect(() => {
157
216
  setAvailableTags(tags || []);
@@ -225,8 +284,6 @@ export default function TituloDetalhePage() {
225
284
  categorias.find((c) => c.id === categoryId);
226
285
  const getCentroCustoById = (costCenterId?: string) =>
227
286
  centrosCusto.find((c) => c.id === costCenterId);
228
- const getContaBancariaById = (bankId?: string) =>
229
- contasBancarias.find((c) => c.id === bankId);
230
287
 
231
288
  const fornecedor = getPessoaById(titulo.fornecedorId);
232
289
  const categoria = getCategoriaById(titulo.categoriaId);
@@ -388,15 +445,12 @@ export default function TituloDetalhePage() {
388
445
  const canApprove = titulo.status === 'rascunho';
389
446
  const canEdit = titulo.status === 'rascunho';
390
447
  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
- });
448
+ const reversibleSettlements = settlementsHistory.filter(
449
+ (group) =>
450
+ group.normal?.id &&
451
+ group.statusLabel === 'ATIVO' &&
452
+ !group.normal?.reconciled
453
+ );
400
454
  const canReverse = reversibleSettlements.length > 0;
401
455
 
402
456
  const getErrorMessage = (error: any, fallback: string) => {
@@ -475,12 +529,16 @@ export default function TituloDetalhePage() {
475
529
  setReversingSettlementId(settlementId);
476
530
  try {
477
531
  await request({
478
- url: `/finance/accounts-payable/installments/${titulo.id}/settlements/${settlementId}/reverse`,
479
- method: 'PATCH',
480
- data: {},
532
+ url: `/finance/settlements/${settlementId}/reverse`,
533
+ method: 'POST',
534
+ data: {
535
+ reason: reverseReason?.trim() || undefined,
536
+ memo: reverseReason?.trim() || undefined,
537
+ },
481
538
  });
482
539
 
483
- await refetch();
540
+ await Promise.all([refetch(), refetchSettlementsHistory()]);
541
+ setReverseReason('');
484
542
  showToastHandler?.('success', t('messages.reverseSuccess'));
485
543
  } catch (error) {
486
544
  showToastHandler?.(
@@ -492,6 +550,37 @@ export default function TituloDetalhePage() {
492
550
  }
493
551
  };
494
552
 
553
+ const handleUnreconcileSettlement = async (reconciliationId: string) => {
554
+ if (!reconciliationId || unreconcilingId) {
555
+ return;
556
+ }
557
+
558
+ setUnreconcilingId(reconciliationId);
559
+ try {
560
+ await request({
561
+ url: `/finance/bank-reconciliations/${reconciliationId}`,
562
+ method: 'DELETE',
563
+ });
564
+
565
+ await Promise.all([refetch(), refetchSettlementsHistory()]);
566
+ showToastHandler?.('success', 'Conciliação removida com sucesso');
567
+ } catch (error) {
568
+ showToastHandler?.(
569
+ 'error',
570
+ getErrorMessage(error, 'Não foi possível desconciliar a liquidação')
571
+ );
572
+ } finally {
573
+ setUnreconcilingId(null);
574
+ }
575
+ };
576
+
577
+ const toggleSettlementExpanded = (settlementId: string) => {
578
+ setExpandedSettlementRows((current) => ({
579
+ ...current,
580
+ [settlementId]: !current[settlementId],
581
+ }));
582
+ };
583
+
495
584
  const handleCancel = async () => {
496
585
  if (!canCancel || isCanceling) {
497
586
  return;
@@ -570,14 +659,15 @@ export default function TituloDetalhePage() {
570
659
  <DropdownMenuItem
571
660
  disabled={!canReverse || !!reversingSettlementId}
572
661
  onClick={() => {
573
- const latestSettlement = reversibleSettlements.at(-1);
574
- if (!latestSettlement?.settlementId) {
662
+ const latestSettlement = reversibleSettlements[0];
663
+ if (!latestSettlement?.normal?.id) {
575
664
  return;
576
665
  }
577
666
 
578
667
  setSelectedSettlementIdToReverse(
579
- String(latestSettlement.settlementId)
668
+ String(latestSettlement.normal.id)
580
669
  );
670
+ setReverseReason('');
581
671
  setIsReverseDialogOpen(true);
582
672
  }}
583
673
  >
@@ -630,6 +720,7 @@ export default function TituloDetalhePage() {
630
720
 
631
721
  if (!open) {
632
722
  setSelectedSettlementIdToReverse(null);
723
+ setReverseReason('');
633
724
  }
634
725
  }}
635
726
  >
@@ -642,6 +733,16 @@ export default function TituloDetalhePage() {
642
733
  {t('dialogs.reverse.description')}
643
734
  </AlertDialogDescription>
644
735
  </AlertDialogHeader>
736
+ <div className="space-y-2">
737
+ <FormLabel>Motivo do estorno</FormLabel>
738
+ <Input
739
+ value={reverseReason}
740
+ onChange={(event) => setReverseReason(event.target.value)}
741
+ placeholder="Ex.: baixa duplicada"
742
+ maxLength={255}
743
+ disabled={!!reversingSettlementId}
744
+ />
745
+ </div>
645
746
  <AlertDialogFooter>
646
747
  <AlertDialogCancel disabled={!!reversingSettlementId}>
647
748
  {t('dialogs.reverse.cancel')}
@@ -660,6 +761,7 @@ export default function TituloDetalhePage() {
660
761
  ).finally(() => {
661
762
  setIsReverseDialogOpen(false);
662
763
  setSelectedSettlementIdToReverse(null);
764
+ setReverseReason('');
663
765
  });
664
766
  }}
665
767
  >
@@ -985,105 +1087,212 @@ export default function TituloDetalhePage() {
985
1087
  <TabsContent value="liquidacoes" className="mt-4">
986
1088
  <Card>
987
1089
  <CardContent className="pt-6">
988
- {titulo.parcelas.some((p: any) => p.liquidacoes.length > 0) ? (
1090
+ {isFetchingSettlementsHistory ? (
1091
+ <div className="flex items-center justify-center py-8 text-muted-foreground">
1092
+ <Loader2 className="mr-2 h-4 w-4 animate-spin" />
1093
+ Carregando histórico de liquidações...
1094
+ </div>
1095
+ ) : settlementsHistory.length > 0 ? (
989
1096
  <Table>
990
1097
  <TableHeader>
991
1098
  <TableRow>
992
- <TableHead>{t('settlementsTable.date')}</TableHead>
993
- <TableHead className="text-right">
994
- {t('settlementsTable.value')}
995
- </TableHead>
996
- <TableHead className="text-right">
997
- {t('settlementsTable.interest')}
998
- </TableHead>
999
- <TableHead className="text-right">
1000
- {t('settlementsTable.discount')}
1001
- </TableHead>
1002
- <TableHead className="text-right">
1003
- {t('settlementsTable.fine')}
1004
- </TableHead>
1005
- <TableHead>{t('settlementsTable.account')}</TableHead>
1006
- <TableHead>{t('settlementsTable.method')}</TableHead>
1007
- <TableHead className="text-right">
1008
- {t('settlementsTable.actions')}
1009
- </TableHead>
1099
+ <TableHead>Date</TableHead>
1100
+ <TableHead>Type</TableHead>
1101
+ <TableHead>Status</TableHead>
1102
+ <TableHead className="text-right">Net Amount</TableHead>
1103
+ <TableHead>Method</TableHead>
1104
+ <TableHead>Account</TableHead>
1105
+ <TableHead>Reconciled</TableHead>
1106
+ <TableHead className="text-right">Actions</TableHead>
1010
1107
  </TableRow>
1011
1108
  </TableHeader>
1012
1109
  <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} />
1110
+ {settlementsHistory.map((group) => {
1111
+ const normalId = group.normal.id;
1112
+ const isExpanded = !!expandedSettlementRows[normalId];
1113
+ const canReverseNormal =
1114
+ group.statusLabel === 'ATIVO' &&
1115
+ !group.normal.reconciled &&
1116
+ !reversingSettlementId;
1117
+
1118
+ return (
1119
+ <Fragment key={normalId}>
1120
+ <TableRow>
1121
+ <TableCell>
1122
+ {formatarData(group.normal.paidAt)}
1021
1123
  </TableCell>
1022
- <TableCell className="text-right">
1023
- <Money value={liq.juros} />
1124
+ <TableCell>
1125
+ <span className="rounded-md border px-2 py-1 text-xs font-medium">
1126
+ NORMAL
1127
+ </span>
1024
1128
  </TableCell>
1025
- <TableCell className="text-right">
1026
- <Money value={liq.desconto} />
1129
+ <TableCell>
1130
+ <span
1131
+ className={`rounded-md px-2 py-1 text-xs font-medium ${
1132
+ group.statusLabel === 'ESTORNADO'
1133
+ ? 'bg-muted text-muted-foreground'
1134
+ : 'bg-primary/10 text-primary'
1135
+ }`}
1136
+ >
1137
+ {group.statusLabel}
1138
+ </span>
1027
1139
  </TableCell>
1028
- <TableCell className="text-right">
1029
- <Money value={liq.multa} />
1140
+ <TableCell className="text-right font-medium">
1141
+ +
1142
+ <Money
1143
+ value={
1144
+ Math.abs(group.normal.amountCents || 0) / 100
1145
+ }
1146
+ />
1030
1147
  </TableCell>
1031
- <TableCell>{conta?.descricao}</TableCell>
1032
1148
  <TableCell className="capitalize">
1033
- {liq.metodo}
1149
+ {group.normal.method || '-'}
1150
+ </TableCell>
1151
+ <TableCell>{group.normal.account || '-'}</TableCell>
1152
+ <TableCell>
1153
+ <span
1154
+ className={`rounded-md px-2 py-1 text-xs font-medium ${
1155
+ group.normal.reconciled
1156
+ ? 'bg-primary/10 text-primary'
1157
+ : 'bg-muted text-muted-foreground'
1158
+ }`}
1159
+ >
1160
+ {group.normal.reconciled
1161
+ ? 'Conciliado'
1162
+ : 'Pendente'}
1163
+ </span>
1034
1164
  </TableCell>
1035
1165
  <TableCell className="text-right">
1036
- <AlertDialog>
1037
- <AlertDialogTrigger asChild>
1166
+ <div className="flex items-center justify-end gap-2">
1167
+ {group.normal.reconciled &&
1168
+ group.normal.reconciliationId ? (
1038
1169
  <Button
1039
1170
  variant="outline"
1040
1171
  size="sm"
1041
1172
  disabled={
1042
- !liq.settlementId ||
1043
- liq.status === 'reversed' ||
1044
- !!reversingSettlementId
1173
+ unreconcilingId ===
1174
+ group.normal.reconciliationId
1175
+ }
1176
+ onClick={() =>
1177
+ void handleUnreconcileSettlement(
1178
+ String(group.normal.reconciliationId)
1179
+ )
1045
1180
  }
1046
1181
  >
1047
- <Undo className="mr-2 h-4 w-4" />
1048
- {t('settlementsTable.reverseButton')}
1182
+ {unreconcilingId ===
1183
+ group.normal.reconciliationId
1184
+ ? 'Desconciliando...'
1185
+ : 'Desconciliar'}
1049
1186
  </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)
1072
- )
1073
- }
1074
- >
1075
- {t(
1076
- 'settlementsTable.reverseDialogConfirm'
1077
- )}
1078
- </AlertDialogAction>
1079
- </AlertDialogFooter>
1080
- </AlertDialogContent>
1081
- </AlertDialog>
1187
+ ) : null}
1188
+
1189
+ <Button
1190
+ variant="outline"
1191
+ size="sm"
1192
+ title={
1193
+ group.normal.reconciled
1194
+ ? 'Desconciliar primeiro'
1195
+ : undefined
1196
+ }
1197
+ disabled={!canReverseNormal}
1198
+ onClick={() => {
1199
+ setSelectedSettlementIdToReverse(normalId);
1200
+ setReverseReason('');
1201
+ setIsReverseDialogOpen(true);
1202
+ }}
1203
+ >
1204
+ <Undo className="mr-2 h-4 w-4" />
1205
+ Estornar
1206
+ </Button>
1207
+
1208
+ <Button
1209
+ variant="ghost"
1210
+ size="sm"
1211
+ onClick={() =>
1212
+ toggleSettlementExpanded(normalId)
1213
+ }
1214
+ >
1215
+ {isExpanded ? (
1216
+ <ChevronUp className="h-4 w-4" />
1217
+ ) : (
1218
+ <ChevronDown className="h-4 w-4" />
1219
+ )}
1220
+ </Button>
1221
+ </div>
1082
1222
  </TableCell>
1083
1223
  </TableRow>
1084
- );
1085
- })
1086
- )}
1224
+
1225
+ {group.reversal ? (
1226
+ <TableRow>
1227
+ <TableCell className="pl-8">
1228
+ {formatarData(group.reversal.paidAt)}
1229
+ </TableCell>
1230
+ <TableCell>
1231
+ <span className="rounded-md border px-2 py-1 text-xs font-medium">
1232
+ REVERSAL
1233
+ </span>
1234
+ </TableCell>
1235
+ <TableCell>
1236
+ <span className="rounded-md bg-muted px-2 py-1 text-xs font-medium text-muted-foreground">
1237
+ ESTORNO
1238
+ </span>
1239
+ </TableCell>
1240
+ <TableCell className="text-right font-medium text-destructive">
1241
+ -
1242
+ <Money
1243
+ value={
1244
+ Math.abs(group.reversal.amountCents || 0) /
1245
+ 100
1246
+ }
1247
+ />
1248
+ </TableCell>
1249
+ <TableCell>-</TableCell>
1250
+ <TableCell>-</TableCell>
1251
+ <TableCell>-</TableCell>
1252
+ <TableCell className="text-right text-xs text-muted-foreground">
1253
+ {group.reversal.memo || '-'}
1254
+ </TableCell>
1255
+ </TableRow>
1256
+ ) : null}
1257
+
1258
+ {isExpanded ? (
1259
+ <TableRow>
1260
+ <TableCell colSpan={8}>
1261
+ <div className="rounded-md border bg-muted/20 p-3">
1262
+ <p className="mb-2 text-xs font-medium text-muted-foreground">
1263
+ Allocations
1264
+ </p>
1265
+ <div className="space-y-1">
1266
+ {group.allocations.map((allocation) => (
1267
+ <div
1268
+ key={`${normalId}-${allocation.installmentId}`}
1269
+ className="flex items-center justify-between text-sm"
1270
+ >
1271
+ <span>
1272
+ Parcela #{allocation.installmentSeq}
1273
+ </span>
1274
+ <span className="font-medium">
1275
+ +
1276
+ <Money
1277
+ value={
1278
+ Math.abs(
1279
+ Number(
1280
+ allocation.amountCents || 0
1281
+ )
1282
+ ) / 100
1283
+ }
1284
+ />
1285
+ </span>
1286
+ </div>
1287
+ ))}
1288
+ </div>
1289
+ </div>
1290
+ </TableCell>
1291
+ </TableRow>
1292
+ ) : null}
1293
+ </Fragment>
1294
+ );
1295
+ })}
1087
1296
  </TableBody>
1088
1297
  </Table>
1089
1298
  ) : (