@hed-hog/finance 0.0.278 → 0.0.285
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.
- package/README.md +65 -29
- package/dist/dto/finance-report-query.dto.d.ts +16 -0
- package/dist/dto/finance-report-query.dto.d.ts.map +1 -0
- package/dist/dto/finance-report-query.dto.js +59 -0
- package/dist/dto/finance-report-query.dto.js.map +1 -0
- package/dist/finance-reports.controller.d.ts +71 -0
- package/dist/finance-reports.controller.d.ts.map +1 -0
- package/dist/finance-reports.controller.js +61 -0
- package/dist/finance-reports.controller.js.map +1 -0
- package/dist/finance.module.d.ts.map +1 -1
- package/dist/finance.module.js +2 -0
- package/dist/finance.module.js.map +1 -1
- package/dist/finance.service.d.ts +93 -0
- package/dist/finance.service.d.ts.map +1 -1
- package/dist/finance.service.js +456 -0
- package/dist/finance.service.js.map +1 -1
- package/dist/index.d.ts +1 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -0
- package/dist/index.js.map +1 -1
- package/hedhog/data/menu.yaml +46 -0
- package/hedhog/data/route.yaml +27 -0
- package/hedhog/frontend/app/accounts-payable/installments/page.tsx.ejs +158 -125
- package/hedhog/frontend/app/accounts-receivable/collections-default/page.tsx.ejs +102 -88
- package/hedhog/frontend/app/cash-and-banks/statements/page.tsx.ejs +113 -89
- package/hedhog/frontend/app/reports/_lib/report-aggregations.ts.ejs +275 -0
- package/hedhog/frontend/app/reports/_lib/report-mocks.ts.ejs +186 -0
- package/hedhog/frontend/app/reports/_lib/use-finance-reports.ts.ejs +233 -0
- package/hedhog/frontend/app/reports/overview-results/page.tsx.ejs +355 -0
- package/hedhog/frontend/app/reports/top-customers/page.tsx.ejs +427 -0
- package/hedhog/frontend/app/reports/top-operational-expenses/page.tsx.ejs +433 -0
- package/hedhog/frontend/messages/en.json +179 -0
- package/hedhog/frontend/messages/pt.json +179 -0
- package/package.json +7 -7
- package/src/dto/finance-report-query.dto.ts +49 -0
- package/src/finance-reports.controller.ts +28 -0
- package/src/finance.module.ts +2 -0
- package/src/finance.service.ts +645 -10
- package/src/index.ts +1 -0
|
@@ -0,0 +1,275 @@
|
|
|
1
|
+
import {
|
|
2
|
+
customerRevenueMock,
|
|
3
|
+
financialResultsMock,
|
|
4
|
+
operationalExpensesMock,
|
|
5
|
+
type GroupBy,
|
|
6
|
+
} from './report-mocks';
|
|
7
|
+
|
|
8
|
+
const dateFormatter = new Intl.DateTimeFormat('pt-BR');
|
|
9
|
+
|
|
10
|
+
function toDate(value: string) {
|
|
11
|
+
return new Date(`${value}T00:00:00`);
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
function isWithinRange(date: string, from: string, to: string) {
|
|
15
|
+
return date >= from && date <= to;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function startOfWeek(date: Date) {
|
|
19
|
+
const current = new Date(date);
|
|
20
|
+
const day = current.getDay();
|
|
21
|
+
const diff = day === 0 ? -6 : 1 - day;
|
|
22
|
+
current.setDate(current.getDate() + diff);
|
|
23
|
+
current.setHours(0, 0, 0, 0);
|
|
24
|
+
|
|
25
|
+
return current;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function getIsoWeek(date: Date) {
|
|
29
|
+
const current = new Date(
|
|
30
|
+
Date.UTC(date.getFullYear(), date.getMonth(), date.getDate())
|
|
31
|
+
);
|
|
32
|
+
const dayNum = current.getUTCDay() || 7;
|
|
33
|
+
current.setUTCDate(current.getUTCDate() + 4 - dayNum);
|
|
34
|
+
const yearStart = new Date(Date.UTC(current.getUTCFullYear(), 0, 1));
|
|
35
|
+
|
|
36
|
+
return Math.ceil(
|
|
37
|
+
((current.getTime() - yearStart.getTime()) / 86400000 + 1) / 7
|
|
38
|
+
);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function getBucketKey(date: string, groupBy: GroupBy) {
|
|
42
|
+
const d = toDate(date);
|
|
43
|
+
|
|
44
|
+
if (groupBy === 'day') {
|
|
45
|
+
return date;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
if (groupBy === 'week') {
|
|
49
|
+
const weekStart = startOfWeek(d);
|
|
50
|
+
const isoWeek = getIsoWeek(weekStart);
|
|
51
|
+
return `${weekStart.getFullYear()}-W${String(isoWeek).padStart(2, '0')}`;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
if (groupBy === 'month') {
|
|
55
|
+
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}`;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
return String(d.getFullYear());
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export function formatBucketLabel(bucket: string, groupBy: GroupBy) {
|
|
62
|
+
if (groupBy === 'day') {
|
|
63
|
+
return dateFormatter.format(toDate(bucket));
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
if (groupBy === 'week') {
|
|
67
|
+
const [year, week] = bucket.split('-W');
|
|
68
|
+
return `Sem ${Number(week)}/${year}`;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
if (groupBy === 'month') {
|
|
72
|
+
const [year, month] = bucket.split('-');
|
|
73
|
+
return `${month}/${year}`;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
return bucket;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function sortBuckets(a: string, b: string, groupBy: GroupBy) {
|
|
80
|
+
if (groupBy === 'year' || groupBy === 'month' || groupBy === 'day') {
|
|
81
|
+
return a.localeCompare(b);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
const [aYear, aWeek] = a.split('-W');
|
|
85
|
+
const [bYear, bWeek] = b.split('-W');
|
|
86
|
+
const yearDiff = Number(aYear) - Number(bYear);
|
|
87
|
+
|
|
88
|
+
if (yearDiff !== 0) {
|
|
89
|
+
return yearDiff;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
return Number(aWeek) - Number(bWeek);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
export function aggregateOverview(params: {
|
|
96
|
+
from: string;
|
|
97
|
+
to: string;
|
|
98
|
+
groupBy: GroupBy;
|
|
99
|
+
}) {
|
|
100
|
+
const { from, to, groupBy } = params;
|
|
101
|
+
const grouped = new Map<
|
|
102
|
+
string,
|
|
103
|
+
{
|
|
104
|
+
period: string;
|
|
105
|
+
label: string;
|
|
106
|
+
faturamento: number;
|
|
107
|
+
despesasEmprestimos: number;
|
|
108
|
+
diferenca: number;
|
|
109
|
+
aporteInvestidor: number;
|
|
110
|
+
emprestimoBanco: number;
|
|
111
|
+
despesas: number;
|
|
112
|
+
}
|
|
113
|
+
>();
|
|
114
|
+
|
|
115
|
+
for (const item of financialResultsMock) {
|
|
116
|
+
if (!isWithinRange(item.date, from, to)) {
|
|
117
|
+
continue;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
const bucket = getBucketKey(item.date, groupBy);
|
|
121
|
+
const current = grouped.get(bucket) || {
|
|
122
|
+
period: bucket,
|
|
123
|
+
label: formatBucketLabel(bucket, groupBy),
|
|
124
|
+
faturamento: 0,
|
|
125
|
+
despesasEmprestimos: 0,
|
|
126
|
+
diferenca: 0,
|
|
127
|
+
aporteInvestidor: 0,
|
|
128
|
+
emprestimoBanco: 0,
|
|
129
|
+
despesas: 0,
|
|
130
|
+
};
|
|
131
|
+
|
|
132
|
+
current.faturamento += item.faturamento;
|
|
133
|
+
current.aporteInvestidor += item.aporteInvestidor;
|
|
134
|
+
current.emprestimoBanco += item.emprestimoBanco;
|
|
135
|
+
current.despesas += item.despesas;
|
|
136
|
+
current.despesasEmprestimos += item.despesas + item.emprestimoBanco;
|
|
137
|
+
current.diferenca = current.faturamento - current.despesasEmprestimos;
|
|
138
|
+
|
|
139
|
+
grouped.set(bucket, current);
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
return Array.from(grouped.values()).sort((a, b) =>
|
|
143
|
+
sortBuckets(a.period, b.period, groupBy)
|
|
144
|
+
);
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
export function aggregateTopCustomers(params: {
|
|
148
|
+
from: string;
|
|
149
|
+
to: string;
|
|
150
|
+
groupBy: GroupBy;
|
|
151
|
+
topN?: number;
|
|
152
|
+
}) {
|
|
153
|
+
const { from, to, groupBy, topN = 20 } = params;
|
|
154
|
+
|
|
155
|
+
const byCustomer = new Map<string, number>();
|
|
156
|
+
const byBucket = new Map<string, number>();
|
|
157
|
+
|
|
158
|
+
for (const item of customerRevenueMock) {
|
|
159
|
+
if (!isWithinRange(item.date, from, to)) {
|
|
160
|
+
continue;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
const current = byCustomer.get(item.customer) || 0;
|
|
164
|
+
byCustomer.set(item.customer, current + item.value);
|
|
165
|
+
|
|
166
|
+
const bucket = getBucketKey(item.date, groupBy);
|
|
167
|
+
const currentBucket = byBucket.get(bucket) || 0;
|
|
168
|
+
byBucket.set(bucket, currentBucket + item.value);
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
const sorted = Array.from(byCustomer.entries())
|
|
172
|
+
.map(([customer, value]) => ({ customer, value }))
|
|
173
|
+
.sort((a, b) => b.value - a.value);
|
|
174
|
+
|
|
175
|
+
const topCustomers = sorted.slice(0, topN);
|
|
176
|
+
const total = sorted.reduce((acc, row) => acc + row.value, 0);
|
|
177
|
+
const top5 = topCustomers
|
|
178
|
+
.slice(0, 5)
|
|
179
|
+
.reduce((acc, row) => acc + row.value, 0);
|
|
180
|
+
|
|
181
|
+
const pieData = topCustomers.slice(0, 9);
|
|
182
|
+
const othersValue = sorted.slice(9).reduce((acc, row) => acc + row.value, 0);
|
|
183
|
+
|
|
184
|
+
if (othersValue > 0) {
|
|
185
|
+
pieData.push({ customer: 'Outros', value: othersValue });
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
const groupedPeriods = Array.from(byBucket.entries())
|
|
189
|
+
.map(([period, value]) => ({
|
|
190
|
+
period,
|
|
191
|
+
label: formatBucketLabel(period, groupBy),
|
|
192
|
+
value,
|
|
193
|
+
}))
|
|
194
|
+
.sort((a, b) => sortBuckets(a.period, b.period, groupBy));
|
|
195
|
+
|
|
196
|
+
return {
|
|
197
|
+
total,
|
|
198
|
+
top5,
|
|
199
|
+
top5Percent: total > 0 ? (top5 / total) * 100 : 0,
|
|
200
|
+
topCustomers,
|
|
201
|
+
pieData,
|
|
202
|
+
groupedPeriods,
|
|
203
|
+
};
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
export function aggregateOperationalExpenses(params: {
|
|
207
|
+
from: string;
|
|
208
|
+
to: string;
|
|
209
|
+
groupBy: GroupBy;
|
|
210
|
+
topN?: number;
|
|
211
|
+
}) {
|
|
212
|
+
const { from, to, groupBy, topN = 20 } = params;
|
|
213
|
+
|
|
214
|
+
const byCategory = new Map<
|
|
215
|
+
string,
|
|
216
|
+
{ category: string; costCenter: string; value: number }
|
|
217
|
+
>();
|
|
218
|
+
const byCostCenter = new Map<string, number>();
|
|
219
|
+
const byBucket = new Map<string, number>();
|
|
220
|
+
|
|
221
|
+
for (const item of operationalExpensesMock) {
|
|
222
|
+
if (!isWithinRange(item.date, from, to)) {
|
|
223
|
+
continue;
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
const currentCategory = byCategory.get(item.category) || {
|
|
227
|
+
category: item.category,
|
|
228
|
+
costCenter: item.costCenter,
|
|
229
|
+
value: 0,
|
|
230
|
+
};
|
|
231
|
+
|
|
232
|
+
currentCategory.value += item.value;
|
|
233
|
+
byCategory.set(item.category, currentCategory);
|
|
234
|
+
|
|
235
|
+
const currentCenter = byCostCenter.get(item.costCenter) || 0;
|
|
236
|
+
byCostCenter.set(item.costCenter, currentCenter + item.value);
|
|
237
|
+
|
|
238
|
+
const bucket = getBucketKey(item.date, groupBy);
|
|
239
|
+
const currentBucket = byBucket.get(bucket) || 0;
|
|
240
|
+
byBucket.set(bucket, currentBucket + item.value);
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
const sorted = Array.from(byCategory.values()).sort(
|
|
244
|
+
(a, b) => b.value - a.value
|
|
245
|
+
);
|
|
246
|
+
const topExpenses = sorted.slice(0, topN);
|
|
247
|
+
const total = sorted.reduce((acc, item) => acc + item.value, 0);
|
|
248
|
+
|
|
249
|
+
const pieData = Array.from(byCostCenter.entries()).map(([name, value]) => ({
|
|
250
|
+
name,
|
|
251
|
+
value,
|
|
252
|
+
}));
|
|
253
|
+
|
|
254
|
+
const groupedPeriods = Array.from(byBucket.entries())
|
|
255
|
+
.map(([period, value]) => ({
|
|
256
|
+
period,
|
|
257
|
+
label: formatBucketLabel(period, groupBy),
|
|
258
|
+
value,
|
|
259
|
+
}))
|
|
260
|
+
.sort((a, b) => sortBuckets(a.period, b.period, groupBy));
|
|
261
|
+
|
|
262
|
+
return {
|
|
263
|
+
total,
|
|
264
|
+
topExpenses,
|
|
265
|
+
pieData,
|
|
266
|
+
groupedPeriods,
|
|
267
|
+
};
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
export function getDefaultDateRange() {
|
|
271
|
+
return {
|
|
272
|
+
from: '2021-01-01',
|
|
273
|
+
to: '2026-12-31',
|
|
274
|
+
};
|
|
275
|
+
}
|
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
export type GroupBy = 'day' | 'week' | 'month' | 'year';
|
|
2
|
+
|
|
3
|
+
export interface FinancialResultEntry {
|
|
4
|
+
date: string;
|
|
5
|
+
faturamento: number;
|
|
6
|
+
emprestimoBanco: number;
|
|
7
|
+
aporteInvestidor: number;
|
|
8
|
+
despesas: number;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export interface CustomerRevenueEntry {
|
|
12
|
+
date: string;
|
|
13
|
+
customer: string;
|
|
14
|
+
value: number;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export interface OperationalExpenseEntry {
|
|
18
|
+
date: string;
|
|
19
|
+
category: string;
|
|
20
|
+
costCenter: string;
|
|
21
|
+
value: number;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const customerNames = [
|
|
25
|
+
'VALE',
|
|
26
|
+
'CALIAN',
|
|
27
|
+
'ALURA',
|
|
28
|
+
'FIAP',
|
|
29
|
+
'UDEMY',
|
|
30
|
+
'HIREWORK',
|
|
31
|
+
'PAULO DE TARSO',
|
|
32
|
+
'BRCLICK',
|
|
33
|
+
'DISTRITO',
|
|
34
|
+
'COINBIT CLUB',
|
|
35
|
+
'IT GLOBAL',
|
|
36
|
+
'INFINITY LEGACY',
|
|
37
|
+
'XFX',
|
|
38
|
+
'CLASS',
|
|
39
|
+
'IMPACTA',
|
|
40
|
+
'AFX',
|
|
41
|
+
'SOLARIUM',
|
|
42
|
+
'ADVANCED RESOURCES',
|
|
43
|
+
'BMX SCHOOL',
|
|
44
|
+
'CODE NATION',
|
|
45
|
+
'NEXT LEVEL',
|
|
46
|
+
'MEGA TECH',
|
|
47
|
+
'EDU PRO',
|
|
48
|
+
'CONECTA BR',
|
|
49
|
+
'SABER MAIS',
|
|
50
|
+
];
|
|
51
|
+
|
|
52
|
+
const topCustomerMultipliers: Record<string, number> = {
|
|
53
|
+
VALE: 4.5,
|
|
54
|
+
CALIAN: 2.4,
|
|
55
|
+
ALURA: 1.65,
|
|
56
|
+
FIAP: 1.45,
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
const expenseCategories = [
|
|
60
|
+
{ category: 'Folha de Pagamento', costCenter: 'Pessoas', weight: 4.6 },
|
|
61
|
+
{ category: 'Marketing', costCenter: 'Comercial', weight: 3.4 },
|
|
62
|
+
{ category: 'Tecnologia SaaS', costCenter: 'TI', weight: 3.1 },
|
|
63
|
+
{ category: 'Infraestrutura Cloud', costCenter: 'TI', weight: 2.8 },
|
|
64
|
+
{
|
|
65
|
+
category: 'Jurídico e Compliance',
|
|
66
|
+
costCenter: 'Administrativo',
|
|
67
|
+
weight: 2.1,
|
|
68
|
+
},
|
|
69
|
+
{
|
|
70
|
+
category: 'Facilities e Escritório',
|
|
71
|
+
costCenter: 'Administrativo',
|
|
72
|
+
weight: 1.9,
|
|
73
|
+
},
|
|
74
|
+
{ category: 'Treinamentos', costCenter: 'Pessoas', weight: 1.7 },
|
|
75
|
+
{ category: 'Viagens e Eventos', costCenter: 'Comercial', weight: 1.5 },
|
|
76
|
+
{ category: 'Suporte ao Cliente', costCenter: 'Operações', weight: 2.0 },
|
|
77
|
+
{
|
|
78
|
+
category: 'Ferramentas Financeiras',
|
|
79
|
+
costCenter: 'Financeiro',
|
|
80
|
+
weight: 1.3,
|
|
81
|
+
},
|
|
82
|
+
{ category: 'Consultorias', costCenter: 'Operações', weight: 1.2 },
|
|
83
|
+
{ category: 'Seguros', costCenter: 'Financeiro', weight: 0.9 },
|
|
84
|
+
];
|
|
85
|
+
|
|
86
|
+
function clamp(value: number, min: number, max: number) {
|
|
87
|
+
return Math.max(min, Math.min(max, value));
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function monthSeed(year: number, monthIndex: number) {
|
|
91
|
+
return year * 17 + monthIndex * 31;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function buildMonthlyDates() {
|
|
95
|
+
const dates: string[] = [];
|
|
96
|
+
|
|
97
|
+
for (let year = 2021; year <= 2026; year += 1) {
|
|
98
|
+
for (let month = 1; month <= 12; month += 1) {
|
|
99
|
+
dates.push(`${year}-${String(month).padStart(2, '0')}-01`);
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
return dates;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
const monthlyDates = buildMonthlyDates();
|
|
107
|
+
|
|
108
|
+
export const financialResultsMock: FinancialResultEntry[] = monthlyDates.map(
|
|
109
|
+
(date, index) => {
|
|
110
|
+
const d = new Date(`${date}T00:00:00`);
|
|
111
|
+
const year = d.getFullYear();
|
|
112
|
+
const month = d.getMonth();
|
|
113
|
+
const growthFactor = 1 + (year - 2021) * 0.08;
|
|
114
|
+
const seasonality = Math.sin((month / 12) * Math.PI * 2) * 0.18;
|
|
115
|
+
const trend = 1 + index * 0.006;
|
|
116
|
+
const seed = monthSeed(year, month);
|
|
117
|
+
|
|
118
|
+
const faturamento = Math.round(
|
|
119
|
+
(260000 + seasonality * 70000 + (seed % 13) * 3500) * trend * growthFactor
|
|
120
|
+
);
|
|
121
|
+
|
|
122
|
+
const despesas = Math.round(
|
|
123
|
+
(190000 +
|
|
124
|
+
Math.cos((month / 12) * Math.PI * 2) * 45000 +
|
|
125
|
+
(seed % 7) * 4200) *
|
|
126
|
+
(0.93 + (year - 2021) * 0.035)
|
|
127
|
+
);
|
|
128
|
+
|
|
129
|
+
const emprestimoBanco =
|
|
130
|
+
month % 6 === 1 ? Math.round(22000 + (seed % 9) * 1800) : 0;
|
|
131
|
+
|
|
132
|
+
const aporteInvestidor =
|
|
133
|
+
year === 2023 && (month === 2 || month === 8)
|
|
134
|
+
? 175000
|
|
135
|
+
: year === 2024 && month === 1
|
|
136
|
+
? 50000
|
|
137
|
+
: year === 2025 && month === 4
|
|
138
|
+
? 52000
|
|
139
|
+
: 0;
|
|
140
|
+
|
|
141
|
+
return {
|
|
142
|
+
date,
|
|
143
|
+
faturamento: clamp(faturamento, 120000, 530000),
|
|
144
|
+
emprestimoBanco,
|
|
145
|
+
aporteInvestidor,
|
|
146
|
+
despesas: clamp(despesas, 130000, 430000),
|
|
147
|
+
};
|
|
148
|
+
}
|
|
149
|
+
);
|
|
150
|
+
|
|
151
|
+
export const customerRevenueMock: CustomerRevenueEntry[] = monthlyDates.flatMap(
|
|
152
|
+
(date, monthIndex) => {
|
|
153
|
+
const dateObj = new Date(`${date}T00:00:00`);
|
|
154
|
+
const seasonal =
|
|
155
|
+
1 + Math.sin((dateObj.getMonth() / 12) * Math.PI * 2) * 0.08;
|
|
156
|
+
|
|
157
|
+
return customerNames.map((customer, index) => {
|
|
158
|
+
const base = Math.max(1800, 46000 - index * 1850);
|
|
159
|
+
const priorityBoost = topCustomerMultipliers[customer] || 1;
|
|
160
|
+
const variation = 1 + ((monthIndex + index * 3) % 9) * 0.023;
|
|
161
|
+
|
|
162
|
+
return {
|
|
163
|
+
date,
|
|
164
|
+
customer,
|
|
165
|
+
value: Math.round(base * priorityBoost * seasonal * variation),
|
|
166
|
+
};
|
|
167
|
+
});
|
|
168
|
+
}
|
|
169
|
+
);
|
|
170
|
+
|
|
171
|
+
export const operationalExpensesMock: OperationalExpenseEntry[] =
|
|
172
|
+
monthlyDates.flatMap((date, monthIndex) =>
|
|
173
|
+
expenseCategories.map((entry, categoryIndex) => {
|
|
174
|
+
const monthPulse = 1 + ((monthIndex + categoryIndex) % 7) * 0.04;
|
|
175
|
+
const quarterEffects = [1.04, 0.97, 1.1, 0.99] as const;
|
|
176
|
+
const quarterEffect =
|
|
177
|
+
quarterEffects[Math.floor((monthIndex % 12) / 3)] ?? 1;
|
|
178
|
+
|
|
179
|
+
return {
|
|
180
|
+
date,
|
|
181
|
+
category: entry.category,
|
|
182
|
+
costCenter: entry.costCenter,
|
|
183
|
+
value: Math.round(14000 * entry.weight * monthPulse * quarterEffect),
|
|
184
|
+
};
|
|
185
|
+
})
|
|
186
|
+
);
|
|
@@ -0,0 +1,233 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { useApp, useQuery } from '@hed-hog/next-app-provider';
|
|
4
|
+
|
|
5
|
+
export type GroupBy = 'day' | 'week' | 'month' | 'year';
|
|
6
|
+
|
|
7
|
+
type QueryValue = string | number | undefined | null;
|
|
8
|
+
|
|
9
|
+
export type OverviewResultsRow = {
|
|
10
|
+
period: string;
|
|
11
|
+
faturamento: number;
|
|
12
|
+
despesasEmprestimos: number;
|
|
13
|
+
diferenca: number;
|
|
14
|
+
aporteInvestidor: number;
|
|
15
|
+
emprestimoBanco: number;
|
|
16
|
+
despesas: number;
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
export type OverviewResultsReportData = {
|
|
20
|
+
rows: OverviewResultsRow[];
|
|
21
|
+
totals: {
|
|
22
|
+
faturamento: number;
|
|
23
|
+
despesasEmprestimos: number;
|
|
24
|
+
diferenca: number;
|
|
25
|
+
aporteInvestidor: number;
|
|
26
|
+
emprestimoBanco: number;
|
|
27
|
+
despesas: number;
|
|
28
|
+
margem: number;
|
|
29
|
+
};
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
export type TopCustomerItem = {
|
|
33
|
+
customer: string;
|
|
34
|
+
value: number;
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
export type TopCustomersReportData = {
|
|
38
|
+
total: number;
|
|
39
|
+
top5Percent: number;
|
|
40
|
+
topCustomers: TopCustomerItem[];
|
|
41
|
+
pieData: TopCustomerItem[];
|
|
42
|
+
groupedPeriods: Array<{ period: string; value: number }>;
|
|
43
|
+
leader: TopCustomerItem | null;
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
export type TopExpenseItem = {
|
|
47
|
+
category: string;
|
|
48
|
+
costCenter: string;
|
|
49
|
+
label: string;
|
|
50
|
+
value: number;
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
export type TopOperationalExpensesReportData = {
|
|
54
|
+
total: number;
|
|
55
|
+
average: number;
|
|
56
|
+
topExpenses: TopExpenseItem[];
|
|
57
|
+
pieData: Array<{ name: string; value: number }>;
|
|
58
|
+
groupedPeriods: Array<{ period: string; value: number }>;
|
|
59
|
+
highest: TopExpenseItem | null;
|
|
60
|
+
};
|
|
61
|
+
|
|
62
|
+
const EMPTY_OVERVIEW_RESULTS: OverviewResultsReportData = {
|
|
63
|
+
rows: [],
|
|
64
|
+
totals: {
|
|
65
|
+
faturamento: 0,
|
|
66
|
+
despesasEmprestimos: 0,
|
|
67
|
+
diferenca: 0,
|
|
68
|
+
aporteInvestidor: 0,
|
|
69
|
+
emprestimoBanco: 0,
|
|
70
|
+
despesas: 0,
|
|
71
|
+
margem: 0,
|
|
72
|
+
},
|
|
73
|
+
};
|
|
74
|
+
|
|
75
|
+
const EMPTY_TOP_CUSTOMERS: TopCustomersReportData = {
|
|
76
|
+
total: 0,
|
|
77
|
+
top5Percent: 0,
|
|
78
|
+
topCustomers: [],
|
|
79
|
+
pieData: [],
|
|
80
|
+
groupedPeriods: [],
|
|
81
|
+
leader: null,
|
|
82
|
+
};
|
|
83
|
+
|
|
84
|
+
const EMPTY_TOP_EXPENSES: TopOperationalExpensesReportData = {
|
|
85
|
+
total: 0,
|
|
86
|
+
average: 0,
|
|
87
|
+
topExpenses: [],
|
|
88
|
+
pieData: [],
|
|
89
|
+
groupedPeriods: [],
|
|
90
|
+
highest: null,
|
|
91
|
+
};
|
|
92
|
+
|
|
93
|
+
function buildQuery(params: Record<string, QueryValue>) {
|
|
94
|
+
const query = new URLSearchParams();
|
|
95
|
+
|
|
96
|
+
Object.entries(params).forEach(([key, value]) => {
|
|
97
|
+
if (value === undefined || value === null || value === '') {
|
|
98
|
+
return;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
query.set(key, String(value));
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
return query.toString();
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function toDate(value: string) {
|
|
108
|
+
return new Date(`${value}T00:00:00`);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function startOfWeek(date: Date) {
|
|
112
|
+
const current = new Date(date);
|
|
113
|
+
const day = current.getDay();
|
|
114
|
+
const diff = day === 0 ? -6 : 1 - day;
|
|
115
|
+
|
|
116
|
+
current.setDate(current.getDate() + diff);
|
|
117
|
+
current.setHours(0, 0, 0, 0);
|
|
118
|
+
|
|
119
|
+
return current;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
export function getDefaultDateRange() {
|
|
123
|
+
return {
|
|
124
|
+
from: '2021-01-01',
|
|
125
|
+
to: '2026-12-31',
|
|
126
|
+
};
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
export function formatReportBucketLabel(
|
|
130
|
+
bucket: string,
|
|
131
|
+
groupBy: GroupBy,
|
|
132
|
+
locale: string
|
|
133
|
+
) {
|
|
134
|
+
const dateFormatter = new Intl.DateTimeFormat(locale);
|
|
135
|
+
|
|
136
|
+
if (groupBy === 'day') {
|
|
137
|
+
return dateFormatter.format(toDate(bucket));
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
if (groupBy === 'week') {
|
|
141
|
+
const [year, week] = bucket.split('-W');
|
|
142
|
+
|
|
143
|
+
return `W${Number(week)}/${year}`;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
if (groupBy === 'month') {
|
|
147
|
+
const [year, month] = bucket.split('-');
|
|
148
|
+
const monthDate = new Date(Number(year), Number(month) - 1, 1);
|
|
149
|
+
|
|
150
|
+
return new Intl.DateTimeFormat(locale, {
|
|
151
|
+
month: 'short',
|
|
152
|
+
year: 'numeric',
|
|
153
|
+
}).format(monthDate);
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
return bucket;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
function useFinanceReportQuery<T>(
|
|
160
|
+
queryKey: string,
|
|
161
|
+
path: string,
|
|
162
|
+
params: Record<string, QueryValue>,
|
|
163
|
+
initialData: T
|
|
164
|
+
) {
|
|
165
|
+
const { request } = useApp();
|
|
166
|
+
const querySuffix = buildQuery(params);
|
|
167
|
+
const url = querySuffix ? `${path}?${querySuffix}` : path;
|
|
168
|
+
|
|
169
|
+
return useQuery<T>({
|
|
170
|
+
queryKey: [queryKey, querySuffix || 'default'],
|
|
171
|
+
staleTime: 0,
|
|
172
|
+
refetchOnMount: 'always',
|
|
173
|
+
queryFn: async () => {
|
|
174
|
+
try {
|
|
175
|
+
const response = await request({
|
|
176
|
+
url,
|
|
177
|
+
method: 'GET',
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
return {
|
|
181
|
+
...initialData,
|
|
182
|
+
...(response?.data || {}),
|
|
183
|
+
} as T;
|
|
184
|
+
} catch {
|
|
185
|
+
return initialData;
|
|
186
|
+
}
|
|
187
|
+
},
|
|
188
|
+
initialData,
|
|
189
|
+
});
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
export function useOverviewResultsReport(filters: {
|
|
193
|
+
from: string;
|
|
194
|
+
to: string;
|
|
195
|
+
groupBy: GroupBy;
|
|
196
|
+
}) {
|
|
197
|
+
return useFinanceReportQuery<OverviewResultsReportData>(
|
|
198
|
+
'finance-overview-results-report',
|
|
199
|
+
'/finance/reports/overview-results',
|
|
200
|
+
filters,
|
|
201
|
+
EMPTY_OVERVIEW_RESULTS
|
|
202
|
+
);
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
export function useTopCustomersReport(filters: {
|
|
206
|
+
from: string;
|
|
207
|
+
to: string;
|
|
208
|
+
groupBy: GroupBy;
|
|
209
|
+
search?: string;
|
|
210
|
+
topN?: number;
|
|
211
|
+
}) {
|
|
212
|
+
return useFinanceReportQuery<TopCustomersReportData>(
|
|
213
|
+
'finance-top-customers-report',
|
|
214
|
+
'/finance/reports/top-customers',
|
|
215
|
+
filters,
|
|
216
|
+
EMPTY_TOP_CUSTOMERS
|
|
217
|
+
);
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
export function useTopOperationalExpensesReport(filters: {
|
|
221
|
+
from: string;
|
|
222
|
+
to: string;
|
|
223
|
+
groupBy: GroupBy;
|
|
224
|
+
search?: string;
|
|
225
|
+
topN?: number;
|
|
226
|
+
}) {
|
|
227
|
+
return useFinanceReportQuery<TopOperationalExpensesReportData>(
|
|
228
|
+
'finance-top-operational-expenses-report',
|
|
229
|
+
'/finance/reports/top-operational-expenses',
|
|
230
|
+
filters,
|
|
231
|
+
EMPTY_TOP_EXPENSES
|
|
232
|
+
);
|
|
233
|
+
}
|