@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,6 @@
1
1
  'use client';
2
2
 
3
- import { Page, PageHeader } from '@/components/entity-list';
3
+ import { EmptyState, Page, PageHeader } from '@/components/entity-list';
4
4
  import { Button } from '@/components/ui/button';
5
5
  import {
6
6
  Card,
@@ -357,16 +357,27 @@ function ImportarExtratoSheet({
357
357
  defaultBankAccountId,
358
358
  onImported,
359
359
  onBankAccountCreated,
360
+ open,
361
+ onOpenChange,
360
362
  }: {
361
363
  contasBancarias: BankAccount[];
362
364
  t: ReturnType<typeof useTranslations>;
363
365
  defaultBankAccountId?: string;
364
366
  onImported: () => Promise<any> | void;
365
367
  onBankAccountCreated: (createdBankAccountId?: string) => Promise<void> | void;
368
+ open?: boolean;
369
+ onOpenChange?: (open: boolean) => void;
366
370
  }) {
367
371
  const { request, showToastHandler } = useApp();
368
- const [open, setOpen] = useState(false);
372
+ const [internalOpen, setInternalOpen] = useState(false);
369
373
  const [openNovaContaSheet, setOpenNovaContaSheet] = useState(false);
374
+ const isOpen = open ?? internalOpen;
375
+ const handleOpenChange = (nextOpen: boolean) => {
376
+ onOpenChange?.(nextOpen);
377
+ if (open === undefined) {
378
+ setInternalOpen(nextOpen);
379
+ }
380
+ };
370
381
 
371
382
  const form = useForm<ImportStatementFormValues>({
372
383
  resolver: zodResolver(importStatementSchema),
@@ -376,7 +387,7 @@ function ImportarExtratoSheet({
376
387
  });
377
388
 
378
389
  useEffect(() => {
379
- if (!open) {
390
+ if (!isOpen) {
380
391
  return;
381
392
  }
382
393
 
@@ -384,7 +395,7 @@ function ImportarExtratoSheet({
384
395
  bankAccountId: defaultBankAccountId || '',
385
396
  file: undefined as unknown as File,
386
397
  });
387
- }, [defaultBankAccountId, form, open]);
398
+ }, [defaultBankAccountId, form, isOpen]);
388
399
 
389
400
  const handleSubmit = async (values: ImportStatementFormValues) => {
390
401
  const formData = new FormData();
@@ -400,15 +411,15 @@ function ImportarExtratoSheet({
400
411
 
401
412
  await onImported();
402
413
  showToastHandler?.('success', 'Extrato importado com sucesso');
403
- setOpen(false);
414
+ handleOpenChange(false);
404
415
  } catch {
405
416
  showToastHandler?.('error', 'Não foi possível importar o extrato');
406
417
  }
407
418
  };
408
419
 
409
420
  return (
410
- <Sheet open={open} onOpenChange={setOpen}>
411
- <Button onClick={() => setOpen(true)}>
421
+ <Sheet open={isOpen} onOpenChange={handleOpenChange}>
422
+ <Button onClick={() => handleOpenChange(true)}>
412
423
  <Upload className="mr-2 h-4 w-4" />
413
424
  {t('importDialog.action')}
414
425
  </Button>
@@ -510,7 +521,7 @@ function ImportarExtratoSheet({
510
521
  <Button
511
522
  type="button"
512
523
  variant="outline"
513
- onClick={() => setOpen(false)}
524
+ onClick={() => handleOpenChange(false)}
514
525
  >
515
526
  {t('common.cancel')}
516
527
  </Button>
@@ -536,6 +547,7 @@ export default function ExtratosPage() {
536
547
  const [contaFilter, setContaFilter] = useState<string>('');
537
548
  const [search, setSearch] = useState('');
538
549
  const [debouncedSearch, setDebouncedSearch] = useState('');
550
+ const [isImportSheetOpen, setIsImportSheetOpen] = useState(false);
539
551
  const [extratoSelecionado, setExtratoSelecionado] =
540
552
  useState<Statement | null>(null);
541
553
 
@@ -717,6 +729,8 @@ export default function ExtratosPage() {
717
729
  contasBancarias={contasBancarias}
718
730
  t={t}
719
731
  defaultBankAccountId={contaFilter}
732
+ open={isImportSheetOpen}
733
+ onOpenChange={setIsImportSheetOpen}
720
734
  onImported={refetchExtratos}
721
735
  onBankAccountCreated={handleBankAccountCreated}
722
736
  />
@@ -797,89 +811,99 @@ export default function ExtratosPage() {
797
811
  </Card>
798
812
  </div>
799
813
 
800
- <Card>
801
- <CardHeader>
802
- <CardTitle>{t('table.title')}</CardTitle>
803
- <CardDescription>
804
- {t('table.foundTransactions', { count: extratos.length })}
805
- </CardDescription>
806
- </CardHeader>
807
- <CardContent>
808
- <div className="overflow-x-auto">
809
- <Table className="min-w-[760px] table-fixed">
810
- <TableHeader>
811
- <TableRow>
812
- <TableHead className="w-[110px]">
813
- {t('table.headers.date')}
814
- </TableHead>
815
- <TableHead>{t('table.headers.description')}</TableHead>
816
- <TableHead className="w-[130px] text-right">
817
- {t('table.headers.value')}
818
- </TableHead>
819
- <TableHead className="w-[110px]">
820
- {t('table.headers.type')}
821
- </TableHead>
822
- <TableHead className="w-[140px]">
823
- {t('table.headers.reconciliation')}
824
- </TableHead>
825
- </TableRow>
826
- </TableHeader>
827
- <TableBody>
828
- {extratos.map((extrato) => (
829
- <TableRow
830
- key={extrato.id}
831
- className="cursor-pointer"
832
- onClick={() => setExtratoSelecionado(extrato)}
833
- onKeyDown={(event) => {
834
- if (event.key === 'Enter' || event.key === ' ') {
835
- event.preventDefault();
836
- setExtratoSelecionado(extrato);
837
- }
838
- }}
839
- role="button"
840
- tabIndex={0}
841
- >
842
- <TableCell>{formatarData(extrato.data)}</TableCell>
843
- <TableCell className="truncate" title={extrato.descricao}>
844
- {extrato.descricao}
845
- </TableCell>
846
- <TableCell className="text-right">
847
- <span
848
- className={
849
- extrato.tipo === 'entrada'
850
- ? 'text-green-600'
851
- : 'text-red-600'
814
+ {extratos.length > 0 ? (
815
+ <Card>
816
+ <CardHeader>
817
+ <CardTitle>{t('table.title')}</CardTitle>
818
+ <CardDescription>
819
+ {t('table.foundTransactions', { count: extratos.length })}
820
+ </CardDescription>
821
+ </CardHeader>
822
+ <CardContent>
823
+ <div className="overflow-x-auto">
824
+ <Table className="min-w-[760px] table-fixed">
825
+ <TableHeader>
826
+ <TableRow>
827
+ <TableHead className="w-[110px]">
828
+ {t('table.headers.date')}
829
+ </TableHead>
830
+ <TableHead>{t('table.headers.description')}</TableHead>
831
+ <TableHead className="w-[130px] text-right">
832
+ {t('table.headers.value')}
833
+ </TableHead>
834
+ <TableHead className="w-[110px]">
835
+ {t('table.headers.type')}
836
+ </TableHead>
837
+ <TableHead className="w-[140px]">
838
+ {t('table.headers.reconciliation')}
839
+ </TableHead>
840
+ </TableRow>
841
+ </TableHeader>
842
+ <TableBody>
843
+ {extratos.map((extrato) => (
844
+ <TableRow
845
+ key={extrato.id}
846
+ className="cursor-pointer"
847
+ onClick={() => setExtratoSelecionado(extrato)}
848
+ onKeyDown={(event) => {
849
+ if (event.key === 'Enter' || event.key === ' ') {
850
+ event.preventDefault();
851
+ setExtratoSelecionado(extrato);
852
852
  }
853
- >
854
- <Money value={extrato.valor} />
855
- </span>
856
- </TableCell>
857
- <TableCell>
858
- {extrato.tipo === 'entrada' ? (
859
- <span className="flex items-center gap-1 text-green-600">
860
- <ArrowUpRight className="h-4 w-4" />
861
- {t('types.inflow')}
862
- </span>
863
- ) : (
864
- <span className="flex items-center gap-1 text-red-600">
865
- <ArrowDownRight className="h-4 w-4" />
866
- {t('types.outflow')}
853
+ }}
854
+ role="button"
855
+ tabIndex={0}
856
+ >
857
+ <TableCell>{formatarData(extrato.data)}</TableCell>
858
+ <TableCell className="truncate" title={extrato.descricao}>
859
+ {extrato.descricao}
860
+ </TableCell>
861
+ <TableCell className="text-right">
862
+ <span
863
+ className={
864
+ extrato.tipo === 'entrada'
865
+ ? 'text-green-600'
866
+ : 'text-red-600'
867
+ }
868
+ >
869
+ <Money value={extrato.valor} />
867
870
  </span>
868
- )}
869
- </TableCell>
870
- <TableCell>
871
- <StatusBadge
872
- status={extrato.statusConciliacao}
873
- type="conciliacao"
874
- />
875
- </TableCell>
876
- </TableRow>
877
- ))}
878
- </TableBody>
879
- </Table>
880
- </div>
881
- </CardContent>
882
- </Card>
871
+ </TableCell>
872
+ <TableCell>
873
+ {extrato.tipo === 'entrada' ? (
874
+ <span className="flex items-center gap-1 text-green-600">
875
+ <ArrowUpRight className="h-4 w-4" />
876
+ {t('types.inflow')}
877
+ </span>
878
+ ) : (
879
+ <span className="flex items-center gap-1 text-red-600">
880
+ <ArrowDownRight className="h-4 w-4" />
881
+ {t('types.outflow')}
882
+ </span>
883
+ )}
884
+ </TableCell>
885
+ <TableCell>
886
+ <StatusBadge
887
+ status={extrato.statusConciliacao}
888
+ type="conciliacao"
889
+ />
890
+ </TableCell>
891
+ </TableRow>
892
+ ))}
893
+ </TableBody>
894
+ </Table>
895
+ </div>
896
+ </CardContent>
897
+ </Card>
898
+ ) : (
899
+ <EmptyState
900
+ icon={<Upload className="h-12 w-12" />}
901
+ title={t('empty.title')}
902
+ description={t('empty.description')}
903
+ actionLabel={t('importDialog.action')}
904
+ onAction={() => setIsImportSheetOpen(true)}
905
+ />
906
+ )}
883
907
 
884
908
  <Dialog
885
909
  open={!!extratoSelecionado}
@@ -0,0 +1,238 @@
1
+ 'use client';
2
+
3
+ import { useApp, useQuery } from '@hed-hog/next-app-provider';
4
+
5
+ export type GroupBy = 'day' | 'week' | 'month' | 'year';
6
+
7
+ type QueryValue = string | number | undefined | null;
8
+
9
+ export type OverviewResultsRow = {
10
+ period: string;
11
+ faturamento: number;
12
+ despesasEmprestimos: number;
13
+ diferenca: number;
14
+ aporteInvestidor: number;
15
+ emprestimoBanco: number;
16
+ despesas: number;
17
+ };
18
+
19
+ export type OverviewResultsReportData = {
20
+ rows: OverviewResultsRow[];
21
+ totals: {
22
+ faturamento: number;
23
+ despesasEmprestimos: number;
24
+ diferenca: number;
25
+ aporteInvestidor: number;
26
+ emprestimoBanco: number;
27
+ despesas: number;
28
+ margem: number;
29
+ };
30
+ };
31
+
32
+ export type TopCustomerItem = {
33
+ customer: string;
34
+ value: number;
35
+ };
36
+
37
+ export type TopCustomersReportData = {
38
+ total: number;
39
+ top5Percent: number;
40
+ topCustomers: TopCustomerItem[];
41
+ pieData: TopCustomerItem[];
42
+ groupedPeriods: Array<{ period: string; value: number }>;
43
+ leader: TopCustomerItem | null;
44
+ };
45
+
46
+ export type TopExpenseItem = {
47
+ category: string;
48
+ costCenter: string;
49
+ label: string;
50
+ value: number;
51
+ };
52
+
53
+ export type TopOperationalExpensesReportData = {
54
+ total: number;
55
+ average: number;
56
+ topExpenses: TopExpenseItem[];
57
+ pieData: Array<{ name: string; value: number }>;
58
+ groupedPeriods: Array<{ period: string; value: number }>;
59
+ highest: TopExpenseItem | null;
60
+ };
61
+
62
+ const EMPTY_OVERVIEW_RESULTS: OverviewResultsReportData = {
63
+ rows: [],
64
+ totals: {
65
+ faturamento: 0,
66
+ despesasEmprestimos: 0,
67
+ diferenca: 0,
68
+ aporteInvestidor: 0,
69
+ emprestimoBanco: 0,
70
+ despesas: 0,
71
+ margem: 0,
72
+ },
73
+ };
74
+
75
+ const EMPTY_TOP_CUSTOMERS: TopCustomersReportData = {
76
+ total: 0,
77
+ top5Percent: 0,
78
+ topCustomers: [],
79
+ pieData: [],
80
+ groupedPeriods: [],
81
+ leader: null,
82
+ };
83
+
84
+ const EMPTY_TOP_EXPENSES: TopOperationalExpensesReportData = {
85
+ total: 0,
86
+ average: 0,
87
+ topExpenses: [],
88
+ pieData: [],
89
+ groupedPeriods: [],
90
+ highest: null,
91
+ };
92
+
93
+ function buildQuery(params: Record<string, QueryValue>) {
94
+ const query = new URLSearchParams();
95
+
96
+ Object.entries(params).forEach(([key, value]) => {
97
+ if (value === undefined || value === null || value === '') {
98
+ return;
99
+ }
100
+
101
+ query.set(key, String(value));
102
+ });
103
+
104
+ return query.toString();
105
+ }
106
+
107
+ function toDate(value: string) {
108
+ return new Date(`${value}T00:00:00`);
109
+ }
110
+
111
+ function startOfWeek(date: Date) {
112
+ const current = new Date(date);
113
+ const day = current.getDay();
114
+ const diff = day === 0 ? -6 : 1 - day;
115
+
116
+ current.setDate(current.getDate() + diff);
117
+ current.setHours(0, 0, 0, 0);
118
+
119
+ return current;
120
+ }
121
+
122
+ export function getDefaultDateRange() {
123
+ return {
124
+ from: '2021-01-01',
125
+ to: '2026-12-31',
126
+ };
127
+ }
128
+
129
+ export function formatReportBucketLabel(
130
+ bucket: string,
131
+ groupBy: GroupBy,
132
+ locale: string
133
+ ) {
134
+ const dateFormatter = new Intl.DateTimeFormat(locale);
135
+
136
+ if (groupBy === 'day') {
137
+ return dateFormatter.format(toDate(bucket));
138
+ }
139
+
140
+ if (groupBy === 'week') {
141
+ const [year, week] = bucket.split('-W');
142
+
143
+ return `W${Number(week)}/${year}`;
144
+ }
145
+
146
+ if (groupBy === 'month') {
147
+ const [year, month] = bucket.split('-');
148
+ const monthDate = new Date(Number(year), Number(month) - 1, 1);
149
+
150
+ return new Intl.DateTimeFormat(locale, {
151
+ month: 'short',
152
+ year: 'numeric',
153
+ }).format(monthDate);
154
+ }
155
+
156
+ return bucket;
157
+ }
158
+
159
+ function useFinanceReportQuery<T>(
160
+ queryKey: string,
161
+ path: string,
162
+ params: Record<string, QueryValue>,
163
+ initialData: T
164
+ ) {
165
+ const { request } = useApp();
166
+ const querySuffix = buildQuery(params);
167
+ const url = querySuffix ? `${path}?${querySuffix}` : path;
168
+
169
+ const query = useQuery<T>({
170
+ queryKey: [queryKey, querySuffix || 'default'],
171
+ staleTime: 0,
172
+ refetchOnMount: 'always',
173
+ queryFn: async () => {
174
+ try {
175
+ const response = await request({
176
+ url,
177
+ method: 'GET',
178
+ });
179
+
180
+ return {
181
+ ...initialData,
182
+ ...(response?.data || {}),
183
+ } as T;
184
+ } catch {
185
+ return initialData;
186
+ }
187
+ },
188
+ initialData,
189
+ });
190
+
191
+ return {
192
+ ...query,
193
+ data: query.data ?? initialData,
194
+ };
195
+ }
196
+
197
+ export function useOverviewResultsReport(filters: {
198
+ from: string;
199
+ to: string;
200
+ groupBy: GroupBy;
201
+ }) {
202
+ return useFinanceReportQuery<OverviewResultsReportData>(
203
+ 'finance-overview-results-report',
204
+ '/finance/reports/overview-results',
205
+ filters,
206
+ EMPTY_OVERVIEW_RESULTS
207
+ );
208
+ }
209
+
210
+ export function useTopCustomersReport(filters: {
211
+ from: string;
212
+ to: string;
213
+ groupBy: GroupBy;
214
+ search?: string;
215
+ topN?: number;
216
+ }) {
217
+ return useFinanceReportQuery<TopCustomersReportData>(
218
+ 'finance-top-customers-report',
219
+ '/finance/reports/top-customers',
220
+ filters,
221
+ EMPTY_TOP_CUSTOMERS
222
+ );
223
+ }
224
+
225
+ export function useTopOperationalExpensesReport(filters: {
226
+ from: string;
227
+ to: string;
228
+ groupBy: GroupBy;
229
+ search?: string;
230
+ topN?: number;
231
+ }) {
232
+ return useFinanceReportQuery<TopOperationalExpensesReportData>(
233
+ 'finance-top-operational-expenses-report',
234
+ '/finance/reports/top-operational-expenses',
235
+ filters,
236
+ EMPTY_TOP_EXPENSES
237
+ );
238
+ }