@hed-hog/finance 0.0.260 → 0.0.262

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 (28) hide show
  1. package/dist/dto/update-finance-scenario-settings.dto.d.ts +7 -0
  2. package/dist/dto/update-finance-scenario-settings.dto.d.ts.map +1 -0
  3. package/dist/dto/update-finance-scenario-settings.dto.js +39 -0
  4. package/dist/dto/update-finance-scenario-settings.dto.js.map +1 -0
  5. package/dist/finance-data.controller.d.ts +61 -7
  6. package/dist/finance-data.controller.d.ts.map +1 -1
  7. package/dist/finance-data.controller.js +23 -3
  8. package/dist/finance-data.controller.js.map +1 -1
  9. package/dist/finance.service.d.ts +79 -9
  10. package/dist/finance.service.d.ts.map +1 -1
  11. package/dist/finance.service.js +471 -70
  12. package/dist/finance.service.js.map +1 -1
  13. package/hedhog/data/route.yaml +9 -0
  14. package/hedhog/data/setting_group.yaml +152 -0
  15. package/hedhog/frontend/app/_lib/use-finance-data.ts.ejs +31 -3
  16. package/hedhog/frontend/app/planning/cash-flow-forecast/page.tsx.ejs +38 -7
  17. package/hedhog/frontend/app/planning/receivables-calendar/page.tsx.ejs +3 -1
  18. package/hedhog/frontend/app/planning/scenarios/page.tsx.ejs +74 -4
  19. package/hedhog/frontend/app/reports/actual-vs-forecast/page.tsx.ejs +361 -0
  20. package/hedhog/frontend/app/reports/aging-default/page.tsx.ejs +368 -0
  21. package/hedhog/frontend/app/reports/cash-position/page.tsx.ejs +432 -0
  22. package/hedhog/frontend/messages/en.json +182 -0
  23. package/hedhog/frontend/messages/pt.json +182 -0
  24. package/hedhog/query/triggers-period-close.sql +361 -0
  25. package/package.json +4 -4
  26. package/src/dto/update-finance-scenario-settings.dto.ts +21 -0
  27. package/src/finance-data.controller.ts +18 -3
  28. package/src/finance.service.ts +781 -79
@@ -0,0 +1,432 @@
1
+ 'use client';
2
+
3
+ import { Page, PageHeader } from '@/components/entity-list';
4
+ import { Badge } from '@/components/ui/badge';
5
+ import { Button } from '@/components/ui/button';
6
+ import {
7
+ Card,
8
+ CardContent,
9
+ CardDescription,
10
+ CardHeader,
11
+ CardTitle,
12
+ } from '@/components/ui/card';
13
+ import { Money } from '@/components/ui/money';
14
+ import {
15
+ Select,
16
+ SelectContent,
17
+ SelectItem,
18
+ SelectTrigger,
19
+ SelectValue,
20
+ } from '@/components/ui/select';
21
+ import {
22
+ Table,
23
+ TableBody,
24
+ TableCell,
25
+ TableHead,
26
+ TableHeader,
27
+ TableRow,
28
+ } from '@/components/ui/table';
29
+ import {
30
+ Download,
31
+ Loader2,
32
+ TrendingDown,
33
+ TrendingUp,
34
+ Wallet,
35
+ } from 'lucide-react';
36
+ import { useTranslations } from 'next-intl';
37
+ import { useMemo, useState } from 'react';
38
+ import {
39
+ Area,
40
+ AreaChart,
41
+ CartesianGrid,
42
+ ResponsiveContainer,
43
+ Tooltip,
44
+ XAxis,
45
+ YAxis,
46
+ } from 'recharts';
47
+ import { formatarData } from '../../_lib/formatters';
48
+ import { useFinanceData } from '../../_lib/use-finance-data';
49
+
50
+ type Scenario = 'base' | 'pessimista' | 'otimista';
51
+
52
+ export default function CashPositionReportPage() {
53
+ const t = useTranslations('finance.CashPositionReportPage');
54
+
55
+ const [horizonte, setHorizonte] = useState<string | null>(null);
56
+ const [cenario, setCenario] = useState<Scenario | null>(null);
57
+ const [bankAccountId, setBankAccountId] = useState<string>('all');
58
+
59
+ const { data, isFetching } = useFinanceData({
60
+ horizonteDias: horizonte ? Number(horizonte) : undefined,
61
+ cenario: cenario || undefined,
62
+ });
63
+
64
+ const selectedHorizon = horizonte || String(data.defaultHorizonDays || 90);
65
+ const selectedScenario = cenario || data.defaultScenario || 'base';
66
+
67
+ const contas = data.contasBancarias || [];
68
+ const extratos = data.extratos || [];
69
+
70
+ const filteredExtratos = useMemo(() => {
71
+ if (bankAccountId === 'all') {
72
+ return extratos;
73
+ }
74
+
75
+ return extratos.filter((item: any) => {
76
+ const accountId =
77
+ item.bankAccountId || item.contaBancariaId || item.bank_account_id;
78
+ return String(accountId) === bankAccountId;
79
+ });
80
+ }, [extratos, bankAccountId]);
81
+
82
+ const totals = useMemo(() => {
83
+ const saldoAtual = contas.reduce(
84
+ (acc: number, conta: any) => acc + Number(conta.saldoAtual || 0),
85
+ 0
86
+ );
87
+
88
+ const saldoConciliado = contas.reduce(
89
+ (acc: number, conta: any) => acc + Number(conta.saldoConciliado || 0),
90
+ 0
91
+ );
92
+
93
+ const entradas = filteredExtratos
94
+ .filter((item: any) => item.tipo === 'entrada')
95
+ .reduce((acc: number, item: any) => acc + Number(item.valor || 0), 0);
96
+
97
+ const saidas = filteredExtratos
98
+ .filter((item: any) => item.tipo === 'saida')
99
+ .reduce((acc: number, item: any) => acc + Number(item.valor || 0), 0);
100
+
101
+ return {
102
+ saldoAtual,
103
+ saldoConciliado,
104
+ diferenca: saldoAtual - saldoConciliado,
105
+ entradas,
106
+ saidas,
107
+ movimentacaoLiquida: entradas - saidas,
108
+ };
109
+ }, [contas, filteredExtratos]);
110
+
111
+ const chartData = useMemo(() => {
112
+ const byDate = new Map<
113
+ string,
114
+ {
115
+ date: string;
116
+ label: string;
117
+ entradas: number;
118
+ saidas: number;
119
+ }
120
+ >();
121
+
122
+ for (const mov of filteredExtratos) {
123
+ const date = String(mov.data || '');
124
+ if (!date) {
125
+ continue;
126
+ }
127
+
128
+ if (!byDate.has(date)) {
129
+ byDate.set(date, {
130
+ date,
131
+ label: formatarData(date),
132
+ entradas: 0,
133
+ saidas: 0,
134
+ });
135
+ }
136
+
137
+ const row = byDate.get(date)!;
138
+ const value = Number(mov.valor || 0);
139
+
140
+ if (mov.tipo === 'entrada') {
141
+ row.entradas += value;
142
+ } else {
143
+ row.saidas += value;
144
+ }
145
+ }
146
+
147
+ const sorted = Array.from(byDate.values()).sort((a, b) =>
148
+ a.date.localeCompare(b.date)
149
+ );
150
+
151
+ let saldoAcumulado = 0;
152
+
153
+ return sorted.map((item) => {
154
+ saldoAcumulado += item.entradas - item.saidas;
155
+
156
+ return {
157
+ ...item,
158
+ saldoAcumulado,
159
+ };
160
+ });
161
+ }, [filteredExtratos]);
162
+
163
+ const latestMovements = useMemo(
164
+ () =>
165
+ [...filteredExtratos]
166
+ .sort((a: any, b: any) => String(b.data).localeCompare(String(a.data)))
167
+ .slice(0, 12),
168
+ [filteredExtratos]
169
+ );
170
+
171
+ return (
172
+ <Page>
173
+ <PageHeader
174
+ title={t('header.title')}
175
+ description={t('header.description')}
176
+ breadcrumbs={[
177
+ { label: t('breadcrumbs.home'), href: '/' },
178
+ { label: t('breadcrumbs.finance'), href: '/finance' },
179
+ { label: t('breadcrumbs.current') },
180
+ ]}
181
+ actions={
182
+ <Button variant="outline" disabled>
183
+ <Download className="mr-2 h-4 w-4" />
184
+ {t('actions.export')}
185
+ </Button>
186
+ }
187
+ />
188
+
189
+ <div className="flex flex-col gap-4 sm:flex-row sm:flex-wrap sm:items-center">
190
+ <Select
191
+ value={selectedHorizon}
192
+ onValueChange={setHorizonte}
193
+ disabled={isFetching}
194
+ >
195
+ <SelectTrigger className="w-[180px]">
196
+ <SelectValue placeholder={t('filters.horizon')} />
197
+ </SelectTrigger>
198
+ <SelectContent>
199
+ <SelectItem value="30">{t('filters.days30')}</SelectItem>
200
+ <SelectItem value="60">{t('filters.days60')}</SelectItem>
201
+ <SelectItem value="90">{t('filters.days90')}</SelectItem>
202
+ <SelectItem value="180">{t('filters.days180')}</SelectItem>
203
+ <SelectItem value="365">{t('filters.days365')}</SelectItem>
204
+ </SelectContent>
205
+ </Select>
206
+
207
+ <Select
208
+ value={selectedScenario}
209
+ onValueChange={(value) => setCenario(value as Scenario)}
210
+ disabled={isFetching}
211
+ >
212
+ <SelectTrigger className="w-[180px]">
213
+ <SelectValue placeholder={t('filters.scenario')} />
214
+ </SelectTrigger>
215
+ <SelectContent>
216
+ <SelectItem value="base">{t('scenarios.base')}</SelectItem>
217
+ <SelectItem value="pessimista">
218
+ {t('scenarios.pessimistic')}
219
+ </SelectItem>
220
+ <SelectItem value="otimista">
221
+ {t('scenarios.optimistic')}
222
+ </SelectItem>
223
+ </SelectContent>
224
+ </Select>
225
+
226
+ <Select
227
+ value={bankAccountId}
228
+ onValueChange={setBankAccountId}
229
+ disabled={isFetching}
230
+ >
231
+ <SelectTrigger className="w-60">
232
+ <SelectValue placeholder={t('filters.bankAccount')} />
233
+ </SelectTrigger>
234
+ <SelectContent>
235
+ <SelectItem value="all">{t('filters.allBankAccounts')}</SelectItem>
236
+ {contas.map((conta: any) => (
237
+ <SelectItem key={conta.id} value={String(conta.id)}>
238
+ {conta.banco}
239
+ </SelectItem>
240
+ ))}
241
+ </SelectContent>
242
+ </Select>
243
+
244
+ {isFetching ? (
245
+ <div className="text-muted-foreground flex items-center gap-2 text-sm">
246
+ <Loader2 className="h-4 w-4 animate-spin" />
247
+ {t('messages.updating')}
248
+ </div>
249
+ ) : null}
250
+ </div>
251
+
252
+ <div className="grid gap-4 md:grid-cols-5">
253
+ <Card>
254
+ <CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
255
+ <CardTitle className="text-sm font-medium">
256
+ {t('cards.currentBalance')}
257
+ </CardTitle>
258
+ <Wallet className="h-4 w-4 text-muted-foreground" />
259
+ </CardHeader>
260
+ <CardContent>
261
+ <div className="text-2xl font-bold">
262
+ <Money value={totals.saldoAtual} />
263
+ </div>
264
+ </CardContent>
265
+ </Card>
266
+
267
+ <Card>
268
+ <CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
269
+ <CardTitle className="text-sm font-medium">
270
+ {t('cards.reconciledBalance')}
271
+ </CardTitle>
272
+ <Wallet className="h-4 w-4 text-muted-foreground" />
273
+ </CardHeader>
274
+ <CardContent>
275
+ <div className="text-2xl font-bold">
276
+ <Money value={totals.saldoConciliado} />
277
+ </div>
278
+ </CardContent>
279
+ </Card>
280
+
281
+ <Card>
282
+ <CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
283
+ <CardTitle className="text-sm font-medium">
284
+ {t('cards.inflows')}
285
+ </CardTitle>
286
+ <TrendingUp className="h-4 w-4 text-green-600" />
287
+ </CardHeader>
288
+ <CardContent>
289
+ <div className="text-2xl font-bold text-green-600">
290
+ <Money value={totals.entradas} />
291
+ </div>
292
+ </CardContent>
293
+ </Card>
294
+
295
+ <Card>
296
+ <CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
297
+ <CardTitle className="text-sm font-medium">
298
+ {t('cards.outflows')}
299
+ </CardTitle>
300
+ <TrendingDown className="h-4 w-4 text-red-600" />
301
+ </CardHeader>
302
+ <CardContent>
303
+ <div className="text-2xl font-bold text-red-600">
304
+ <Money value={totals.saidas} />
305
+ </div>
306
+ </CardContent>
307
+ </Card>
308
+
309
+ <Card>
310
+ <CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
311
+ <CardTitle className="text-sm font-medium">
312
+ {t('cards.netMovement')}
313
+ </CardTitle>
314
+ {totals.movimentacaoLiquida >= 0 ? (
315
+ <TrendingUp className="h-4 w-4 text-green-600" />
316
+ ) : (
317
+ <TrendingDown className="h-4 w-4 text-red-600" />
318
+ )}
319
+ </CardHeader>
320
+ <CardContent>
321
+ <div
322
+ className={`text-2xl font-bold ${totals.movimentacaoLiquida >= 0 ? 'text-green-600' : 'text-red-600'}`}
323
+ >
324
+ <Money value={totals.movimentacaoLiquida} showSign />
325
+ </div>
326
+ </CardContent>
327
+ </Card>
328
+ </div>
329
+
330
+ <Card>
331
+ <CardHeader>
332
+ <CardTitle>{t('chart.title')}</CardTitle>
333
+ <CardDescription>{t('chart.description')}</CardDescription>
334
+ </CardHeader>
335
+ <CardContent>
336
+ <ResponsiveContainer width="100%" height={320}>
337
+ <AreaChart data={chartData}>
338
+ <CartesianGrid strokeDasharray="3 3" className="stroke-muted" />
339
+ <XAxis dataKey="label" tick={{ fontSize: 12 }} />
340
+ <YAxis
341
+ tick={{ fontSize: 12 }}
342
+ tickFormatter={(value) => `${(value / 1000).toFixed(0)}k`}
343
+ />
344
+ <Tooltip
345
+ formatter={(value: number) =>
346
+ new Intl.NumberFormat('pt-BR', {
347
+ style: 'currency',
348
+ currency: 'BRL',
349
+ }).format(value)
350
+ }
351
+ contentStyle={{
352
+ backgroundColor: 'hsl(var(--background))',
353
+ border: '1px solid hsl(var(--border))',
354
+ borderRadius: '8px',
355
+ }}
356
+ />
357
+ <Area
358
+ type="monotone"
359
+ dataKey="saldoAcumulado"
360
+ name={t('chart.balanceLabel')}
361
+ stroke="hsl(var(--primary))"
362
+ fill="hsl(var(--primary))"
363
+ fillOpacity={0.2}
364
+ />
365
+ </AreaChart>
366
+ </ResponsiveContainer>
367
+ </CardContent>
368
+ </Card>
369
+
370
+ <Card>
371
+ <CardHeader>
372
+ <CardTitle>{t('table.title')}</CardTitle>
373
+ <CardDescription>{t('table.description')}</CardDescription>
374
+ </CardHeader>
375
+ <CardContent>
376
+ <Table>
377
+ <TableHeader>
378
+ <TableRow>
379
+ <TableHead>{t('table.headers.date')}</TableHead>
380
+ <TableHead>{t('table.headers.description')}</TableHead>
381
+ <TableHead>{t('table.headers.type')}</TableHead>
382
+ <TableHead className="text-right">
383
+ {t('table.headers.value')}
384
+ </TableHead>
385
+ </TableRow>
386
+ </TableHeader>
387
+ <TableBody>
388
+ {latestMovements.length === 0 ? (
389
+ <TableRow>
390
+ <TableCell
391
+ colSpan={4}
392
+ className="text-muted-foreground text-center"
393
+ >
394
+ {t('table.empty')}
395
+ </TableCell>
396
+ </TableRow>
397
+ ) : (
398
+ latestMovements.map((mov: any) => (
399
+ <TableRow key={mov.id}>
400
+ <TableCell>{formatarData(mov.data)}</TableCell>
401
+ <TableCell
402
+ className="max-w-[320px] truncate"
403
+ title={mov.descricao}
404
+ >
405
+ {mov.descricao}
406
+ </TableCell>
407
+ <TableCell>
408
+ {mov.tipo === 'entrada' ? (
409
+ <Badge variant="secondary" className="text-green-700">
410
+ {t('types.inflow')}
411
+ </Badge>
412
+ ) : (
413
+ <Badge variant="secondary" className="text-red-700">
414
+ {t('types.outflow')}
415
+ </Badge>
416
+ )}
417
+ </TableCell>
418
+ <TableCell
419
+ className={`text-right font-medium ${mov.tipo === 'entrada' ? 'text-green-600' : 'text-red-600'}`}
420
+ >
421
+ <Money value={mov.valor} />
422
+ </TableCell>
423
+ </TableRow>
424
+ ))
425
+ )}
426
+ </TableBody>
427
+ </Table>
428
+ </CardContent>
429
+ </Card>
430
+ </Page>
431
+ );
432
+ }
@@ -1310,6 +1310,188 @@
1310
1310
  "total": "Total"
1311
1311
  }
1312
1312
  },
1313
+ "ActualVsForecastPage": {
1314
+ "header": {
1315
+ "title": "Actual vs Forecast",
1316
+ "description": "Compare realized performance against financial projections"
1317
+ },
1318
+ "breadcrumbs": {
1319
+ "home": "Home",
1320
+ "finance": "Finance",
1321
+ "current": "Actual vs Forecast"
1322
+ },
1323
+ "actions": {
1324
+ "export": "Export"
1325
+ },
1326
+ "filters": {
1327
+ "horizon": "Horizon",
1328
+ "scenario": "Scenario",
1329
+ "days30": "30 days",
1330
+ "days60": "60 days",
1331
+ "days90": "90 days",
1332
+ "days180": "180 days",
1333
+ "days365": "365 days"
1334
+ },
1335
+ "scenarios": {
1336
+ "base": "Base",
1337
+ "pessimistic": "Pessimistic",
1338
+ "optimistic": "Optimistic"
1339
+ },
1340
+ "messages": {
1341
+ "updating": "Updating data..."
1342
+ },
1343
+ "cards": {
1344
+ "totalForecast": "Total Forecast",
1345
+ "totalActual": "Total Actual",
1346
+ "totalDeviation": "Total Deviation",
1347
+ "accuracy": "Accuracy"
1348
+ },
1349
+ "chart": {
1350
+ "title": "Actual vs Forecast Trend",
1351
+ "description": "Time-series comparison between forecast and actual values",
1352
+ "forecastLabel": "Forecast",
1353
+ "actualLabel": "Actual"
1354
+ },
1355
+ "table": {
1356
+ "title": "Period breakdown",
1357
+ "description": "Track deviation for each projection point",
1358
+ "headers": {
1359
+ "date": "Date",
1360
+ "forecast": "Forecast",
1361
+ "actual": "Actual",
1362
+ "deviation": "Deviation",
1363
+ "percentDeviation": "% Deviation",
1364
+ "status": "Status"
1365
+ },
1366
+ "empty": "No data for the selected period."
1367
+ },
1368
+ "status": {
1369
+ "above": "Above",
1370
+ "below": "Below",
1371
+ "onTarget": "On target"
1372
+ }
1373
+ },
1374
+ "CashPositionReportPage": {
1375
+ "header": {
1376
+ "title": "Cash Position",
1377
+ "description": "Track balances and cash movements by account"
1378
+ },
1379
+ "breadcrumbs": {
1380
+ "home": "Home",
1381
+ "finance": "Finance",
1382
+ "current": "Cash Position"
1383
+ },
1384
+ "actions": {
1385
+ "export": "Export"
1386
+ },
1387
+ "filters": {
1388
+ "horizon": "Horizon",
1389
+ "scenario": "Scenario",
1390
+ "bankAccount": "Bank account",
1391
+ "allBankAccounts": "All accounts",
1392
+ "days30": "30 days",
1393
+ "days60": "60 days",
1394
+ "days90": "90 days",
1395
+ "days180": "180 days",
1396
+ "days365": "365 days"
1397
+ },
1398
+ "scenarios": {
1399
+ "base": "Base",
1400
+ "pessimistic": "Pessimistic",
1401
+ "optimistic": "Optimistic"
1402
+ },
1403
+ "messages": {
1404
+ "updating": "Updating data..."
1405
+ },
1406
+ "cards": {
1407
+ "currentBalance": "Current Balance",
1408
+ "reconciledBalance": "Reconciled Balance",
1409
+ "inflows": "Inflows",
1410
+ "outflows": "Outflows",
1411
+ "netMovement": "Net Movement"
1412
+ },
1413
+ "chart": {
1414
+ "title": "Balance evolution",
1415
+ "description": "Accumulated balance variation in the selected period",
1416
+ "balanceLabel": "Accumulated balance"
1417
+ },
1418
+ "table": {
1419
+ "title": "Latest movements",
1420
+ "description": "Most recent entries according to applied filters",
1421
+ "headers": {
1422
+ "date": "Date",
1423
+ "description": "Description",
1424
+ "type": "Type",
1425
+ "value": "Value"
1426
+ },
1427
+ "empty": "No movements found."
1428
+ },
1429
+ "types": {
1430
+ "inflow": "Inflow",
1431
+ "outflow": "Outflow"
1432
+ }
1433
+ },
1434
+ "AgingDefaultReportPage": {
1435
+ "header": {
1436
+ "title": "Default Aging",
1437
+ "description": "View receivables delinquency by due range"
1438
+ },
1439
+ "breadcrumbs": {
1440
+ "home": "Home",
1441
+ "finance": "Finance",
1442
+ "current": "Default Aging"
1443
+ },
1444
+ "filters": {
1445
+ "horizon": "Horizon",
1446
+ "scenario": "Scenario",
1447
+ "days30": "30 days",
1448
+ "days60": "60 days",
1449
+ "days90": "90 days",
1450
+ "days180": "180 days",
1451
+ "days365": "365 days"
1452
+ },
1453
+ "scenarios": {
1454
+ "base": "Base",
1455
+ "pessimistic": "Pessimistic",
1456
+ "optimistic": "Optimistic"
1457
+ },
1458
+ "messages": {
1459
+ "updating": "Updating data..."
1460
+ },
1461
+ "cards": {
1462
+ "totalDefault": "Total Default",
1463
+ "lateClients": "Late clients",
1464
+ "over90Days": "Over 90 days",
1465
+ "defaultRate": "Default Rate"
1466
+ },
1467
+ "chart": {
1468
+ "title": "Distribution by range",
1469
+ "description": "Default concentration by delay range",
1470
+ "range0to30": "0–30 days",
1471
+ "range31to60": "31–60 days",
1472
+ "range61to90": "61–90 days",
1473
+ "range90plus": "> 90 days"
1474
+ },
1475
+ "table": {
1476
+ "title": "Client breakdown",
1477
+ "description": "Default aging by client",
1478
+ "headers": {
1479
+ "client": "Client",
1480
+ "range0to30": "0–30 days",
1481
+ "range31to60": "31–60 days",
1482
+ "range61to90": "61–90 days",
1483
+ "range90plus": "> 90 days",
1484
+ "total": "Total",
1485
+ "risk": "Risk"
1486
+ },
1487
+ "empty": "No default data found."
1488
+ },
1489
+ "risk": {
1490
+ "high": "High",
1491
+ "medium": "Medium",
1492
+ "low": "Low"
1493
+ }
1494
+ },
1313
1495
  "ReceivablesCalendarPage": {
1314
1496
  "status": {
1315
1497
  "confirmado": "Confirmed",