@hed-hog/finance 0.0.304 → 0.0.305

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.
@@ -36,6 +36,8 @@ import {
36
36
  SheetHeader,
37
37
  SheetTitle,
38
38
  } from '@/components/ui/sheet';
39
+ import { useFormDraft } from '@/hooks/use-form-draft';
40
+ import { formatDateTime } from '@/lib/format-date';
39
41
  import {
40
42
  DndContext,
41
43
  DragEndEvent,
@@ -52,6 +54,8 @@ import {
52
54
  } from '@dnd-kit/sortable';
53
55
  import { useApp, useQuery } from '@hed-hog/next-app-provider';
54
56
  import { zodResolver } from '@hookform/resolvers/zod';
57
+ import { formatDistanceToNow } from 'date-fns';
58
+ import { enUS, ptBR } from 'date-fns/locale';
55
59
  import {
56
60
  ChevronDown,
57
61
  ChevronRight,
@@ -63,7 +67,7 @@ import {
63
67
  } from 'lucide-react';
64
68
  import { useTranslations } from 'next-intl';
65
69
  import { useEffect, useMemo, useState } from 'react';
66
- import { useForm } from 'react-hook-form';
70
+ import { useForm, useWatch } from 'react-hook-form';
67
71
  import { z } from 'zod';
68
72
 
69
73
  type CategoryFormValues = {
@@ -84,6 +88,14 @@ type FinanceCategory = {
84
88
 
85
89
  const EMPTY_CATEGORIES: FinanceCategory[] = [];
86
90
 
91
+ type CategoryDraftPayload = {
92
+ mode: 'create' | 'edit';
93
+ categoryId: string | null;
94
+ values: CategoryFormValues;
95
+ };
96
+
97
+ const CATEGORY_FORM_DRAFT_STORAGE_KEY = 'finance-categories-form-draft';
98
+
87
99
  type CategoryNode = FinanceCategory & { children: CategoryNode[] };
88
100
 
89
101
  function buildTree(categories: FinanceCategory[]): CategoryNode[] {
@@ -135,7 +147,8 @@ function CategoriaSheet({
135
147
  categories: FinanceCategory[];
136
148
  t: ReturnType<typeof useTranslations>;
137
149
  }) {
138
- const { request, showToastHandler } = useApp();
150
+ const { request, showToastHandler, currentLocaleCode, getSettingValue } =
151
+ useApp();
139
152
 
140
153
  const categorySchema = z.object({
141
154
  nome: z.string().trim().min(1, t('sheet.validation.nameRequired')),
@@ -152,30 +165,96 @@ function CategoriaSheet({
152
165
  },
153
166
  });
154
167
 
168
+ const watchedValues = useWatch({
169
+ control: form.control,
170
+ });
171
+
172
+ const {
173
+ clearDraft,
174
+ loadDraft,
175
+ hasDraft,
176
+ savedAt: draftSavedAt,
177
+ } = useFormDraft<CategoryDraftPayload>({
178
+ storageKey: CATEGORY_FORM_DRAFT_STORAGE_KEY,
179
+ value: {
180
+ mode: editing ? 'edit' : 'create',
181
+ categoryId: editing?.id ?? null,
182
+ values: {
183
+ nome: watchedValues.nome ?? '',
184
+ natureza: watchedValues.natureza ?? 'despesa',
185
+ parentId: watchedValues.parentId ?? 'root',
186
+ },
187
+ },
188
+ hasData: Boolean(
189
+ (watchedValues.nome ?? '').trim() ||
190
+ (watchedValues.natureza ?? 'despesa') !== 'despesa' ||
191
+ (watchedValues.parentId ?? 'root') !== 'root'
192
+ ),
193
+ enabled: open,
194
+ });
195
+
196
+ const draftStatusContent = useMemo(() => {
197
+ if (!hasDraft || !draftSavedAt) {
198
+ return null;
199
+ }
200
+
201
+ const savedDate = new Date(draftSavedAt);
202
+ if (Number.isNaN(savedDate.getTime())) {
203
+ return null;
204
+ }
205
+
206
+ const locale = currentLocaleCode.startsWith('pt') ? ptBR : enUS;
207
+ const relativeLabel = formatDistanceToNow(savedDate, {
208
+ addSuffix: true,
209
+ locale,
210
+ });
211
+ const absoluteLabel = formatDateTime(
212
+ savedDate,
213
+ getSettingValue,
214
+ currentLocaleCode
215
+ );
216
+
217
+ return currentLocaleCode.startsWith('pt')
218
+ ? `Rascunho salvo ${relativeLabel} • Último salvamento: ${absoluteLabel}`
219
+ : `Draft saved ${relativeLabel} • Last saved: ${absoluteLabel}`;
220
+ }, [draftSavedAt, currentLocaleCode, getSettingValue, hasDraft]);
221
+
155
222
  useEffect(() => {
156
223
  if (!open) {
157
- form.reset({ nome: '', natureza: 'despesa', parentId: 'root' });
158
224
  return;
159
225
  }
160
226
 
227
+ const storedDraft = loadDraft();
228
+ const shouldRestoreEditDraft =
229
+ Boolean(editing) &&
230
+ storedDraft?.payload.mode === 'edit' &&
231
+ storedDraft.payload.categoryId === editing?.id;
232
+
161
233
  if (editing) {
162
- form.reset({
163
- nome: editing.nome,
164
- natureza: editing.natureza,
165
- parentId: editing.parentId || 'root',
166
- });
234
+ form.reset(
235
+ shouldRestoreEditDraft
236
+ ? storedDraft.payload.values
237
+ : {
238
+ nome: editing.nome,
239
+ natureza: editing.natureza,
240
+ parentId: editing.parentId || 'root',
241
+ }
242
+ );
167
243
  return;
168
244
  }
169
245
 
170
- form.reset({ nome: '', natureza: 'despesa', parentId: 'root' });
171
- }, [open, editing, form]);
246
+ form.reset(
247
+ storedDraft?.payload.mode === 'create'
248
+ ? storedDraft.payload.values
249
+ : { nome: '', natureza: 'despesa', parentId: 'root' }
250
+ );
251
+ }, [open, editing, form, loadDraft]);
172
252
 
173
253
  const handleOpenChange = (nextOpen: boolean) => {
174
254
  onOpenChange(nextOpen);
175
255
 
176
256
  if (!nextOpen) {
177
257
  setEditing(null);
178
- form.reset({ nome: '', natureza: 'despesa', parentId: 'root' });
179
258
  }
180
259
  };
181
260
 
@@ -205,6 +284,7 @@ function CategoriaSheet({
205
284
  });
206
285
  }
207
286
 
287
+ clearDraft();
208
288
  await onSaved();
209
289
  onOpenChange(false);
210
290
  setEditing(null);
@@ -318,6 +398,12 @@ function CategoriaSheet({
318
398
  )}
319
399
  />
320
400
 
401
+ {draftStatusContent ? (
402
+ <p className="text-xs text-muted-foreground">
403
+ {draftStatusContent}
404
+ </p>
405
+ ) : null}
406
+
321
407
  <div className="flex justify-end gap-2">
322
408
  <Button
323
409
  type="button"
@@ -29,12 +29,16 @@ import {
29
29
  SheetHeader,
30
30
  SheetTitle,
31
31
  } from '@/components/ui/sheet';
32
+ import { useFormDraft } from '@/hooks/use-form-draft';
33
+ import { formatDateTime } from '@/lib/format-date';
32
34
  import { useApp, useQuery } from '@hed-hog/next-app-provider';
33
35
  import { zodResolver } from '@hookform/resolvers/zod';
36
+ import { formatDistanceToNow } from 'date-fns';
37
+ import { enUS, ptBR } from 'date-fns/locale';
34
38
  import { Building2, Pencil, Plus, Trash2 } from 'lucide-react';
35
39
  import { useTranslations } from 'next-intl';
36
- import { useEffect, useState } from 'react';
37
- import { useForm } from 'react-hook-form';
40
+ import { useEffect, useMemo, useState } from 'react';
41
+ import { useForm, useWatch } from 'react-hook-form';
38
42
  import { z } from 'zod';
39
43
 
40
44
  type CostCenterFormValues = {
@@ -49,6 +53,14 @@ type CostCenter = {
49
53
  ativo: boolean;
50
54
  };
51
55
 
56
+ type CostCenterDraftPayload = {
57
+ mode: 'create' | 'edit';
58
+ costCenterId: string | null;
59
+ values: CostCenterFormValues;
60
+ };
61
+
62
+ const COST_CENTER_FORM_DRAFT_STORAGE_KEY = 'finance-cost-centers-form-draft';
63
+
52
64
  function CentroCustoSheet({
53
65
  open,
54
66
  onOpenChange,
@@ -64,7 +76,8 @@ function CentroCustoSheet({
64
76
  onEditingChange: (costCenter: CostCenter | null) => void;
65
77
  t: ReturnType<typeof useTranslations>;
66
78
  }) {
67
- const { request, showToastHandler } = useApp();
79
+ const { request, showToastHandler, currentLocaleCode, getSettingValue } =
80
+ useApp();
68
81
 
69
82
  const costCenterFormSchema = z.object({
70
83
  nome: z.string().trim().min(1, t('sheet.validation.nameRequired')),
@@ -77,19 +90,80 @@ function CentroCustoSheet({
77
90
  },
78
91
  });
79
92
 
93
+ const watchedValues = useWatch({
94
+ control: form.control,
95
+ });
96
+
97
+ const {
98
+ clearDraft,
99
+ loadDraft,
100
+ hasDraft,
101
+ savedAt: draftSavedAt,
102
+ } = useFormDraft<CostCenterDraftPayload>({
103
+ storageKey: COST_CENTER_FORM_DRAFT_STORAGE_KEY,
104
+ value: {
105
+ mode: editingCostCenter ? 'edit' : 'create',
106
+ costCenterId: editingCostCenter?.id ?? null,
107
+ values: {
108
+ nome: watchedValues.nome ?? '',
109
+ },
110
+ },
111
+ hasData: Boolean((watchedValues.nome ?? '').trim()),
112
+ enabled: open,
113
+ });
114
+
115
+ const draftStatusContent = useMemo(() => {
116
+ if (!hasDraft || !draftSavedAt) {
117
+ return null;
118
+ }
119
+
120
+ const savedDate = new Date(draftSavedAt);
121
+ if (Number.isNaN(savedDate.getTime())) {
122
+ return null;
123
+ }
124
+
125
+ const locale = currentLocaleCode.startsWith('pt') ? ptBR : enUS;
126
+ const relativeLabel = formatDistanceToNow(savedDate, {
127
+ addSuffix: true,
128
+ locale,
129
+ });
130
+ const absoluteLabel = formatDateTime(
131
+ savedDate,
132
+ getSettingValue,
133
+ currentLocaleCode
134
+ );
135
+
136
+ return currentLocaleCode.startsWith('pt')
137
+ ? `Rascunho salvo ${relativeLabel} • Último salvamento: ${absoluteLabel}`
138
+ : `Draft saved ${relativeLabel} • Last saved: ${absoluteLabel}`;
139
+ }, [draftSavedAt, currentLocaleCode, getSettingValue, hasDraft]);
140
+
80
141
  useEffect(() => {
81
142
  if (!open) {
82
- form.reset({ nome: '' });
83
143
  return;
84
144
  }
85
145
 
146
+ const storedDraft = loadDraft();
147
+ const shouldRestoreEditDraft =
148
+ Boolean(editingCostCenter) &&
149
+ storedDraft?.payload.mode === 'edit' &&
150
+ storedDraft.payload.costCenterId === editingCostCenter?.id;
151
+
86
152
  if (editingCostCenter) {
87
- form.reset({ nome: editingCostCenter.nome });
153
+ form.reset(
154
+ shouldRestoreEditDraft
155
+ ? storedDraft.payload.values
156
+ : { nome: editingCostCenter.nome }
157
+ );
88
158
  return;
89
159
  }
90
160
 
91
- form.reset({ nome: '' });
92
- }, [open, editingCostCenter, form]);
161
+ form.reset(
162
+ storedDraft?.payload.mode === 'create'
163
+ ? storedDraft.payload.values
164
+ : { nome: '' }
165
+ );
166
+ }, [open, editingCostCenter, form, loadDraft]);
93
167
 
94
168
  const handleSubmit = async (values: CostCenterFormValues) => {
95
169
  try {
@@ -111,6 +185,7 @@ function CentroCustoSheet({
111
185
  });
112
186
  }
113
187
 
188
+ clearDraft();
114
189
  await onSaved();
115
190
  form.reset({ nome: '' });
116
191
  onEditingChange(null);
@@ -135,7 +210,6 @@ function CentroCustoSheet({
135
210
  onOpenChange(nextOpen);
136
211
 
137
212
  if (!nextOpen) {
138
- form.reset({ nome: '' });
139
213
  onEditingChange(null);
140
214
  }
141
215
  };
@@ -176,6 +250,12 @@ function CentroCustoSheet({
176
250
  )}
177
251
  />
178
252
 
253
+ {draftStatusContent ? (
254
+ <p className="text-xs text-muted-foreground">
255
+ {draftStatusContent}
256
+ </p>
257
+ ) : null}
258
+
179
259
  <div className="flex justify-end gap-2">
180
260
  <Button
181
261
  type="button"
@@ -34,12 +34,16 @@ import {
34
34
  TableHeader,
35
35
  TableRow,
36
36
  } from '@/components/ui/table';
37
+ import { useFormDraft } from '@/hooks/use-form-draft';
38
+ import { formatDateTime } from '@/lib/format-date';
37
39
  import { useApp, useQuery } from '@hed-hog/next-app-provider';
38
40
  import { zodResolver } from '@hookform/resolvers/zod';
41
+ import { formatDistanceToNow } from 'date-fns';
42
+ import { enUS, ptBR } from 'date-fns/locale';
39
43
  import { Plus, Search, X } from 'lucide-react';
40
44
  import { useTranslations } from 'next-intl';
41
- import { useState } from 'react';
42
- import { useForm } from 'react-hook-form';
45
+ import { useEffect, useMemo, useState } from 'react';
46
+ import { useForm, useWatch } from 'react-hook-form';
43
47
  import { z } from 'zod';
44
48
 
45
49
  type PeriodClose = {
@@ -70,6 +74,12 @@ type ClosePeriodFormValues = {
70
74
  status: 'closed' | 'open';
71
75
  };
72
76
 
77
+ type ClosePeriodDraftPayload = {
78
+ values: ClosePeriodFormValues;
79
+ };
80
+
81
+ const PERIOD_CLOSE_FORM_DRAFT_STORAGE_KEY = 'finance-period-close-form-draft';
82
+
73
83
  function ClosePeriodSheet({
74
84
  open,
75
85
  onOpenChange,
@@ -81,7 +91,8 @@ function ClosePeriodSheet({
81
91
  onCreated: () => Promise<unknown> | void;
82
92
  t: ReturnType<typeof useTranslations>;
83
93
  }) {
84
- const { request, showToastHandler } = useApp();
94
+ const { request, showToastHandler, currentLocaleCode, getSettingValue } =
95
+ useApp();
85
96
 
86
97
  const closePeriodSchema = z.object({
87
98
  periodStart: z.string().min(1, t('sheet.validation.startRequired')),
@@ -100,6 +111,77 @@ function ClosePeriodSheet({
100
111
  },
101
112
  });
102
113
 
114
+ const watchedValues = useWatch({
115
+ control: form.control,
116
+ });
117
+
118
+ const {
119
+ clearDraft,
120
+ loadDraft,
121
+ hasDraft,
122
+ savedAt: draftSavedAt,
123
+ } = useFormDraft<ClosePeriodDraftPayload>({
124
+ storageKey: PERIOD_CLOSE_FORM_DRAFT_STORAGE_KEY,
125
+ value: {
126
+ values: {
127
+ periodStart: watchedValues.periodStart ?? '',
128
+ periodEnd: watchedValues.periodEnd ?? '',
129
+ notes: watchedValues.notes ?? '',
130
+ status: watchedValues.status ?? 'closed',
131
+ },
132
+ },
133
+ hasData: Boolean(
134
+ (watchedValues.periodStart ?? '').trim() ||
135
+ (watchedValues.periodEnd ?? '').trim() ||
136
+ (watchedValues.notes ?? '').trim() ||
137
+ (watchedValues.status ?? 'closed') !== 'closed'
138
+ ),
139
+ enabled: open,
140
+ });
141
+
142
+ const draftStatusContent = useMemo(() => {
143
+ if (!hasDraft || !draftSavedAt) {
144
+ return null;
145
+ }
146
+
147
+ const savedDate = new Date(draftSavedAt);
148
+ if (Number.isNaN(savedDate.getTime())) {
149
+ return null;
150
+ }
151
+
152
+ const locale = currentLocaleCode.startsWith('pt') ? ptBR : enUS;
153
+ const relativeLabel = formatDistanceToNow(savedDate, {
154
+ addSuffix: true,
155
+ locale,
156
+ });
157
+ const absoluteLabel = formatDateTime(
158
+ savedDate,
159
+ getSettingValue,
160
+ currentLocaleCode
161
+ );
162
+
163
+ return currentLocaleCode.startsWith('pt')
164
+ ? `Rascunho salvo ${relativeLabel} • Último salvamento: ${absoluteLabel}`
165
+ : `Draft saved ${relativeLabel} • Last saved: ${absoluteLabel}`;
166
+ }, [draftSavedAt, currentLocaleCode, getSettingValue, hasDraft]);
167
+
168
+ useEffect(() => {
169
+ if (!open) {
170
+ return;
171
+ }
172
+
173
+ const storedDraft = loadDraft();
174
+
175
+ form.reset(
176
+ storedDraft?.payload.values ?? {
177
+ periodStart: '',
178
+ periodEnd: '',
179
+ notes: '',
180
+ status: 'closed',
181
+ }
182
+ );
183
+ }, [form, loadDraft, open]);
184
+
103
185
  const onSubmit = async (values: ClosePeriodFormValues) => {
104
186
  try {
105
187
  await request({
@@ -113,6 +195,7 @@ function ClosePeriodSheet({
113
195
  },
114
196
  });
115
197
 
198
+ clearDraft();
116
199
  await onCreated();
117
200
  form.reset();
118
201
  onOpenChange(false);
@@ -127,9 +210,6 @@ function ClosePeriodSheet({
127
210
  open={open}
128
211
  onOpenChange={(nextOpen) => {
129
212
  onOpenChange(nextOpen);
130
- if (!nextOpen) {
131
- form.reset();
132
- }
133
213
  }}
134
214
  >
135
215
  <SheetContent className="w-full sm:max-w-lg">
@@ -215,6 +295,12 @@ function ClosePeriodSheet({
215
295
  )}
216
296
  />
217
297
 
298
+ {draftStatusContent ? (
299
+ <p className="text-xs text-muted-foreground">
300
+ {draftStatusContent}
301
+ </p>
302
+ ) : null}
303
+
218
304
  <div className="flex justify-end gap-2">
219
305
  <Button
220
306
  type="button"
@@ -311,7 +397,7 @@ export default function PeriodClosePage() {
311
397
  />
312
398
 
313
399
  <div className="flex flex-col gap-4 xl:flex-row xl:flex-wrap xl:items-center">
314
- <div className="relative min-w-[260px] flex-1">
400
+ <div className="relative min-w-65 flex-1">
315
401
  <Search className="pointer-events-none absolute left-2.5 top-2.5 h-4 w-4 text-muted-foreground" />
316
402
  <Input
317
403
  placeholder={t('filters.searchPlaceholder')}
@@ -331,7 +417,7 @@ export default function PeriodClosePage() {
331
417
  setPage(1);
332
418
  }}
333
419
  >
334
- <SelectTrigger className="w-full sm:w-[180px]">
420
+ <SelectTrigger className="w-full sm:w-45">
335
421
  <SelectValue placeholder={t('filters.status')} />
336
422
  </SelectTrigger>
337
423
  <SelectContent>