@hed-hog/finance 0.0.3 → 0.0.224

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.
@@ -0,0 +1,321 @@
1
+ 'use client';
2
+
3
+ import { Badge } from '@/components/ui/badge';
4
+ import { Button } from '@/components/ui/button';
5
+ import {
6
+ Card,
7
+ CardContent,
8
+ CardDescription,
9
+ CardHeader,
10
+ CardTitle,
11
+ } from '@/components/ui/card';
12
+ import { Label } from '@/components/ui/label';
13
+ import { Money } from '@/components/ui/money';
14
+ import { PageHeader } from '@/components/ui/page-header';
15
+ import { Slider } from '@/components/ui/slider';
16
+ import {
17
+ Check,
18
+ Lightbulb,
19
+ Target,
20
+ TrendingDown,
21
+ TrendingUp,
22
+ } from 'lucide-react';
23
+ import { useState } from 'react';
24
+ import { formatarMoeda } from '../../_lib/formatters';
25
+ import { useFinanceData } from '../../_lib/use-finance-data';
26
+
27
+ export default function CenariosPage() {
28
+ const { data } = useFinanceData();
29
+ const { cenarios, kpis } = data;
30
+
31
+ const [cenarioAtivo, setCenarioAtivo] = useState('1');
32
+ const [premissas, setPremissas] = useState({
33
+ atrasoMedio: 5,
34
+ taxaInadimplencia: 3,
35
+ crescimentoReceita: 5,
36
+ });
37
+
38
+ const cenarioSelecionado = cenarios.find((c) => c.id === cenarioAtivo);
39
+
40
+ // Simulação de impacto baseada nas premissas
41
+ const impactoSaldo =
42
+ kpis.saldoCaixa * (1 + premissas.crescimentoReceita / 100);
43
+ const impactoInadimplencia =
44
+ kpis.inadimplencia * (1 + premissas.taxaInadimplencia / 10);
45
+
46
+ return (
47
+ <div className="space-y-6">
48
+ <PageHeader
49
+ title="Cenários"
50
+ description="Simule diferentes cenários financeiros"
51
+ breadcrumbs={[
52
+ { label: 'Planejamento', href: '/finance/planning/cash-flow-forecast' },
53
+ { label: 'Cenários' },
54
+ ]}
55
+ />
56
+
57
+ <div className="grid gap-4 md:grid-cols-3">
58
+ {cenarios.map((cenario) => {
59
+ const isAtivo = cenario.id === cenarioAtivo;
60
+ const Icon =
61
+ cenario.nome === 'Otimista'
62
+ ? TrendingUp
63
+ : cenario.nome === 'Pessimista'
64
+ ? TrendingDown
65
+ : Target;
66
+
67
+ return (
68
+ <Card
69
+ key={cenario.id}
70
+ className={`cursor-pointer transition-all ${
71
+ isAtivo
72
+ ? 'border-primary ring-2 ring-primary/20'
73
+ : 'hover:border-muted-foreground/50'
74
+ }`}
75
+ onClick={() => {
76
+ setCenarioAtivo(cenario.id);
77
+ setPremissas({
78
+ atrasoMedio: cenario.atrasoMedio,
79
+ taxaInadimplencia: cenario.taxaInadimplencia,
80
+ crescimentoReceita: cenario.crescimentoReceita,
81
+ });
82
+ }}
83
+ >
84
+ <CardHeader>
85
+ <div className="flex items-center justify-between">
86
+ <div className="flex items-center gap-3">
87
+ <div
88
+ className={`flex h-10 w-10 items-center justify-center rounded-lg ${
89
+ cenario.nome === 'Otimista'
90
+ ? 'bg-green-100'
91
+ : cenario.nome === 'Pessimista'
92
+ ? 'bg-red-100'
93
+ : 'bg-blue-100'
94
+ }`}
95
+ >
96
+ <Icon
97
+ className={`h-5 w-5 ${
98
+ cenario.nome === 'Otimista'
99
+ ? 'text-green-600'
100
+ : cenario.nome === 'Pessimista'
101
+ ? 'text-red-600'
102
+ : 'text-blue-600'
103
+ }`}
104
+ />
105
+ </div>
106
+ <div>
107
+ <CardTitle className="text-base">
108
+ {cenario.nome}
109
+ </CardTitle>
110
+ <CardDescription>{cenario.descricao}</CardDescription>
111
+ </div>
112
+ </div>
113
+ {isAtivo && (
114
+ <Badge className="bg-primary text-primary-foreground">
115
+ <Check className="mr-1 h-3 w-3" />
116
+ Ativo
117
+ </Badge>
118
+ )}
119
+ </div>
120
+ </CardHeader>
121
+ <CardContent>
122
+ <div className="grid grid-cols-3 gap-4 text-center">
123
+ <div>
124
+ <p className="text-xs text-muted-foreground">Atraso</p>
125
+ <p className="font-semibold">{cenario.atrasoMedio} dias</p>
126
+ </div>
127
+ <div>
128
+ <p className="text-xs text-muted-foreground">
129
+ Inadimplência
130
+ </p>
131
+ <p className="font-semibold">
132
+ {cenario.taxaInadimplencia}%
133
+ </p>
134
+ </div>
135
+ <div>
136
+ <p className="text-xs text-muted-foreground">Crescimento</p>
137
+ <p
138
+ className={`font-semibold ${
139
+ cenario.crescimentoReceita >= 0
140
+ ? 'text-green-600'
141
+ : 'text-red-600'
142
+ }`}
143
+ >
144
+ {cenario.crescimentoReceita > 0 && '+'}
145
+ {cenario.crescimentoReceita}%
146
+ </p>
147
+ </div>
148
+ </div>
149
+ </CardContent>
150
+ </Card>
151
+ );
152
+ })}
153
+ </div>
154
+
155
+ <div className="grid gap-6 lg:grid-cols-2">
156
+ <Card>
157
+ <CardHeader>
158
+ <CardTitle className="flex items-center gap-2">
159
+ <Lightbulb className="h-4 w-4" />
160
+ Editor de Premissas
161
+ </CardTitle>
162
+ <CardDescription>
163
+ Ajuste as premissas do cenário {cenarioSelecionado?.nome}
164
+ </CardDescription>
165
+ </CardHeader>
166
+ <CardContent className="space-y-6">
167
+ <div className="space-y-4">
168
+ <div className="space-y-2">
169
+ <div className="flex items-center justify-between">
170
+ <Label>Atraso Médio de Recebimento</Label>
171
+ <span className="text-sm font-medium">
172
+ {premissas.atrasoMedio} dias
173
+ </span>
174
+ </div>
175
+ <Slider
176
+ value={[premissas.atrasoMedio]}
177
+ onValueChange={([value]) =>
178
+ setPremissas({
179
+ ...premissas,
180
+ atrasoMedio: value ?? premissas.atrasoMedio,
181
+ })
182
+ }
183
+ max={30}
184
+ step={1}
185
+ />
186
+ </div>
187
+
188
+ <div className="space-y-2">
189
+ <div className="flex items-center justify-between">
190
+ <Label>Taxa de Inadimplência</Label>
191
+ <span className="text-sm font-medium">
192
+ {premissas.taxaInadimplencia}%
193
+ </span>
194
+ </div>
195
+ <Slider
196
+ value={[premissas.taxaInadimplencia]}
197
+ onValueChange={([value]) =>
198
+ setPremissas({
199
+ ...premissas,
200
+ taxaInadimplencia: value ?? premissas.taxaInadimplencia,
201
+ })
202
+ }
203
+ max={15}
204
+ step={0.5}
205
+ />
206
+ </div>
207
+
208
+ <div className="space-y-2">
209
+ <div className="flex items-center justify-between">
210
+ <Label>Crescimento de Receita</Label>
211
+ <span
212
+ className={`text-sm font-medium ${
213
+ premissas.crescimentoReceita >= 0
214
+ ? 'text-green-600'
215
+ : 'text-red-600'
216
+ }`}
217
+ >
218
+ {premissas.crescimentoReceita > 0 && '+'}
219
+ {premissas.crescimentoReceita}%
220
+ </span>
221
+ </div>
222
+ <Slider
223
+ value={[premissas.crescimentoReceita + 10]} // Offset para permitir valores negativos
224
+ onValueChange={([value]) =>
225
+ setPremissas({
226
+ ...premissas,
227
+ crescimentoReceita:
228
+ (value ?? premissas.crescimentoReceita + 10) - 10,
229
+ })
230
+ }
231
+ max={25}
232
+ step={1}
233
+ />
234
+ </div>
235
+ </div>
236
+
237
+ <div className="flex justify-end gap-2 pt-4">
238
+ <Button
239
+ variant="outline"
240
+ onClick={() => {
241
+ if (cenarioSelecionado) {
242
+ setPremissas({
243
+ atrasoMedio: cenarioSelecionado.atrasoMedio,
244
+ taxaInadimplencia: cenarioSelecionado.taxaInadimplencia,
245
+ crescimentoReceita: cenarioSelecionado.crescimentoReceita,
246
+ });
247
+ }
248
+ }}
249
+ >
250
+ Restaurar
251
+ </Button>
252
+ <Button>Aplicar Cenário</Button>
253
+ </div>
254
+ </CardContent>
255
+ </Card>
256
+
257
+ <Card>
258
+ <CardHeader>
259
+ <CardTitle>Impacto Projetado</CardTitle>
260
+ <CardDescription>Resultado das premissas aplicadas</CardDescription>
261
+ </CardHeader>
262
+ <CardContent>
263
+ <div className="space-y-6">
264
+ <div className="rounded-lg border p-4">
265
+ <p className="text-sm text-muted-foreground">
266
+ Saldo Projetado (30 dias)
267
+ </p>
268
+ <p className="text-3xl font-bold">
269
+ <Money value={impactoSaldo} />
270
+ </p>
271
+ <p
272
+ className={`text-sm ${
273
+ impactoSaldo >= kpis.saldoCaixa
274
+ ? 'text-green-600'
275
+ : 'text-red-600'
276
+ }`}
277
+ >
278
+ {impactoSaldo >= kpis.saldoCaixa ? '+' : ''}
279
+ {formatarMoeda(impactoSaldo - kpis.saldoCaixa)} vs atual
280
+ </p>
281
+ </div>
282
+
283
+ <div className="rounded-lg border p-4">
284
+ <p className="text-sm text-muted-foreground">
285
+ Inadimplência Projetada
286
+ </p>
287
+ <p className="text-3xl font-bold text-red-600">
288
+ <Money value={impactoInadimplencia} />
289
+ </p>
290
+ <p className="text-sm text-muted-foreground">
291
+ {formatarMoeda(impactoInadimplencia - kpis.inadimplencia)} vs
292
+ atual
293
+ </p>
294
+ </div>
295
+
296
+ <div className="rounded-lg border p-4">
297
+ <p className="text-sm text-muted-foreground">
298
+ Fluxo de Caixa Previsto
299
+ </p>
300
+ <p
301
+ className={`text-3xl font-bold ${
302
+ premissas.crescimentoReceita >= 0
303
+ ? 'text-green-600'
304
+ : 'text-red-600'
305
+ }`}
306
+ >
307
+ {premissas.crescimentoReceita >= 0 ? 'Positivo' : 'Negativo'}
308
+ </p>
309
+ <p className="text-sm text-muted-foreground">
310
+ Com crescimento de {premissas.crescimentoReceita}%
311
+ </p>
312
+ </div>
313
+ </div>
314
+ </CardContent>
315
+ </Card>
316
+ </div>
317
+ </div>
318
+ );
319
+ }
320
+
321
+
@@ -0,0 +1,169 @@
1
+ -- Regras de integridade do módulo financeiro (PostgreSQL)
2
+ -- Script idempotente: pode ser executado mais de uma vez.
3
+
4
+ -- 1) CHECKs simples de domínio
5
+ DO $$
6
+ BEGIN
7
+ IF NOT EXISTS (
8
+ SELECT 1
9
+ FROM pg_constraint
10
+ WHERE conname = 'chk_financial_installment_open_amount_non_negative'
11
+ ) THEN
12
+ ALTER TABLE financial_installment
13
+ ADD CONSTRAINT chk_financial_installment_open_amount_non_negative
14
+ CHECK (open_amount_cents >= 0);
15
+ END IF;
16
+
17
+ IF NOT EXISTS (
18
+ SELECT 1
19
+ FROM pg_constraint
20
+ WHERE conname = 'chk_financial_installment_open_amount_lte_amount'
21
+ ) THEN
22
+ ALTER TABLE financial_installment
23
+ ADD CONSTRAINT chk_financial_installment_open_amount_lte_amount
24
+ CHECK (open_amount_cents <= amount_cents);
25
+ END IF;
26
+
27
+ IF NOT EXISTS (
28
+ SELECT 1
29
+ FROM pg_constraint
30
+ WHERE conname = 'chk_period_close_period_end_gte_start'
31
+ ) THEN
32
+ ALTER TABLE period_close
33
+ ADD CONSTRAINT chk_period_close_period_end_gte_start
34
+ CHECK (period_end >= period_start);
35
+ END IF;
36
+ END $$;
37
+
38
+ -- 2) Unicidade em tabelas de vínculo N:N
39
+ DO $$
40
+ BEGIN
41
+ IF NOT EXISTS (
42
+ SELECT 1
43
+ FROM pg_constraint
44
+ WHERE conname = 'uq_financial_installment_tag_installment_tag'
45
+ ) THEN
46
+ ALTER TABLE financial_installment_tag
47
+ ADD CONSTRAINT uq_financial_installment_tag_installment_tag
48
+ UNIQUE (installment_id, tag_id);
49
+ END IF;
50
+
51
+ IF NOT EXISTS (
52
+ SELECT 1
53
+ FROM pg_constraint
54
+ WHERE conname = 'uq_installment_allocation_installment_cost_center'
55
+ ) THEN
56
+ ALTER TABLE installment_allocation
57
+ ADD CONSTRAINT uq_installment_allocation_installment_cost_center
58
+ UNIQUE (installment_id, cost_center_id);
59
+ END IF;
60
+
61
+ IF NOT EXISTS (
62
+ SELECT 1
63
+ FROM pg_constraint
64
+ WHERE conname = 'uq_settlement_allocation_settlement_installment'
65
+ ) THEN
66
+ ALTER TABLE settlement_allocation
67
+ ADD CONSTRAINT uq_settlement_allocation_settlement_installment
68
+ UNIQUE (settlement_id, installment_id);
69
+ END IF;
70
+ END $$;
71
+
72
+ -- 3) Política de alocação por parcela (somatórios não podem exceder amount_cents)
73
+ -- settlement_allocation: soma(allocated_amount_cents) por installment_id <= financial_installment.amount_cents
74
+
75
+ CREATE OR REPLACE FUNCTION fn_check_settlement_allocation_total_per_installment()
76
+ RETURNS TRIGGER
77
+ LANGUAGE plpgsql
78
+ AS $$
79
+ DECLARE
80
+ target_installment_id financial_installment.id%TYPE;
81
+ installment_amount_cents integer;
82
+ allocated_total_cents bigint;
83
+ BEGIN
84
+ target_installment_id := COALESCE(NEW.installment_id, OLD.installment_id);
85
+
86
+ SELECT fi.amount_cents
87
+ INTO installment_amount_cents
88
+ FROM financial_installment fi
89
+ WHERE fi.id = target_installment_id;
90
+
91
+ IF installment_amount_cents IS NULL THEN
92
+ RETURN NULL;
93
+ END IF;
94
+
95
+ SELECT COALESCE(SUM(sa.allocated_amount_cents), 0)
96
+ INTO allocated_total_cents
97
+ FROM settlement_allocation sa
98
+ WHERE sa.installment_id = target_installment_id;
99
+
100
+ IF allocated_total_cents > installment_amount_cents THEN
101
+ RAISE EXCEPTION
102
+ 'Soma de settlement_allocation (%) excede amount_cents (%) para installment_id=%',
103
+ allocated_total_cents,
104
+ installment_amount_cents,
105
+ target_installment_id;
106
+ END IF;
107
+
108
+ RETURN NULL;
109
+ END;
110
+ $$;
111
+
112
+ DROP TRIGGER IF EXISTS trg_check_settlement_allocation_total_per_installment
113
+ ON settlement_allocation;
114
+
115
+ CREATE CONSTRAINT TRIGGER trg_check_settlement_allocation_total_per_installment
116
+ AFTER INSERT OR UPDATE OR DELETE
117
+ ON settlement_allocation
118
+ DEFERRABLE INITIALLY DEFERRED
119
+ FOR EACH ROW
120
+ EXECUTE FUNCTION fn_check_settlement_allocation_total_per_installment();
121
+
122
+ -- installment_allocation: soma(allocated_amount_cents) por installment_id <= financial_installment.amount_cents
123
+
124
+ CREATE OR REPLACE FUNCTION fn_check_installment_allocation_total_per_installment()
125
+ RETURNS TRIGGER
126
+ LANGUAGE plpgsql
127
+ AS $$
128
+ DECLARE
129
+ target_installment_id financial_installment.id%TYPE;
130
+ installment_amount_cents integer;
131
+ allocated_total_cents bigint;
132
+ BEGIN
133
+ target_installment_id := COALESCE(NEW.installment_id, OLD.installment_id);
134
+
135
+ SELECT fi.amount_cents
136
+ INTO installment_amount_cents
137
+ FROM financial_installment fi
138
+ WHERE fi.id = target_installment_id;
139
+
140
+ IF installment_amount_cents IS NULL THEN
141
+ RETURN NULL;
142
+ END IF;
143
+
144
+ SELECT COALESCE(SUM(ia.allocated_amount_cents), 0)
145
+ INTO allocated_total_cents
146
+ FROM installment_allocation ia
147
+ WHERE ia.installment_id = target_installment_id;
148
+
149
+ IF allocated_total_cents > installment_amount_cents THEN
150
+ RAISE EXCEPTION
151
+ 'Soma de installment_allocation (%) excede amount_cents (%) para installment_id=%',
152
+ allocated_total_cents,
153
+ installment_amount_cents,
154
+ target_installment_id;
155
+ END IF;
156
+
157
+ RETURN NULL;
158
+ END;
159
+ $$;
160
+
161
+ DROP TRIGGER IF EXISTS trg_check_installment_allocation_total_per_installment
162
+ ON installment_allocation;
163
+
164
+ CREATE CONSTRAINT TRIGGER trg_check_installment_allocation_total_per_installment
165
+ AFTER INSERT OR UPDATE OR DELETE
166
+ ON installment_allocation
167
+ DEFERRABLE INITIALLY DEFERRED
168
+ FOR EACH ROW
169
+ EXECUTE FUNCTION fn_check_installment_allocation_total_per_installment();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hed-hog/finance",
3
- "version": "0.0.3",
3
+ "version": "0.0.224",
4
4
  "main": "dist/index.js",
5
5
  "types": "dist/index.d.ts",
6
6
  "dependencies": {
@@ -9,12 +9,12 @@
9
9
  "@nestjs/core": "^11",
10
10
  "@nestjs/jwt": "^11",
11
11
  "@nestjs/mapped-types": "*",
12
+ "@hed-hog/api-locale": "0.0.11",
12
13
  "@hed-hog/api-prisma": "0.0.4",
13
14
  "@hed-hog/api-pagination": "0.0.5",
14
- "@hed-hog/api-locale": "0.0.11",
15
15
  "@hed-hog/api": "0.0.3",
16
- "@hed-hog/tag": "0.0.222",
17
- "@hed-hog/contact": "0.0.222",
16
+ "@hed-hog/tag": "0.0.223",
17
+ "@hed-hog/contact": "0.0.223",
18
18
  "@hed-hog/api-types": "0.0.1"
19
19
  },
20
20
  "exports": {