@hed-hog/finance 0.0.237 → 0.0.239

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 (47) hide show
  1. package/dist/dto/create-finance-tag.dto.d.ts +5 -0
  2. package/dist/dto/create-finance-tag.dto.d.ts.map +1 -0
  3. package/dist/dto/create-finance-tag.dto.js +29 -0
  4. package/dist/dto/create-finance-tag.dto.js.map +1 -0
  5. package/dist/dto/reject-title.dto.d.ts +4 -0
  6. package/dist/dto/reject-title.dto.d.ts.map +1 -0
  7. package/dist/dto/reject-title.dto.js +22 -0
  8. package/dist/dto/reject-title.dto.js.map +1 -0
  9. package/dist/dto/reverse-settlement.dto.d.ts +4 -0
  10. package/dist/dto/reverse-settlement.dto.d.ts.map +1 -0
  11. package/dist/dto/reverse-settlement.dto.js +22 -0
  12. package/dist/dto/reverse-settlement.dto.js.map +1 -0
  13. package/dist/dto/settle-installment.dto.d.ts +12 -0
  14. package/dist/dto/settle-installment.dto.d.ts.map +1 -0
  15. package/dist/dto/settle-installment.dto.js +71 -0
  16. package/dist/dto/settle-installment.dto.js.map +1 -0
  17. package/dist/dto/update-installment-tags.dto.d.ts +4 -0
  18. package/dist/dto/update-installment-tags.dto.d.ts.map +1 -0
  19. package/dist/dto/update-installment-tags.dto.js +27 -0
  20. package/dist/dto/update-installment-tags.dto.js.map +1 -0
  21. package/dist/finance-data.controller.d.ts +17 -5
  22. package/dist/finance-data.controller.d.ts.map +1 -1
  23. package/dist/finance-installments.controller.d.ts +325 -8
  24. package/dist/finance-installments.controller.d.ts.map +1 -1
  25. package/dist/finance-installments.controller.js +128 -0
  26. package/dist/finance-installments.controller.js.map +1 -1
  27. package/dist/finance.service.d.ts +357 -13
  28. package/dist/finance.service.d.ts.map +1 -1
  29. package/dist/finance.service.js +835 -64
  30. package/dist/finance.service.js.map +1 -1
  31. package/hedhog/data/route.yaml +90 -0
  32. package/hedhog/frontend/app/_lib/use-finance-data.ts.ejs +2 -0
  33. package/hedhog/frontend/app/accounts-payable/approvals/page.tsx.ejs +92 -12
  34. package/hedhog/frontend/app/accounts-payable/installments/[id]/page.tsx.ejs +601 -79
  35. package/hedhog/frontend/app/accounts-payable/installments/page.tsx.ejs +481 -19
  36. package/hedhog/frontend/app/accounts-receivable/installments/[id]/page.tsx.ejs +598 -69
  37. package/hedhog/frontend/app/accounts-receivable/installments/page.tsx.ejs +472 -15
  38. package/hedhog/frontend/messages/en.json +38 -0
  39. package/hedhog/frontend/messages/pt.json +38 -0
  40. package/package.json +5 -5
  41. package/src/dto/create-finance-tag.dto.ts +15 -0
  42. package/src/dto/reject-title.dto.ts +7 -0
  43. package/src/dto/reverse-settlement.dto.ts +7 -0
  44. package/src/dto/settle-installment.dto.ts +55 -0
  45. package/src/dto/update-installment-tags.dto.ts +12 -0
  46. package/src/finance-installments.controller.ts +145 -9
  47. package/src/finance.service.ts +1333 -165
@@ -1,8 +1,18 @@
1
1
  'use client';
2
2
 
3
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
+ AlertDialogTrigger,
14
+ } from '@/components/ui/alert-dialog';
4
15
  import { AuditTimeline } from '@/components/ui/audit-timeline';
5
- import { Badge } from '@/components/ui/badge';
6
16
  import { Button } from '@/components/ui/button';
7
17
  import {
8
18
  Card,
@@ -11,6 +21,14 @@ import {
11
21
  CardHeader,
12
22
  CardTitle,
13
23
  } from '@/components/ui/card';
24
+ import {
25
+ Dialog,
26
+ DialogContent,
27
+ DialogDescription,
28
+ DialogFooter,
29
+ DialogHeader,
30
+ DialogTitle,
31
+ } from '@/components/ui/dialog';
14
32
  import {
15
33
  DropdownMenu,
16
34
  DropdownMenuContent,
@@ -18,7 +36,24 @@ import {
18
36
  DropdownMenuSeparator,
19
37
  DropdownMenuTrigger,
20
38
  } from '@/components/ui/dropdown-menu';
39
+ import {
40
+ Form,
41
+ FormControl,
42
+ FormField,
43
+ FormItem,
44
+ FormLabel,
45
+ FormMessage,
46
+ } from '@/components/ui/form';
47
+ import { Input } from '@/components/ui/input';
48
+ import { InputMoney } from '@/components/ui/input-money';
21
49
  import { Money } from '@/components/ui/money';
50
+ import {
51
+ Select,
52
+ SelectContent,
53
+ SelectItem,
54
+ SelectTrigger,
55
+ SelectValue,
56
+ } from '@/components/ui/select';
22
57
  import { StatusBadge } from '@/components/ui/status-badge';
23
58
  import {
24
59
  Table,
@@ -29,8 +64,10 @@ import {
29
64
  TableRow,
30
65
  } from '@/components/ui/table';
31
66
  import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
67
+ import { TagSelectorSheet } from '@/components/ui/tag-selector-sheet';
68
+ import { useApp } from '@hed-hog/next-app-provider';
69
+ import { zodResolver } from '@hookform/resolvers/zod';
32
70
  import {
33
- ArrowLeft,
34
71
  CheckCircle,
35
72
  Download,
36
73
  Edit,
@@ -42,14 +79,26 @@ import {
42
79
  import { useTranslations } from 'next-intl';
43
80
  import Link from 'next/link';
44
81
  import { useParams } from 'next/navigation';
82
+ import { useEffect, useState } from 'react';
83
+ import { useForm } from 'react-hook-form';
84
+ import { z } from 'zod';
45
85
  import { formatarData } from '../../../_lib/formatters';
46
86
  import { useFinanceData } from '../../../_lib/use-finance-data';
47
87
 
88
+ const settleSchema = z.object({
89
+ installmentId: z.string().min(1, 'Parcela obrigatória'),
90
+ amount: z.number().min(0.01, 'Valor deve ser maior que zero'),
91
+ description: z.string().optional(),
92
+ });
93
+
94
+ type SettleFormValues = z.infer<typeof settleSchema>;
95
+
48
96
  export default function TituloDetalhePage() {
49
97
  const t = useTranslations('finance.PayableInstallmentDetailPage');
98
+ const { request, showToastHandler } = useApp();
50
99
  const params = useParams<{ id: string }>();
51
100
  const id = params?.id;
52
- const { data } = useFinanceData();
101
+ const { data, refetch } = useFinanceData();
53
102
  const {
54
103
  titulosPagar,
55
104
  pessoas,
@@ -62,6 +111,65 @@ export default function TituloDetalhePage() {
62
111
 
63
112
  const titulo = titulosPagar.find((t) => t.id === id);
64
113
 
114
+ const settleCandidates = (titulo?.parcelas || []).filter(
115
+ (parcela: any) =>
116
+ parcela.status === 'aberto' ||
117
+ parcela.status === 'parcial' ||
118
+ parcela.status === 'vencido'
119
+ );
120
+
121
+ const settleForm = useForm<SettleFormValues>({
122
+ resolver: zodResolver(settleSchema),
123
+ defaultValues: {
124
+ installmentId: settleCandidates[0]?.id || '',
125
+ amount: Number(settleCandidates[0]?.valorAberto || 0),
126
+ description: '',
127
+ },
128
+ });
129
+
130
+ const [availableTags, setAvailableTags] = useState<any[]>([]);
131
+ const [selectedTagIds, setSelectedTagIds] = useState<string[]>([]);
132
+ const [isCreatingTag, setIsCreatingTag] = useState(false);
133
+ const [isApproving, setIsApproving] = useState(false);
134
+ const [isSettling, setIsSettling] = useState(false);
135
+ const [isSettleDialogOpen, setIsSettleDialogOpen] = useState(false);
136
+ const [reversingSettlementId, setReversingSettlementId] = useState<
137
+ string | null
138
+ >(null);
139
+
140
+ useEffect(() => {
141
+ setAvailableTags(tags || []);
142
+ }, [tags]);
143
+
144
+ useEffect(() => {
145
+ if (!titulo) {
146
+ return;
147
+ }
148
+
149
+ setSelectedTagIds(Array.isArray(titulo.tags) ? titulo.tags : []);
150
+ }, [titulo]);
151
+
152
+ useEffect(() => {
153
+ const firstCandidate = settleCandidates[0];
154
+ const nextInstallmentId = firstCandidate?.id || '';
155
+ const nextAmount = Number(firstCandidate?.valorAberto || 0);
156
+ const currentInstallmentId = settleForm.getValues('installmentId') || '';
157
+ const currentAmount = Number(settleForm.getValues('amount') || 0);
158
+
159
+ if (
160
+ currentInstallmentId === nextInstallmentId &&
161
+ currentAmount === nextAmount
162
+ ) {
163
+ return;
164
+ }
165
+
166
+ settleForm.reset({
167
+ installmentId: nextInstallmentId,
168
+ amount: nextAmount,
169
+ description: '',
170
+ });
171
+ }, [settleForm, settleCandidates]);
172
+
65
173
  if (!titulo) {
66
174
  return (
67
175
  <div className="space-y-6">
@@ -95,9 +203,20 @@ export default function TituloDetalhePage() {
95
203
  const fornecedor = getPessoaById(titulo.fornecedorId);
96
204
  const categoria = getCategoriaById(titulo.categoriaId);
97
205
  const centroCusto = getCentroCustoById(titulo.centroCustoId);
98
- const tituloTags = titulo.tags
99
- .map((tagId: any) => tags.find((t: any) => t.id === tagId))
100
- .filter(Boolean);
206
+
207
+ const tagOptions = availableTags.map((tag) => ({
208
+ id: String(tag.id),
209
+ name: String(tag.nome || ''),
210
+ color: tag.cor,
211
+ usageCount:
212
+ typeof tag.usageCount === 'number'
213
+ ? tag.usageCount
214
+ : typeof tag.usoCount === 'number'
215
+ ? tag.usoCount
216
+ : typeof tag.count === 'number'
217
+ ? tag.count
218
+ : undefined,
219
+ }));
101
220
 
102
221
  const auditEvents = logsAuditoria
103
222
  .filter(
@@ -113,64 +232,398 @@ export default function TituloDetalhePage() {
113
232
  depois: log.depois,
114
233
  }));
115
234
 
235
+ const attachmentDetails = Array.isArray(titulo.anexosDetalhes)
236
+ ? titulo.anexosDetalhes
237
+ : (titulo.anexos || []).map((nome: string) => ({ nome }));
238
+
239
+ const handleOpenAttachment = async (fileId?: string) => {
240
+ if (!fileId) {
241
+ return;
242
+ }
243
+
244
+ const response = await request<{ url?: string }>({
245
+ url: `/file/open/${fileId}`,
246
+ method: 'PUT',
247
+ });
248
+
249
+ const url = response?.data?.url;
250
+ if (!url) {
251
+ return;
252
+ }
253
+
254
+ window.open(url, '_blank', 'noopener,noreferrer');
255
+ };
256
+
257
+ const tTagSelector = (key: string, fallback: string) => {
258
+ const fullKey = `tagSelector.${key}`;
259
+ return t.has(fullKey) ? t(fullKey) : fallback;
260
+ };
261
+
262
+ const toTagSlug = (value: string) => {
263
+ return value
264
+ .normalize('NFD')
265
+ .replace(/[\u0300-\u036f]/g, '')
266
+ .toLowerCase()
267
+ .trim()
268
+ .replace(/[^a-z0-9]+/g, '-')
269
+ .replace(/(^-|-$)+/g, '');
270
+ };
271
+
272
+ const handleCreateTag = async (name: string) => {
273
+ const slug = toTagSlug(name);
274
+
275
+ if (!slug) {
276
+ return null;
277
+ }
278
+
279
+ setIsCreatingTag(true);
280
+ try {
281
+ const response = await request<{
282
+ id?: string | number;
283
+ nome?: string;
284
+ cor?: string;
285
+ }>({
286
+ url: '/finance/tags',
287
+ method: 'POST',
288
+ data: {
289
+ name: slug,
290
+ color: '#000000',
291
+ },
292
+ });
293
+
294
+ const created = response?.data;
295
+ const newTag = {
296
+ id: String(created?.id || `temp-${Date.now()}`),
297
+ nome: created?.nome || slug,
298
+ cor: created?.cor || '#000000',
299
+ };
300
+
301
+ setAvailableTags((current) => {
302
+ if (current.some((tag) => String(tag.id) === newTag.id)) {
303
+ return current;
304
+ }
305
+
306
+ return [...current, newTag];
307
+ });
308
+
309
+ showToastHandler?.(
310
+ 'success',
311
+ tTagSelector('messages.createSuccess', 'Tag criada com sucesso')
312
+ );
313
+
314
+ return {
315
+ id: newTag.id,
316
+ name: newTag.nome,
317
+ color: newTag.cor,
318
+ };
319
+ } catch {
320
+ showToastHandler?.(
321
+ 'error',
322
+ tTagSelector('messages.createError', 'Não foi possível criar a tag')
323
+ );
324
+ return null;
325
+ } finally {
326
+ setIsCreatingTag(false);
327
+ }
328
+ };
329
+
330
+ const handleChangeTags = async (nextTagIds: string[]) => {
331
+ if (!titulo?.id) {
332
+ return;
333
+ }
334
+
335
+ try {
336
+ const response = await request<{ tags?: string[] }>({
337
+ url: `/finance/accounts-payable/installments/${titulo.id}/tags`,
338
+ method: 'PATCH',
339
+ data: {
340
+ tag_ids: nextTagIds.map((tagId) => Number(tagId)),
341
+ },
342
+ });
343
+
344
+ if (Array.isArray(response?.data?.tags)) {
345
+ setSelectedTagIds(response.data.tags);
346
+ } else {
347
+ setSelectedTagIds(nextTagIds);
348
+ }
349
+ } catch {
350
+ showToastHandler?.(
351
+ 'error',
352
+ tTagSelector(
353
+ 'messages.updateError',
354
+ 'Não foi possível atualizar as tags'
355
+ )
356
+ );
357
+ }
358
+ };
359
+
360
+ const canApprove = titulo.status === 'rascunho';
361
+ const canSettle = ['aprovado', 'aberto', 'parcial'].includes(titulo.status);
362
+
363
+ const getErrorMessage = (error: any, fallback: string) => {
364
+ const message = error?.response?.data?.message;
365
+
366
+ if (Array.isArray(message)) {
367
+ return message.join(', ');
368
+ }
369
+
370
+ if (typeof message === 'string' && message.trim()) {
371
+ return message;
372
+ }
373
+
374
+ return fallback;
375
+ };
376
+
377
+ const handleApprove = async () => {
378
+ if (!canApprove || isApproving) {
379
+ return;
380
+ }
381
+
382
+ setIsApproving(true);
383
+ try {
384
+ await request({
385
+ url: `/finance/accounts-payable/installments/${titulo.id}/approve`,
386
+ method: 'PATCH',
387
+ });
388
+
389
+ await refetch();
390
+ showToastHandler?.('success', 'Título aprovado com sucesso');
391
+ } catch (error) {
392
+ showToastHandler?.(
393
+ 'error',
394
+ getErrorMessage(error, 'Não foi possível aprovar o título')
395
+ );
396
+ } finally {
397
+ setIsApproving(false);
398
+ }
399
+ };
400
+
401
+ const handleSettle = async (values: SettleFormValues) => {
402
+ if (!canSettle || isSettling) {
403
+ return;
404
+ }
405
+
406
+ setIsSettling(true);
407
+ try {
408
+ await request({
409
+ url: `/finance/accounts-payable/installments/${titulo.id}/settlements`,
410
+ method: 'POST',
411
+ data: {
412
+ installment_id: Number(values.installmentId),
413
+ amount: values.amount,
414
+ description: values.description?.trim() || undefined,
415
+ },
416
+ });
417
+
418
+ await refetch();
419
+ setIsSettleDialogOpen(false);
420
+ showToastHandler?.('success', 'Baixa registrada com sucesso');
421
+ } catch (error) {
422
+ showToastHandler?.(
423
+ 'error',
424
+ getErrorMessage(error, 'Não foi possível registrar a baixa')
425
+ );
426
+ } finally {
427
+ setIsSettling(false);
428
+ }
429
+ };
430
+
431
+ const handleReverseSettlement = async (settlementId: string) => {
432
+ if (!settlementId || reversingSettlementId) {
433
+ return;
434
+ }
435
+
436
+ setReversingSettlementId(settlementId);
437
+ try {
438
+ await request({
439
+ url: `/finance/accounts-payable/installments/${titulo.id}/settlements/${settlementId}/reverse`,
440
+ method: 'PATCH',
441
+ data: {},
442
+ });
443
+
444
+ await refetch();
445
+ showToastHandler?.('success', 'Estorno realizado com sucesso');
446
+ } catch (error) {
447
+ showToastHandler?.(
448
+ 'error',
449
+ getErrorMessage(error, 'Não foi possível estornar a liquidação')
450
+ );
451
+ } finally {
452
+ setReversingSettlementId(null);
453
+ }
454
+ };
455
+
116
456
  return (
117
457
  <Page>
118
- <div className="flex items-center gap-4">
119
- <Button variant="ghost" size="icon" asChild>
120
- <Link href="/finance/accounts-payable/installments">
121
- <ArrowLeft className="h-4 w-4" />
122
- </Link>
123
- </Button>
124
- <PageHeader
125
- title={titulo.documento}
126
- description={titulo.descricao}
127
- breadcrumbs={[
128
- { label: t('breadcrumbs.home'), href: '/' },
129
- { label: t('breadcrumbs.finance'), href: '/finance' },
130
- {
131
- label: t('breadcrumbs.payables'),
132
- href: '/finance/accounts-payable/installments',
133
- },
134
- { label: titulo.documento },
135
- ]}
136
- actions={
137
- <div className="flex items-center gap-2">
138
- <StatusBadge status={titulo.status} />
139
- <DropdownMenu>
140
- <DropdownMenuTrigger asChild>
141
- <Button variant="outline">
142
- <MoreHorizontal className="mr-2 h-4 w-4" />
143
- {t('actions.title')}
144
- </Button>
145
- </DropdownMenuTrigger>
146
- <DropdownMenuContent align="end">
147
- <DropdownMenuItem>
148
- <Edit className="mr-2 h-4 w-4" />
149
- {t('actions.edit')}
150
- </DropdownMenuItem>
151
- <DropdownMenuItem>
152
- <CheckCircle className="mr-2 h-4 w-4" />
153
- {t('actions.approve')}
154
- </DropdownMenuItem>
155
- <DropdownMenuItem>
156
- <Download className="mr-2 h-4 w-4" />
157
- {t('actions.settle')}
158
- </DropdownMenuItem>
159
- <DropdownMenuItem>
160
- <Undo className="mr-2 h-4 w-4" />
161
- {t('actions.reverse')}
162
- </DropdownMenuItem>
163
- <DropdownMenuSeparator />
164
- <DropdownMenuItem className="text-destructive">
165
- <XCircle className="mr-2 h-4 w-4" />
166
- {t('actions.cancel')}
167
- </DropdownMenuItem>
168
- </DropdownMenuContent>
169
- </DropdownMenu>
170
- </div>
171
- }
172
- />
173
- </div>
458
+ <PageHeader
459
+ title={titulo.documento}
460
+ description={titulo.descricao}
461
+ breadcrumbs={[
462
+ { label: t('breadcrumbs.home'), href: '/' },
463
+ { label: t('breadcrumbs.finance'), href: '/finance' },
464
+ {
465
+ label: t('breadcrumbs.payables'),
466
+ href: '/finance/accounts-payable/installments',
467
+ },
468
+ { label: titulo.documento },
469
+ ]}
470
+ actions={
471
+ <div className="flex items-center gap-2">
472
+ <DropdownMenu>
473
+ <DropdownMenuTrigger asChild>
474
+ <Button variant="outline">
475
+ <MoreHorizontal className="mr-2 h-4 w-4" />
476
+ {t('actions.title')}
477
+ </Button>
478
+ </DropdownMenuTrigger>
479
+ <DropdownMenuContent align="end">
480
+ <DropdownMenuItem>
481
+ <Edit className="mr-2 h-4 w-4" />
482
+ {t('actions.edit')}
483
+ </DropdownMenuItem>
484
+ <DropdownMenuItem
485
+ disabled={!canApprove || isApproving}
486
+ onClick={() => void handleApprove()}
487
+ >
488
+ <CheckCircle className="mr-2 h-4 w-4" />
489
+ {t('actions.approve')}
490
+ </DropdownMenuItem>
491
+ <DropdownMenuItem
492
+ disabled={!canSettle || settleCandidates.length === 0}
493
+ onClick={() => setIsSettleDialogOpen(true)}
494
+ >
495
+ <Download className="mr-2 h-4 w-4" />
496
+ {t('actions.settle')}
497
+ </DropdownMenuItem>
498
+ <DropdownMenuItem disabled>
499
+ <Undo className="mr-2 h-4 w-4" />
500
+ {t('actions.reverse')}
501
+ </DropdownMenuItem>
502
+ <DropdownMenuSeparator />
503
+ <DropdownMenuItem className="text-destructive">
504
+ <XCircle className="mr-2 h-4 w-4" />
505
+ {t('actions.cancel')}
506
+ </DropdownMenuItem>
507
+ </DropdownMenuContent>
508
+ </DropdownMenu>
509
+
510
+ <Dialog
511
+ open={isSettleDialogOpen}
512
+ onOpenChange={setIsSettleDialogOpen}
513
+ >
514
+ <DialogContent>
515
+ <DialogHeader>
516
+ <DialogTitle>Registrar baixa</DialogTitle>
517
+ <DialogDescription>
518
+ Informe a parcela e o valor da baixa. O backend valida os
519
+ estados e limites de valor automaticamente.
520
+ </DialogDescription>
521
+ </DialogHeader>
522
+
523
+ <Form {...settleForm}>
524
+ <form
525
+ className="space-y-4"
526
+ onSubmit={settleForm.handleSubmit(handleSettle)}
527
+ >
528
+ <FormField
529
+ control={settleForm.control}
530
+ name="installmentId"
531
+ render={({ field }) => (
532
+ <FormItem>
533
+ <FormLabel>Parcela</FormLabel>
534
+ <Select
535
+ value={field.value}
536
+ onValueChange={(value) => {
537
+ field.onChange(value);
538
+
539
+ const selected = settleCandidates.find(
540
+ (parcela: any) => parcela.id === value
541
+ );
542
+
543
+ if (selected) {
544
+ settleForm.setValue(
545
+ 'amount',
546
+ Number(selected.valorAberto || 0),
547
+ { shouldValidate: true }
548
+ );
549
+ }
550
+ }}
551
+ >
552
+ <FormControl>
553
+ <SelectTrigger>
554
+ <SelectValue placeholder="Selecione" />
555
+ </SelectTrigger>
556
+ </FormControl>
557
+ <SelectContent>
558
+ {settleCandidates.map((parcela: any) => (
559
+ <SelectItem key={parcela.id} value={parcela.id}>
560
+ Parcela {parcela.numero} - em aberto:{' '}
561
+ {new Intl.NumberFormat('pt-BR', {
562
+ style: 'currency',
563
+ currency: 'BRL',
564
+ }).format(Number(parcela.valorAberto || 0))}
565
+ </SelectItem>
566
+ ))}
567
+ </SelectContent>
568
+ </Select>
569
+ <FormMessage />
570
+ </FormItem>
571
+ )}
572
+ />
573
+
574
+ <FormField
575
+ control={settleForm.control}
576
+ name="amount"
577
+ render={({ field }) => (
578
+ <FormItem>
579
+ <FormLabel>Valor</FormLabel>
580
+ <FormControl>
581
+ <InputMoney
582
+ value={Number(field.value || 0)}
583
+ onValueChange={(value) => {
584
+ field.onChange(Number(value || 0));
585
+ }}
586
+ />
587
+ </FormControl>
588
+ <FormMessage />
589
+ </FormItem>
590
+ )}
591
+ />
592
+
593
+ <FormField
594
+ control={settleForm.control}
595
+ name="description"
596
+ render={({ field }) => (
597
+ <FormItem>
598
+ <FormLabel>Descrição (opcional)</FormLabel>
599
+ <FormControl>
600
+ <Input {...field} value={field.value || ''} />
601
+ </FormControl>
602
+ <FormMessage />
603
+ </FormItem>
604
+ )}
605
+ />
606
+
607
+ <DialogFooter>
608
+ <Button
609
+ type="button"
610
+ variant="outline"
611
+ disabled={isSettling}
612
+ onClick={() => setIsSettleDialogOpen(false)}
613
+ >
614
+ Cancelar
615
+ </Button>
616
+ <Button type="submit" disabled={isSettling}>
617
+ Confirmar baixa
618
+ </Button>
619
+ </DialogFooter>
620
+ </form>
621
+ </Form>
622
+ </DialogContent>
623
+ </Dialog>
624
+ </div>
625
+ }
626
+ />
174
627
 
175
628
  <div className="grid gap-6 lg:grid-cols-3">
176
629
  <Card className="lg:col-span-2">
@@ -234,20 +687,44 @@ export default function TituloDetalhePage() {
234
687
  <dt className="text-sm font-medium text-muted-foreground">
235
688
  {t('documentData.tags')}
236
689
  </dt>
237
- <dd className="mt-1 flex gap-1">
238
- {tituloTags.length > 0 ? (
239
- tituloTags.map((tag: any) => (
240
- <Badge
241
- key={tag?.id}
242
- variant="outline"
243
- style={{ borderColor: tag?.cor, color: tag?.cor }}
244
- >
245
- {tag?.nome}
246
- </Badge>
247
- ))
248
- ) : (
249
- <span className="text-muted-foreground">-</span>
250
- )}
690
+ <dd className="mt-1">
691
+ <TagSelectorSheet
692
+ selectedTagIds={selectedTagIds}
693
+ tags={tagOptions}
694
+ onChange={handleChangeTags}
695
+ onCreateTag={handleCreateTag}
696
+ disabled={isCreatingTag}
697
+ emptyText={tTagSelector('noTags', 'Sem tags')}
698
+ labels={{
699
+ addTag: tTagSelector('addTag', 'Adicionar tag'),
700
+ sheetTitle: tTagSelector('sheetTitle', 'Gerenciar tags'),
701
+ sheetDescription: tTagSelector(
702
+ 'sheetDescription',
703
+ 'Selecione tags existentes ou crie uma nova.'
704
+ ),
705
+ createLabel: tTagSelector('createLabel', 'Nova tag'),
706
+ createPlaceholder: tTagSelector(
707
+ 'createPlaceholder',
708
+ 'Digite o nome da tag'
709
+ ),
710
+ createAction: tTagSelector('createAction', 'Criar tag'),
711
+ popularTitle: tTagSelector(
712
+ 'popularTitle',
713
+ 'Tags mais usadas'
714
+ ),
715
+ selectedTitle: tTagSelector(
716
+ 'selectedTitle',
717
+ 'Tags selecionadas'
718
+ ),
719
+ noTags: tTagSelector('noTags', 'Sem tags'),
720
+ cancel: tTagSelector('cancel', 'Cancelar'),
721
+ apply: tTagSelector('apply', 'Aplicar'),
722
+ removeTagAria: (tagName: string) =>
723
+ t.has('tagSelector.removeTagAria')
724
+ ? t('tagSelector.removeTagAria', { tag: tagName })
725
+ : `Remover tag ${tagName}`,
726
+ }}
727
+ />
251
728
  </dd>
252
729
  </div>
253
730
  </dl>
@@ -260,16 +737,17 @@ export default function TituloDetalhePage() {
260
737
  <CardDescription>{t('attachments.description')}</CardDescription>
261
738
  </CardHeader>
262
739
  <CardContent>
263
- {titulo.anexos.length > 0 ? (
740
+ {attachmentDetails.length > 0 ? (
264
741
  <ul className="space-y-2">
265
- {titulo.anexos.map((anexo: any, i: number) => (
742
+ {attachmentDetails.map((anexo: any, i: number) => (
266
743
  <li key={i}>
267
744
  <Button
268
745
  variant="ghost"
269
746
  className="h-auto w-full justify-start p-2"
747
+ onClick={() => void handleOpenAttachment(anexo?.id)}
270
748
  >
271
749
  <FileText className="mr-2 h-4 w-4" />
272
- {anexo}
750
+ {anexo?.nome}
273
751
  </Button>
274
752
  </li>
275
753
  ))}
@@ -351,6 +829,7 @@ export default function TituloDetalhePage() {
351
829
  </TableHead>
352
830
  <TableHead>{t('settlementsTable.account')}</TableHead>
353
831
  <TableHead>{t('settlementsTable.method')}</TableHead>
832
+ <TableHead className="text-right">Ações</TableHead>
354
833
  </TableRow>
355
834
  </TableHeader>
356
835
  <TableBody>
@@ -376,6 +855,49 @@ export default function TituloDetalhePage() {
376
855
  <TableCell className="capitalize">
377
856
  {liq.metodo}
378
857
  </TableCell>
858
+ <TableCell className="text-right">
859
+ <AlertDialog>
860
+ <AlertDialogTrigger asChild>
861
+ <Button
862
+ variant="outline"
863
+ size="sm"
864
+ disabled={
865
+ !liq.settlementId ||
866
+ liq.status === 'reversed' ||
867
+ !!reversingSettlementId
868
+ }
869
+ >
870
+ <Undo className="mr-2 h-4 w-4" />
871
+ Estornar
872
+ </Button>
873
+ </AlertDialogTrigger>
874
+ <AlertDialogContent>
875
+ <AlertDialogHeader>
876
+ <AlertDialogTitle>
877
+ Confirmar estorno
878
+ </AlertDialogTitle>
879
+ <AlertDialogDescription>
880
+ Esta ação cria o estorno da liquidação e
881
+ recalcula saldos e status.
882
+ </AlertDialogDescription>
883
+ </AlertDialogHeader>
884
+ <AlertDialogFooter>
885
+ <AlertDialogCancel>
886
+ Cancelar
887
+ </AlertDialogCancel>
888
+ <AlertDialogAction
889
+ onClick={() =>
890
+ void handleReverseSettlement(
891
+ String(liq.settlementId)
892
+ )
893
+ }
894
+ >
895
+ Confirmar estorno
896
+ </AlertDialogAction>
897
+ </AlertDialogFooter>
898
+ </AlertDialogContent>
899
+ </AlertDialog>
900
+ </TableCell>
379
901
  </TableRow>
380
902
  );
381
903
  })