@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,6 +1,17 @@
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
16
  import { Badge } from '@/components/ui/badge';
6
17
  import { Button } from '@/components/ui/button';
@@ -11,13 +22,38 @@ import {
11
22
  CardHeader,
12
23
  CardTitle,
13
24
  } from '@/components/ui/card';
25
+ import {
26
+ Dialog,
27
+ DialogContent,
28
+ DialogDescription,
29
+ DialogFooter,
30
+ DialogHeader,
31
+ DialogTitle,
32
+ } from '@/components/ui/dialog';
14
33
  import {
15
34
  DropdownMenu,
16
35
  DropdownMenuContent,
17
36
  DropdownMenuItem,
18
37
  DropdownMenuTrigger,
19
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';
20
49
  import { Money } from '@/components/ui/money';
50
+ import {
51
+ Select,
52
+ SelectContent,
53
+ SelectItem,
54
+ SelectTrigger,
55
+ SelectValue,
56
+ } from '@/components/ui/select';
21
57
  import { StatusBadge } from '@/components/ui/status-badge';
22
58
  import {
23
59
  Table,
@@ -28,25 +64,41 @@ import {
28
64
  TableRow,
29
65
  } from '@/components/ui/table';
30
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';
31
70
  import {
32
- ArrowLeft,
71
+ CheckCircle,
33
72
  Download,
34
73
  Edit,
35
74
  FileText,
36
75
  MoreHorizontal,
37
76
  Send,
77
+ Undo,
38
78
  } from 'lucide-react';
39
79
  import { useTranslations } from 'next-intl';
40
80
  import Link from 'next/link';
41
81
  import { useParams } from 'next/navigation';
82
+ import { useEffect, useState } from 'react';
83
+ import { useForm } from 'react-hook-form';
84
+ import { z } from 'zod';
42
85
  import { formatarData } from '../../../_lib/formatters';
43
86
  import { useFinanceData } from '../../../_lib/use-finance-data';
44
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
+
45
96
  export default function TituloReceberDetalhePage() {
46
97
  const t = useTranslations('finance.ReceivableInstallmentDetailPage');
98
+ const { request, showToastHandler } = useApp();
47
99
  const params = useParams<{ id: string }>();
48
100
  const id = params?.id;
49
- const { data } = useFinanceData();
101
+ const { data, refetch } = useFinanceData();
50
102
  const {
51
103
  titulosReceber,
52
104
  pessoas,
@@ -59,6 +111,65 @@ export default function TituloReceberDetalhePage() {
59
111
 
60
112
  const titulo = titulosReceber.find((t) => t.id === id);
61
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
+ }, [settleCandidates, settleForm]);
172
+
62
173
  const canalBadge = {
63
174
  boleto: {
64
175
  label: t('channels.boleto'),
@@ -108,9 +219,21 @@ export default function TituloReceberDetalhePage() {
108
219
  const cliente = getPessoaById(titulo.clienteId);
109
220
  const categoria = getCategoriaById(titulo.categoriaId);
110
221
  const centroCusto = getCentroCustoById(titulo.centroCustoId);
111
- const tituloTags = titulo.tags
112
- .map((tagId: any) => tags.find((t: any) => t.id === tagId))
113
- .filter(Boolean);
222
+
223
+ const tagOptions = availableTags.map((tag) => ({
224
+ id: String(tag.id),
225
+ name: String(tag.nome || ''),
226
+ color: tag.cor,
227
+ usageCount:
228
+ typeof tag.usageCount === 'number'
229
+ ? tag.usageCount
230
+ : typeof tag.usoCount === 'number'
231
+ ? tag.usoCount
232
+ : typeof tag.count === 'number'
233
+ ? tag.count
234
+ : undefined,
235
+ }));
236
+
114
237
  const canal =
115
238
  canalBadge[titulo.canal as keyof typeof canalBadge] ||
116
239
  canalBadge.transferencia;
@@ -129,55 +252,392 @@ export default function TituloReceberDetalhePage() {
129
252
  depois: log.depois,
130
253
  }));
131
254
 
255
+ const attachmentDetails = Array.isArray(titulo.anexosDetalhes)
256
+ ? titulo.anexosDetalhes
257
+ : (titulo.anexos || []).map((nome: string) => ({ nome }));
258
+
259
+ const handleOpenAttachment = async (fileId?: string) => {
260
+ if (!fileId) {
261
+ return;
262
+ }
263
+
264
+ const response = await request<{ url?: string }>({
265
+ url: `/file/open/${fileId}`,
266
+ method: 'PUT',
267
+ });
268
+
269
+ const url = response?.data?.url;
270
+ if (!url) {
271
+ return;
272
+ }
273
+
274
+ window.open(url, '_blank', 'noopener,noreferrer');
275
+ };
276
+
277
+ const tTagSelector = (key: string, fallback: string) => {
278
+ const fullKey = `tagSelector.${key}`;
279
+ return t.has(fullKey) ? t(fullKey) : fallback;
280
+ };
281
+
282
+ const toTagSlug = (value: string) => {
283
+ return value
284
+ .normalize('NFD')
285
+ .replace(/[\u0300-\u036f]/g, '')
286
+ .toLowerCase()
287
+ .trim()
288
+ .replace(/[^a-z0-9]+/g, '-')
289
+ .replace(/(^-|-$)+/g, '');
290
+ };
291
+
292
+ const handleCreateTag = async (name: string) => {
293
+ const slug = toTagSlug(name);
294
+
295
+ if (!slug) {
296
+ return null;
297
+ }
298
+
299
+ setIsCreatingTag(true);
300
+ try {
301
+ const response = await request<{
302
+ id?: string | number;
303
+ nome?: string;
304
+ cor?: string;
305
+ }>({
306
+ url: '/finance/tags',
307
+ method: 'POST',
308
+ data: {
309
+ name: slug,
310
+ color: '#000000',
311
+ },
312
+ });
313
+
314
+ const created = response?.data;
315
+ const newTag = {
316
+ id: String(created?.id || `temp-${Date.now()}`),
317
+ nome: created?.nome || slug,
318
+ cor: created?.cor || '#000000',
319
+ };
320
+
321
+ setAvailableTags((current) => {
322
+ if (current.some((tag) => String(tag.id) === newTag.id)) {
323
+ return current;
324
+ }
325
+
326
+ return [...current, newTag];
327
+ });
328
+
329
+ showToastHandler?.(
330
+ 'success',
331
+ tTagSelector('messages.createSuccess', 'Tag criada com sucesso')
332
+ );
333
+
334
+ return {
335
+ id: newTag.id,
336
+ name: newTag.nome,
337
+ color: newTag.cor,
338
+ };
339
+ } catch {
340
+ showToastHandler?.(
341
+ 'error',
342
+ tTagSelector('messages.createError', 'Não foi possível criar a tag')
343
+ );
344
+ return null;
345
+ } finally {
346
+ setIsCreatingTag(false);
347
+ }
348
+ };
349
+
350
+ const handleChangeTags = async (nextTagIds: string[]) => {
351
+ if (!titulo?.id) {
352
+ return;
353
+ }
354
+
355
+ try {
356
+ const response = await request<{ tags?: string[] }>({
357
+ url: `/finance/accounts-receivable/installments/${titulo.id}/tags`,
358
+ method: 'PATCH',
359
+ data: {
360
+ tag_ids: nextTagIds.map((tagId) => Number(tagId)),
361
+ },
362
+ });
363
+
364
+ if (Array.isArray(response?.data?.tags)) {
365
+ setSelectedTagIds(response.data.tags);
366
+ } else {
367
+ setSelectedTagIds(nextTagIds);
368
+ }
369
+ } catch {
370
+ showToastHandler?.(
371
+ 'error',
372
+ tTagSelector(
373
+ 'messages.updateError',
374
+ 'Não foi possível atualizar as tags'
375
+ )
376
+ );
377
+ }
378
+ };
379
+
380
+ const canApprove = titulo.status === 'rascunho';
381
+ const canSettle = ['aprovado', 'aberto', 'parcial'].includes(titulo.status);
382
+
383
+ const getErrorMessage = (error: any, fallback: string) => {
384
+ const message = error?.response?.data?.message;
385
+
386
+ if (Array.isArray(message)) {
387
+ return message.join(', ');
388
+ }
389
+
390
+ if (typeof message === 'string' && message.trim()) {
391
+ return message;
392
+ }
393
+
394
+ return fallback;
395
+ };
396
+
397
+ const handleApprove = async () => {
398
+ if (!canApprove || isApproving) {
399
+ return;
400
+ }
401
+
402
+ setIsApproving(true);
403
+ try {
404
+ await request({
405
+ url: `/finance/accounts-receivable/installments/${titulo.id}/approve`,
406
+ method: 'PATCH',
407
+ });
408
+
409
+ await refetch();
410
+ showToastHandler?.('success', 'Título aprovado com sucesso');
411
+ } catch (error) {
412
+ showToastHandler?.(
413
+ 'error',
414
+ getErrorMessage(error, 'Não foi possível aprovar o título')
415
+ );
416
+ } finally {
417
+ setIsApproving(false);
418
+ }
419
+ };
420
+
421
+ const handleSettle = async (values: SettleFormValues) => {
422
+ if (!canSettle || isSettling) {
423
+ return;
424
+ }
425
+
426
+ setIsSettling(true);
427
+ try {
428
+ await request({
429
+ url: `/finance/accounts-receivable/installments/${titulo.id}/settlements`,
430
+ method: 'POST',
431
+ data: {
432
+ installment_id: Number(values.installmentId),
433
+ amount: values.amount,
434
+ description: values.description?.trim() || undefined,
435
+ },
436
+ });
437
+
438
+ await refetch();
439
+ setIsSettleDialogOpen(false);
440
+ showToastHandler?.('success', 'Recebimento registrado com sucesso');
441
+ } catch (error) {
442
+ showToastHandler?.(
443
+ 'error',
444
+ getErrorMessage(error, 'Não foi possível registrar o recebimento')
445
+ );
446
+ } finally {
447
+ setIsSettling(false);
448
+ }
449
+ };
450
+
451
+ const handleReverseSettlement = async (settlementId: string) => {
452
+ if (!settlementId || reversingSettlementId) {
453
+ return;
454
+ }
455
+
456
+ setReversingSettlementId(settlementId);
457
+ try {
458
+ await request({
459
+ url: `/finance/accounts-receivable/installments/${titulo.id}/settlements/${settlementId}/reverse`,
460
+ method: 'PATCH',
461
+ data: {},
462
+ });
463
+
464
+ await refetch();
465
+ showToastHandler?.('success', 'Estorno realizado com sucesso');
466
+ } catch (error) {
467
+ showToastHandler?.(
468
+ 'error',
469
+ getErrorMessage(error, 'Não foi possível estornar o recebimento')
470
+ );
471
+ } finally {
472
+ setReversingSettlementId(null);
473
+ }
474
+ };
475
+
132
476
  return (
133
477
  <Page>
134
- <div className="flex items-center gap-4">
135
- <Button variant="ghost" size="icon" asChild>
136
- <Link href="/finance/accounts-receivable/installments">
137
- <ArrowLeft className="h-4 w-4" />
138
- </Link>
139
- </Button>
140
- <PageHeader
141
- title={titulo.documento}
142
- description={titulo.descricao}
143
- breadcrumbs={[
144
- { label: t('breadcrumbs.home'), href: '/' },
145
- { label: t('breadcrumbs.finance'), href: '/finance' },
146
- {
147
- label: t('breadcrumbs.receivables'),
148
- href: '/finance/accounts-receivable/installments',
149
- },
150
- { label: titulo.documento },
151
- ]}
152
- actions={
153
- <div className="flex items-center gap-2">
154
- <StatusBadge status={titulo.status} />
155
- <DropdownMenu>
156
- <DropdownMenuTrigger asChild>
157
- <Button variant="outline">
158
- <MoreHorizontal className="mr-2 h-4 w-4" />
159
- {t('actions.title')}
160
- </Button>
161
- </DropdownMenuTrigger>
162
- <DropdownMenuContent align="end">
163
- <DropdownMenuItem>
164
- <Edit className="mr-2 h-4 w-4" />
165
- {t('actions.edit')}
166
- </DropdownMenuItem>
167
- <DropdownMenuItem>
168
- <Download className="mr-2 h-4 w-4" />
169
- {t('actions.registerReceipt')}
170
- </DropdownMenuItem>
171
- <DropdownMenuItem>
172
- <Send className="mr-2 h-4 w-4" />
173
- {t('actions.sendCollection')}
174
- </DropdownMenuItem>
175
- </DropdownMenuContent>
176
- </DropdownMenu>
177
- </div>
178
- }
179
- />
180
- </div>
478
+ <PageHeader
479
+ title={titulo.documento}
480
+ description={titulo.descricao}
481
+ breadcrumbs={[
482
+ { label: t('breadcrumbs.home'), href: '/' },
483
+ { label: t('breadcrumbs.finance'), href: '/finance' },
484
+ {
485
+ label: t('breadcrumbs.receivables'),
486
+ href: '/finance/accounts-receivable/installments',
487
+ },
488
+ { label: titulo.documento },
489
+ ]}
490
+ actions={
491
+ <div className="flex items-center gap-2">
492
+ <DropdownMenu>
493
+ <DropdownMenuTrigger asChild>
494
+ <Button variant="outline">
495
+ <MoreHorizontal className="mr-2 h-4 w-4" />
496
+ {t('actions.title')}
497
+ </Button>
498
+ </DropdownMenuTrigger>
499
+ <DropdownMenuContent align="end">
500
+ <DropdownMenuItem>
501
+ <Edit className="mr-2 h-4 w-4" />
502
+ {t('actions.edit')}
503
+ </DropdownMenuItem>
504
+ <DropdownMenuItem
505
+ disabled={!canApprove || isApproving}
506
+ onClick={() => void handleApprove()}
507
+ >
508
+ <CheckCircle className="mr-2 h-4 w-4" />
509
+ Aprovar
510
+ </DropdownMenuItem>
511
+ <DropdownMenuItem
512
+ disabled={!canSettle || settleCandidates.length === 0}
513
+ onClick={() => setIsSettleDialogOpen(true)}
514
+ >
515
+ <Download className="mr-2 h-4 w-4" />
516
+ {t('actions.registerReceipt')}
517
+ </DropdownMenuItem>
518
+ <DropdownMenuItem>
519
+ <Send className="mr-2 h-4 w-4" />
520
+ {t('actions.sendCollection')}
521
+ </DropdownMenuItem>
522
+ </DropdownMenuContent>
523
+ </DropdownMenu>
524
+
525
+ <Dialog
526
+ open={isSettleDialogOpen}
527
+ onOpenChange={setIsSettleDialogOpen}
528
+ >
529
+ <DialogContent>
530
+ <DialogHeader>
531
+ <DialogTitle>Registrar recebimento</DialogTitle>
532
+ <DialogDescription>
533
+ Informe a parcela e o valor para baixa parcial ou total.
534
+ </DialogDescription>
535
+ </DialogHeader>
536
+
537
+ <Form {...settleForm}>
538
+ <form
539
+ className="space-y-4"
540
+ onSubmit={settleForm.handleSubmit(handleSettle)}
541
+ >
542
+ <FormField
543
+ control={settleForm.control}
544
+ name="installmentId"
545
+ render={({ field }) => (
546
+ <FormItem>
547
+ <FormLabel>Parcela</FormLabel>
548
+ <Select
549
+ value={field.value}
550
+ onValueChange={(value) => {
551
+ field.onChange(value);
552
+
553
+ const selected = settleCandidates.find(
554
+ (parcela: any) => parcela.id === value
555
+ );
556
+
557
+ if (selected) {
558
+ settleForm.setValue(
559
+ 'amount',
560
+ Number(selected.valorAberto || 0),
561
+ { shouldValidate: true }
562
+ );
563
+ }
564
+ }}
565
+ >
566
+ <FormControl>
567
+ <SelectTrigger>
568
+ <SelectValue placeholder="Selecione" />
569
+ </SelectTrigger>
570
+ </FormControl>
571
+ <SelectContent>
572
+ {settleCandidates.map((parcela: any) => (
573
+ <SelectItem key={parcela.id} value={parcela.id}>
574
+ Parcela {parcela.numero} - em aberto:{' '}
575
+ {new Intl.NumberFormat('pt-BR', {
576
+ style: 'currency',
577
+ currency: 'BRL',
578
+ }).format(Number(parcela.valorAberto || 0))}
579
+ </SelectItem>
580
+ ))}
581
+ </SelectContent>
582
+ </Select>
583
+ <FormMessage />
584
+ </FormItem>
585
+ )}
586
+ />
587
+
588
+ <FormField
589
+ control={settleForm.control}
590
+ name="amount"
591
+ render={({ field }) => (
592
+ <FormItem>
593
+ <FormLabel>Valor</FormLabel>
594
+ <FormControl>
595
+ <InputMoney
596
+ value={Number(field.value || 0)}
597
+ onValueChange={(value) => {
598
+ field.onChange(Number(value || 0));
599
+ }}
600
+ />
601
+ </FormControl>
602
+ <FormMessage />
603
+ </FormItem>
604
+ )}
605
+ />
606
+
607
+ <FormField
608
+ control={settleForm.control}
609
+ name="description"
610
+ render={({ field }) => (
611
+ <FormItem>
612
+ <FormLabel>Descrição (opcional)</FormLabel>
613
+ <FormControl>
614
+ <Input {...field} value={field.value || ''} />
615
+ </FormControl>
616
+ <FormMessage />
617
+ </FormItem>
618
+ )}
619
+ />
620
+
621
+ <DialogFooter>
622
+ <Button
623
+ type="button"
624
+ variant="outline"
625
+ disabled={isSettling}
626
+ onClick={() => setIsSettleDialogOpen(false)}
627
+ >
628
+ Cancelar
629
+ </Button>
630
+ <Button type="submit" disabled={isSettling}>
631
+ Confirmar recebimento
632
+ </Button>
633
+ </DialogFooter>
634
+ </form>
635
+ </Form>
636
+ </DialogContent>
637
+ </Dialog>
638
+ </div>
639
+ }
640
+ />
181
641
 
182
642
  <div className="grid gap-6 lg:grid-cols-3">
183
643
  <Card className="lg:col-span-2">
@@ -245,20 +705,44 @@ export default function TituloReceberDetalhePage() {
245
705
  <dt className="text-sm font-medium text-muted-foreground">
246
706
  {t('documentData.tags')}
247
707
  </dt>
248
- <dd className="mt-1 flex gap-1">
249
- {tituloTags.length > 0 ? (
250
- tituloTags.map((tag: any) => (
251
- <Badge
252
- key={tag?.id}
253
- variant="outline"
254
- style={{ borderColor: tag?.cor, color: tag?.cor }}
255
- >
256
- {tag?.nome}
257
- </Badge>
258
- ))
259
- ) : (
260
- <span className="text-muted-foreground">-</span>
261
- )}
708
+ <dd className="mt-1">
709
+ <TagSelectorSheet
710
+ selectedTagIds={selectedTagIds}
711
+ tags={tagOptions}
712
+ onChange={handleChangeTags}
713
+ onCreateTag={handleCreateTag}
714
+ disabled={isCreatingTag}
715
+ emptyText={tTagSelector('noTags', 'Sem tags')}
716
+ labels={{
717
+ addTag: tTagSelector('addTag', 'Adicionar tag'),
718
+ sheetTitle: tTagSelector('sheetTitle', 'Gerenciar tags'),
719
+ sheetDescription: tTagSelector(
720
+ 'sheetDescription',
721
+ 'Selecione tags existentes ou crie uma nova.'
722
+ ),
723
+ createLabel: tTagSelector('createLabel', 'Nova tag'),
724
+ createPlaceholder: tTagSelector(
725
+ 'createPlaceholder',
726
+ 'Digite o nome da tag'
727
+ ),
728
+ createAction: tTagSelector('createAction', 'Criar tag'),
729
+ popularTitle: tTagSelector(
730
+ 'popularTitle',
731
+ 'Tags mais usadas'
732
+ ),
733
+ selectedTitle: tTagSelector(
734
+ 'selectedTitle',
735
+ 'Tags selecionadas'
736
+ ),
737
+ noTags: tTagSelector('noTags', 'Sem tags'),
738
+ cancel: tTagSelector('cancel', 'Cancelar'),
739
+ apply: tTagSelector('apply', 'Aplicar'),
740
+ removeTagAria: (tagName: string) =>
741
+ t.has('tagSelector.removeTagAria')
742
+ ? t('tagSelector.removeTagAria', { tag: tagName })
743
+ : `Remover tag ${tagName}`,
744
+ }}
745
+ />
262
746
  </dd>
263
747
  </div>
264
748
  </dl>
@@ -271,16 +755,17 @@ export default function TituloReceberDetalhePage() {
271
755
  <CardDescription>{t('attachments.description')}</CardDescription>
272
756
  </CardHeader>
273
757
  <CardContent>
274
- {titulo.anexos.length > 0 ? (
758
+ {attachmentDetails.length > 0 ? (
275
759
  <ul className="space-y-2">
276
- {titulo.anexos.map((anexo: any, i: number) => (
760
+ {attachmentDetails.map((anexo: any, i: number) => (
277
761
  <li key={i}>
278
762
  <Button
279
763
  variant="ghost"
280
764
  className="h-auto w-full justify-start p-2"
765
+ onClick={() => void handleOpenAttachment(anexo?.id)}
281
766
  >
282
767
  <FileText className="mr-2 h-4 w-4" />
283
- {anexo}
768
+ {anexo?.nome}
284
769
  </Button>
285
770
  </li>
286
771
  ))}
@@ -355,6 +840,7 @@ export default function TituloReceberDetalhePage() {
355
840
  </TableHead>
356
841
  <TableHead>{t('receiptsTable.account')}</TableHead>
357
842
  <TableHead>{t('receiptsTable.method')}</TableHead>
843
+ <TableHead className="text-right">Ações</TableHead>
358
844
  </TableRow>
359
845
  </TableHeader>
360
846
  <TableBody>
@@ -377,6 +863,49 @@ export default function TituloReceberDetalhePage() {
377
863
  <TableCell className="capitalize">
378
864
  {liq.metodo}
379
865
  </TableCell>
866
+ <TableCell className="text-right">
867
+ <AlertDialog>
868
+ <AlertDialogTrigger asChild>
869
+ <Button
870
+ variant="outline"
871
+ size="sm"
872
+ disabled={
873
+ !liq.settlementId ||
874
+ liq.status === 'reversed' ||
875
+ !!reversingSettlementId
876
+ }
877
+ >
878
+ <Undo className="mr-2 h-4 w-4" />
879
+ Estornar
880
+ </Button>
881
+ </AlertDialogTrigger>
882
+ <AlertDialogContent>
883
+ <AlertDialogHeader>
884
+ <AlertDialogTitle>
885
+ Confirmar estorno
886
+ </AlertDialogTitle>
887
+ <AlertDialogDescription>
888
+ Esta ação estorna o recebimento e
889
+ recalcula saldos e status.
890
+ </AlertDialogDescription>
891
+ </AlertDialogHeader>
892
+ <AlertDialogFooter>
893
+ <AlertDialogCancel>
894
+ Cancelar
895
+ </AlertDialogCancel>
896
+ <AlertDialogAction
897
+ onClick={() =>
898
+ void handleReverseSettlement(
899
+ String(liq.settlementId)
900
+ )
901
+ }
902
+ >
903
+ Confirmar estorno
904
+ </AlertDialogAction>
905
+ </AlertDialogFooter>
906
+ </AlertDialogContent>
907
+ </AlertDialog>
908
+ </TableCell>
380
909
  </TableRow>
381
910
  );
382
911
  })