@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.
Files changed (39) hide show
  1. package/README.md +65 -29
  2. package/dist/dto/finance-report-query.dto.d.ts +16 -0
  3. package/dist/dto/finance-report-query.dto.d.ts.map +1 -0
  4. package/dist/dto/finance-report-query.dto.js +59 -0
  5. package/dist/dto/finance-report-query.dto.js.map +1 -0
  6. package/dist/finance-reports.controller.d.ts +71 -0
  7. package/dist/finance-reports.controller.d.ts.map +1 -0
  8. package/dist/finance-reports.controller.js +61 -0
  9. package/dist/finance-reports.controller.js.map +1 -0
  10. package/dist/finance.module.d.ts.map +1 -1
  11. package/dist/finance.module.js +2 -0
  12. package/dist/finance.module.js.map +1 -1
  13. package/dist/finance.service.d.ts +93 -0
  14. package/dist/finance.service.d.ts.map +1 -1
  15. package/dist/finance.service.js +456 -0
  16. package/dist/finance.service.js.map +1 -1
  17. package/dist/index.d.ts +1 -0
  18. package/dist/index.d.ts.map +1 -1
  19. package/dist/index.js +1 -0
  20. package/dist/index.js.map +1 -1
  21. package/hedhog/data/menu.yaml +46 -0
  22. package/hedhog/data/route.yaml +27 -0
  23. package/hedhog/frontend/app/accounts-payable/installments/page.tsx.ejs +158 -125
  24. package/hedhog/frontend/app/accounts-receivable/collections-default/page.tsx.ejs +102 -88
  25. package/hedhog/frontend/app/cash-and-banks/statements/page.tsx.ejs +113 -89
  26. package/hedhog/frontend/app/reports/_lib/report-aggregations.ts.ejs +275 -0
  27. package/hedhog/frontend/app/reports/_lib/report-mocks.ts.ejs +186 -0
  28. package/hedhog/frontend/app/reports/_lib/use-finance-reports.ts.ejs +233 -0
  29. package/hedhog/frontend/app/reports/overview-results/page.tsx.ejs +355 -0
  30. package/hedhog/frontend/app/reports/top-customers/page.tsx.ejs +427 -0
  31. package/hedhog/frontend/app/reports/top-operational-expenses/page.tsx.ejs +433 -0
  32. package/hedhog/frontend/messages/en.json +179 -0
  33. package/hedhog/frontend/messages/pt.json +179 -0
  34. package/package.json +7 -7
  35. package/src/dto/finance-report-query.dto.ts +49 -0
  36. package/src/finance-reports.controller.ts +28 -0
  37. package/src/finance.module.ts +2 -0
  38. package/src/finance.service.ts +645 -10
  39. 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
+ }