@hed-hog/finance 0.0.279 → 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 (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 +233 -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 +247 -130
  27. package/hedhog/frontend/app/reports/top-operational-expenses/page.tsx.ejs +250 -135
  28. package/hedhog/frontend/messages/en.json +33 -2
  29. package/hedhog/frontend/messages/pt.json +33 -2
  30. package/package.json +6 -6
  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,13 @@ 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
32
  import {
33
33
  Bar,
34
34
  BarChart,
35
+ CartesianGrid,
35
36
  Cell,
37
+ Legend,
36
38
  Pie,
37
39
  PieChart,
38
40
  ResponsiveContainer,
@@ -41,43 +43,95 @@ import {
41
43
  YAxis,
42
44
  } from 'recharts';
43
45
  import {
44
- aggregateTopCustomers,
45
46
  getDefaultDateRange,
46
- } from '../_lib/report-aggregations';
47
- import { type GroupBy } from '../_lib/report-mocks';
47
+ type GroupBy,
48
+ useTopCustomersReport,
49
+ } from '../_lib/use-finance-reports';
48
50
 
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',
51
+ const rankingColors = [
52
+ '#2563EB',
53
+ '#DC2626',
54
+ '#059669',
55
+ '#D97706',
56
+ '#7C3AED',
57
+ '#0F766E',
58
+ '#BE123C',
59
+ '#1D4ED8',
60
+ '#15803D',
61
+ '#B45309',
60
62
  ];
61
63
 
64
+ const currencyFormatter = new Intl.NumberFormat('pt-BR', {
65
+ style: 'currency',
66
+ currency: 'BRL',
67
+ });
68
+
62
69
  export default function TopCustomersReportPage() {
63
70
  const t = useTranslations('finance.TopCustomersReportPage');
64
71
  const defaults = getDefaultDateRange();
65
- const [from, setFrom] = useState(defaults.from);
66
- const [to, setTo] = useState(defaults.to);
67
- const [groupBy, setGroupBy] = useState<GroupBy>('year');
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
+ });
68
86
 
69
- const data = useMemo(
70
- () =>
71
- aggregateTopCustomers({
72
- from,
73
- to,
74
- groupBy,
75
- topN: 20,
76
- }),
77
- [from, to, groupBy]
78
- );
87
+ const handleSubmitFilters = (event: FormEvent<HTMLFormElement>) => {
88
+ event.preventDefault();
89
+ setAppliedFilters(filters);
90
+ };
79
91
 
80
- const leader = data.topCustomers[0];
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
+ };
81
135
 
82
136
  return (
83
137
  <Page>
@@ -91,164 +145,225 @@ export default function TopCustomersReportPage() {
91
145
  ]}
92
146
  />
93
147
 
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>
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>
139
225
 
140
- <div className="grid gap-4 md:grid-cols-3">
226
+ <div className="grid gap-3 md:grid-cols-3">
141
227
  <Card>
142
- <CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
143
- <CardTitle className="text-sm font-medium">
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">
144
230
  {t('cards.total')}
145
231
  </CardTitle>
146
- <Users className="h-4 w-4 text-muted-foreground" />
232
+ <div className="rounded-full bg-blue-500/10 p-1.5">
233
+ <Users className="h-4 w-4 text-blue-600" />
234
+ </div>
147
235
  </CardHeader>
148
- <CardContent>
149
- <div className="text-2xl font-bold">
150
- <Money value={data.total} />
236
+ <CardContent className="pb-3 pt-0 px-4">
237
+ <div className="text-xl font-bold">
238
+ <Money value={viewData.total} />
151
239
  </div>
152
240
  </CardContent>
153
241
  </Card>
154
242
  <Card>
155
- <CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
156
- <CardTitle className="text-sm font-medium">
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">
157
245
  {t('cards.top5Concentration')}
158
246
  </CardTitle>
159
- <PieChartIcon className="h-4 w-4 text-muted-foreground" />
247
+ <div className="rounded-full bg-purple-500/10 p-1.5">
248
+ <PieChartIcon className="h-4 w-4 text-purple-600" />
249
+ </div>
160
250
  </CardHeader>
161
- <CardContent>
162
- <div className="text-2xl font-bold">
163
- {data.top5Percent.toFixed(1)}%
251
+ <CardContent className="pb-3 pt-0 px-4">
252
+ <div className="text-xl font-bold">
253
+ {viewData.top5Percent.toFixed(1)}%
164
254
  </div>
165
255
  </CardContent>
166
256
  </Card>
167
257
  <Card>
168
- <CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
169
- <CardTitle className="text-sm font-medium">
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">
170
260
  {t('cards.leader')}
171
261
  </CardTitle>
172
- <Crown className="h-4 w-4 text-amber-500" />
262
+ <div className="rounded-full bg-amber-500/10 p-1.5">
263
+ <Crown className="h-4 w-4 text-amber-500" />
264
+ </div>
173
265
  </CardHeader>
174
- <CardContent>
175
- <div className="text-lg font-semibold">
176
- {leader?.customer || '-'}
266
+ <CardContent className="pb-3 pt-0 px-4">
267
+ <div className="text-sm font-medium">
268
+ {viewData.leader?.customer || '-'}
177
269
  </div>
178
- <div className="text-muted-foreground text-sm">
179
- {leader ? <Money value={leader.value} /> : '-'}
270
+ <div className="text-xs text-muted-foreground">
271
+ {viewData.leader ? <Money value={viewData.leader.value} /> : '-'}
180
272
  </div>
181
273
  </CardContent>
182
274
  </Card>
183
275
  </div>
184
276
 
185
- <div className="grid gap-4 xl:grid-cols-5">
277
+ <div className="grid gap-3 xl:grid-cols-5">
186
278
  <Card className="xl:col-span-3">
187
- <CardHeader>
279
+ <CardHeader className="pb-3">
188
280
  <CardTitle>{t('bars.title')}</CardTitle>
189
281
  <CardDescription>{t('bars.description')}</CardDescription>
190
282
  </CardHeader>
191
- <CardContent>
192
- <ResponsiveContainer width="100%" height={560}>
283
+ <CardContent className="pb-4 pt-0">
284
+ <ResponsiveContainer width="100%" height={360}>
193
285
  <BarChart
194
- data={[...data.topCustomers].reverse()}
286
+ data={viewData.topCustomers}
195
287
  layout="vertical"
196
288
  margin={{ left: 60, right: 16 }}
197
289
  >
290
+ <CartesianGrid strokeDasharray="3 3" className="stroke-muted" />
198
291
  <XAxis
199
292
  type="number"
200
293
  tickFormatter={(value) => `${(value / 1000).toFixed(0)}k`}
201
294
  />
202
295
  <YAxis dataKey="customer" type="category" width={190} />
203
296
  <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))"
297
+ cursor={{ fill: 'hsl(var(--muted) / 0.35)' }}
298
+ content={renderSolidTooltip}
215
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>
216
310
  </BarChart>
217
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>
218
328
  </CardContent>
219
329
  </Card>
220
330
 
221
331
  <Card className="xl:col-span-2">
222
- <CardHeader>
332
+ <CardHeader className="pb-3">
223
333
  <CardTitle>{t('pie.title')}</CardTitle>
224
334
  <CardDescription>{t('pie.description')}</CardDescription>
225
335
  </CardHeader>
226
- <CardContent>
227
- <ResponsiveContainer width="100%" height={380}>
336
+ <CardContent className="pb-4 pt-0">
337
+ <ResponsiveContainer width="100%" height={340}>
228
338
  <PieChart>
229
339
  <Pie
230
- data={data.pieData}
340
+ data={viewData.pieData}
231
341
  dataKey="value"
232
342
  nameKey="customer"
233
- innerRadius={65}
234
- outerRadius={120}
343
+ innerRadius={55}
344
+ outerRadius={100}
235
345
  paddingAngle={2}
346
+ cy="42%"
236
347
  >
237
- {data.pieData.map((entry, index) => (
348
+ {viewData.pieData.map((entry, index) => (
238
349
  <Cell
239
350
  key={entry.customer}
240
- fill={pieColors[index % pieColors.length]}
351
+ fill={rankingColors[index % rankingColors.length]}
352
+ stroke="hsl(var(--background))"
353
+ strokeWidth={1}
241
354
  />
242
355
  ))}
243
356
  </Pie>
244
- <Tooltip
245
- formatter={(value: number) =>
246
- new Intl.NumberFormat('pt-BR', {
247
- style: 'currency',
248
- currency: 'BRL',
249
- }).format(value)
250
- }
357
+ <Legend
358
+ iconType="circle"
359
+ verticalAlign="bottom"
360
+ wrapperStyle={{
361
+ fontSize: 12,
362
+ lineHeight: '20px',
363
+ paddingTop: 12,
364
+ }}
251
365
  />
366
+ <Tooltip cursor={false} content={renderSolidTooltip} />
252
367
  </PieChart>
253
368
  </ResponsiveContainer>
254
369
  </CardContent>
@@ -274,7 +389,7 @@ export default function TopCustomersReportPage() {
274
389
  </TableRow>
275
390
  </TableHeader>
276
391
  <TableBody>
277
- {data.topCustomers.length === 0 ? (
392
+ {viewData.topCustomers.length === 0 ? (
278
393
  <TableRow>
279
394
  <TableCell
280
395
  colSpan={3}
@@ -284,9 +399,11 @@ export default function TopCustomersReportPage() {
284
399
  </TableCell>
285
400
  </TableRow>
286
401
  ) : (
287
- data.topCustomers.map((item) => {
402
+ viewData.topCustomers.map((item) => {
288
403
  const participation =
289
- data.total > 0 ? (item.value / data.total) * 100 : 0;
404
+ viewData.total > 0
405
+ ? (item.value / viewData.total) * 100
406
+ : 0;
290
407
 
291
408
  return (
292
409
  <TableRow key={item.customer}>