@hed-hog/finance 0.0.278 → 0.0.279

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.
@@ -0,0 +1,337 @@
1
+ 'use client';
2
+
3
+ import { Page, PageHeader } from '@/components/entity-list';
4
+ import { Badge } from '@/components/ui/badge';
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 { Label } from '@/components/ui/label';
14
+ import { Money } from '@/components/ui/money';
15
+ import {
16
+ Select,
17
+ SelectContent,
18
+ SelectItem,
19
+ SelectTrigger,
20
+ SelectValue,
21
+ } from '@/components/ui/select';
22
+ import {
23
+ Table,
24
+ TableBody,
25
+ TableCell,
26
+ TableHead,
27
+ TableHeader,
28
+ TableRow,
29
+ } from '@/components/ui/table';
30
+ import { Target, TrendingDown, TrendingUp } from 'lucide-react';
31
+ import { useTranslations } from 'next-intl';
32
+ import { useMemo, useState } from 'react';
33
+ import {
34
+ CartesianGrid,
35
+ Legend,
36
+ Line,
37
+ LineChart,
38
+ ResponsiveContainer,
39
+ Tooltip,
40
+ XAxis,
41
+ YAxis,
42
+ } from 'recharts';
43
+ import {
44
+ aggregateOverview,
45
+ getDefaultDateRange,
46
+ } from '../_lib/report-aggregations';
47
+ import { type GroupBy } from '../_lib/report-mocks';
48
+
49
+ export default function OverviewResultsReportPage() {
50
+ const t = useTranslations('finance.OverviewResultsReportPage');
51
+ const defaults = getDefaultDateRange();
52
+ const [from, setFrom] = useState(defaults.from);
53
+ const [to, setTo] = useState(defaults.to);
54
+ const [groupBy, setGroupBy] = useState<GroupBy>('year');
55
+
56
+ const rows = useMemo(
57
+ () =>
58
+ aggregateOverview({
59
+ from,
60
+ to,
61
+ groupBy,
62
+ }),
63
+ [from, to, groupBy]
64
+ );
65
+
66
+ const totals = useMemo(() => {
67
+ const faturamento = rows.reduce((acc, row) => acc + row.faturamento, 0);
68
+ const despesasEmprestimos = rows.reduce(
69
+ (acc, row) => acc + row.despesasEmprestimos,
70
+ 0
71
+ );
72
+ const diferenca = faturamento - despesasEmprestimos;
73
+ const aporteInvestidor = rows.reduce(
74
+ (acc, row) => acc + row.aporteInvestidor,
75
+ 0
76
+ );
77
+
78
+ const margem =
79
+ faturamento > 0 ? (diferenca / Math.abs(faturamento)) * 100 : 0;
80
+
81
+ return {
82
+ faturamento,
83
+ despesasEmprestimos,
84
+ diferenca,
85
+ aporteInvestidor,
86
+ margem,
87
+ };
88
+ }, [rows]);
89
+
90
+ return (
91
+ <Page>
92
+ <PageHeader
93
+ title={t('header.title')}
94
+ description={t('header.description')}
95
+ breadcrumbs={[
96
+ { label: t('breadcrumbs.home'), href: '/' },
97
+ { label: t('breadcrumbs.finance'), href: '/finance' },
98
+ { label: t('breadcrumbs.current') },
99
+ ]}
100
+ />
101
+
102
+ <Card>
103
+ <CardHeader>
104
+ <CardTitle>{t('filters.title')}</CardTitle>
105
+ <CardDescription>{t('filters.description')}</CardDescription>
106
+ </CardHeader>
107
+ <CardContent className="grid gap-4 md:grid-cols-3">
108
+ <div className="space-y-2">
109
+ <Label htmlFor="from">{t('filters.from')}</Label>
110
+ <Input
111
+ id="from"
112
+ type="date"
113
+ value={from}
114
+ onChange={(event) => setFrom(event.target.value)}
115
+ max={to}
116
+ />
117
+ </div>
118
+ <div className="space-y-2">
119
+ <Label htmlFor="to">{t('filters.to')}</Label>
120
+ <Input
121
+ id="to"
122
+ type="date"
123
+ value={to}
124
+ onChange={(event) => setTo(event.target.value)}
125
+ min={from}
126
+ />
127
+ </div>
128
+ <div className="space-y-2">
129
+ <Label>{t('filters.groupBy')}</Label>
130
+ <Select
131
+ value={groupBy}
132
+ onValueChange={(value) => setGroupBy(value as GroupBy)}
133
+ >
134
+ <SelectTrigger>
135
+ <SelectValue />
136
+ </SelectTrigger>
137
+ <SelectContent>
138
+ <SelectItem value="day">{t('groupBy.day')}</SelectItem>
139
+ <SelectItem value="week">{t('groupBy.week')}</SelectItem>
140
+ <SelectItem value="month">{t('groupBy.month')}</SelectItem>
141
+ <SelectItem value="year">{t('groupBy.year')}</SelectItem>
142
+ </SelectContent>
143
+ </Select>
144
+ </div>
145
+ </CardContent>
146
+ </Card>
147
+
148
+ <div className="grid gap-4 md:grid-cols-4">
149
+ <Card>
150
+ <CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
151
+ <CardTitle className="text-sm font-medium">
152
+ {t('cards.revenue')}
153
+ </CardTitle>
154
+ <TrendingUp className="h-4 w-4 text-blue-600" />
155
+ </CardHeader>
156
+ <CardContent>
157
+ <div className="text-2xl font-bold">
158
+ <Money value={totals.faturamento} />
159
+ </div>
160
+ </CardContent>
161
+ </Card>
162
+
163
+ <Card>
164
+ <CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
165
+ <CardTitle className="text-sm font-medium">
166
+ {t('cards.expensesAndLoans')}
167
+ </CardTitle>
168
+ <TrendingDown className="h-4 w-4 text-orange-600" />
169
+ </CardHeader>
170
+ <CardContent>
171
+ <div className="text-2xl font-bold">
172
+ <Money value={totals.despesasEmprestimos} />
173
+ </div>
174
+ </CardContent>
175
+ </Card>
176
+
177
+ <Card>
178
+ <CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
179
+ <CardTitle className="text-sm font-medium">
180
+ {t('cards.resultDifference')}
181
+ </CardTitle>
182
+ {totals.diferenca >= 0 ? (
183
+ <TrendingUp className="h-4 w-4 text-green-600" />
184
+ ) : (
185
+ <TrendingDown className="h-4 w-4 text-red-600" />
186
+ )}
187
+ </CardHeader>
188
+ <CardContent>
189
+ <div
190
+ className={`text-2xl font-bold ${totals.diferenca >= 0 ? 'text-green-600' : 'text-red-600'}`}
191
+ >
192
+ <Money value={totals.diferenca} showSign />
193
+ </div>
194
+ </CardContent>
195
+ </Card>
196
+
197
+ <Card>
198
+ <CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
199
+ <CardTitle className="text-sm font-medium">
200
+ {t('cards.margin')}
201
+ </CardTitle>
202
+ <Target className="h-4 w-4 text-muted-foreground" />
203
+ </CardHeader>
204
+ <CardContent>
205
+ <div className="text-2xl font-bold">
206
+ {totals.margem.toFixed(1)}%
207
+ </div>
208
+ <Badge
209
+ className="mt-2"
210
+ variant={totals.margem >= 0 ? 'default' : 'destructive'}
211
+ >
212
+ {totals.margem >= 0 ? t('status.positive') : t('status.negative')}
213
+ </Badge>
214
+ </CardContent>
215
+ </Card>
216
+ </div>
217
+
218
+ <Card>
219
+ <CardHeader>
220
+ <CardTitle>{t('chart.title')}</CardTitle>
221
+ <CardDescription>{t('chart.description')}</CardDescription>
222
+ </CardHeader>
223
+ <CardContent>
224
+ <ResponsiveContainer width="100%" height={360}>
225
+ <LineChart data={rows}>
226
+ <CartesianGrid strokeDasharray="3 3" className="stroke-muted" />
227
+ <XAxis dataKey="label" tick={{ fontSize: 12 }} />
228
+ <YAxis
229
+ tick={{ fontSize: 12 }}
230
+ tickFormatter={(value) => `${(value / 1000).toFixed(0)}k`}
231
+ />
232
+ <Tooltip
233
+ formatter={(value: number) =>
234
+ new Intl.NumberFormat('pt-BR', {
235
+ style: 'currency',
236
+ currency: 'BRL',
237
+ }).format(value)
238
+ }
239
+ contentStyle={{
240
+ backgroundColor: 'hsl(var(--background))',
241
+ border: '1px solid hsl(var(--border))',
242
+ borderRadius: '8px',
243
+ }}
244
+ />
245
+ <Legend />
246
+ <Line
247
+ type="monotone"
248
+ dataKey="faturamento"
249
+ name={t('chart.revenueLabel')}
250
+ stroke="hsl(var(--chart-1))"
251
+ strokeWidth={3}
252
+ dot={false}
253
+ />
254
+ <Line
255
+ type="monotone"
256
+ dataKey="despesasEmprestimos"
257
+ name={t('chart.expensesLabel')}
258
+ stroke="hsl(var(--chart-5))"
259
+ strokeWidth={3}
260
+ dot={false}
261
+ />
262
+ <Line
263
+ type="monotone"
264
+ dataKey="diferenca"
265
+ name={t('chart.differenceLabel')}
266
+ stroke="hsl(var(--chart-2))"
267
+ strokeWidth={2}
268
+ strokeDasharray="6 4"
269
+ dot={false}
270
+ />
271
+ </LineChart>
272
+ </ResponsiveContainer>
273
+ </CardContent>
274
+ </Card>
275
+
276
+ <Card>
277
+ <CardHeader>
278
+ <CardTitle>{t('table.title')}</CardTitle>
279
+ <CardDescription>{t('table.description')}</CardDescription>
280
+ </CardHeader>
281
+ <CardContent>
282
+ <Table>
283
+ <TableHeader>
284
+ <TableRow>
285
+ <TableHead>{t('table.headers.period')}</TableHead>
286
+ <TableHead className="text-right">
287
+ {t('table.headers.revenue')}
288
+ </TableHead>
289
+ <TableHead className="text-right">
290
+ {t('table.headers.expensesAndLoans')}
291
+ </TableHead>
292
+ <TableHead className="text-right">
293
+ {t('table.headers.investorContribution')}
294
+ </TableHead>
295
+ <TableHead className="text-right">
296
+ {t('table.headers.difference')}
297
+ </TableHead>
298
+ </TableRow>
299
+ </TableHeader>
300
+ <TableBody>
301
+ {rows.length === 0 ? (
302
+ <TableRow>
303
+ <TableCell
304
+ colSpan={5}
305
+ className="text-muted-foreground text-center"
306
+ >
307
+ {t('table.empty')}
308
+ </TableCell>
309
+ </TableRow>
310
+ ) : (
311
+ rows.map((row) => (
312
+ <TableRow key={row.period}>
313
+ <TableCell>{row.label}</TableCell>
314
+ <TableCell className="text-right">
315
+ <Money value={row.faturamento} />
316
+ </TableCell>
317
+ <TableCell className="text-right">
318
+ <Money value={row.despesasEmprestimos} />
319
+ </TableCell>
320
+ <TableCell className="text-right">
321
+ <Money value={row.aporteInvestidor} />
322
+ </TableCell>
323
+ <TableCell
324
+ className={`text-right font-semibold ${row.diferenca >= 0 ? 'text-green-600' : 'text-red-600'}`}
325
+ >
326
+ <Money value={row.diferenca} showSign />
327
+ </TableCell>
328
+ </TableRow>
329
+ ))
330
+ )}
331
+ </TableBody>
332
+ </Table>
333
+ </CardContent>
334
+ </Card>
335
+ </Page>
336
+ );
337
+ }
@@ -0,0 +1,310 @@
1
+ 'use client';
2
+
3
+ import { Page, PageHeader } from '@/components/entity-list';
4
+ import {
5
+ Card,
6
+ CardContent,
7
+ CardDescription,
8
+ CardHeader,
9
+ CardTitle,
10
+ } from '@/components/ui/card';
11
+ import { Input } from '@/components/ui/input';
12
+ import { Label } from '@/components/ui/label';
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 { useMemo, useState } from 'react';
32
+ import {
33
+ Bar,
34
+ BarChart,
35
+ Cell,
36
+ Pie,
37
+ PieChart,
38
+ ResponsiveContainer,
39
+ Tooltip,
40
+ XAxis,
41
+ YAxis,
42
+ } from 'recharts';
43
+ import {
44
+ aggregateTopCustomers,
45
+ getDefaultDateRange,
46
+ } from '../_lib/report-aggregations';
47
+ import { type GroupBy } from '../_lib/report-mocks';
48
+
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',
60
+ ];
61
+
62
+ export default function TopCustomersReportPage() {
63
+ const t = useTranslations('finance.TopCustomersReportPage');
64
+ const defaults = getDefaultDateRange();
65
+ const [from, setFrom] = useState(defaults.from);
66
+ const [to, setTo] = useState(defaults.to);
67
+ const [groupBy, setGroupBy] = useState<GroupBy>('year');
68
+
69
+ const data = useMemo(
70
+ () =>
71
+ aggregateTopCustomers({
72
+ from,
73
+ to,
74
+ groupBy,
75
+ topN: 20,
76
+ }),
77
+ [from, to, groupBy]
78
+ );
79
+
80
+ const leader = data.topCustomers[0];
81
+
82
+ return (
83
+ <Page>
84
+ <PageHeader
85
+ title={t('header.title')}
86
+ description={t('header.description')}
87
+ breadcrumbs={[
88
+ { label: t('breadcrumbs.home'), href: '/' },
89
+ { label: t('breadcrumbs.finance'), href: '/finance' },
90
+ { label: t('breadcrumbs.current') },
91
+ ]}
92
+ />
93
+
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>
139
+
140
+ <div className="grid gap-4 md:grid-cols-3">
141
+ <Card>
142
+ <CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
143
+ <CardTitle className="text-sm font-medium">
144
+ {t('cards.total')}
145
+ </CardTitle>
146
+ <Users className="h-4 w-4 text-muted-foreground" />
147
+ </CardHeader>
148
+ <CardContent>
149
+ <div className="text-2xl font-bold">
150
+ <Money value={data.total} />
151
+ </div>
152
+ </CardContent>
153
+ </Card>
154
+ <Card>
155
+ <CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
156
+ <CardTitle className="text-sm font-medium">
157
+ {t('cards.top5Concentration')}
158
+ </CardTitle>
159
+ <PieChartIcon className="h-4 w-4 text-muted-foreground" />
160
+ </CardHeader>
161
+ <CardContent>
162
+ <div className="text-2xl font-bold">
163
+ {data.top5Percent.toFixed(1)}%
164
+ </div>
165
+ </CardContent>
166
+ </Card>
167
+ <Card>
168
+ <CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
169
+ <CardTitle className="text-sm font-medium">
170
+ {t('cards.leader')}
171
+ </CardTitle>
172
+ <Crown className="h-4 w-4 text-amber-500" />
173
+ </CardHeader>
174
+ <CardContent>
175
+ <div className="text-lg font-semibold">
176
+ {leader?.customer || '-'}
177
+ </div>
178
+ <div className="text-muted-foreground text-sm">
179
+ {leader ? <Money value={leader.value} /> : '-'}
180
+ </div>
181
+ </CardContent>
182
+ </Card>
183
+ </div>
184
+
185
+ <div className="grid gap-4 xl:grid-cols-5">
186
+ <Card className="xl:col-span-3">
187
+ <CardHeader>
188
+ <CardTitle>{t('bars.title')}</CardTitle>
189
+ <CardDescription>{t('bars.description')}</CardDescription>
190
+ </CardHeader>
191
+ <CardContent>
192
+ <ResponsiveContainer width="100%" height={560}>
193
+ <BarChart
194
+ data={[...data.topCustomers].reverse()}
195
+ layout="vertical"
196
+ margin={{ left: 60, right: 16 }}
197
+ >
198
+ <XAxis
199
+ type="number"
200
+ tickFormatter={(value) => `${(value / 1000).toFixed(0)}k`}
201
+ />
202
+ <YAxis dataKey="customer" type="category" width={190} />
203
+ <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))"
215
+ />
216
+ </BarChart>
217
+ </ResponsiveContainer>
218
+ </CardContent>
219
+ </Card>
220
+
221
+ <Card className="xl:col-span-2">
222
+ <CardHeader>
223
+ <CardTitle>{t('pie.title')}</CardTitle>
224
+ <CardDescription>{t('pie.description')}</CardDescription>
225
+ </CardHeader>
226
+ <CardContent>
227
+ <ResponsiveContainer width="100%" height={380}>
228
+ <PieChart>
229
+ <Pie
230
+ data={data.pieData}
231
+ dataKey="value"
232
+ nameKey="customer"
233
+ innerRadius={65}
234
+ outerRadius={120}
235
+ paddingAngle={2}
236
+ >
237
+ {data.pieData.map((entry, index) => (
238
+ <Cell
239
+ key={entry.customer}
240
+ fill={pieColors[index % pieColors.length]}
241
+ />
242
+ ))}
243
+ </Pie>
244
+ <Tooltip
245
+ formatter={(value: number) =>
246
+ new Intl.NumberFormat('pt-BR', {
247
+ style: 'currency',
248
+ currency: 'BRL',
249
+ }).format(value)
250
+ }
251
+ />
252
+ </PieChart>
253
+ </ResponsiveContainer>
254
+ </CardContent>
255
+ </Card>
256
+ </div>
257
+
258
+ <Card>
259
+ <CardHeader>
260
+ <CardTitle>{t('table.title')}</CardTitle>
261
+ <CardDescription>{t('table.description')}</CardDescription>
262
+ </CardHeader>
263
+ <CardContent>
264
+ <Table>
265
+ <TableHeader>
266
+ <TableRow>
267
+ <TableHead>{t('table.headers.customer')}</TableHead>
268
+ <TableHead className="text-right">
269
+ {t('table.headers.value')}
270
+ </TableHead>
271
+ <TableHead className="text-right">
272
+ {t('table.headers.participation')}
273
+ </TableHead>
274
+ </TableRow>
275
+ </TableHeader>
276
+ <TableBody>
277
+ {data.topCustomers.length === 0 ? (
278
+ <TableRow>
279
+ <TableCell
280
+ colSpan={3}
281
+ className="text-muted-foreground text-center"
282
+ >
283
+ {t('table.empty')}
284
+ </TableCell>
285
+ </TableRow>
286
+ ) : (
287
+ data.topCustomers.map((item) => {
288
+ const participation =
289
+ data.total > 0 ? (item.value / data.total) * 100 : 0;
290
+
291
+ return (
292
+ <TableRow key={item.customer}>
293
+ <TableCell>{item.customer}</TableCell>
294
+ <TableCell className="text-right">
295
+ <Money value={item.value} />
296
+ </TableCell>
297
+ <TableCell className="text-right">
298
+ {participation.toFixed(2)}%
299
+ </TableCell>
300
+ </TableRow>
301
+ );
302
+ })
303
+ )}
304
+ </TableBody>
305
+ </Table>
306
+ </CardContent>
307
+ </Card>
308
+ </Page>
309
+ );
310
+ }