@hed-hog/finance 0.0.235 → 0.0.237

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 (75) hide show
  1. package/dist/dto/create-cost-center.dto.d.ts +4 -0
  2. package/dist/dto/create-cost-center.dto.d.ts.map +1 -0
  3. package/dist/dto/create-cost-center.dto.js +24 -0
  4. package/dist/dto/create-cost-center.dto.js.map +1 -0
  5. package/dist/dto/create-finance-category.dto.d.ts +6 -0
  6. package/dist/dto/create-finance-category.dto.d.ts.map +1 -0
  7. package/dist/dto/create-finance-category.dto.js +37 -0
  8. package/dist/dto/create-finance-category.dto.js.map +1 -0
  9. package/dist/dto/create-period-close.dto.d.ts +7 -0
  10. package/dist/dto/create-period-close.dto.d.ts.map +1 -0
  11. package/dist/dto/create-period-close.dto.js +44 -0
  12. package/dist/dto/create-period-close.dto.js.map +1 -0
  13. package/dist/dto/move-finance-category.dto.d.ts +5 -0
  14. package/dist/dto/move-finance-category.dto.d.ts.map +1 -0
  15. package/dist/dto/move-finance-category.dto.js +32 -0
  16. package/dist/dto/move-finance-category.dto.js.map +1 -0
  17. package/dist/dto/update-cost-center.dto.d.ts +5 -0
  18. package/dist/dto/update-cost-center.dto.d.ts.map +1 -0
  19. package/dist/dto/update-cost-center.dto.js +32 -0
  20. package/dist/dto/update-cost-center.dto.js.map +1 -0
  21. package/dist/dto/update-finance-category.dto.d.ts +7 -0
  22. package/dist/dto/update-finance-category.dto.d.ts.map +1 -0
  23. package/dist/dto/update-finance-category.dto.js +46 -0
  24. package/dist/dto/update-finance-category.dto.js.map +1 -0
  25. package/dist/finance-audit-logs.controller.d.ts +13 -0
  26. package/dist/finance-audit-logs.controller.d.ts.map +1 -0
  27. package/dist/finance-audit-logs.controller.js +54 -0
  28. package/dist/finance-audit-logs.controller.js.map +1 -0
  29. package/dist/finance-categories.controller.d.ts +42 -0
  30. package/dist/finance-categories.controller.d.ts.map +1 -0
  31. package/dist/finance-categories.controller.js +84 -0
  32. package/dist/finance-categories.controller.js.map +1 -0
  33. package/dist/finance-cost-centers.controller.d.ts +32 -0
  34. package/dist/finance-cost-centers.controller.d.ts.map +1 -0
  35. package/dist/finance-cost-centers.controller.js +72 -0
  36. package/dist/finance-cost-centers.controller.js.map +1 -0
  37. package/dist/finance-period-close.controller.d.ts +27 -0
  38. package/dist/finance-period-close.controller.d.ts.map +1 -0
  39. package/dist/finance-period-close.controller.js +64 -0
  40. package/dist/finance-period-close.controller.js.map +1 -0
  41. package/dist/finance.module.d.ts.map +1 -1
  42. package/dist/finance.module.js +8 -0
  43. package/dist/finance.module.js.map +1 -1
  44. package/dist/finance.service.d.ts +111 -0
  45. package/dist/finance.service.d.ts.map +1 -1
  46. package/dist/finance.service.js +446 -17
  47. package/dist/finance.service.js.map +1 -1
  48. package/dist/index.d.ts +4 -0
  49. package/dist/index.d.ts.map +1 -1
  50. package/dist/index.js +4 -0
  51. package/dist/index.js.map +1 -1
  52. package/hedhog/data/route.yaml +108 -0
  53. package/hedhog/frontend/app/_components/person-field-with-create.tsx.ejs +627 -0
  54. package/hedhog/frontend/app/accounts-payable/installments/page.tsx.ejs +865 -883
  55. package/hedhog/frontend/app/accounts-receivable/installments/page.tsx.ejs +838 -861
  56. package/hedhog/frontend/app/administration/audit-logs/page.tsx.ejs +309 -0
  57. package/hedhog/frontend/app/administration/categories/page.tsx.ejs +725 -0
  58. package/hedhog/frontend/app/administration/cost-centers/page.tsx.ejs +378 -0
  59. package/hedhog/frontend/app/administration/period-close/page.tsx.ejs +502 -0
  60. package/hedhog/frontend/messages/en.json +225 -0
  61. package/hedhog/frontend/messages/pt.json +225 -0
  62. package/package.json +5 -5
  63. package/src/dto/create-cost-center.dto.ts +9 -0
  64. package/src/dto/create-finance-category.dto.ts +21 -0
  65. package/src/dto/create-period-close.dto.ts +34 -0
  66. package/src/dto/move-finance-category.dto.ts +18 -0
  67. package/src/dto/update-cost-center.dto.ts +17 -0
  68. package/src/dto/update-finance-category.dto.ts +30 -0
  69. package/src/finance-audit-logs.controller.ts +30 -0
  70. package/src/finance-categories.controller.ts +52 -0
  71. package/src/finance-cost-centers.controller.ts +43 -0
  72. package/src/finance-period-close.controller.ts +34 -0
  73. package/src/finance.module.ts +8 -0
  74. package/src/finance.service.ts +578 -9
  75. package/src/index.ts +4 -0
@@ -0,0 +1,725 @@
1
+ 'use client';
2
+
3
+ import { Page, PageHeader } from '@/components/entity-list';
4
+ import {
5
+ AlertDialog,
6
+ AlertDialogAction,
7
+ AlertDialogCancel,
8
+ AlertDialogContent,
9
+ AlertDialogDescription,
10
+ AlertDialogFooter,
11
+ AlertDialogHeader,
12
+ AlertDialogTitle,
13
+ } from '@/components/ui/alert-dialog';
14
+ import { Badge } from '@/components/ui/badge';
15
+ import { Button } from '@/components/ui/button';
16
+ import {
17
+ Card,
18
+ CardContent,
19
+ CardDescription,
20
+ CardHeader,
21
+ CardTitle,
22
+ } from '@/components/ui/card';
23
+ import {
24
+ Form,
25
+ FormControl,
26
+ FormField,
27
+ FormItem,
28
+ FormLabel,
29
+ FormMessage,
30
+ } from '@/components/ui/form';
31
+ import { Input } from '@/components/ui/input';
32
+ import {
33
+ Select,
34
+ SelectContent,
35
+ SelectItem,
36
+ SelectTrigger,
37
+ SelectValue,
38
+ } from '@/components/ui/select';
39
+ import {
40
+ Sheet,
41
+ SheetContent,
42
+ SheetDescription,
43
+ SheetHeader,
44
+ SheetTitle,
45
+ } from '@/components/ui/sheet';
46
+ import {
47
+ DndContext,
48
+ DragEndEvent,
49
+ DragOverEvent,
50
+ PointerSensor,
51
+ closestCenter,
52
+ useSensor,
53
+ useSensors,
54
+ } from '@dnd-kit/core';
55
+ import {
56
+ SortableContext,
57
+ useSortable,
58
+ verticalListSortingStrategy,
59
+ } from '@dnd-kit/sortable';
60
+ import { useApp, useQuery } from '@hed-hog/next-app-provider';
61
+ import { zodResolver } from '@hookform/resolvers/zod';
62
+ import {
63
+ ChevronDown,
64
+ ChevronRight,
65
+ FolderTree,
66
+ GripVertical,
67
+ Pencil,
68
+ Plus,
69
+ Trash2,
70
+ } from 'lucide-react';
71
+ import { useTranslations } from 'next-intl';
72
+ import { useEffect, useMemo, useState } from 'react';
73
+ import { useForm } from 'react-hook-form';
74
+ import { z } from 'zod';
75
+
76
+ type CategoryFormValues = {
77
+ nome: string;
78
+ natureza: string;
79
+ parentId?: string;
80
+ };
81
+
82
+ type FinanceCategory = {
83
+ id: string;
84
+ codigo: string;
85
+ nome: string;
86
+ parentId: string | null;
87
+ natureza: 'receita' | 'despesa' | 'transferencia' | 'ajuste' | 'outro';
88
+ status: 'active' | 'inactive';
89
+ ativo: boolean;
90
+ };
91
+
92
+ type CategoryNode = FinanceCategory & { children: CategoryNode[] };
93
+
94
+ function buildTree(categories: FinanceCategory[]): CategoryNode[] {
95
+ const byId = new Map<string, CategoryNode>();
96
+ const roots: CategoryNode[] = [];
97
+
98
+ categories.forEach((item) => {
99
+ byId.set(item.id, { ...item, children: [] });
100
+ });
101
+
102
+ categories.forEach((item) => {
103
+ const node = byId.get(item.id)!;
104
+
105
+ if (item.parentId && byId.has(item.parentId)) {
106
+ byId.get(item.parentId)!.children.push(node);
107
+ return;
108
+ }
109
+
110
+ roots.push(node);
111
+ });
112
+
113
+ return roots;
114
+ }
115
+
116
+ function flattenTree(
117
+ nodes: CategoryNode[],
118
+ level = 0
119
+ ): Array<CategoryNode & { level: number }> {
120
+ return nodes.flatMap((node) => [
121
+ { ...node, level },
122
+ ...flattenTree(node.children, level + 1),
123
+ ]);
124
+ }
125
+
126
+ function CategoriaSheet({
127
+ open,
128
+ onOpenChange,
129
+ onSaved,
130
+ editing,
131
+ setEditing,
132
+ categories,
133
+ t,
134
+ }: {
135
+ open: boolean;
136
+ onOpenChange: (open: boolean) => void;
137
+ onSaved: () => Promise<any> | void;
138
+ editing: FinanceCategory | null;
139
+ setEditing: (item: FinanceCategory | null) => void;
140
+ categories: FinanceCategory[];
141
+ t: ReturnType<typeof useTranslations>;
142
+ }) {
143
+ const { request, showToastHandler } = useApp();
144
+
145
+ const categorySchema = z.object({
146
+ nome: z.string().trim().min(1, t('sheet.validation.nameRequired')),
147
+ natureza: z.string().min(1, t('sheet.validation.kindRequired')),
148
+ parentId: z.string().optional(),
149
+ });
150
+
151
+ const form = useForm<CategoryFormValues>({
152
+ resolver: zodResolver(categorySchema),
153
+ defaultValues: {
154
+ nome: '',
155
+ natureza: 'despesa',
156
+ parentId: 'root',
157
+ },
158
+ });
159
+
160
+ useEffect(() => {
161
+ if (!open) {
162
+ form.reset({ nome: '', natureza: 'despesa', parentId: 'root' });
163
+ return;
164
+ }
165
+
166
+ if (editing) {
167
+ form.reset({
168
+ nome: editing.nome,
169
+ natureza: editing.natureza,
170
+ parentId: editing.parentId || 'root',
171
+ });
172
+ return;
173
+ }
174
+
175
+ form.reset({ nome: '', natureza: 'despesa', parentId: 'root' });
176
+ }, [open, editing, form]);
177
+
178
+ const handleOpenChange = (nextOpen: boolean) => {
179
+ onOpenChange(nextOpen);
180
+
181
+ if (!nextOpen) {
182
+ setEditing(null);
183
+ form.reset({ nome: '', natureza: 'despesa', parentId: 'root' });
184
+ }
185
+ };
186
+
187
+ const onSubmit = async (values: CategoryFormValues) => {
188
+ try {
189
+ if (editing) {
190
+ await request({
191
+ url: `/finance/categories/${editing.id}`,
192
+ method: 'PATCH',
193
+ data: {
194
+ name: values.nome,
195
+ kind: values.natureza,
196
+ parent_id:
197
+ values.parentId === 'root' ? null : Number(values.parentId),
198
+ },
199
+ });
200
+ } else {
201
+ await request({
202
+ url: '/finance/categories',
203
+ method: 'POST',
204
+ data: {
205
+ name: values.nome,
206
+ kind: values.natureza,
207
+ parent_id:
208
+ values.parentId === 'root' ? null : Number(values.parentId),
209
+ },
210
+ });
211
+ }
212
+
213
+ await onSaved();
214
+ onOpenChange(false);
215
+ setEditing(null);
216
+ form.reset({ nome: '', natureza: 'despesa', parentId: 'root' });
217
+ showToastHandler?.(
218
+ 'success',
219
+ editing ? t('messages.updateSuccess') : t('messages.createSuccess')
220
+ );
221
+ } catch {
222
+ showToastHandler?.('error', t('messages.saveError'));
223
+ }
224
+ };
225
+
226
+ return (
227
+ <Sheet open={open} onOpenChange={handleOpenChange}>
228
+ <SheetContent className="w-full sm:max-w-lg">
229
+ <SheetHeader>
230
+ <SheetTitle>
231
+ {editing ? t('sheet.editTitle') : t('sheet.newTitle')}
232
+ </SheetTitle>
233
+ <SheetDescription>{t('sheet.description')}</SheetDescription>
234
+ </SheetHeader>
235
+
236
+ <Form {...form}>
237
+ <form
238
+ className="space-y-4 p-4"
239
+ onSubmit={form.handleSubmit(onSubmit)}
240
+ >
241
+ <FormField
242
+ control={form.control}
243
+ name="nome"
244
+ render={({ field }) => (
245
+ <FormItem>
246
+ <FormLabel>{t('sheet.fields.name')}</FormLabel>
247
+ <FormControl>
248
+ <Input
249
+ placeholder={t('sheet.fields.namePlaceholder')}
250
+ {...field}
251
+ />
252
+ </FormControl>
253
+ <FormMessage />
254
+ </FormItem>
255
+ )}
256
+ />
257
+
258
+ <FormField
259
+ control={form.control}
260
+ name="natureza"
261
+ render={({ field }) => (
262
+ <FormItem>
263
+ <FormLabel>{t('sheet.fields.kind')}</FormLabel>
264
+ <Select value={field.value} onValueChange={field.onChange}>
265
+ <FormControl>
266
+ <SelectTrigger className="w-full">
267
+ <SelectValue placeholder={t('common.select')} />
268
+ </SelectTrigger>
269
+ </FormControl>
270
+ <SelectContent>
271
+ <SelectItem value="receita">
272
+ {t('natureOptions.receita')}
273
+ </SelectItem>
274
+ <SelectItem value="despesa">
275
+ {t('natureOptions.despesa')}
276
+ </SelectItem>
277
+ <SelectItem value="transferencia">
278
+ {t('natureOptions.transferencia')}
279
+ </SelectItem>
280
+ <SelectItem value="ajuste">
281
+ {t('natureOptions.ajuste')}
282
+ </SelectItem>
283
+ <SelectItem value="outro">
284
+ {t('natureOptions.outro')}
285
+ </SelectItem>
286
+ </SelectContent>
287
+ </Select>
288
+ <FormMessage />
289
+ </FormItem>
290
+ )}
291
+ />
292
+
293
+ <FormField
294
+ control={form.control}
295
+ name="parentId"
296
+ render={({ field }) => (
297
+ <FormItem>
298
+ <FormLabel>{t('sheet.fields.parent')}</FormLabel>
299
+ <Select
300
+ value={field.value || 'root'}
301
+ onValueChange={field.onChange}
302
+ >
303
+ <FormControl>
304
+ <SelectTrigger className="w-full">
305
+ <SelectValue placeholder={t('sheet.fields.noParent')} />
306
+ </SelectTrigger>
307
+ </FormControl>
308
+ <SelectContent>
309
+ <SelectItem value="root">
310
+ {t('sheet.fields.noneRoot')}
311
+ </SelectItem>
312
+ {categories
313
+ .filter((item) => item.id !== editing?.id)
314
+ .map((item) => (
315
+ <SelectItem key={item.id} value={item.id}>
316
+ {item.nome}
317
+ </SelectItem>
318
+ ))}
319
+ </SelectContent>
320
+ </Select>
321
+ <FormMessage />
322
+ </FormItem>
323
+ )}
324
+ />
325
+
326
+ <div className="flex justify-end gap-2">
327
+ <Button
328
+ type="button"
329
+ variant="outline"
330
+ onClick={() => handleOpenChange(false)}
331
+ >
332
+ {t('common.cancel')}
333
+ </Button>
334
+ <Button type="submit" disabled={form.formState.isSubmitting}>
335
+ {t('common.save')}
336
+ </Button>
337
+ </div>
338
+ </form>
339
+ </Form>
340
+ </SheetContent>
341
+ </Sheet>
342
+ );
343
+ }
344
+
345
+ function CategoryRow({
346
+ item,
347
+ expanded,
348
+ setExpanded,
349
+ onEdit,
350
+ onDelete,
351
+ dropHint,
352
+ t,
353
+ }: {
354
+ item: CategoryNode & { level: number };
355
+ expanded: Set<string>;
356
+ setExpanded: (value: Set<string>) => void;
357
+ onEdit: (item: FinanceCategory) => void;
358
+ onDelete: (id: string) => void;
359
+ dropHint?: 'inside' | 'before' | 'after';
360
+ t: ReturnType<typeof useTranslations>;
361
+ }) {
362
+ const sortable = useSortable({ id: item.id });
363
+ const style = {
364
+ transform: sortable.transform
365
+ ? `translate3d(${sortable.transform.x}px, ${sortable.transform.y}px, 0)`
366
+ : undefined,
367
+ transition: sortable.transition,
368
+ };
369
+
370
+ const hasChildren = item.children.length > 0;
371
+ const isExpanded = expanded.has(item.id);
372
+
373
+ return (
374
+ <div
375
+ ref={sortable.setNodeRef}
376
+ style={style}
377
+ className={`rounded-md border bg-background ${
378
+ dropHint === 'inside' ? 'ring-1 ring-primary/50 bg-muted/40' : ''
379
+ } ${dropHint === 'before' ? 'border-t-2 border-t-primary' : ''} ${
380
+ dropHint === 'after' ? 'border-b-2 border-b-primary' : ''
381
+ }`}
382
+ >
383
+ <div
384
+ className="flex items-center justify-between gap-2 p-3"
385
+ style={{ paddingLeft: `${12 + item.level * 24}px` }}
386
+ >
387
+ <div className="flex min-w-0 items-center gap-2">
388
+ <button
389
+ type="button"
390
+ className="inline-flex h-5 w-5 items-center justify-center rounded"
391
+ onClick={() => {
392
+ if (!hasChildren) return;
393
+ const next = new Set(expanded);
394
+ if (next.has(item.id)) {
395
+ next.delete(item.id);
396
+ } else {
397
+ next.add(item.id);
398
+ }
399
+ setExpanded(next);
400
+ }}
401
+ >
402
+ {hasChildren ? (
403
+ isExpanded ? (
404
+ <ChevronDown className="h-4 w-4" />
405
+ ) : (
406
+ <ChevronRight className="h-4 w-4" />
407
+ )
408
+ ) : null}
409
+ </button>
410
+
411
+ <button
412
+ type="button"
413
+ ref={sortable.setActivatorNodeRef}
414
+ {...sortable.listeners}
415
+ {...sortable.attributes}
416
+ className="inline-flex h-7 w-7 items-center justify-center rounded border"
417
+ >
418
+ <GripVertical className="h-4 w-4" />
419
+ </button>
420
+
421
+ <div className="min-w-0">
422
+ <p className="truncate text-sm font-medium">{item.nome}</p>
423
+ <p className="text-xs text-muted-foreground">{item.codigo}</p>
424
+ </div>
425
+ </div>
426
+
427
+ <div className="flex items-center gap-2">
428
+ <Badge variant={item.ativo ? 'default' : 'secondary'}>
429
+ {item.ativo ? t('table.status.active') : t('table.status.inactive')}
430
+ </Badge>
431
+ <Badge variant="outline">{t(`natureOptions.${item.natureza}`)}</Badge>
432
+ <Button variant="outline" size="icon" onClick={() => onEdit(item)}>
433
+ <Pencil className="h-4 w-4" />
434
+ </Button>
435
+ <Button
436
+ variant="outline"
437
+ size="icon"
438
+ disabled={!item.ativo}
439
+ onClick={() => onDelete(item.id)}
440
+ >
441
+ <Trash2 className="h-4 w-4" />
442
+ </Button>
443
+ </div>
444
+ </div>
445
+ </div>
446
+ );
447
+ }
448
+
449
+ export default function CategoriesPage() {
450
+ const t = useTranslations('finance.AdminCategoriesPage');
451
+ const { request, showToastHandler } = useApp();
452
+
453
+ const [sheetOpen, setSheetOpen] = useState(false);
454
+ const [editing, setEditing] = useState<FinanceCategory | null>(null);
455
+ const [deleteId, setDeleteId] = useState<string | null>(null);
456
+ const [expanded, setExpanded] = useState<Set<string>>(new Set());
457
+ const [dragOverHint, setDragOverHint] = useState<{
458
+ overId: string;
459
+ mode: 'inside' | 'before' | 'after';
460
+ } | null>(null);
461
+
462
+ const sensors = useSensors(
463
+ useSensor(PointerSensor, { activationConstraint: { distance: 6 } })
464
+ );
465
+
466
+ const { data, refetch } = useQuery<FinanceCategory[]>({
467
+ queryKey: ['finance-categories'],
468
+ queryFn: async () => {
469
+ const response = await request({
470
+ url: '/finance/categories',
471
+ method: 'GET',
472
+ });
473
+ return (response.data || []) as FinanceCategory[];
474
+ },
475
+ placeholderData: [],
476
+ });
477
+
478
+ const categories = data || [];
479
+ const tree = useMemo(() => buildTree(categories), [categories]);
480
+ const flat = useMemo(() => flattenTree(tree), [tree]);
481
+
482
+ const idToParent = useMemo(() => {
483
+ const map = new Map<string, string | null>();
484
+ categories.forEach((item) => map.set(item.id, item.parentId || null));
485
+ return map;
486
+ }, [categories]);
487
+
488
+ const handleDelete = async () => {
489
+ if (!deleteId) return;
490
+
491
+ try {
492
+ await request({
493
+ url: `/finance/categories/${deleteId}`,
494
+ method: 'DELETE',
495
+ });
496
+ await refetch();
497
+ setDeleteId(null);
498
+ showToastHandler?.('success', t('messages.deleteSuccess'));
499
+ } catch {
500
+ showToastHandler?.('error', t('messages.deleteError'));
501
+ }
502
+ };
503
+
504
+ const handleDragEnd = async (event: DragEndEvent) => {
505
+ const { active, over } = event;
506
+
507
+ if (!over || active.id === over.id) {
508
+ return;
509
+ }
510
+
511
+ const activeId = String(active.id);
512
+ const overId = String(over.id);
513
+
514
+ const overRect = over.rect;
515
+ const activeTop =
516
+ active.rect.current.translated?.top ?? active.rect.current.initial?.top;
517
+
518
+ const isDropInsideTarget =
519
+ typeof activeTop === 'number' &&
520
+ activeTop > overRect.top + overRect.height * 0.25 &&
521
+ activeTop < overRect.top + overRect.height * 0.75;
522
+
523
+ const overParentId = idToParent.get(overId) || null;
524
+ const targetParentId = isDropInsideTarget ? overId : overParentId;
525
+
526
+ let cursor: string | null = targetParentId;
527
+ while (cursor) {
528
+ if (cursor === activeId) {
529
+ return;
530
+ }
531
+ cursor = idToParent.get(cursor) || null;
532
+ }
533
+
534
+ const siblings = categories.filter(
535
+ (item) =>
536
+ (item.parentId || null) === targetParentId && item.id !== activeId
537
+ );
538
+
539
+ let position = siblings.length;
540
+
541
+ if (!isDropInsideTarget) {
542
+ const overIndex = siblings.findIndex((item) => item.id === overId);
543
+ if (overIndex !== -1) {
544
+ const isDropAfter =
545
+ typeof activeTop === 'number' &&
546
+ activeTop > overRect.top + overRect.height / 2;
547
+ position = isDropAfter ? overIndex + 1 : overIndex;
548
+ }
549
+ }
550
+
551
+ try {
552
+ await request({
553
+ url: `/finance/categories/${activeId}/move`,
554
+ method: 'PATCH',
555
+ data: {
556
+ parent_id: targetParentId ? Number(targetParentId) : null,
557
+ position,
558
+ },
559
+ });
560
+
561
+ await refetch();
562
+ if (targetParentId) {
563
+ setExpanded((prev) => {
564
+ const next = new Set(prev);
565
+ next.add(targetParentId);
566
+ return next;
567
+ });
568
+ }
569
+ showToastHandler?.('success', t('messages.moveSuccess'));
570
+ } catch {
571
+ showToastHandler?.('error', t('messages.moveError'));
572
+ } finally {
573
+ setDragOverHint(null);
574
+ }
575
+ };
576
+
577
+ const handleDragOver = (event: DragOverEvent) => {
578
+ const { active, over } = event;
579
+
580
+ if (!over || active.id === over.id) {
581
+ setDragOverHint(null);
582
+ return;
583
+ }
584
+
585
+ const overRect = over.rect;
586
+ const activeTop =
587
+ active.rect.current.translated?.top ?? active.rect.current.initial?.top;
588
+
589
+ if (typeof activeTop !== 'number') {
590
+ setDragOverHint(null);
591
+ return;
592
+ }
593
+
594
+ const overId = String(over.id);
595
+ const inside =
596
+ activeTop > overRect.top + overRect.height * 0.25 &&
597
+ activeTop < overRect.top + overRect.height * 0.75;
598
+
599
+ if (inside) {
600
+ setDragOverHint({ overId, mode: 'inside' });
601
+ return;
602
+ }
603
+
604
+ setDragOverHint({
605
+ overId,
606
+ mode: activeTop > overRect.top + overRect.height / 2 ? 'after' : 'before',
607
+ });
608
+ };
609
+
610
+ return (
611
+ <Page>
612
+ <PageHeader
613
+ title={t('header.title')}
614
+ description={t('header.description')}
615
+ breadcrumbs={[
616
+ { label: t('breadcrumbs.finance'), href: '/finance' },
617
+ {
618
+ label: t('breadcrumbs.administration'),
619
+ href: '/finance/administration',
620
+ },
621
+ { label: t('breadcrumbs.current') },
622
+ ]}
623
+ />
624
+
625
+ <Card>
626
+ <CardHeader className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
627
+ <div>
628
+ <CardTitle className="flex items-center gap-2">
629
+ <FolderTree className="h-5 w-5" />
630
+ {t('card.title')}
631
+ </CardTitle>
632
+ <CardDescription>{t('card.description')}</CardDescription>
633
+ </div>
634
+ <Button
635
+ className="gap-2"
636
+ onClick={() => {
637
+ setEditing(null);
638
+ setSheetOpen(true);
639
+ }}
640
+ >
641
+ <Plus className="h-4 w-4" />
642
+ {t('actions.newCategory')}
643
+ </Button>
644
+ </CardHeader>
645
+
646
+ <CardContent>
647
+ {flat.length === 0 ? (
648
+ <div className="rounded-md border border-dashed p-8 text-center text-sm text-muted-foreground">
649
+ {t('table.empty')}
650
+ </div>
651
+ ) : (
652
+ <DndContext
653
+ sensors={sensors}
654
+ collisionDetection={closestCenter}
655
+ onDragOver={handleDragOver}
656
+ onDragEnd={handleDragEnd}
657
+ onDragCancel={() => setDragOverHint(null)}
658
+ >
659
+ <SortableContext
660
+ items={flat.map((item) => item.id)}
661
+ strategy={verticalListSortingStrategy}
662
+ >
663
+ <div className="space-y-2">
664
+ {flat.map((item) => (
665
+ <CategoryRow
666
+ key={item.id}
667
+ item={item}
668
+ expanded={expanded}
669
+ setExpanded={setExpanded}
670
+ dropHint={
671
+ dragOverHint?.overId === item.id
672
+ ? dragOverHint.mode
673
+ : undefined
674
+ }
675
+ onEdit={(category) => {
676
+ setEditing(category);
677
+ setSheetOpen(true);
678
+ }}
679
+ onDelete={(id) => setDeleteId(id)}
680
+ t={t}
681
+ />
682
+ ))}
683
+ </div>
684
+ </SortableContext>
685
+ </DndContext>
686
+ )}
687
+ </CardContent>
688
+ </Card>
689
+
690
+ <CategoriaSheet
691
+ open={sheetOpen}
692
+ onOpenChange={setSheetOpen}
693
+ onSaved={refetch}
694
+ editing={editing}
695
+ setEditing={setEditing}
696
+ categories={categories}
697
+ t={t}
698
+ />
699
+
700
+ <AlertDialog
701
+ open={!!deleteId}
702
+ onOpenChange={(open) => {
703
+ if (!open) {
704
+ setDeleteId(null);
705
+ }
706
+ }}
707
+ >
708
+ <AlertDialogContent>
709
+ <AlertDialogHeader>
710
+ <AlertDialogTitle>{t('deleteDialog.title')}</AlertDialogTitle>
711
+ <AlertDialogDescription>
712
+ {t('deleteDialog.description')}
713
+ </AlertDialogDescription>
714
+ </AlertDialogHeader>
715
+ <AlertDialogFooter>
716
+ <AlertDialogCancel>{t('common.cancel')}</AlertDialogCancel>
717
+ <AlertDialogAction onClick={handleDelete}>
718
+ {t('deleteDialog.confirm')}
719
+ </AlertDialogAction>
720
+ </AlertDialogFooter>
721
+ </AlertDialogContent>
722
+ </AlertDialog>
723
+ </Page>
724
+ );
725
+ }