@hed-hog/finance 0.0.279 → 0.0.286

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