@hed-hog/finance 0.0.304 → 0.0.306

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.
@@ -47,8 +47,12 @@ import {
47
47
  TableHeader,
48
48
  TableRow,
49
49
  } from '@/components/ui/table';
50
+ import { useFormDraft } from '@/hooks/use-form-draft';
51
+ import { formatDateTime } from '@/lib/format-date';
50
52
  import { useApp, useQuery } from '@hed-hog/next-app-provider';
51
53
  import { zodResolver } from '@hookform/resolvers/zod';
54
+ import { formatDistanceToNow } from 'date-fns';
55
+ import { enUS, ptBR } from 'date-fns/locale';
52
56
  import {
53
57
  ArrowDownRight,
54
58
  ArrowUpRight,
@@ -58,8 +62,8 @@ import {
58
62
  } from 'lucide-react';
59
63
  import { useTranslations } from 'next-intl';
60
64
  import { usePathname, useRouter, useSearchParams } from 'next/navigation';
61
- import { useEffect, useState } from 'react';
62
- import { useForm } from 'react-hook-form';
65
+ import { useEffect, useMemo, useState } from 'react';
66
+ import { useForm, useWatch } from 'react-hook-form';
63
67
  import { z } from 'zod';
64
68
  import { formatarData } from '../../_lib/formatters';
65
69
 
@@ -108,6 +112,21 @@ const importStatementSchema = z.object({
108
112
  type ImportStatementFormValues = z.infer<typeof importStatementSchema>;
109
113
  type BankAccountFormValues = z.infer<typeof bankAccountFormSchema>;
110
114
 
115
+ type StatementBankAccountDraftPayload = {
116
+ values: BankAccountFormValues;
117
+ };
118
+
119
+ type StatementImportDraftPayload = {
120
+ defaultBankAccountId?: string;
121
+ values: {
122
+ bankAccountId: string;
123
+ };
124
+ };
125
+
126
+ const STATEMENT_BANK_ACCOUNT_DRAFT_STORAGE_KEY =
127
+ 'finance-statements-bank-account-draft';
128
+ const STATEMENT_IMPORT_DRAFT_STORAGE_KEY = 'finance-statements-import-draft';
129
+
111
130
  function NovaContaBancariaSheet({
112
131
  open,
113
132
  onOpenChange,
@@ -118,7 +137,8 @@ function NovaContaBancariaSheet({
118
137
  onCreated: (createdBankAccountId?: string) => Promise<void> | void;
119
138
  }) {
120
139
  const tBank = useTranslations('finance.BankAccountsPage');
121
- const { request, showToastHandler } = useApp();
140
+ const { request, showToastHandler, currentLocaleCode, getSettingValue } =
141
+ useApp();
122
142
 
123
143
  const form = useForm<BankAccountFormValues>({
124
144
  resolver: zodResolver(bankAccountFormSchema),
@@ -132,20 +152,82 @@ function NovaContaBancariaSheet({
132
152
  },
133
153
  });
134
154
 
155
+ const watchedBankValues = useWatch({
156
+ control: form.control,
157
+ });
158
+
159
+ const {
160
+ clearDraft,
161
+ loadDraft,
162
+ hasDraft,
163
+ savedAt: draftSavedAt,
164
+ } = useFormDraft<StatementBankAccountDraftPayload>({
165
+ storageKey: STATEMENT_BANK_ACCOUNT_DRAFT_STORAGE_KEY,
166
+ value: {
167
+ values: {
168
+ banco: watchedBankValues.banco ?? '',
169
+ agencia: watchedBankValues.agencia ?? '',
170
+ conta: watchedBankValues.conta ?? '',
171
+ tipo: watchedBankValues.tipo ?? '',
172
+ descricao: watchedBankValues.descricao ?? '',
173
+ saldoInicial: watchedBankValues.saldoInicial ?? 0,
174
+ },
175
+ },
176
+ hasData: Boolean(
177
+ (watchedBankValues.banco ?? '').trim() ||
178
+ (watchedBankValues.agencia ?? '').trim() ||
179
+ (watchedBankValues.conta ?? '').trim() ||
180
+ (watchedBankValues.tipo ?? '').trim() ||
181
+ (watchedBankValues.descricao ?? '').trim() ||
182
+ (watchedBankValues.saldoInicial ?? 0) > 0
183
+ ),
184
+ enabled: open,
185
+ });
186
+
187
+ const draftStatusContent = useMemo(() => {
188
+ if (!hasDraft || !draftSavedAt) {
189
+ return null;
190
+ }
191
+
192
+ const savedDate = new Date(draftSavedAt);
193
+ if (Number.isNaN(savedDate.getTime())) {
194
+ return null;
195
+ }
196
+
197
+ const locale = currentLocaleCode.startsWith('pt') ? ptBR : enUS;
198
+ const relativeLabel = formatDistanceToNow(savedDate, {
199
+ addSuffix: true,
200
+ locale,
201
+ });
202
+ const absoluteLabel = formatDateTime(
203
+ savedDate,
204
+ getSettingValue,
205
+ currentLocaleCode
206
+ );
207
+
208
+ return currentLocaleCode.startsWith('pt')
209
+ ? `Rascunho salvo ${relativeLabel} • Último salvamento: ${absoluteLabel}`
210
+ : `Draft saved ${relativeLabel} • Last saved: ${absoluteLabel}`;
211
+ }, [draftSavedAt, currentLocaleCode, getSettingValue, hasDraft]);
212
+
135
213
  useEffect(() => {
136
214
  if (!open) {
137
215
  return;
138
216
  }
139
217
 
140
- form.reset({
141
- banco: '',
142
- agencia: '',
143
- conta: '',
144
- tipo: '',
145
- descricao: '',
146
- saldoInicial: 0,
147
- });
148
- }, [form, open]);
218
+ const storedDraft = loadDraft();
219
+
220
+ form.reset(
221
+ storedDraft?.payload.values ?? {
222
+ banco: '',
223
+ agencia: '',
224
+ conta: '',
225
+ tipo: '',
226
+ descricao: '',
227
+ saldoInicial: 0,
228
+ }
229
+ );
230
+ }, [form, loadDraft, open]);
149
231
 
150
232
  const handleSubmit = async (values: BankAccountFormValues) => {
151
233
  try {
@@ -164,6 +246,7 @@ function NovaContaBancariaSheet({
164
246
 
165
247
  const createdBankAccountId = response?.data?.id;
166
248
 
249
+ clearDraft();
167
250
  await onCreated(
168
251
  createdBankAccountId !== undefined
169
252
  ? String(createdBankAccountId)
@@ -327,6 +410,12 @@ function NovaContaBancariaSheet({
327
410
  )}
328
411
  />
329
412
 
413
+ {draftStatusContent ? (
414
+ <p className="text-xs text-muted-foreground">
415
+ {draftStatusContent}
416
+ </p>
417
+ ) : null}
418
+
330
419
  <div className="flex justify-end gap-2 pt-2">
331
420
  <Button
332
421
  type="button"
@@ -358,12 +447,13 @@ function ImportarExtratoSheet({
358
447
  contasBancarias: BankAccount[];
359
448
  t: ReturnType<typeof useTranslations>;
360
449
  defaultBankAccountId?: string;
361
- onImported: () => Promise<any> | void;
450
+ onImported: () => Promise<unknown> | void;
362
451
  onBankAccountCreated: (createdBankAccountId?: string) => Promise<void> | void;
363
452
  open?: boolean;
364
453
  onOpenChange?: (open: boolean) => void;
365
454
  }) {
366
- const { request, showToastHandler } = useApp();
455
+ const { request, showToastHandler, currentLocaleCode, getSettingValue } =
456
+ useApp();
367
457
  const [internalOpen, setInternalOpen] = useState(false);
368
458
  const [openNovaContaSheet, setOpenNovaContaSheet] = useState(false);
369
459
  const isOpen = open ?? internalOpen;
@@ -381,16 +471,69 @@ function ImportarExtratoSheet({
381
471
  },
382
472
  });
383
473
 
474
+ const watchedImportValues = useWatch({
475
+ control: form.control,
476
+ });
477
+
478
+ const {
479
+ clearDraft,
480
+ loadDraft,
481
+ hasDraft,
482
+ savedAt: draftSavedAt,
483
+ } = useFormDraft<StatementImportDraftPayload>({
484
+ storageKey: STATEMENT_IMPORT_DRAFT_STORAGE_KEY,
485
+ value: {
486
+ defaultBankAccountId,
487
+ values: {
488
+ bankAccountId: watchedImportValues.bankAccountId ?? '',
489
+ },
490
+ },
491
+ hasData: Boolean(
492
+ (watchedImportValues.bankAccountId ?? '').trim() &&
493
+ (watchedImportValues.bankAccountId ?? '') !== (defaultBankAccountId ?? '')
494
+ ),
495
+ enabled: isOpen,
496
+ });
497
+
498
+ const draftStatusContent = useMemo(() => {
499
+ if (!hasDraft || !draftSavedAt) {
500
+ return null;
501
+ }
502
+
503
+ const savedDate = new Date(draftSavedAt);
504
+ if (Number.isNaN(savedDate.getTime())) {
505
+ return null;
506
+ }
507
+
508
+ const locale = currentLocaleCode.startsWith('pt') ? ptBR : enUS;
509
+ const relativeLabel = formatDistanceToNow(savedDate, {
510
+ addSuffix: true,
511
+ locale,
512
+ });
513
+ const absoluteLabel = formatDateTime(
514
+ savedDate,
515
+ getSettingValue,
516
+ currentLocaleCode
517
+ );
518
+
519
+ return currentLocaleCode.startsWith('pt')
520
+ ? `Rascunho salvo ${relativeLabel} • Último salvamento: ${absoluteLabel}`
521
+ : `Draft saved ${relativeLabel} • Last saved: ${absoluteLabel}`;
522
+ }, [draftSavedAt, currentLocaleCode, getSettingValue, hasDraft]);
523
+
384
524
  useEffect(() => {
385
525
  if (!isOpen) {
386
526
  return;
387
527
  }
388
528
 
529
+ const storedDraft = loadDraft();
530
+
389
531
  form.reset({
390
- bankAccountId: defaultBankAccountId || '',
532
+ bankAccountId:
533
+ storedDraft?.payload.values.bankAccountId || defaultBankAccountId || '',
391
534
  file: undefined as unknown as File,
392
535
  });
393
- }, [defaultBankAccountId, form, isOpen]);
536
+ }, [defaultBankAccountId, form, isOpen, loadDraft]);
394
537
 
395
538
  const handleSubmit = async (values: ImportStatementFormValues) => {
396
539
  const formData = new FormData();
@@ -404,6 +547,7 @@ function ImportarExtratoSheet({
404
547
  data: formData,
405
548
  });
406
549
 
550
+ clearDraft();
407
551
  await onImported();
408
552
  showToastHandler?.('success', 'Extrato importado com sucesso');
409
553
  handleOpenChange(false);
@@ -512,6 +656,12 @@ function ImportarExtratoSheet({
512
656
  )}
513
657
  />
514
658
 
659
+ {draftStatusContent ? (
660
+ <p className="text-xs text-muted-foreground">
661
+ {draftStatusContent}
662
+ </p>
663
+ ) : null}
664
+
515
665
  <div className="flex justify-end gap-2 pt-2">
516
666
  <Button
517
667
  type="button"
@@ -539,7 +689,9 @@ export default function ExtratosPage() {
539
689
  const searchParams = useSearchParams();
540
690
  const bankAccountIdFromUrl = searchParams.get('bank_account_id');
541
691
 
542
- const [contaFilter, setContaFilter] = useState<string>('');
692
+ const [contaFilter, setContaFilter] = useState<string>(
693
+ () => bankAccountIdFromUrl || ''
694
+ );
543
695
  const [search, setSearch] = useState('');
544
696
  const [debouncedSearch, setDebouncedSearch] = useState('');
545
697
  const [isImportSheetOpen, setIsImportSheetOpen] = useState(false);
@@ -583,50 +735,51 @@ export default function ExtratosPage() {
583
735
  router.replace(`${pathname}?${params.toString()}`);
584
736
  };
585
737
 
586
- useEffect(() => {
738
+ const activeContaFilter = useMemo(() => {
587
739
  const firstAccount = contasBancarias[0];
588
740
 
589
741
  if (!firstAccount) {
590
- return;
742
+ return '';
591
743
  }
592
744
 
593
- const hasAccountFromUrl =
594
- !!bankAccountIdFromUrl &&
595
- contasBancarias.some((account) => account.id === bankAccountIdFromUrl);
596
-
597
- const nextAccountId = hasAccountFromUrl
598
- ? (bankAccountIdFromUrl as string)
599
- : firstAccount.id;
745
+ if (
746
+ contaFilter &&
747
+ contasBancarias.some((account) => account.id === contaFilter)
748
+ ) {
749
+ return contaFilter;
750
+ }
600
751
 
601
- if (contaFilter !== nextAccountId) {
602
- setContaFilter(nextAccountId);
752
+ if (
753
+ bankAccountIdFromUrl &&
754
+ contasBancarias.some((account) => account.id === bankAccountIdFromUrl)
755
+ ) {
756
+ return bankAccountIdFromUrl;
603
757
  }
604
758
 
605
- if (bankAccountIdFromUrl !== nextAccountId) {
606
- const params = new URLSearchParams(searchParams.toString());
607
- params.set('bank_account_id', nextAccountId);
608
- router.replace(`${pathname}?${params.toString()}`);
759
+ return firstAccount.id;
760
+ }, [bankAccountIdFromUrl, contaFilter, contasBancarias]);
761
+
762
+ useEffect(() => {
763
+ if (!activeContaFilter || bankAccountIdFromUrl === activeContaFilter) {
764
+ return;
609
765
  }
610
- }, [
611
- bankAccountIdFromUrl,
612
- contaFilter,
613
- contasBancarias,
614
- pathname,
615
- router,
616
- searchParams,
617
- ]);
766
+
767
+ const params = new URLSearchParams(searchParams.toString());
768
+ params.set('bank_account_id', activeContaFilter);
769
+ router.replace(`${pathname}?${params.toString()}`);
770
+ }, [activeContaFilter, bankAccountIdFromUrl, pathname, router, searchParams]);
618
771
 
619
772
  const { data: extratos = [], refetch: refetchExtratos } = useQuery<
620
773
  Statement[]
621
774
  >({
622
- queryKey: ['finance-statements', contaFilter, debouncedSearch],
775
+ queryKey: ['finance-statements', activeContaFilter, debouncedSearch],
623
776
  queryFn: async () => {
624
- if (!contaFilter) {
777
+ if (!activeContaFilter) {
625
778
  return [];
626
779
  }
627
780
 
628
781
  const params = new URLSearchParams();
629
- params.set('bank_account_id', contaFilter);
782
+ params.set('bank_account_id', activeContaFilter);
630
783
 
631
784
  const trimmedSearch = debouncedSearch.trim();
632
785
  if (trimmedSearch) {
@@ -642,7 +795,7 @@ export default function ExtratosPage() {
642
795
  },
643
796
  });
644
797
 
645
- const conta = contasBancarias.find((item) => item.id === contaFilter);
798
+ const conta = contasBancarias.find((item) => item.id === activeContaFilter);
646
799
  const contaExtratoSelecionado = extratoSelecionado
647
800
  ? contasBancarias.find(
648
801
  (item) => item.id === extratoSelecionado.contaBancariaId
@@ -685,14 +838,14 @@ export default function ExtratosPage() {
685
838
  ];
686
839
 
687
840
  const handleExport = async () => {
688
- if (!contaFilter) {
841
+ if (!activeContaFilter) {
689
842
  showToastHandler?.('error', 'Selecione uma conta bancária para exportar');
690
843
  return;
691
844
  }
692
845
 
693
846
  try {
694
847
  const params = new URLSearchParams();
695
- params.set('bank_account_id', contaFilter);
848
+ params.set('bank_account_id', activeContaFilter);
696
849
 
697
850
  const trimmedSearch = search.trim();
698
851
  if (trimmedSearch) {
@@ -714,7 +867,7 @@ export default function ExtratosPage() {
714
867
  : null;
715
868
  const fileName = fileNameMatch?.[1]
716
869
  ? decodeURIComponent(fileNameMatch[1])
717
- : `extrato-bancario-${contaFilter}.csv`;
870
+ : `extrato-bancario-${activeContaFilter}.csv`;
718
871
 
719
872
  const blob = new Blob([response.data], {
720
873
  type: 'text/csv;charset=utf-8;',
@@ -752,7 +905,7 @@ export default function ExtratosPage() {
752
905
  <ImportarExtratoSheet
753
906
  contasBancarias={contasBancarias}
754
907
  t={t}
755
- defaultBankAccountId={contaFilter}
908
+ defaultBankAccountId={activeContaFilter}
756
909
  open={isImportSheetOpen}
757
910
  onOpenChange={setIsImportSheetOpen}
758
911
  onImported={refetchExtratos}
@@ -764,7 +917,7 @@ export default function ExtratosPage() {
764
917
 
765
918
  <div className="flex flex-col gap-4 sm:flex-row sm:items-center">
766
919
  <Select
767
- value={contaFilter}
920
+ value={activeContaFilter}
768
921
  onValueChange={(value) => {
769
922
  setContaFilter(value);
770
923
 
@@ -39,12 +39,16 @@ import {
39
39
  TableRow,
40
40
  } from '@/components/ui/table';
41
41
  import { Textarea } from '@/components/ui/textarea';
42
+ import { useFormDraft } from '@/hooks/use-form-draft';
43
+ import { formatDateTime } from '@/lib/format-date';
42
44
  import { useApp, useQuery } from '@hed-hog/next-app-provider';
43
45
  import { zodResolver } from '@hookform/resolvers/zod';
46
+ import { formatDistanceToNow } from 'date-fns';
47
+ import { enUS, ptBR } from 'date-fns/locale';
44
48
  import { ArrowRight, Plus } from 'lucide-react';
45
49
  import { useTranslations } from 'next-intl';
46
- import { useEffect, useState } from 'react';
47
- import { useForm } from 'react-hook-form';
50
+ import { useEffect, useMemo, useState } from 'react';
51
+ import { useForm, useWatch } from 'react-hook-form';
48
52
  import { z } from 'zod';
49
53
  import { formatarData } from '../../_lib/formatters';
50
54
 
@@ -64,6 +68,12 @@ type Transfer = {
64
68
  descricao: string;
65
69
  };
66
70
 
71
+ type TransferDraftPayload = {
72
+ values: TransferFormValues;
73
+ };
74
+
75
+ const TRANSFER_FORM_DRAFT_STORAGE_KEY = 'finance-transfer-form-draft';
76
+
67
77
  const transferFormSchema = z
68
78
  .object({
69
79
  sourceAccountId: z.string().trim().min(1, 'Conta de origem é obrigatória'),
@@ -91,7 +101,8 @@ function NovaTransferenciaSheet({
91
101
  t: ReturnType<typeof useTranslations>;
92
102
  onCreated: () => Promise<void> | void;
93
103
  }) {
94
- const { request, showToastHandler } = useApp();
104
+ const { request, showToastHandler, currentLocaleCode, getSettingValue } =
105
+ useApp();
95
106
  const [open, setOpen] = useState(false);
96
107
 
97
108
  const form = useForm<TransferFormValues>({
@@ -105,19 +116,85 @@ function NovaTransferenciaSheet({
105
116
  },
106
117
  });
107
118
 
119
+ const watchedFormValues = useWatch({
120
+ control: form.control,
121
+ });
122
+
123
+ const hasDraftContent = useMemo(
124
+ () =>
125
+ Boolean(
126
+ (watchedFormValues.sourceAccountId ?? '').trim() ||
127
+ (watchedFormValues.destinationAccountId ?? '').trim() ||
128
+ (watchedFormValues.description ?? '').trim() ||
129
+ (watchedFormValues.amount ?? 0) > 0 ||
130
+ (watchedFormValues.date ?? '') !== new Date().toISOString().slice(0, 10)
131
+ ),
132
+ [watchedFormValues]
133
+ );
134
+
135
+ const {
136
+ clearDraft,
137
+ loadDraft,
138
+ hasDraft,
139
+ savedAt: draftSavedAt,
140
+ } = useFormDraft<TransferDraftPayload>({
141
+ storageKey: TRANSFER_FORM_DRAFT_STORAGE_KEY,
142
+ value: {
143
+ values: {
144
+ sourceAccountId: watchedFormValues.sourceAccountId ?? '',
145
+ destinationAccountId: watchedFormValues.destinationAccountId ?? '',
146
+ date: watchedFormValues.date ?? new Date().toISOString().slice(0, 10),
147
+ amount: watchedFormValues.amount ?? 0,
148
+ description: watchedFormValues.description ?? '',
149
+ },
150
+ },
151
+ hasData: hasDraftContent,
152
+ enabled: open,
153
+ });
154
+
155
+ const draftStatusContent = useMemo(() => {
156
+ if (!hasDraft || !draftSavedAt) {
157
+ return null;
158
+ }
159
+
160
+ const savedDate = new Date(draftSavedAt);
161
+ if (Number.isNaN(savedDate.getTime())) {
162
+ return null;
163
+ }
164
+
165
+ const locale = currentLocaleCode.startsWith('pt') ? ptBR : enUS;
166
+ const relativeLabel = formatDistanceToNow(savedDate, {
167
+ addSuffix: true,
168
+ locale,
169
+ });
170
+ const absoluteLabel = formatDateTime(
171
+ savedDate,
172
+ getSettingValue,
173
+ currentLocaleCode
174
+ );
175
+
176
+ return currentLocaleCode.startsWith('pt')
177
+ ? `Rascunho salvo ${relativeLabel} • Último salvamento: ${absoluteLabel}`
178
+ : `Draft saved ${relativeLabel} • Last saved: ${absoluteLabel}`;
179
+ }, [draftSavedAt, currentLocaleCode, getSettingValue, hasDraft]);
180
+
108
181
  useEffect(() => {
109
182
  if (!open) {
110
183
  return;
111
184
  }
112
185
 
113
- form.reset({
114
- sourceAccountId: '',
115
- destinationAccountId: '',
116
- date: new Date().toISOString().slice(0, 10),
117
- amount: 0,
118
- description: '',
119
- });
120
- }, [form, open]);
186
+ const storedDraft = loadDraft();
187
+
188
+ form.reset(
189
+ storedDraft?.payload.values ?? {
190
+ sourceAccountId: '',
191
+ destinationAccountId: '',
192
+ date: new Date().toISOString().slice(0, 10),
193
+ amount: 0,
194
+ description: '',
195
+ }
196
+ );
197
+ }, [form, loadDraft, open]);
121
198
 
122
199
  const handleSubmit = async (values: TransferFormValues) => {
123
200
  try {
@@ -133,6 +210,7 @@ function NovaTransferenciaSheet({
133
210
  },
134
211
  });
135
212
 
213
+ clearDraft();
136
214
  await onCreated();
137
215
  setOpen(false);
138
216
  showToastHandler?.('success', 'Transferência cadastrada com sucesso');
@@ -263,17 +341,24 @@ function NovaTransferenciaSheet({
263
341
  )}
264
342
  />
265
343
 
266
- <div className="flex justify-end gap-2 pt-4">
267
- <Button
268
- type="button"
269
- variant="outline"
270
- onClick={() => setOpen(false)}
271
- >
272
- {t('common.cancel')}
273
- </Button>
274
- <Button type="submit" disabled={form.formState.isSubmitting}>
275
- {t('newTransfer.submit')}
276
- </Button>
344
+ <div className="pt-4">
345
+ {draftStatusContent ? (
346
+ <p className="mb-3 text-xs text-muted-foreground">
347
+ {draftStatusContent}
348
+ </p>
349
+ ) : null}
350
+ <div className="flex justify-end gap-2">
351
+ <Button
352
+ type="button"
353
+ variant="outline"
354
+ onClick={() => setOpen(false)}
355
+ >
356
+ {t('common.cancel')}
357
+ </Button>
358
+ <Button type="submit" disabled={form.formState.isSubmitting}>
359
+ {t('newTransfer.submit')}
360
+ </Button>
361
+ </div>
277
362
  </div>
278
363
  </form>
279
364
  </Form>