@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,427 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { Page, PageHeader } from '@/components/entity-list';
|
|
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 { Input } from '@/components/ui/input';
|
|
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 { Crown, PieChartIcon, Users } from 'lucide-react';
|
|
30
|
+
import { useTranslations } from 'next-intl';
|
|
31
|
+
import { type FormEvent, useState } from 'react';
|
|
32
|
+
import {
|
|
33
|
+
Bar,
|
|
34
|
+
BarChart,
|
|
35
|
+
CartesianGrid,
|
|
36
|
+
Cell,
|
|
37
|
+
Legend,
|
|
38
|
+
Pie,
|
|
39
|
+
PieChart,
|
|
40
|
+
ResponsiveContainer,
|
|
41
|
+
Tooltip,
|
|
42
|
+
XAxis,
|
|
43
|
+
YAxis,
|
|
44
|
+
} from 'recharts';
|
|
45
|
+
import {
|
|
46
|
+
getDefaultDateRange,
|
|
47
|
+
type GroupBy,
|
|
48
|
+
useTopCustomersReport,
|
|
49
|
+
} from '../_lib/use-finance-reports';
|
|
50
|
+
|
|
51
|
+
const rankingColors = [
|
|
52
|
+
'#2563EB',
|
|
53
|
+
'#DC2626',
|
|
54
|
+
'#059669',
|
|
55
|
+
'#D97706',
|
|
56
|
+
'#7C3AED',
|
|
57
|
+
'#0F766E',
|
|
58
|
+
'#BE123C',
|
|
59
|
+
'#1D4ED8',
|
|
60
|
+
'#15803D',
|
|
61
|
+
'#B45309',
|
|
62
|
+
];
|
|
63
|
+
|
|
64
|
+
const currencyFormatter = new Intl.NumberFormat('pt-BR', {
|
|
65
|
+
style: 'currency',
|
|
66
|
+
currency: 'BRL',
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
export default function TopCustomersReportPage() {
|
|
70
|
+
const t = useTranslations('finance.TopCustomersReportPage');
|
|
71
|
+
const defaults = getDefaultDateRange();
|
|
72
|
+
const [filters, setFilters] = useState({
|
|
73
|
+
from: defaults.from,
|
|
74
|
+
to: defaults.to,
|
|
75
|
+
groupBy: 'year' as GroupBy,
|
|
76
|
+
search: '',
|
|
77
|
+
});
|
|
78
|
+
const [appliedFilters, setAppliedFilters] = useState(filters);
|
|
79
|
+
const { data: viewData, isFetching } = useTopCustomersReport({
|
|
80
|
+
from: appliedFilters.from,
|
|
81
|
+
to: appliedFilters.to,
|
|
82
|
+
groupBy: appliedFilters.groupBy,
|
|
83
|
+
search: appliedFilters.search,
|
|
84
|
+
topN: 20,
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
const handleSubmitFilters = (event: FormEvent<HTMLFormElement>) => {
|
|
88
|
+
event.preventDefault();
|
|
89
|
+
setAppliedFilters(filters);
|
|
90
|
+
};
|
|
91
|
+
|
|
92
|
+
const renderSolidTooltip = ({
|
|
93
|
+
active,
|
|
94
|
+
payload,
|
|
95
|
+
label,
|
|
96
|
+
}: {
|
|
97
|
+
active?: boolean;
|
|
98
|
+
payload?: Array<{
|
|
99
|
+
name?: string;
|
|
100
|
+
value?: number | string;
|
|
101
|
+
color?: string;
|
|
102
|
+
dataKey?: string;
|
|
103
|
+
}>;
|
|
104
|
+
label?: string | number;
|
|
105
|
+
}) => {
|
|
106
|
+
if (!active || !payload || payload.length === 0) {
|
|
107
|
+
return null;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
return (
|
|
111
|
+
<div className="min-w-52 rounded-md border bg-popover px-3 py-2 shadow-xl">
|
|
112
|
+
<p className="mb-1 text-sm font-semibold text-popover-foreground">
|
|
113
|
+
{String(label ?? payload[0]?.name ?? '')}
|
|
114
|
+
</p>
|
|
115
|
+
{payload.map((row, index) => (
|
|
116
|
+
<div
|
|
117
|
+
key={`${row.dataKey ?? row.name ?? 'item'}-${index}`}
|
|
118
|
+
className="flex items-center justify-between gap-3 text-sm"
|
|
119
|
+
>
|
|
120
|
+
<div className="flex items-center gap-2 text-popover-foreground">
|
|
121
|
+
<span
|
|
122
|
+
className="inline-block h-2.5 w-2.5 rounded-full"
|
|
123
|
+
style={{ backgroundColor: row.color || 'hsl(var(--chart-1))' }}
|
|
124
|
+
/>
|
|
125
|
+
<span>{row.name ?? row.dataKey ?? 'Valor'}</span>
|
|
126
|
+
</div>
|
|
127
|
+
<span className="font-semibold text-popover-foreground">
|
|
128
|
+
{currencyFormatter.format(Number(row.value || 0))}
|
|
129
|
+
</span>
|
|
130
|
+
</div>
|
|
131
|
+
))}
|
|
132
|
+
</div>
|
|
133
|
+
);
|
|
134
|
+
};
|
|
135
|
+
|
|
136
|
+
return (
|
|
137
|
+
<Page>
|
|
138
|
+
<PageHeader
|
|
139
|
+
title={t('header.title')}
|
|
140
|
+
description={t('header.description')}
|
|
141
|
+
breadcrumbs={[
|
|
142
|
+
{ label: t('breadcrumbs.home'), href: '/' },
|
|
143
|
+
{ label: t('breadcrumbs.finance'), href: '/finance' },
|
|
144
|
+
{ label: t('breadcrumbs.current') },
|
|
145
|
+
]}
|
|
146
|
+
/>
|
|
147
|
+
|
|
148
|
+
<form
|
|
149
|
+
onSubmit={handleSubmitFilters}
|
|
150
|
+
className="flex w-full flex-col gap-3 lg:flex-row"
|
|
151
|
+
>
|
|
152
|
+
<Input
|
|
153
|
+
type="search"
|
|
154
|
+
value={filters.search}
|
|
155
|
+
onChange={(event) =>
|
|
156
|
+
setFilters((current) => ({
|
|
157
|
+
...current,
|
|
158
|
+
search: event.target.value,
|
|
159
|
+
}))
|
|
160
|
+
}
|
|
161
|
+
placeholder={t('filters.searchPlaceholder')}
|
|
162
|
+
className="w-full lg:flex-[2.4]"
|
|
163
|
+
aria-label={t('filters.searchAria')}
|
|
164
|
+
/>
|
|
165
|
+
<Input
|
|
166
|
+
id="from"
|
|
167
|
+
type="date"
|
|
168
|
+
value={filters.from}
|
|
169
|
+
onChange={(event) =>
|
|
170
|
+
setFilters((current) => ({
|
|
171
|
+
...current,
|
|
172
|
+
from: event.target.value,
|
|
173
|
+
}))
|
|
174
|
+
}
|
|
175
|
+
max={filters.to}
|
|
176
|
+
className="w-full lg:w-[170px]"
|
|
177
|
+
aria-label={t('filters.fromAria')}
|
|
178
|
+
placeholder={t('filters.fromPlaceholder')}
|
|
179
|
+
/>
|
|
180
|
+
<Input
|
|
181
|
+
id="to"
|
|
182
|
+
type="date"
|
|
183
|
+
value={filters.to}
|
|
184
|
+
onChange={(event) =>
|
|
185
|
+
setFilters((current) => ({
|
|
186
|
+
...current,
|
|
187
|
+
to: event.target.value,
|
|
188
|
+
}))
|
|
189
|
+
}
|
|
190
|
+
min={filters.from}
|
|
191
|
+
className="w-full lg:w-[170px]"
|
|
192
|
+
aria-label={t('filters.toAria')}
|
|
193
|
+
placeholder={t('filters.toPlaceholder')}
|
|
194
|
+
/>
|
|
195
|
+
<Select
|
|
196
|
+
value={filters.groupBy}
|
|
197
|
+
onValueChange={(value) =>
|
|
198
|
+
setFilters((current) => ({
|
|
199
|
+
...current,
|
|
200
|
+
groupBy: value as GroupBy,
|
|
201
|
+
}))
|
|
202
|
+
}
|
|
203
|
+
>
|
|
204
|
+
<SelectTrigger
|
|
205
|
+
className="w-full lg:w-[150px]"
|
|
206
|
+
aria-label={t('filters.groupByAria')}
|
|
207
|
+
>
|
|
208
|
+
<SelectValue placeholder={t('filters.groupByPlaceholder')} />
|
|
209
|
+
</SelectTrigger>
|
|
210
|
+
<SelectContent>
|
|
211
|
+
<SelectItem value="day">{t('groupBy.day')}</SelectItem>
|
|
212
|
+
<SelectItem value="week">{t('groupBy.week')}</SelectItem>
|
|
213
|
+
<SelectItem value="month">{t('groupBy.month')}</SelectItem>
|
|
214
|
+
<SelectItem value="year">{t('groupBy.year')}</SelectItem>
|
|
215
|
+
</SelectContent>
|
|
216
|
+
</Select>
|
|
217
|
+
<Button
|
|
218
|
+
type="submit"
|
|
219
|
+
className="w-full lg:w-auto"
|
|
220
|
+
disabled={isFetching}
|
|
221
|
+
>
|
|
222
|
+
{t('filters.submit')}
|
|
223
|
+
</Button>
|
|
224
|
+
</form>
|
|
225
|
+
|
|
226
|
+
<div className="grid gap-3 md:grid-cols-3">
|
|
227
|
+
<Card>
|
|
228
|
+
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-1 pt-3 px-4">
|
|
229
|
+
<CardTitle className="text-xs font-medium tracking-wide text-muted-foreground uppercase">
|
|
230
|
+
{t('cards.total')}
|
|
231
|
+
</CardTitle>
|
|
232
|
+
<div className="rounded-full bg-blue-500/10 p-1.5">
|
|
233
|
+
<Users className="h-4 w-4 text-blue-600" />
|
|
234
|
+
</div>
|
|
235
|
+
</CardHeader>
|
|
236
|
+
<CardContent className="pb-3 pt-0 px-4">
|
|
237
|
+
<div className="text-xl font-bold">
|
|
238
|
+
<Money value={viewData.total} />
|
|
239
|
+
</div>
|
|
240
|
+
</CardContent>
|
|
241
|
+
</Card>
|
|
242
|
+
<Card>
|
|
243
|
+
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-1 pt-3 px-4">
|
|
244
|
+
<CardTitle className="text-xs font-medium tracking-wide text-muted-foreground uppercase">
|
|
245
|
+
{t('cards.top5Concentration')}
|
|
246
|
+
</CardTitle>
|
|
247
|
+
<div className="rounded-full bg-purple-500/10 p-1.5">
|
|
248
|
+
<PieChartIcon className="h-4 w-4 text-purple-600" />
|
|
249
|
+
</div>
|
|
250
|
+
</CardHeader>
|
|
251
|
+
<CardContent className="pb-3 pt-0 px-4">
|
|
252
|
+
<div className="text-xl font-bold">
|
|
253
|
+
{viewData.top5Percent.toFixed(1)}%
|
|
254
|
+
</div>
|
|
255
|
+
</CardContent>
|
|
256
|
+
</Card>
|
|
257
|
+
<Card>
|
|
258
|
+
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-1 pt-3 px-4">
|
|
259
|
+
<CardTitle className="text-xs font-medium tracking-wide text-muted-foreground uppercase">
|
|
260
|
+
{t('cards.leader')}
|
|
261
|
+
</CardTitle>
|
|
262
|
+
<div className="rounded-full bg-amber-500/10 p-1.5">
|
|
263
|
+
<Crown className="h-4 w-4 text-amber-500" />
|
|
264
|
+
</div>
|
|
265
|
+
</CardHeader>
|
|
266
|
+
<CardContent className="pb-3 pt-0 px-4">
|
|
267
|
+
<div className="text-sm font-medium">
|
|
268
|
+
{viewData.leader?.customer || '-'}
|
|
269
|
+
</div>
|
|
270
|
+
<div className="text-xs text-muted-foreground">
|
|
271
|
+
{viewData.leader ? <Money value={viewData.leader.value} /> : '-'}
|
|
272
|
+
</div>
|
|
273
|
+
</CardContent>
|
|
274
|
+
</Card>
|
|
275
|
+
</div>
|
|
276
|
+
|
|
277
|
+
<div className="grid gap-3 xl:grid-cols-5">
|
|
278
|
+
<Card className="xl:col-span-3">
|
|
279
|
+
<CardHeader className="pb-3">
|
|
280
|
+
<CardTitle>{t('bars.title')}</CardTitle>
|
|
281
|
+
<CardDescription>{t('bars.description')}</CardDescription>
|
|
282
|
+
</CardHeader>
|
|
283
|
+
<CardContent className="pb-4 pt-0">
|
|
284
|
+
<ResponsiveContainer width="100%" height={360}>
|
|
285
|
+
<BarChart
|
|
286
|
+
data={viewData.topCustomers}
|
|
287
|
+
layout="vertical"
|
|
288
|
+
margin={{ left: 60, right: 16 }}
|
|
289
|
+
>
|
|
290
|
+
<CartesianGrid strokeDasharray="3 3" className="stroke-muted" />
|
|
291
|
+
<XAxis
|
|
292
|
+
type="number"
|
|
293
|
+
tickFormatter={(value) => `${(value / 1000).toFixed(0)}k`}
|
|
294
|
+
/>
|
|
295
|
+
<YAxis dataKey="customer" type="category" width={190} />
|
|
296
|
+
<Tooltip
|
|
297
|
+
cursor={{ fill: 'hsl(var(--muted) / 0.35)' }}
|
|
298
|
+
content={renderSolidTooltip}
|
|
299
|
+
/>
|
|
300
|
+
<Bar dataKey="value" radius={[0, 6, 6, 0]}>
|
|
301
|
+
{viewData.topCustomers.map((entry, index) => (
|
|
302
|
+
<Cell
|
|
303
|
+
key={entry.customer}
|
|
304
|
+
fill={rankingColors[index % rankingColors.length]}
|
|
305
|
+
stroke="hsl(var(--background))"
|
|
306
|
+
strokeWidth={1}
|
|
307
|
+
/>
|
|
308
|
+
))}
|
|
309
|
+
</Bar>
|
|
310
|
+
</BarChart>
|
|
311
|
+
</ResponsiveContainer>
|
|
312
|
+
<div className="mt-4 grid grid-cols-2 gap-2 text-sm sm:grid-cols-3">
|
|
313
|
+
{viewData.topCustomers.slice(0, 6).map((item, index) => (
|
|
314
|
+
<div key={item.customer} className="flex items-center gap-2">
|
|
315
|
+
<span
|
|
316
|
+
className="inline-block h-2.5 w-2.5 rounded-full"
|
|
317
|
+
style={{
|
|
318
|
+
backgroundColor:
|
|
319
|
+
rankingColors[index % rankingColors.length],
|
|
320
|
+
}}
|
|
321
|
+
/>
|
|
322
|
+
<span className="truncate text-foreground/90">
|
|
323
|
+
#{index + 1} {item.customer}
|
|
324
|
+
</span>
|
|
325
|
+
</div>
|
|
326
|
+
))}
|
|
327
|
+
</div>
|
|
328
|
+
</CardContent>
|
|
329
|
+
</Card>
|
|
330
|
+
|
|
331
|
+
<Card className="xl:col-span-2">
|
|
332
|
+
<CardHeader className="pb-3">
|
|
333
|
+
<CardTitle>{t('pie.title')}</CardTitle>
|
|
334
|
+
<CardDescription>{t('pie.description')}</CardDescription>
|
|
335
|
+
</CardHeader>
|
|
336
|
+
<CardContent className="pb-4 pt-0">
|
|
337
|
+
<ResponsiveContainer width="100%" height={340}>
|
|
338
|
+
<PieChart>
|
|
339
|
+
<Pie
|
|
340
|
+
data={viewData.pieData}
|
|
341
|
+
dataKey="value"
|
|
342
|
+
nameKey="customer"
|
|
343
|
+
innerRadius={55}
|
|
344
|
+
outerRadius={100}
|
|
345
|
+
paddingAngle={2}
|
|
346
|
+
cy="42%"
|
|
347
|
+
>
|
|
348
|
+
{viewData.pieData.map((entry, index) => (
|
|
349
|
+
<Cell
|
|
350
|
+
key={entry.customer}
|
|
351
|
+
fill={rankingColors[index % rankingColors.length]}
|
|
352
|
+
stroke="hsl(var(--background))"
|
|
353
|
+
strokeWidth={1}
|
|
354
|
+
/>
|
|
355
|
+
))}
|
|
356
|
+
</Pie>
|
|
357
|
+
<Legend
|
|
358
|
+
iconType="circle"
|
|
359
|
+
verticalAlign="bottom"
|
|
360
|
+
wrapperStyle={{
|
|
361
|
+
fontSize: 12,
|
|
362
|
+
lineHeight: '20px',
|
|
363
|
+
paddingTop: 12,
|
|
364
|
+
}}
|
|
365
|
+
/>
|
|
366
|
+
<Tooltip cursor={false} content={renderSolidTooltip} />
|
|
367
|
+
</PieChart>
|
|
368
|
+
</ResponsiveContainer>
|
|
369
|
+
</CardContent>
|
|
370
|
+
</Card>
|
|
371
|
+
</div>
|
|
372
|
+
|
|
373
|
+
<Card>
|
|
374
|
+
<CardHeader>
|
|
375
|
+
<CardTitle>{t('table.title')}</CardTitle>
|
|
376
|
+
<CardDescription>{t('table.description')}</CardDescription>
|
|
377
|
+
</CardHeader>
|
|
378
|
+
<CardContent>
|
|
379
|
+
<Table>
|
|
380
|
+
<TableHeader>
|
|
381
|
+
<TableRow>
|
|
382
|
+
<TableHead>{t('table.headers.customer')}</TableHead>
|
|
383
|
+
<TableHead className="text-right">
|
|
384
|
+
{t('table.headers.value')}
|
|
385
|
+
</TableHead>
|
|
386
|
+
<TableHead className="text-right">
|
|
387
|
+
{t('table.headers.participation')}
|
|
388
|
+
</TableHead>
|
|
389
|
+
</TableRow>
|
|
390
|
+
</TableHeader>
|
|
391
|
+
<TableBody>
|
|
392
|
+
{viewData.topCustomers.length === 0 ? (
|
|
393
|
+
<TableRow>
|
|
394
|
+
<TableCell
|
|
395
|
+
colSpan={3}
|
|
396
|
+
className="text-muted-foreground text-center"
|
|
397
|
+
>
|
|
398
|
+
{t('table.empty')}
|
|
399
|
+
</TableCell>
|
|
400
|
+
</TableRow>
|
|
401
|
+
) : (
|
|
402
|
+
viewData.topCustomers.map((item) => {
|
|
403
|
+
const participation =
|
|
404
|
+
viewData.total > 0
|
|
405
|
+
? (item.value / viewData.total) * 100
|
|
406
|
+
: 0;
|
|
407
|
+
|
|
408
|
+
return (
|
|
409
|
+
<TableRow key={item.customer}>
|
|
410
|
+
<TableCell>{item.customer}</TableCell>
|
|
411
|
+
<TableCell className="text-right">
|
|
412
|
+
<Money value={item.value} />
|
|
413
|
+
</TableCell>
|
|
414
|
+
<TableCell className="text-right">
|
|
415
|
+
{participation.toFixed(2)}%
|
|
416
|
+
</TableCell>
|
|
417
|
+
</TableRow>
|
|
418
|
+
);
|
|
419
|
+
})
|
|
420
|
+
)}
|
|
421
|
+
</TableBody>
|
|
422
|
+
</Table>
|
|
423
|
+
</CardContent>
|
|
424
|
+
</Card>
|
|
425
|
+
</Page>
|
|
426
|
+
);
|
|
427
|
+
}
|