@hed-hog/finance 0.0.299 → 0.0.301

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 (79) hide show
  1. package/dist/dto/create-bank-account.dto.d.ts +1 -0
  2. package/dist/dto/create-bank-account.dto.d.ts.map +1 -1
  3. package/dist/dto/create-bank-account.dto.js +7 -0
  4. package/dist/dto/create-bank-account.dto.js.map +1 -1
  5. package/dist/dto/update-bank-account.dto.d.ts +1 -0
  6. package/dist/dto/update-bank-account.dto.d.ts.map +1 -1
  7. package/dist/dto/update-bank-account.dto.js +7 -0
  8. package/dist/dto/update-bank-account.dto.js.map +1 -1
  9. package/dist/finance-bank-accounts.controller.d.ts +3 -0
  10. package/dist/finance-bank-accounts.controller.d.ts.map +1 -1
  11. package/dist/finance-data.controller.d.ts +1 -0
  12. package/dist/finance-data.controller.d.ts.map +1 -1
  13. package/dist/finance.contract-activated.subscriber.d.ts +24 -0
  14. package/dist/finance.contract-activated.subscriber.d.ts.map +1 -0
  15. package/dist/finance.contract-activated.subscriber.js +519 -0
  16. package/dist/finance.contract-activated.subscriber.js.map +1 -0
  17. package/dist/finance.contract-activated.subscriber.spec.d.ts +2 -0
  18. package/dist/finance.contract-activated.subscriber.spec.d.ts.map +1 -0
  19. package/dist/finance.contract-activated.subscriber.spec.js +302 -0
  20. package/dist/finance.contract-activated.subscriber.spec.js.map +1 -0
  21. package/dist/finance.module.d.ts.map +1 -1
  22. package/dist/finance.module.js +6 -1
  23. package/dist/finance.module.js.map +1 -1
  24. package/dist/finance.service.d.ts +4 -0
  25. package/dist/finance.service.d.ts.map +1 -1
  26. package/dist/finance.service.js +5 -0
  27. package/dist/finance.service.js.map +1 -1
  28. package/hedhog/data/dashboard.yaml +6 -0
  29. package/hedhog/data/dashboard_component.yaml +72 -17
  30. package/hedhog/data/dashboard_component_role.yaml +30 -0
  31. package/hedhog/data/dashboard_item.yaml +155 -0
  32. package/hedhog/data/dashboard_role.yaml +6 -0
  33. package/hedhog/data/role_menu.yaml +6 -0
  34. package/hedhog/data/role_route.yaml +127 -0
  35. package/hedhog/frontend/app/_components/finance-layout.tsx.ejs +108 -0
  36. package/hedhog/frontend/app/accounts-payable/approvals/page.tsx.ejs +91 -106
  37. package/hedhog/frontend/app/accounts-payable/installments/page.tsx.ejs +1306 -1145
  38. package/hedhog/frontend/app/accounts-receivable/collections-default/page.tsx.ejs +288 -268
  39. package/hedhog/frontend/app/accounts-receivable/installments/page.tsx.ejs +491 -351
  40. package/hedhog/frontend/app/administration/audit-logs/page.tsx.ejs +157 -173
  41. package/hedhog/frontend/app/administration/categories/page.tsx.ejs +44 -62
  42. package/hedhog/frontend/app/administration/cost-centers/page.tsx.ejs +62 -80
  43. package/hedhog/frontend/app/administration/period-close/page.tsx.ejs +151 -170
  44. package/hedhog/frontend/app/cash-and-banks/bank-accounts/page.tsx.ejs +586 -224
  45. package/hedhog/frontend/app/cash-and-banks/bank-reconciliation/page.tsx.ejs +204 -226
  46. package/hedhog/frontend/app/cash-and-banks/statements/page.tsx.ejs +122 -140
  47. package/hedhog/frontend/app/cash-and-banks/transfers/page.tsx.ejs +32 -49
  48. package/hedhog/frontend/app/planning/cash-flow-forecast/page.tsx.ejs +84 -108
  49. package/hedhog/frontend/app/planning/receivables-calendar/page.tsx.ejs +53 -70
  50. package/hedhog/frontend/app/planning/scenarios/page.tsx.ejs +98 -95
  51. package/hedhog/frontend/app/reports/actual-vs-forecast/page.tsx.ejs +100 -125
  52. package/hedhog/frontend/app/reports/aging-default/page.tsx.ejs +77 -105
  53. package/hedhog/frontend/app/reports/cash-position/page.tsx.ejs +99 -134
  54. package/hedhog/frontend/app/reports/overview-results/page.tsx.ejs +147 -182
  55. package/hedhog/frontend/app/reports/top-customers/page.tsx.ejs +49 -61
  56. package/hedhog/frontend/app/reports/top-operational-expenses/page.tsx.ejs +49 -67
  57. package/hedhog/frontend/messages/en.json +224 -70
  58. package/hedhog/frontend/messages/pt.json +224 -70
  59. package/hedhog/frontend/widgets/alerts.tsx.ejs +1 -1
  60. package/hedhog/frontend/widgets/bank-reconciliation-status.tsx.ejs +142 -0
  61. package/hedhog/frontend/widgets/cash-balance-kpi.tsx.ejs +9 -9
  62. package/hedhog/frontend/widgets/cash-flow-chart.tsx.ejs +1 -1
  63. package/hedhog/frontend/widgets/default-kpi.tsx.ejs +9 -9
  64. package/hedhog/frontend/widgets/payable-30d-kpi.tsx.ejs +9 -9
  65. package/hedhog/frontend/widgets/pending-approvals-kpi.tsx.ejs +78 -0
  66. package/hedhog/frontend/widgets/pending-approvals-list.tsx.ejs +147 -0
  67. package/hedhog/frontend/widgets/pending-reconciliation-kpi.tsx.ejs +84 -0
  68. package/hedhog/frontend/widgets/receivable-30d-kpi.tsx.ejs +9 -9
  69. package/hedhog/frontend/widgets/receivable-aging-analysis.tsx.ejs +163 -0
  70. package/hedhog/frontend/widgets/upcoming-payable.tsx.ejs +1 -1
  71. package/hedhog/frontend/widgets/upcoming-receivable.tsx.ejs +1 -1
  72. package/hedhog/table/bank_account.yaml +8 -0
  73. package/package.json +7 -6
  74. package/src/dto/create-bank-account.dto.ts +7 -1
  75. package/src/dto/update-bank-account.dto.ts +7 -1
  76. package/src/finance.contract-activated.subscriber.spec.ts +392 -0
  77. package/src/finance.contract-activated.subscriber.ts +780 -0
  78. package/src/finance.module.ts +6 -1
  79. package/src/finance.service.ts +4 -0
@@ -1,6 +1,6 @@
1
- 'use client';
2
-
3
- import { Page, PageHeader } from '@/components/entity-list';
1
+ 'use client';
2
+
3
+ import { EmptyState, Page, PageHeader } from '@/components/entity-list';
4
4
  import {
5
5
  AlertDialog,
6
6
  AlertDialogAction,
@@ -13,13 +13,14 @@ import {
13
13
  } from '@/components/ui/alert-dialog';
14
14
  import { Badge } from '@/components/ui/badge';
15
15
  import { Button } from '@/components/ui/button';
16
- import {
17
- Card,
18
- CardContent,
19
- CardDescription,
20
- CardHeader,
21
- CardTitle,
22
- } from '@/components/ui/card';
16
+ import {
17
+ Card,
18
+ CardContent,
19
+ CardDescription,
20
+ CardHeader,
21
+ CardTitle,
22
+ } from '@/components/ui/card';
23
+ import { KpiCardsGrid } from '@/components/ui/kpi-cards-grid';
23
24
  import {
24
25
  Form,
25
26
  FormControl,
@@ -38,13 +39,18 @@ import {
38
39
  SelectTrigger,
39
40
  SelectValue,
40
41
  } from '@/components/ui/select';
41
- import {
42
- Sheet,
43
- SheetContent,
44
- SheetDescription,
45
- SheetHeader,
46
- SheetTitle,
47
- } from '@/components/ui/sheet';
42
+ import {
43
+ Sheet,
44
+ SheetContent,
45
+ SheetDescription,
46
+ SheetHeader,
47
+ SheetTitle,
48
+ } from '@/components/ui/sheet';
49
+ import {
50
+ FinancePageSection,
51
+ FinanceSheetBody,
52
+ FinanceSheetSection,
53
+ } from '../../_components/finance-layout';
48
54
  import { useApp, useQuery } from '@hed-hog/next-app-provider';
49
55
  import { zodResolver } from '@hookform/resolvers/zod';
50
56
  import {
@@ -59,10 +65,11 @@ import {
59
65
  TrendingUp,
60
66
  Upload,
61
67
  Wallet,
68
+ type LucideIcon,
62
69
  } from 'lucide-react';
63
70
  import { useTranslations } from 'next-intl';
64
71
  import Link from 'next/link';
65
- import { useEffect, useState } from 'react';
72
+ import { useEffect, useRef, useState, type ChangeEvent } from 'react';
66
73
  import { useForm } from 'react-hook-form';
67
74
  import { z } from 'zod';
68
75
 
@@ -72,6 +79,7 @@ const bankAccountFormSchema = z.object({
72
79
  conta: z.string().optional(),
73
80
  tipo: z.string().min(1, 'Tipo é obrigatório'),
74
81
  descricao: z.string().optional(),
82
+ logoFileId: z.number().int().nullable().optional(),
75
83
  saldoInicial: z.number().min(0, 'Saldo inicial inválido'),
76
84
  });
77
85
 
@@ -85,11 +93,48 @@ type BankAccount = {
85
93
  agencia: string;
86
94
  conta: string;
87
95
  tipo: 'corrente' | 'poupanca' | 'investimento' | 'caixa';
96
+ logoFileId: number | null;
88
97
  saldoAtual: number;
89
98
  saldoConciliado: number;
90
99
  ativo: boolean;
91
100
  };
92
101
 
102
+ type UploadedFilePayload = {
103
+ id?: number | string | null;
104
+ };
105
+
106
+ function BankAccountLogo({
107
+ account,
108
+ icon: Icon,
109
+ }: {
110
+ account: BankAccount;
111
+ icon: LucideIcon;
112
+ }) {
113
+ const [hasImageError, setHasImageError] = useState(false);
114
+ const apiBaseUrl = String(process.env.NEXT_PUBLIC_API_BASE_URL || '');
115
+ const logoUrl =
116
+ account.logoFileId && account.logoFileId > 0
117
+ ? `${apiBaseUrl}/file/open/${account.logoFileId}?v=${account.logoFileId}`
118
+ : null;
119
+
120
+ if (!logoUrl || hasImageError) {
121
+ return (
122
+ <div className="flex h-10 w-10 items-center justify-center rounded-lg bg-muted">
123
+ <Icon className="h-5 w-5" />
124
+ </div>
125
+ );
126
+ }
127
+
128
+ return (
129
+ <img
130
+ src={logoUrl}
131
+ alt={account.banco}
132
+ className="h-10 w-10 rounded-lg border bg-muted object-cover"
133
+ onError={() => setHasImageError(true)}
134
+ />
135
+ );
136
+ }
137
+
93
138
  function NovaContaSheet({
94
139
  t,
95
140
  onCreated,
@@ -119,6 +164,21 @@ function NovaContaSheet({
119
164
  const updateErrorMessage = t.has('messages.updateError')
120
165
  ? t('messages.updateError')
121
166
  : 'Erro ao atualizar conta bancária';
167
+ const logoUploadSuccessMessage = t.has('messages.logoUploadSuccess')
168
+ ? t('messages.logoUploadSuccess')
169
+ : 'Logo enviado com sucesso';
170
+ const logoUploadErrorMessage = t.has('messages.logoUploadError')
171
+ ? t('messages.logoUploadError')
172
+ : 'Erro ao enviar logo';
173
+ const logoRemoveSuccessMessage = t.has('messages.logoRemoveSuccess')
174
+ ? t('messages.logoRemoveSuccess')
175
+ : 'Logo removido com sucesso';
176
+ const logoInvalidTypeMessage = t.has('messages.logoInvalidType')
177
+ ? t('messages.logoInvalidType')
178
+ : 'Selecione um arquivo de imagem válido';
179
+ const logoTooLargeMessage = t.has('messages.logoTooLarge')
180
+ ? t('messages.logoTooLarge')
181
+ : 'O logo deve ter no máximo 5 MB';
122
182
 
123
183
  const form = useForm<BankAccountFormValues>({
124
184
  resolver: zodResolver(bankAccountFormSchema),
@@ -128,38 +188,204 @@ function NovaContaSheet({
128
188
  conta: '',
129
189
  tipo: '',
130
190
  descricao: '',
191
+ logoFileId: null,
131
192
  saldoInicial: 0,
132
193
  },
133
194
  });
134
195
 
196
+ const [logoFileId, setLogoFileId] = useState<number | null>(null);
197
+ const [persistedLogoFileId, setPersistedLogoFileId] = useState<number | null>(
198
+ null
199
+ );
200
+ const [logoPreviewUrl, setLogoPreviewUrl] = useState<string | null>(null);
201
+ const [isUploadingLogo, setIsUploadingLogo] = useState(false);
202
+ const [logoUploadProgress, setLogoUploadProgress] = useState(0);
203
+ const logoInputRef = useRef<HTMLInputElement | null>(null);
204
+
205
+ const getLogoUrl = (fileId?: number | null) => {
206
+ if (typeof fileId !== 'number' || fileId <= 0) {
207
+ return null;
208
+ }
209
+
210
+ return `${String(process.env.NEXT_PUBLIC_API_BASE_URL || '')}/file/open/${fileId}`;
211
+ };
212
+
213
+ const deleteFileById = async (fileId?: number | null) => {
214
+ if (!fileId || fileId <= 0) {
215
+ return;
216
+ }
217
+
218
+ try {
219
+ await request({
220
+ url: '/file',
221
+ method: 'DELETE',
222
+ data: { ids: [fileId] },
223
+ });
224
+ } catch {
225
+ // Ignore cleanup failures to keep the form stable.
226
+ }
227
+ };
228
+
229
+ const cleanupUnsavedLogo = async () => {
230
+ if (logoFileId && logoFileId !== persistedLogoFileId) {
231
+ await deleteFileById(logoFileId);
232
+ }
233
+ };
234
+
235
+ const handleSelectLogo = () => {
236
+ if (!logoInputRef.current || isUploadingLogo) {
237
+ return;
238
+ }
239
+
240
+ logoInputRef.current.value = '';
241
+ logoInputRef.current.click();
242
+ };
243
+
244
+ const handleLogoUpload = async (event: ChangeEvent<HTMLInputElement>) => {
245
+ const file = event.target.files?.[0];
246
+
247
+ if (!file) {
248
+ return;
249
+ }
250
+
251
+ if (!file.type.startsWith('image/')) {
252
+ showToastHandler?.('error', logoInvalidTypeMessage);
253
+ event.target.value = '';
254
+ return;
255
+ }
256
+
257
+ if (file.size > 5 * 1024 * 1024) {
258
+ showToastHandler?.('error', logoTooLargeMessage);
259
+ event.target.value = '';
260
+ return;
261
+ }
262
+
263
+ setIsUploadingLogo(true);
264
+ setLogoUploadProgress(0);
265
+
266
+ try {
267
+ const previousUploadedLogoId = logoFileId;
268
+ const formData = new FormData();
269
+ formData.append('file', file);
270
+ formData.append('destination', 'finance/bank-account/logo');
271
+
272
+ const { data } = await request<UploadedFilePayload>({
273
+ url: '/file',
274
+ method: 'POST',
275
+ data: formData,
276
+ headers: {
277
+ 'Content-Type': 'multipart/form-data',
278
+ },
279
+ onUploadProgress: (progressEvent) => {
280
+ if (!progressEvent.total) {
281
+ return;
282
+ }
283
+
284
+ const progress = Math.round(
285
+ (progressEvent.loaded * 100) / progressEvent.total
286
+ );
287
+ setLogoUploadProgress(progress);
288
+ },
289
+ });
290
+
291
+ const nextLogoFileId = Number(data?.id);
292
+
293
+ if (!nextLogoFileId) {
294
+ throw new Error('Logo upload failed');
295
+ }
296
+
297
+ if (
298
+ previousUploadedLogoId &&
299
+ previousUploadedLogoId !== persistedLogoFileId
300
+ ) {
301
+ await deleteFileById(previousUploadedLogoId);
302
+ }
303
+
304
+ setLogoFileId(nextLogoFileId);
305
+ setLogoPreviewUrl(`${getLogoUrl(nextLogoFileId)}?ts=${Date.now()}`);
306
+ setLogoUploadProgress(100);
307
+ form.setValue('logoFileId', nextLogoFileId, {
308
+ shouldDirty: true,
309
+ shouldValidate: true,
310
+ });
311
+ showToastHandler?.('success', logoUploadSuccessMessage);
312
+ } catch {
313
+ showToastHandler?.('error', logoUploadErrorMessage);
314
+ setLogoUploadProgress(0);
315
+ } finally {
316
+ setIsUploadingLogo(false);
317
+ event.target.value = '';
318
+ }
319
+ };
320
+
321
+ const handleRemoveLogo = async () => {
322
+ if (isUploadingLogo) {
323
+ return;
324
+ }
325
+
326
+ if (logoFileId && logoFileId !== persistedLogoFileId) {
327
+ await deleteFileById(logoFileId);
328
+ }
329
+
330
+ setLogoFileId(null);
331
+ setLogoPreviewUrl(null);
332
+ setLogoUploadProgress(0);
333
+ form.setValue('logoFileId', null, {
334
+ shouldDirty: true,
335
+ shouldValidate: true,
336
+ });
337
+ showToastHandler?.('success', logoRemoveSuccessMessage);
338
+ };
339
+
135
340
  useEffect(() => {
136
341
  if (!open) {
137
342
  return;
138
343
  }
139
344
 
140
345
  if (editingAccount) {
346
+ const currentLogoFileId = editingAccount.logoFileId ?? null;
347
+
348
+ setLogoFileId(currentLogoFileId);
349
+ setPersistedLogoFileId(currentLogoFileId);
350
+ setLogoPreviewUrl(
351
+ currentLogoFileId
352
+ ? `${getLogoUrl(currentLogoFileId)}?ts=${Date.now()}`
353
+ : null
354
+ );
355
+ setLogoUploadProgress(0);
356
+
141
357
  form.reset({
142
358
  banco: editingAccount.banco,
143
359
  agencia: editingAccount.agencia === '-' ? '' : editingAccount.agencia,
144
360
  conta: editingAccount.conta === '-' ? '' : editingAccount.conta,
145
361
  tipo: editingAccount.tipo,
146
362
  descricao: editingAccount.descricao,
363
+ logoFileId: currentLogoFileId,
147
364
  saldoInicial: editingAccount.saldoAtual,
148
365
  });
149
366
  return;
150
367
  }
151
368
 
369
+ setLogoFileId(null);
370
+ setPersistedLogoFileId(null);
371
+ setLogoPreviewUrl(null);
372
+ setLogoUploadProgress(0);
373
+
152
374
  form.reset({
153
375
  banco: '',
154
376
  agencia: '',
155
377
  conta: '',
156
378
  tipo: '',
157
379
  descricao: '',
380
+ logoFileId: null,
158
381
  saldoInicial: 0,
159
382
  });
160
383
  }, [editingAccount, form, open]);
161
384
 
162
385
  const handleSubmit = async (values: BankAccountFormValues) => {
386
+ const nextLogoFileId = values.logoFileId ?? null;
387
+ const previousPersistedLogoId = persistedLogoFileId;
388
+
163
389
  try {
164
390
  if (editingAccount) {
165
391
  await request({
@@ -171,6 +397,7 @@ function NovaContaSheet({
171
397
  account: values.conta || undefined,
172
398
  type: values.tipo,
173
399
  description: values.descricao?.trim() || undefined,
400
+ logo_file_id: nextLogoFileId,
174
401
  },
175
402
  });
176
403
  } else {
@@ -183,13 +410,36 @@ function NovaContaSheet({
183
410
  account: values.conta || undefined,
184
411
  type: values.tipo,
185
412
  description: values.descricao?.trim() || undefined,
413
+ logo_file_id: nextLogoFileId,
186
414
  initial_balance: values.saldoInicial,
187
415
  },
188
416
  });
189
417
  }
190
418
 
191
419
  await onCreated();
192
- form.reset();
420
+
421
+ if (
422
+ previousPersistedLogoId &&
423
+ previousPersistedLogoId !== nextLogoFileId
424
+ ) {
425
+ await deleteFileById(previousPersistedLogoId);
426
+ }
427
+
428
+ setPersistedLogoFileId(nextLogoFileId);
429
+ setLogoFileId(nextLogoFileId);
430
+ setLogoPreviewUrl(
431
+ nextLogoFileId ? `${getLogoUrl(nextLogoFileId)}?ts=${Date.now()}` : null
432
+ );
433
+ setLogoUploadProgress(0);
434
+ form.reset({
435
+ banco: '',
436
+ agencia: '',
437
+ conta: '',
438
+ tipo: '',
439
+ descricao: '',
440
+ logoFileId: null,
441
+ saldoInicial: 0,
442
+ });
193
443
  onOpenChange(false);
194
444
  onEditingAccountChange(null);
195
445
  showToastHandler?.(
@@ -205,6 +455,7 @@ function NovaContaSheet({
205
455
  };
206
456
 
207
457
  const handleCancel = () => {
458
+ void cleanupUnsavedLogo();
208
459
  form.reset();
209
460
  onEditingAccountChange(null);
210
461
  onOpenChange(false);
@@ -214,40 +465,51 @@ function NovaContaSheet({
214
465
  <Sheet
215
466
  open={open}
216
467
  onOpenChange={(nextOpen) => {
468
+ if (!nextOpen) {
469
+ void cleanupUnsavedLogo();
470
+ }
471
+
217
472
  onOpenChange(nextOpen);
218
473
  if (!nextOpen) {
219
474
  onEditingAccountChange(null);
220
475
  }
221
476
  }}
222
477
  >
223
- <SheetContent className="w-full sm:max-w-lg">
478
+ <SheetContent className="flex h-full w-full flex-col sm:max-w-2xl">
224
479
  <SheetHeader>
225
480
  <SheetTitle>
226
481
  {editingAccount ? t('common.edit') : t('newAccount.title')}
227
482
  </SheetTitle>
228
483
  <SheetDescription>{t('newAccount.description')}</SheetDescription>
229
484
  </SheetHeader>
230
- <Form {...form}>
231
- <form className="p-4" onSubmit={form.handleSubmit(handleSubmit)}>
232
- <div className="grid gap-4">
233
- <FormField
234
- control={form.control}
235
- name="banco"
236
- render={({ field }) => (
237
- <FormItem>
238
- <FormLabel>{t('fields.bank')}</FormLabel>
239
- <FormControl>
240
- <Input
241
- placeholder={t('fields.bankPlaceholder')}
242
- {...field}
243
- />
244
- </FormControl>
245
- <FormMessage />
246
- </FormItem>
247
- )}
248
- />
485
+ <Form {...form}>
486
+ <form
487
+ className="flex h-full flex-col"
488
+ onSubmit={form.handleSubmit(handleSubmit)}
489
+ >
490
+ <FinanceSheetBody>
491
+ <FinanceSheetSection
492
+ title={t('sections.accountData.title')}
493
+ description={t('sections.accountData.description')}
494
+ >
495
+ <FormField
496
+ control={form.control}
497
+ name="banco"
498
+ render={({ field }) => (
499
+ <FormItem>
500
+ <FormLabel>{t('fields.bank')}</FormLabel>
501
+ <FormControl>
502
+ <Input
503
+ placeholder={t('fields.bankPlaceholder')}
504
+ {...field}
505
+ />
506
+ </FormControl>
507
+ <FormMessage />
508
+ </FormItem>
509
+ )}
510
+ />
249
511
 
250
- <div className="grid grid-cols-2 gap-4">
512
+ <div className="grid gap-4 md:grid-cols-2">
251
513
  <FormField
252
514
  control={form.control}
253
515
  name="agencia"
@@ -285,7 +547,7 @@ function NovaContaSheet({
285
547
  />
286
548
  </div>
287
549
 
288
- <div className="grid grid-cols-2 gap-4">
550
+ <div className="grid gap-4 md:grid-cols-2">
289
551
  <FormField
290
552
  control={form.control}
291
553
  name="tipo"
@@ -344,11 +606,11 @@ function NovaContaSheet({
344
606
  />
345
607
  </div>
346
608
 
347
- <FormField
348
- control={form.control}
349
- name="descricao"
350
- render={({ field }) => (
351
- <FormItem>
609
+ <FormField
610
+ control={form.control}
611
+ name="descricao"
612
+ render={({ field }) => (
613
+ <FormItem>
352
614
  <FormLabel>{t('fields.description')}</FormLabel>
353
615
  <FormControl>
354
616
  <Input
@@ -357,17 +619,89 @@ function NovaContaSheet({
357
619
  value={field.value || ''}
358
620
  />
359
621
  </FormControl>
360
- <FormMessage />
361
- </FormItem>
362
- )}
363
- />
364
- </div>
365
-
366
- <div className="flex justify-end gap-2 pt-4">
622
+ <FormMessage />
623
+ </FormItem>
624
+ )}
625
+ />
626
+ </FinanceSheetSection>
627
+
628
+ <FinanceSheetSection
629
+ title={t('sections.logo.title')}
630
+ description={t('sections.logo.description')}
631
+ >
632
+ <FormItem>
633
+ <FormLabel>{t('fields.logo')}</FormLabel>
634
+ <div className="flex flex-col gap-4 rounded-xl border border-dashed border-border/70 bg-muted/20 p-4 sm:flex-row sm:items-start">
635
+ <div className="flex h-20 w-20 shrink-0 items-center justify-center overflow-hidden rounded-lg bg-muted">
636
+ {logoPreviewUrl ? (
637
+ <img
638
+ src={logoPreviewUrl}
639
+ alt={t('fields.logo')}
640
+ className="h-full w-full object-cover"
641
+ />
642
+ ) : (
643
+ <Landmark className="h-8 w-8 text-muted-foreground" />
644
+ )}
645
+ </div>
646
+
647
+ <div className="flex-1 space-y-2">
648
+ <input
649
+ ref={logoInputRef}
650
+ type="file"
651
+ accept="image/*"
652
+ className="hidden"
653
+ onChange={handleLogoUpload}
654
+ />
655
+
656
+ <div className="flex flex-wrap gap-2">
657
+ <Button
658
+ type="button"
659
+ variant="outline"
660
+ onClick={handleSelectLogo}
661
+ disabled={isUploadingLogo}
662
+ >
663
+ <Upload className="mr-2 h-4 w-4" />
664
+ {t('fields.logoAction')}
665
+ </Button>
666
+
667
+ {logoFileId ? (
668
+ <Button
669
+ type="button"
670
+ variant="ghost"
671
+ onClick={() => void handleRemoveLogo()}
672
+ disabled={isUploadingLogo}
673
+ >
674
+ <Trash2 className="mr-2 h-4 w-4" />
675
+ {t('fields.logoRemove')}
676
+ </Button>
677
+ ) : null}
678
+ </div>
679
+
680
+ <p className="text-xs text-muted-foreground">
681
+ {t('fields.logoHint')}
682
+ </p>
683
+
684
+ {isUploadingLogo ? (
685
+ <p className="text-xs text-muted-foreground">
686
+ {t('fields.logoUploading', {
687
+ progress: logoUploadProgress,
688
+ })}
689
+ </p>
690
+ ) : null}
691
+ </div>
692
+ </div>
693
+ </FormItem>
694
+ </FinanceSheetSection>
695
+ </FinanceSheetBody>
696
+
697
+ <div className="flex justify-end gap-2 border-t px-4 py-4 sm:px-6">
367
698
  <Button type="button" variant="outline" onClick={handleCancel}>
368
699
  {t('common.cancel')}
369
700
  </Button>
370
- <Button type="submit" disabled={form.formState.isSubmitting}>
701
+ <Button
702
+ type="submit"
703
+ disabled={form.formState.isSubmitting || isUploadingLogo}
704
+ >
371
705
  {t('common.save')}
372
706
  </Button>
373
707
  </div>
@@ -430,9 +764,47 @@ export default function ContasBancariasPage() {
430
764
  .filter((c) => c.ativo)
431
765
  .reduce((acc, c) => acc + c.saldoAtual, 0);
432
766
 
433
- const saldoConciliadoTotal = accounts
434
- .filter((c) => c.ativo)
435
- .reduce((acc, c) => acc + c.saldoConciliado, 0);
767
+ const saldoConciliadoTotal = accounts
768
+ .filter((c) => c.ativo)
769
+ .reduce((acc, c) => acc + c.saldoConciliado, 0);
770
+ const activeAccountsCount = accounts.filter((c) => c.ativo).length;
771
+ const inactiveAccountsCount = accounts.length - activeAccountsCount;
772
+ const summaryCards = [
773
+ {
774
+ key: 'balance',
775
+ title: t('cards.totalBalance'),
776
+ value: <Money value={saldoTotal} />,
777
+ description: t('cards.activeAccounts', {
778
+ count: activeAccountsCount,
779
+ }),
780
+ icon: Landmark,
781
+ layout: 'compact' as const,
782
+ },
783
+ {
784
+ key: 'reconciled',
785
+ title: t('cards.reconciledBalance'),
786
+ value: <Money value={saldoConciliadoTotal} />,
787
+ description: `${t('cards.difference')}: ${new Intl.NumberFormat(
788
+ currentLocaleCode === 'pt' ? 'pt-BR' : 'en-US',
789
+ {
790
+ style: 'currency',
791
+ currency: 'BRL',
792
+ }
793
+ ).format(saldoTotal - saldoConciliadoTotal)}`,
794
+ icon: RefreshCw,
795
+ layout: 'compact' as const,
796
+ },
797
+ {
798
+ key: 'accounts',
799
+ title: t('cards.accountsOverview'),
800
+ value: activeAccountsCount,
801
+ description: t('cards.inactiveAccounts', {
802
+ count: inactiveAccountsCount,
803
+ }),
804
+ icon: Building2,
805
+ layout: 'compact' as const,
806
+ },
807
+ ];
436
808
 
437
809
  const handleCreate = () => {
438
810
  setEditingAccount(null);
@@ -514,173 +886,163 @@ export default function ContasBancariasPage() {
514
886
  </AlertDialogContent>
515
887
  </AlertDialog>
516
888
 
517
- <div className="grid gap-4 md:grid-cols-2">
518
- <Card>
519
- <CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
520
- <CardTitle className="text-sm font-medium">
521
- {t('cards.totalBalance')}
522
- </CardTitle>
523
- <Landmark className="h-4 w-4 text-muted-foreground" />
524
- </CardHeader>
525
- <CardContent>
526
- <div className="text-2xl font-bold">
527
- <Money value={saldoTotal} />
528
- </div>
529
- <p className="text-xs text-muted-foreground">
530
- {t('cards.activeAccounts', {
531
- count: accounts.filter((c) => c.ativo).length,
532
- })}
533
- </p>
534
- </CardContent>
535
- </Card>
536
- <Card>
537
- <CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
538
- <CardTitle className="text-sm font-medium">
539
- {t('cards.reconciledBalance')}
540
- </CardTitle>
541
- <RefreshCw className="h-4 w-4 text-muted-foreground" />
542
- </CardHeader>
543
- <CardContent>
544
- <div className="text-2xl font-bold">
545
- <Money value={saldoConciliadoTotal} />
546
- </div>
547
- <p className="text-xs text-muted-foreground">
548
- {t('cards.difference')}:{' '}
549
- <Money value={saldoTotal - saldoConciliadoTotal} />
550
- </p>
551
- </CardContent>
552
- </Card>
553
- </div>
554
-
555
- <div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
556
- {accounts.map((conta) => {
557
- const tipo =
558
- tipoConfig[conta.tipo as keyof typeof tipoConfig] ||
559
- tipoConfig.corrente;
560
- const TipoIcon = tipo.icon;
561
- const diferenca = conta.saldoAtual - conta.saldoConciliado;
562
-
563
- return (
564
- <Card key={conta.id} className={!conta.ativo ? 'opacity-60' : ''}>
565
- <CardHeader>
566
- <div className="flex items-center justify-between">
567
- <div className="flex items-center gap-2">
568
- <div className="flex h-10 w-10 items-center justify-center rounded-lg bg-muted">
569
- <TipoIcon className="h-5 w-5" />
570
- </div>
571
- <div>
572
- <div className="flex gap-4 items-center">
573
- <CardTitle className="text-base">
574
- {conta.banco}
575
- </CardTitle>
576
- {conta.descricao && (
577
- <span className="block text-muted-foreground text-xs">
578
- {conta.descricao}
579
- </span>
580
- )}
581
- </div>
582
- <CardDescription className="space-y-0.5">
583
- {conta.agencia !== '-' && (
584
- <span className="block">
585
- {t('accountCard.bankAccount', {
586
- agency: conta.agencia,
587
- account: conta.conta,
588
- })}
589
- </span>
590
- )}
591
- </CardDescription>
592
- </div>
593
- </div>
594
- {!conta.ativo && (
595
- <Badge variant="outline" className="text-muted-foreground">
596
- {t('status.inactive')}
597
- </Badge>
598
- )}
599
- </div>
600
- </CardHeader>
601
- <CardContent>
602
- <div className="space-y-3">
603
- <div>
604
- <p className="text-sm text-muted-foreground">
605
- {t('accountCard.currentBalance')}
606
- </p>
607
- <p className="text-2xl font-bold">
608
- <Money value={conta.saldoAtual} />
609
- </p>
610
- </div>
611
- <div className="flex items-center justify-between text-sm">
612
- <span className="text-muted-foreground">
613
- {t('accountCard.reconciledBalance')}
614
- </span>
615
- <Money value={conta.saldoConciliado} />
616
- </div>
617
- {diferenca !== 0 && (
618
- <div className="flex items-center justify-between text-sm">
619
- <span className="text-muted-foreground">
620
- {t('accountCard.difference')}
621
- </span>
622
- <span
623
- className={
624
- diferenca > 0 ? 'text-green-600' : 'text-red-600'
625
- }
626
- >
627
- <Money value={diferenca} showSign />
628
- </span>
629
- </div>
630
- )}
631
- <div className="flex flex-wrap gap-2 pt-2">
632
- <Button
633
- variant="outline"
634
- size="sm"
635
- className="min-w-0 flex-1 basis-[calc(50%-0.25rem)] bg-transparent"
636
- asChild
637
- >
638
- <Link
639
- href={`/finance/cash-and-banks/statements?bank_account_id=${conta.id}`}
640
- >
641
- <Eye className="mr-2 h-4 w-4" />
642
- {t('accountCard.statement')}
643
- </Link>
644
- </Button>
645
- <Button
646
- variant="outline"
647
- size="sm"
648
- className="min-w-0 flex-1 basis-[calc(50%-0.25rem)] bg-transparent"
649
- asChild
650
- >
651
- <Link
652
- href={`/finance/cash-and-banks/bank-reconciliation?bank_account_id=${conta.id}`}
653
- >
654
- <RefreshCw className="mr-2 h-4 w-4" />
655
- {t('accountCard.reconcile')}
656
- </Link>
657
- </Button>
658
- <div className="ml-auto flex shrink-0 gap-2">
659
- <Button variant="outline" size="sm">
660
- <Upload className="h-4 w-4" />
661
- </Button>
662
- <Button
663
- variant="outline"
664
- size="sm"
665
- onClick={() => handleEdit(conta)}
666
- >
667
- <Pencil className="h-4 w-4" />
668
- </Button>
669
- <Button
670
- variant="outline"
671
- size="sm"
672
- onClick={() => setAccountIdToDelete(conta.id)}
673
- >
674
- <Trash2 className="h-4 w-4" />
675
- </Button>
676
- </div>
677
- </div>
678
- </div>
679
- </CardContent>
680
- </Card>
681
- );
682
- })}
683
- </div>
889
+ <KpiCardsGrid items={summaryCards} columns={3} />
890
+
891
+ <FinancePageSection
892
+ title={t('list.title')}
893
+ description={t('list.description')}
894
+ contentClassName="p-4 sm:p-5"
895
+ >
896
+ {accounts.length > 0 ? (
897
+ <div className="grid gap-4 md:grid-cols-2 xl:grid-cols-3">
898
+ {accounts.map((conta) => {
899
+ const tipo =
900
+ tipoConfig[conta.tipo as keyof typeof tipoConfig] ||
901
+ tipoConfig.corrente;
902
+ const TipoIcon = tipo.icon;
903
+ const diferenca = conta.saldoAtual - conta.saldoConciliado;
904
+
905
+ return (
906
+ <Card
907
+ key={conta.id}
908
+ className={!conta.ativo ? 'border-border/60 opacity-70' : ''}
909
+ >
910
+ <CardHeader className="space-y-4">
911
+ <div className="flex items-start justify-between gap-3">
912
+ <div className="flex min-w-0 items-center gap-3">
913
+ <BankAccountLogo account={conta} icon={TipoIcon} />
914
+ <div className="min-w-0">
915
+ <div className="flex flex-wrap items-center gap-2">
916
+ <CardTitle className="text-base">
917
+ {conta.banco}
918
+ </CardTitle>
919
+ {conta.descricao ? (
920
+ <span className="text-xs text-muted-foreground">
921
+ {conta.descricao}
922
+ </span>
923
+ ) : null}
924
+ </div>
925
+ <CardDescription className="space-y-0.5">
926
+ {conta.agencia !== '-' ? (
927
+ <span className="block">
928
+ {t('accountCard.bankAccount', {
929
+ agency: conta.agencia,
930
+ account: conta.conta,
931
+ })}
932
+ </span>
933
+ ) : null}
934
+ <span className="block">{tipo.label}</span>
935
+ </CardDescription>
936
+ </div>
937
+ </div>
938
+ {!conta.ativo ? (
939
+ <Badge
940
+ variant="outline"
941
+ className="text-muted-foreground"
942
+ >
943
+ {t('status.inactive')}
944
+ </Badge>
945
+ ) : null}
946
+ </div>
947
+ </CardHeader>
948
+ <CardContent>
949
+ <div className="space-y-3">
950
+ <div>
951
+ <p className="text-sm text-muted-foreground">
952
+ {t('accountCard.currentBalance')}
953
+ </p>
954
+ <p className="text-2xl font-bold">
955
+ <Money value={conta.saldoAtual} />
956
+ </p>
957
+ </div>
958
+ <div className="flex items-center justify-between text-sm">
959
+ <span className="text-muted-foreground">
960
+ {t('accountCard.reconciledBalance')}
961
+ </span>
962
+ <Money value={conta.saldoConciliado} />
963
+ </div>
964
+ {diferenca !== 0 ? (
965
+ <div className="flex items-center justify-between text-sm">
966
+ <span className="text-muted-foreground">
967
+ {t('accountCard.difference')}
968
+ </span>
969
+ <span
970
+ className={
971
+ diferenca > 0 ? 'text-green-600' : 'text-red-600'
972
+ }
973
+ >
974
+ <Money value={diferenca} showSign />
975
+ </span>
976
+ </div>
977
+ ) : null}
978
+ <div className="flex flex-wrap gap-2 pt-2">
979
+ <Button
980
+ variant="outline"
981
+ size="sm"
982
+ className="min-w-0 flex-1 basis-[calc(50%-0.25rem)] bg-transparent"
983
+ asChild
984
+ >
985
+ <Link
986
+ href={`/finance/cash-and-banks/statements?bank_account_id=${conta.id}`}
987
+ >
988
+ <Eye className="mr-2 h-4 w-4" />
989
+ {t('accountCard.statement')}
990
+ </Link>
991
+ </Button>
992
+ <Button
993
+ variant="outline"
994
+ size="sm"
995
+ className="min-w-0 flex-1 basis-[calc(50%-0.25rem)] bg-transparent"
996
+ asChild
997
+ >
998
+ <Link
999
+ href={`/finance/cash-and-banks/bank-reconciliation?bank_account_id=${conta.id}`}
1000
+ >
1001
+ <RefreshCw className="mr-2 h-4 w-4" />
1002
+ {t('accountCard.reconcile')}
1003
+ </Link>
1004
+ </Button>
1005
+ <div className="ml-auto inline-flex shrink-0 overflow-hidden rounded-md border bg-background shadow-sm">
1006
+ <Button
1007
+ variant="ghost"
1008
+ size="sm"
1009
+ className="rounded-none border-0 px-3 hover:bg-muted"
1010
+ onClick={() => handleEdit(conta)}
1011
+ aria-label={t('common.edit')}
1012
+ title={t('common.edit')}
1013
+ >
1014
+ <Pencil className="h-4 w-4" />
1015
+ </Button>
1016
+ <Button
1017
+ variant="ghost"
1018
+ size="sm"
1019
+ className="rounded-none border-0 border-l px-3 text-destructive hover:bg-destructive/10 hover:text-destructive"
1020
+ onClick={() => setAccountIdToDelete(conta.id)}
1021
+ aria-label={deleteDialogTitle}
1022
+ title={deleteDialogTitle}
1023
+ >
1024
+ <Trash2 className="h-4 w-4" />
1025
+ </Button>
1026
+ </div>
1027
+ </div>
1028
+ </div>
1029
+ </CardContent>
1030
+ </Card>
1031
+ );
1032
+ })}
1033
+ </div>
1034
+ ) : (
1035
+ <div className="p-6 sm:p-8">
1036
+ <EmptyState
1037
+ icon={<Landmark className="h-12 w-12" />}
1038
+ title={t('empty.title')}
1039
+ description={t('empty.description')}
1040
+ actionLabel={t('newAccount.action')}
1041
+ onAction={handleCreate}
1042
+ />
1043
+ </div>
1044
+ )}
1045
+ </FinancePageSection>
684
1046
  </Page>
685
1047
  );
686
1048
  }