@hed-hog/finance 0.0.274 → 0.0.276

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 (40) hide show
  1. package/README.md +228 -126
  2. package/dist/dto/create-bank-reconciliation.dto.d.ts +8 -0
  3. package/dist/dto/create-bank-reconciliation.dto.d.ts.map +1 -0
  4. package/dist/dto/create-bank-reconciliation.dto.js +43 -0
  5. package/dist/dto/create-bank-reconciliation.dto.js.map +1 -0
  6. package/dist/finance-data.controller.d.ts +2 -0
  7. package/dist/finance-data.controller.d.ts.map +1 -1
  8. package/dist/finance-statements.controller.d.ts +42 -0
  9. package/dist/finance-statements.controller.d.ts.map +1 -1
  10. package/dist/finance-statements.controller.js +13 -0
  11. package/dist/finance-statements.controller.js.map +1 -1
  12. package/dist/finance.service.d.ts +44 -0
  13. package/dist/finance.service.d.ts.map +1 -1
  14. package/dist/finance.service.js +98 -9
  15. package/dist/finance.service.js.map +1 -1
  16. package/hedhog/data/route.yaml +9 -0
  17. package/hedhog/frontend/app/_components/person-field-with-create.tsx.ejs +126 -126
  18. package/hedhog/frontend/app/accounts-payable/approvals/page.tsx.ejs +373 -373
  19. package/hedhog/frontend/app/accounts-payable/installments/[id]/page.tsx.ejs +1270 -1270
  20. package/hedhog/frontend/app/accounts-receivable/installments/[id]/page.tsx.ejs +982 -982
  21. package/hedhog/frontend/app/cash-and-banks/bank-accounts/page.tsx.ejs +686 -686
  22. package/hedhog/frontend/app/cash-and-banks/bank-reconciliation/page.tsx.ejs +152 -32
  23. package/hedhog/frontend/app/cash-and-banks/statements/page.tsx.ejs +986 -986
  24. package/hedhog/frontend/app/cash-and-banks/transfers/page.tsx.ejs +492 -492
  25. package/hedhog/frontend/app/page.tsx.ejs +372 -372
  26. package/hedhog/frontend/app/planning/cash-flow-forecast/page.tsx.ejs +329 -329
  27. package/hedhog/frontend/app/planning/receivables-calendar/page.tsx.ejs +227 -227
  28. package/hedhog/frontend/app/planning/scenarios/page.tsx.ejs +408 -408
  29. package/hedhog/frontend/messages/en.json +15 -5
  30. package/hedhog/frontend/messages/pt.json +15 -5
  31. package/package.json +7 -7
  32. package/src/dto/create-bank-reconciliation.dto.ts +24 -0
  33. package/src/finance-statements.controller.ts +14 -0
  34. package/src/finance.module.ts +43 -43
  35. package/src/finance.service.ts +118 -0
  36. package/src/index.ts +14 -14
  37. package/dist/finance.controller.d.ts +0 -276
  38. package/dist/finance.controller.d.ts.map +0 -1
  39. package/dist/finance.controller.js +0 -110
  40. package/dist/finance.controller.js.map +0 -1
@@ -1,982 +1,982 @@
1
- 'use client';
2
-
3
- import { Page, PageHeader } from '@/components/entity-list';
4
- import { AuditTimeline } from '@/components/ui/audit-timeline';
5
- import { Badge } from '@/components/ui/badge';
6
- import { Button } from '@/components/ui/button';
7
- import {
8
- Card,
9
- CardContent,
10
- CardDescription,
11
- CardHeader,
12
- CardTitle,
13
- } from '@/components/ui/card';
14
- import {
15
- DropdownMenu,
16
- DropdownMenuContent,
17
- DropdownMenuItem,
18
- DropdownMenuTrigger,
19
- } from '@/components/ui/dropdown-menu';
20
- import {
21
- Form,
22
- FormControl,
23
- FormField,
24
- FormItem,
25
- FormLabel,
26
- FormMessage,
27
- } from '@/components/ui/form';
28
- import { Input } from '@/components/ui/input';
29
- import { InputMoney } from '@/components/ui/input-money';
30
- import { Label } from '@/components/ui/label';
31
- import { Money } from '@/components/ui/money';
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 { StatusBadge } from '@/components/ui/status-badge';
47
- import {
48
- Table,
49
- TableBody,
50
- TableCell,
51
- TableHead,
52
- TableHeader,
53
- TableRow,
54
- } from '@/components/ui/table';
55
- import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
56
- import { TagSelectorSheet } from '@/components/ui/tag-selector-sheet';
57
- import { useApp } from '@hed-hog/next-app-provider';
58
- import { zodResolver } from '@hookform/resolvers/zod';
59
- import {
60
- CheckCircle,
61
- Download,
62
- Edit,
63
- FileText,
64
- MoreHorizontal,
65
- Send,
66
- Undo,
67
- } from 'lucide-react';
68
- import { useTranslations } from 'next-intl';
69
- import Link from 'next/link';
70
- import { useParams } from 'next/navigation';
71
- import { useEffect, useMemo, useState } from 'react';
72
- import { useForm } from 'react-hook-form';
73
- import { z } from 'zod';
74
- import { formatarData } from '../../../_lib/formatters';
75
- import { useFinanceData } from '../../../_lib/use-finance-data';
76
-
77
- type SettleFormValues = {
78
- installmentId: string;
79
- amount: number;
80
- description?: string;
81
- };
82
-
83
- export default function TituloReceberDetalhePage() {
84
- const t = useTranslations('finance.ReceivableInstallmentDetailPage');
85
- const { request, showToastHandler } = useApp();
86
- const params = useParams<{ id: string }>();
87
- const id = params?.id;
88
- const { data, refetch } = useFinanceData();
89
- const {
90
- titulosReceber,
91
- pessoas,
92
- categorias,
93
- centrosCusto,
94
- contasBancarias,
95
- logsAuditoria,
96
- tags,
97
- } = data;
98
-
99
- const titulo = titulosReceber.find((t) => t.id === id);
100
-
101
- const settleCandidates = (titulo?.parcelas || []).filter(
102
- (parcela: any) =>
103
- parcela.status === 'aberto' ||
104
- parcela.status === 'parcial' ||
105
- parcela.status === 'vencido'
106
- );
107
-
108
- const settleSchema = useMemo(
109
- () =>
110
- z.object({
111
- installmentId: z.string().min(1, t('validation.installmentRequired')),
112
- amount: z.number().min(0.01, t('validation.amountGreaterThanZero')),
113
- description: z.string().optional(),
114
- }),
115
- [t]
116
- );
117
-
118
- const settleForm = useForm<SettleFormValues>({
119
- resolver: zodResolver(settleSchema),
120
- defaultValues: {
121
- installmentId: settleCandidates[0]?.id || '',
122
- amount: Number(settleCandidates[0]?.valorAberto || 0),
123
- description: '',
124
- },
125
- });
126
-
127
- const [availableTags, setAvailableTags] = useState<any[]>([]);
128
- const [selectedTagIds, setSelectedTagIds] = useState<string[]>([]);
129
- const [isCreatingTag, setIsCreatingTag] = useState(false);
130
- const [isApproving, setIsApproving] = useState(false);
131
- const [isSettling, setIsSettling] = useState(false);
132
- const [isSettleDialogOpen, setIsSettleDialogOpen] = useState(false);
133
- const [isReverseSheetOpen, setIsReverseSheetOpen] = useState(false);
134
- const [selectedSettlementIdToReverse, setSelectedSettlementIdToReverse] =
135
- useState<string | null>(null);
136
- const [reverseReason, setReverseReason] = useState('');
137
- const [reversingSettlementId, setReversingSettlementId] = useState<
138
- string | null
139
- >(null);
140
-
141
- useEffect(() => {
142
- setAvailableTags(tags || []);
143
- }, [tags]);
144
-
145
- useEffect(() => {
146
- if (!titulo) {
147
- return;
148
- }
149
-
150
- setSelectedTagIds(Array.isArray(titulo.tags) ? titulo.tags : []);
151
- }, [titulo]);
152
-
153
- useEffect(() => {
154
- const firstCandidate = settleCandidates[0];
155
- const nextInstallmentId = firstCandidate?.id || '';
156
- const nextAmount = Number(firstCandidate?.valorAberto || 0);
157
- const currentInstallmentId = settleForm.getValues('installmentId') || '';
158
- const currentAmount = Number(settleForm.getValues('amount') || 0);
159
-
160
- if (
161
- currentInstallmentId === nextInstallmentId &&
162
- currentAmount === nextAmount
163
- ) {
164
- return;
165
- }
166
-
167
- settleForm.reset({
168
- installmentId: nextInstallmentId,
169
- amount: nextAmount,
170
- description: '',
171
- });
172
- }, [settleCandidates, settleForm]);
173
-
174
- const canalBadge = {
175
- boleto: {
176
- label: t('channels.boleto'),
177
- className: 'bg-blue-100 text-blue-700',
178
- },
179
- pix: { label: 'PIX', className: 'bg-green-100 text-green-700' },
180
- cartao: {
181
- label: t('channels.card'),
182
- className: 'bg-purple-100 text-purple-700',
183
- },
184
- transferencia: {
185
- label: t('channels.transfer'),
186
- className: 'bg-orange-100 text-orange-700',
187
- },
188
- };
189
-
190
- if (!titulo) {
191
- return (
192
- <div className="space-y-6">
193
- <PageHeader
194
- title={t('notFound.title')}
195
- description={t('notFound.description')}
196
- breadcrumbs={[
197
- {
198
- label: t('notFound.breadcrumbReceivables'),
199
- href: '/finance/accounts-receivable/installments',
200
- },
201
- {
202
- label: t('notFound.breadcrumbInstallments'),
203
- href: '/finance/accounts-receivable/installments',
204
- },
205
- ]}
206
- />
207
- </div>
208
- );
209
- }
210
-
211
- const getPessoaById = (personId?: string) =>
212
- pessoas.find((p) => p.id === personId);
213
- const getCategoriaById = (categoryId?: string) =>
214
- categorias.find((c) => c.id === categoryId);
215
- const getCentroCustoById = (costCenterId?: string) =>
216
- centrosCusto.find((c) => c.id === costCenterId);
217
- const getContaBancariaById = (bankId?: string) =>
218
- contasBancarias.find((c) => c.id === bankId);
219
-
220
- const cliente = getPessoaById(titulo.clienteId);
221
- const categoria = getCategoriaById(titulo.categoriaId);
222
- const centroCusto = getCentroCustoById(titulo.centroCustoId);
223
-
224
- const tagOptions = availableTags.map((tag) => ({
225
- id: String(tag.id),
226
- name: String(tag.nome || ''),
227
- color: tag.cor,
228
- usageCount:
229
- typeof tag.usageCount === 'number'
230
- ? tag.usageCount
231
- : typeof tag.usoCount === 'number'
232
- ? tag.usoCount
233
- : typeof tag.count === 'number'
234
- ? tag.count
235
- : undefined,
236
- }));
237
-
238
- const canal =
239
- canalBadge[titulo.canal as keyof typeof canalBadge] ||
240
- canalBadge.transferencia;
241
-
242
- const auditEvents = logsAuditoria
243
- .filter(
244
- (log) => log.entidadeId === titulo.id && log.entidade === 'TituloReceber'
245
- )
246
- .map((log) => ({
247
- id: log.id,
248
- data: log.data,
249
- usuarioId: log.usuarioId,
250
- acao: log.acao,
251
- detalhes: log.detalhes,
252
- antes: log.antes,
253
- depois: log.depois,
254
- }));
255
-
256
- const attachmentDetails = Array.isArray(titulo.anexosDetalhes)
257
- ? titulo.anexosDetalhes
258
- : (titulo.anexos || []).map((nome: string) => ({ nome }));
259
-
260
- const handleOpenAttachment = async (fileId?: string) => {
261
- if (!fileId) {
262
- return;
263
- }
264
-
265
- const response = await request<{ url?: string }>({
266
- url: `/file/open/${fileId}`,
267
- method: 'PUT',
268
- });
269
-
270
- const url = response?.data?.url;
271
- if (!url) {
272
- return;
273
- }
274
-
275
- window.open(url, '_blank', 'noopener,noreferrer');
276
- };
277
-
278
- const toTagSlug = (value: string) => {
279
- return value
280
- .normalize('NFD')
281
- .replace(/[\u0300-\u036f]/g, '')
282
- .toLowerCase()
283
- .trim()
284
- .replace(/[^a-z0-9]+/g, '-')
285
- .replace(/(^-|-$)+/g, '');
286
- };
287
-
288
- const handleCreateTag = async (name: string) => {
289
- const slug = toTagSlug(name);
290
-
291
- if (!slug) {
292
- return null;
293
- }
294
-
295
- setIsCreatingTag(true);
296
- try {
297
- const response = await request<{
298
- id?: string | number;
299
- nome?: string;
300
- cor?: string;
301
- }>({
302
- url: '/finance/tags',
303
- method: 'POST',
304
- data: {
305
- name: slug,
306
- color: '#000000',
307
- },
308
- });
309
-
310
- const created = response?.data;
311
- const newTag = {
312
- id: String(created?.id || `temp-${Date.now()}`),
313
- nome: created?.nome || slug,
314
- cor: created?.cor || '#000000',
315
- };
316
-
317
- setAvailableTags((current) => {
318
- if (current.some((tag) => String(tag.id) === newTag.id)) {
319
- return current;
320
- }
321
-
322
- return [...current, newTag];
323
- });
324
-
325
- showToastHandler?.('success', t('tagSelector.messages.createSuccess'));
326
-
327
- return {
328
- id: newTag.id,
329
- name: newTag.nome,
330
- color: newTag.cor,
331
- };
332
- } catch {
333
- showToastHandler?.('error', t('tagSelector.messages.createError'));
334
- return null;
335
- } finally {
336
- setIsCreatingTag(false);
337
- }
338
- };
339
-
340
- const handleChangeTags = async (nextTagIds: string[]) => {
341
- if (!titulo?.id) {
342
- return;
343
- }
344
-
345
- try {
346
- const response = await request<{ tags?: string[] }>({
347
- url: `/finance/accounts-receivable/installments/${titulo.id}/tags`,
348
- method: 'PATCH',
349
- data: {
350
- tag_ids: nextTagIds.map((tagId) => Number(tagId)),
351
- },
352
- });
353
-
354
- if (Array.isArray(response?.data?.tags)) {
355
- setSelectedTagIds(response.data.tags);
356
- } else {
357
- setSelectedTagIds(nextTagIds);
358
- }
359
- } catch {
360
- showToastHandler?.('error', t('tagSelector.messages.updateError'));
361
- }
362
- };
363
-
364
- const canApprove = titulo.status === 'rascunho';
365
- const canSettle = ['aberto', 'parcial', 'vencido'].includes(titulo.status);
366
-
367
- const getErrorMessage = (error: any, fallback: string) => {
368
- const message = error?.response?.data?.message;
369
-
370
- if (Array.isArray(message)) {
371
- return message.join(', ');
372
- }
373
-
374
- if (typeof message === 'string' && message.trim()) {
375
- return message;
376
- }
377
-
378
- return fallback;
379
- };
380
-
381
- const handleApprove = async () => {
382
- if (!canApprove || isApproving) {
383
- return;
384
- }
385
-
386
- setIsApproving(true);
387
- try {
388
- await request({
389
- url: `/finance/accounts-receivable/installments/${titulo.id}/approve`,
390
- method: 'PATCH',
391
- });
392
-
393
- await refetch();
394
- showToastHandler?.('success', t('messages.approveSuccess'));
395
- } catch (error) {
396
- showToastHandler?.(
397
- 'error',
398
- getErrorMessage(error, t('messages.approveError'))
399
- );
400
- } finally {
401
- setIsApproving(false);
402
- }
403
- };
404
-
405
- const handleSettle = async (values: SettleFormValues) => {
406
- if (!canSettle || isSettling) {
407
- return;
408
- }
409
-
410
- setIsSettling(true);
411
- try {
412
- await request({
413
- url: `/finance/accounts-receivable/installments/${titulo.id}/settlements`,
414
- method: 'POST',
415
- data: {
416
- installment_id: Number(values.installmentId),
417
- amount: values.amount,
418
- description: values.description?.trim() || undefined,
419
- },
420
- });
421
-
422
- await refetch();
423
- setIsSettleDialogOpen(false);
424
- showToastHandler?.('success', t('messages.settleSuccess'));
425
- } catch (error) {
426
- showToastHandler?.(
427
- 'error',
428
- getErrorMessage(error, t('messages.settleError'))
429
- );
430
- } finally {
431
- setIsSettling(false);
432
- }
433
- };
434
-
435
- const handleReverseSettlement = async (
436
- settlementId: string,
437
- reasonOverride?: string
438
- ) => {
439
- if (!settlementId || reversingSettlementId) {
440
- return;
441
- }
442
-
443
- setReversingSettlementId(settlementId);
444
- try {
445
- await request({
446
- url: `/finance/settlements/${settlementId}/reverse`,
447
- method: 'POST',
448
- data: {
449
- reason:
450
- reasonOverride?.trim() ||
451
- reverseReason?.trim() ||
452
- t('messages.reverseDefaultReason'),
453
- memo:
454
- reasonOverride?.trim() ||
455
- reverseReason?.trim() ||
456
- t('messages.reverseDefaultReason'),
457
- },
458
- });
459
-
460
- await refetch();
461
- setReverseReason('');
462
- showToastHandler?.('success', t('messages.reverseSuccess'));
463
- } catch (error) {
464
- showToastHandler?.(
465
- 'error',
466
- getErrorMessage(error, t('messages.reverseError'))
467
- );
468
- } finally {
469
- setReversingSettlementId(null);
470
- }
471
- };
472
-
473
- return (
474
- <Page>
475
- <PageHeader
476
- title={titulo.documento}
477
- description={titulo.descricao}
478
- breadcrumbs={[
479
- { label: t('breadcrumbs.home'), href: '/' },
480
- { label: t('breadcrumbs.finance'), href: '/finance' },
481
- {
482
- label: t('breadcrumbs.receivables'),
483
- href: '/finance/accounts-receivable/installments',
484
- },
485
- { label: titulo.documento },
486
- ]}
487
- actions={
488
- <div className="flex items-center gap-2">
489
- <DropdownMenu>
490
- <DropdownMenuTrigger asChild>
491
- <Button variant="outline">
492
- <MoreHorizontal className="mr-2 h-4 w-4" />
493
- {t('actions.title')}
494
- </Button>
495
- </DropdownMenuTrigger>
496
- <DropdownMenuContent align="end">
497
- <DropdownMenuItem>
498
- <Edit className="mr-2 h-4 w-4" />
499
- {t('actions.edit')}
500
- </DropdownMenuItem>
501
- <DropdownMenuItem
502
- disabled={!canApprove || isApproving}
503
- onClick={() => void handleApprove()}
504
- >
505
- <CheckCircle className="mr-2 h-4 w-4" />
506
- {t('actions.approve')}
507
- </DropdownMenuItem>
508
- <DropdownMenuItem
509
- disabled={!canSettle || settleCandidates.length === 0}
510
- onClick={() => setIsSettleDialogOpen(true)}
511
- >
512
- <Download className="mr-2 h-4 w-4" />
513
- {t('actions.registerReceipt')}
514
- </DropdownMenuItem>
515
- <DropdownMenuItem>
516
- <Send className="mr-2 h-4 w-4" />
517
- {t('actions.sendCollection')}
518
- </DropdownMenuItem>
519
- </DropdownMenuContent>
520
- </DropdownMenu>
521
-
522
- <Sheet
523
- open={isSettleDialogOpen}
524
- onOpenChange={setIsSettleDialogOpen}
525
- >
526
- <SheetContent className="w-full sm:max-w-lg overflow-y-auto">
527
- <SheetHeader>
528
- <SheetTitle>{t('settleSheet.title')}</SheetTitle>
529
- <SheetDescription>
530
- {t('settleSheet.description')}
531
- </SheetDescription>
532
- </SheetHeader>
533
-
534
- <Form {...settleForm}>
535
- <form
536
- className="space-y-4 px-1"
537
- onSubmit={settleForm.handleSubmit(handleSettle)}
538
- >
539
- <FormField
540
- control={settleForm.control}
541
- name="installmentId"
542
- render={({ field }) => (
543
- <FormItem>
544
- <FormLabel>
545
- {t('settleSheet.installmentLabel')}
546
- </FormLabel>
547
- <Select
548
- value={field.value}
549
- onValueChange={(value) => {
550
- field.onChange(value);
551
-
552
- const selected = settleCandidates.find(
553
- (parcela: any) => parcela.id === value
554
- );
555
-
556
- if (selected) {
557
- settleForm.setValue(
558
- 'amount',
559
- Number(selected.valorAberto || 0),
560
- { shouldValidate: true }
561
- );
562
- }
563
- }}
564
- >
565
- <FormControl>
566
- <SelectTrigger>
567
- <SelectValue
568
- placeholder={t(
569
- 'settleSheet.installmentPlaceholder'
570
- )}
571
- />
572
- </SelectTrigger>
573
- </FormControl>
574
- <SelectContent>
575
- {settleCandidates.map((parcela: any) => (
576
- <SelectItem key={parcela.id} value={parcela.id}>
577
- {t('settleSheet.installmentOption', {
578
- number: parcela.numero,
579
- amount: new Intl.NumberFormat('pt-BR', {
580
- style: 'currency',
581
- currency: 'BRL',
582
- }).format(Number(parcela.valorAberto || 0)),
583
- })}
584
- </SelectItem>
585
- ))}
586
- </SelectContent>
587
- </Select>
588
- <FormMessage />
589
- </FormItem>
590
- )}
591
- />
592
-
593
- <FormField
594
- control={settleForm.control}
595
- name="amount"
596
- render={({ field }) => (
597
- <FormItem>
598
- <FormLabel>{t('settleSheet.amountLabel')}</FormLabel>
599
- <FormControl>
600
- <InputMoney
601
- value={Number(field.value || 0)}
602
- onValueChange={(value) => {
603
- field.onChange(Number(value || 0));
604
- }}
605
- />
606
- </FormControl>
607
- <FormMessage />
608
- </FormItem>
609
- )}
610
- />
611
-
612
- <FormField
613
- control={settleForm.control}
614
- name="description"
615
- render={({ field }) => (
616
- <FormItem>
617
- <FormLabel>
618
- {t('settleSheet.descriptionLabel')}
619
- </FormLabel>
620
- <FormControl>
621
- <Input {...field} value={field.value || ''} />
622
- </FormControl>
623
- <FormMessage />
624
- </FormItem>
625
- )}
626
- />
627
-
628
- <div className="flex justify-end gap-2 pt-2">
629
- <Button
630
- type="button"
631
- variant="outline"
632
- disabled={isSettling}
633
- onClick={() => setIsSettleDialogOpen(false)}
634
- >
635
- {t('common.cancel')}
636
- </Button>
637
- <Button type="submit" disabled={isSettling}>
638
- {t('settleSheet.confirm')}
639
- </Button>
640
- </div>
641
- </form>
642
- </Form>
643
- </SheetContent>
644
- </Sheet>
645
-
646
- <Sheet
647
- open={isReverseSheetOpen}
648
- onOpenChange={(open) => {
649
- setIsReverseSheetOpen(open);
650
-
651
- if (!open) {
652
- setSelectedSettlementIdToReverse(null);
653
- setReverseReason('');
654
- }
655
- }}
656
- >
657
- <SheetContent className="w-full sm:max-w-lg overflow-y-auto">
658
- <SheetHeader>
659
- <SheetTitle>{t('reverseSheet.title')}</SheetTitle>
660
- <SheetDescription>
661
- {t('reverseSheet.description')}
662
- </SheetDescription>
663
- </SheetHeader>
664
-
665
- <div className="space-y-4 px-4">
666
- <div className="space-y-2">
667
- <Label>{t('reverseSheet.reasonLabel')}</Label>
668
- <Input
669
- value={reverseReason}
670
- onChange={(event) => setReverseReason(event.target.value)}
671
- placeholder={t('reverseSheet.reasonPlaceholder')}
672
- maxLength={255}
673
- disabled={!!reversingSettlementId}
674
- />
675
- </div>
676
-
677
- <div className="flex flex-col gap-2">
678
- <Button
679
- disabled={
680
- !!reversingSettlementId ||
681
- !selectedSettlementIdToReverse
682
- }
683
- onClick={() => {
684
- if (!selectedSettlementIdToReverse) {
685
- return;
686
- }
687
-
688
- void handleReverseSettlement(
689
- selectedSettlementIdToReverse
690
- ).finally(() => {
691
- setIsReverseSheetOpen(false);
692
- setSelectedSettlementIdToReverse(null);
693
- setReverseReason('');
694
- });
695
- }}
696
- >
697
- {t('reverseSheet.confirm')}
698
- </Button>
699
- <Button
700
- variant="outline"
701
- disabled={!!reversingSettlementId}
702
- onClick={() => {
703
- setIsReverseSheetOpen(false);
704
- setSelectedSettlementIdToReverse(null);
705
- setReverseReason('');
706
- }}
707
- >
708
- {t('common.cancel')}
709
- </Button>
710
- </div>
711
- </div>
712
- </SheetContent>
713
- </Sheet>
714
- </div>
715
- }
716
- />
717
-
718
- <div className="grid gap-6 lg:grid-cols-3">
719
- <Card className="lg:col-span-2">
720
- <CardHeader>
721
- <CardTitle>{t('documentData.title')}</CardTitle>
722
- </CardHeader>
723
- <CardContent>
724
- <dl className="grid gap-4 sm:grid-cols-2">
725
- <div>
726
- <dt className="text-sm font-medium text-muted-foreground">
727
- {t('documentData.client')}
728
- </dt>
729
- <dd className="mt-1">
730
- <Link
731
- href={`/cadastros/pessoas/${cliente?.id}`}
732
- className="hover:underline"
733
- >
734
- {cliente?.nome}
735
- </Link>
736
- </dd>
737
- </div>
738
- <div>
739
- <dt className="text-sm font-medium text-muted-foreground">
740
- {t('documentData.cnpjCpf')}
741
- </dt>
742
- <dd className="mt-1">{cliente?.documento}</dd>
743
- </div>
744
- <div>
745
- <dt className="text-sm font-medium text-muted-foreground">
746
- {t('documentData.competency')}
747
- </dt>
748
- <dd className="mt-1">{titulo.competencia}</dd>
749
- </div>
750
- <div>
751
- <dt className="text-sm font-medium text-muted-foreground">
752
- {t('documentData.totalValue')}
753
- </dt>
754
- <dd className="mt-1 text-lg font-semibold">
755
- <Money value={titulo.valorTotal} />
756
- </dd>
757
- </div>
758
- <div>
759
- <dt className="text-sm font-medium text-muted-foreground">
760
- {t('documentData.category')}
761
- </dt>
762
- <dd className="mt-1">{categoria?.nome}</dd>
763
- </div>
764
- <div>
765
- <dt className="text-sm font-medium text-muted-foreground">
766
- {t('documentData.costCenter')}
767
- </dt>
768
- <dd className="mt-1">{centroCusto?.nome}</dd>
769
- </div>
770
- <div>
771
- <dt className="text-sm font-medium text-muted-foreground">
772
- {t('documentData.channel')}
773
- </dt>
774
- <dd className="mt-1">
775
- <Badge className={canal.className} variant="outline">
776
- {canal.label}
777
- </Badge>
778
- </dd>
779
- </div>
780
- <div>
781
- <dt className="text-sm font-medium text-muted-foreground">
782
- {t('documentData.tags')}
783
- </dt>
784
- <dd className="mt-1">
785
- <TagSelectorSheet
786
- selectedTagIds={selectedTagIds}
787
- tags={tagOptions}
788
- onChange={handleChangeTags}
789
- onCreateTag={handleCreateTag}
790
- disabled={isCreatingTag}
791
- emptyText={t('tagSelector.noTags')}
792
- labels={{
793
- addTag: t('tagSelector.addTag'),
794
- sheetTitle: t('tagSelector.sheetTitle'),
795
- sheetDescription: t('tagSelector.sheetDescription'),
796
- createLabel: t('tagSelector.createLabel'),
797
- createPlaceholder: t('tagSelector.createPlaceholder'),
798
- createAction: t('tagSelector.createAction'),
799
- popularTitle: t('tagSelector.popularTitle'),
800
- selectedTitle: t('tagSelector.selectedTitle'),
801
- noTags: t('tagSelector.noTags'),
802
- cancel: t('tagSelector.cancel'),
803
- apply: t('tagSelector.apply'),
804
- removeTagAria: (tagName: string) =>
805
- t('tagSelector.removeTagAria', { tag: tagName }),
806
- }}
807
- />
808
- </dd>
809
- </div>
810
- </dl>
811
- </CardContent>
812
- </Card>
813
-
814
- <Card>
815
- <CardHeader>
816
- <CardTitle>{t('attachments.title')}</CardTitle>
817
- <CardDescription>{t('attachments.description')}</CardDescription>
818
- </CardHeader>
819
- <CardContent>
820
- {attachmentDetails.length > 0 ? (
821
- <ul className="space-y-2">
822
- {attachmentDetails.map((anexo: any, i: number) => (
823
- <li key={i}>
824
- <Button
825
- variant="ghost"
826
- className="h-auto w-full justify-start p-2"
827
- onClick={() => void handleOpenAttachment(anexo?.id)}
828
- >
829
- <FileText className="mr-2 h-4 w-4" />
830
- {anexo?.nome}
831
- </Button>
832
- </li>
833
- ))}
834
- </ul>
835
- ) : (
836
- <p className="text-sm text-muted-foreground">
837
- {t('attachments.none')}
838
- </p>
839
- )}
840
- </CardContent>
841
- </Card>
842
- </div>
843
-
844
- <Tabs defaultValue="parcelas">
845
- <TabsList>
846
- <TabsTrigger value="parcelas">{t('tabs.installments')}</TabsTrigger>
847
- <TabsTrigger value="liquidacoes">{t('tabs.receipts')}</TabsTrigger>
848
- <TabsTrigger value="auditoria">{t('tabs.audit')}</TabsTrigger>
849
- </TabsList>
850
-
851
- <TabsContent value="parcelas" className="mt-4">
852
- <Card>
853
- <CardContent className="pt-6">
854
- <Table>
855
- <TableHeader>
856
- <TableRow>
857
- <TableHead>{t('installmentsTable.installment')}</TableHead>
858
- <TableHead>{t('installmentsTable.dueDate')}</TableHead>
859
- <TableHead className="text-right">
860
- {t('installmentsTable.value')}
861
- </TableHead>
862
- <TableHead>{t('installmentsTable.status')}</TableHead>
863
- </TableRow>
864
- </TableHeader>
865
- <TableBody>
866
- {titulo.parcelas.map((parcela: any) => (
867
- <TableRow key={parcela.id}>
868
- <TableCell>
869
- {parcela.numero}/{titulo.parcelas.length}
870
- </TableCell>
871
- <TableCell>{formatarData(parcela.vencimento)}</TableCell>
872
- <TableCell className="text-right">
873
- <Money value={parcela.valor} />
874
- </TableCell>
875
- <TableCell>
876
- <StatusBadge status={parcela.status} />
877
- </TableCell>
878
- </TableRow>
879
- ))}
880
- </TableBody>
881
- </Table>
882
- </CardContent>
883
- </Card>
884
- </TabsContent>
885
-
886
- <TabsContent value="liquidacoes" className="mt-4">
887
- <Card>
888
- <CardContent className="pt-6">
889
- {titulo.parcelas.some((p: any) => p.liquidacoes.length > 0) ? (
890
- <Table>
891
- <TableHeader>
892
- <TableRow>
893
- <TableHead>{t('receiptsTable.date')}</TableHead>
894
- <TableHead className="text-right">
895
- {t('receiptsTable.value')}
896
- </TableHead>
897
- <TableHead className="text-right">
898
- {t('receiptsTable.interest')}
899
- </TableHead>
900
- <TableHead className="text-right">
901
- {t('receiptsTable.discount')}
902
- </TableHead>
903
- <TableHead>{t('receiptsTable.account')}</TableHead>
904
- <TableHead>{t('receiptsTable.method')}</TableHead>
905
- <TableHead className="text-right">
906
- {t('receiptsTable.actions')}
907
- </TableHead>
908
- </TableRow>
909
- </TableHeader>
910
- <TableBody>
911
- {titulo.parcelas.flatMap((parcela: any) =>
912
- parcela.liquidacoes.map((liq: any) => {
913
- const conta = getContaBancariaById(liq.contaBancariaId);
914
- return (
915
- <TableRow key={liq.id}>
916
- <TableCell>{formatarData(liq.data)}</TableCell>
917
- <TableCell className="text-right">
918
- <Money value={liq.valor} />
919
- </TableCell>
920
- <TableCell className="text-right">
921
- <Money value={liq.juros} />
922
- </TableCell>
923
- <TableCell className="text-right">
924
- <Money value={liq.desconto} />
925
- </TableCell>
926
- <TableCell>{conta?.descricao}</TableCell>
927
- <TableCell className="capitalize">
928
- {liq.metodo}
929
- </TableCell>
930
- <TableCell className="text-right">
931
- <Button
932
- variant="outline"
933
- size="sm"
934
- disabled={
935
- !liq.settlementId ||
936
- liq.status === 'reversed' ||
937
- !!reversingSettlementId
938
- }
939
- onClick={() => {
940
- setSelectedSettlementIdToReverse(
941
- String(liq.settlementId)
942
- );
943
- setReverseReason('');
944
- setIsReverseSheetOpen(true);
945
- }}
946
- >
947
- <Undo className="mr-2 h-4 w-4" />
948
- {t('receiptsTable.reverseButton')}
949
- </Button>
950
- </TableCell>
951
- </TableRow>
952
- );
953
- })
954
- )}
955
- </TableBody>
956
- </Table>
957
- ) : (
958
- <p className="text-center text-muted-foreground py-8">
959
- {t('receiptsTable.none')}
960
- </p>
961
- )}
962
- </CardContent>
963
- </Card>
964
- </TabsContent>
965
-
966
- <TabsContent value="auditoria" className="mt-4">
967
- <Card>
968
- <CardContent className="pt-6">
969
- {auditEvents.length > 0 ? (
970
- <AuditTimeline events={auditEvents} />
971
- ) : (
972
- <p className="text-center text-muted-foreground py-8">
973
- {t('audit.none')}
974
- </p>
975
- )}
976
- </CardContent>
977
- </Card>
978
- </TabsContent>
979
- </Tabs>
980
- </Page>
981
- );
982
- }
1
+ 'use client';
2
+
3
+ import { Page, PageHeader } from '@/components/entity-list';
4
+ import { AuditTimeline } from '@/components/ui/audit-timeline';
5
+ import { Badge } from '@/components/ui/badge';
6
+ import { Button } from '@/components/ui/button';
7
+ import {
8
+ Card,
9
+ CardContent,
10
+ CardDescription,
11
+ CardHeader,
12
+ CardTitle,
13
+ } from '@/components/ui/card';
14
+ import {
15
+ DropdownMenu,
16
+ DropdownMenuContent,
17
+ DropdownMenuItem,
18
+ DropdownMenuTrigger,
19
+ } from '@/components/ui/dropdown-menu';
20
+ import {
21
+ Form,
22
+ FormControl,
23
+ FormField,
24
+ FormItem,
25
+ FormLabel,
26
+ FormMessage,
27
+ } from '@/components/ui/form';
28
+ import { Input } from '@/components/ui/input';
29
+ import { InputMoney } from '@/components/ui/input-money';
30
+ import { Label } from '@/components/ui/label';
31
+ import { Money } from '@/components/ui/money';
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 { StatusBadge } from '@/components/ui/status-badge';
47
+ import {
48
+ Table,
49
+ TableBody,
50
+ TableCell,
51
+ TableHead,
52
+ TableHeader,
53
+ TableRow,
54
+ } from '@/components/ui/table';
55
+ import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
56
+ import { TagSelectorSheet } from '@/components/ui/tag-selector-sheet';
57
+ import { useApp } from '@hed-hog/next-app-provider';
58
+ import { zodResolver } from '@hookform/resolvers/zod';
59
+ import {
60
+ CheckCircle,
61
+ Download,
62
+ Edit,
63
+ FileText,
64
+ MoreHorizontal,
65
+ Send,
66
+ Undo,
67
+ } from 'lucide-react';
68
+ import { useTranslations } from 'next-intl';
69
+ import Link from 'next/link';
70
+ import { useParams } from 'next/navigation';
71
+ import { useEffect, useMemo, useState } from 'react';
72
+ import { useForm } from 'react-hook-form';
73
+ import { z } from 'zod';
74
+ import { formatarData } from '../../../_lib/formatters';
75
+ import { useFinanceData } from '../../../_lib/use-finance-data';
76
+
77
+ type SettleFormValues = {
78
+ installmentId: string;
79
+ amount: number;
80
+ description?: string;
81
+ };
82
+
83
+ export default function TituloReceberDetalhePage() {
84
+ const t = useTranslations('finance.ReceivableInstallmentDetailPage');
85
+ const { request, showToastHandler } = useApp();
86
+ const params = useParams<{ id: string }>();
87
+ const id = params?.id;
88
+ const { data, refetch } = useFinanceData();
89
+ const {
90
+ titulosReceber,
91
+ pessoas,
92
+ categorias,
93
+ centrosCusto,
94
+ contasBancarias,
95
+ logsAuditoria,
96
+ tags,
97
+ } = data;
98
+
99
+ const titulo = titulosReceber.find((t) => t.id === id);
100
+
101
+ const settleCandidates = (titulo?.parcelas || []).filter(
102
+ (parcela: any) =>
103
+ parcela.status === 'aberto' ||
104
+ parcela.status === 'parcial' ||
105
+ parcela.status === 'vencido'
106
+ );
107
+
108
+ const settleSchema = useMemo(
109
+ () =>
110
+ z.object({
111
+ installmentId: z.string().min(1, t('validation.installmentRequired')),
112
+ amount: z.number().min(0.01, t('validation.amountGreaterThanZero')),
113
+ description: z.string().optional(),
114
+ }),
115
+ [t]
116
+ );
117
+
118
+ const settleForm = useForm<SettleFormValues>({
119
+ resolver: zodResolver(settleSchema),
120
+ defaultValues: {
121
+ installmentId: settleCandidates[0]?.id || '',
122
+ amount: Number(settleCandidates[0]?.valorAberto || 0),
123
+ description: '',
124
+ },
125
+ });
126
+
127
+ const [availableTags, setAvailableTags] = useState<any[]>([]);
128
+ const [selectedTagIds, setSelectedTagIds] = useState<string[]>([]);
129
+ const [isCreatingTag, setIsCreatingTag] = useState(false);
130
+ const [isApproving, setIsApproving] = useState(false);
131
+ const [isSettling, setIsSettling] = useState(false);
132
+ const [isSettleDialogOpen, setIsSettleDialogOpen] = useState(false);
133
+ const [isReverseSheetOpen, setIsReverseSheetOpen] = useState(false);
134
+ const [selectedSettlementIdToReverse, setSelectedSettlementIdToReverse] =
135
+ useState<string | null>(null);
136
+ const [reverseReason, setReverseReason] = useState('');
137
+ const [reversingSettlementId, setReversingSettlementId] = useState<
138
+ string | null
139
+ >(null);
140
+
141
+ useEffect(() => {
142
+ setAvailableTags(tags || []);
143
+ }, [tags]);
144
+
145
+ useEffect(() => {
146
+ if (!titulo) {
147
+ return;
148
+ }
149
+
150
+ setSelectedTagIds(Array.isArray(titulo.tags) ? titulo.tags : []);
151
+ }, [titulo]);
152
+
153
+ useEffect(() => {
154
+ const firstCandidate = settleCandidates[0];
155
+ const nextInstallmentId = firstCandidate?.id || '';
156
+ const nextAmount = Number(firstCandidate?.valorAberto || 0);
157
+ const currentInstallmentId = settleForm.getValues('installmentId') || '';
158
+ const currentAmount = Number(settleForm.getValues('amount') || 0);
159
+
160
+ if (
161
+ currentInstallmentId === nextInstallmentId &&
162
+ currentAmount === nextAmount
163
+ ) {
164
+ return;
165
+ }
166
+
167
+ settleForm.reset({
168
+ installmentId: nextInstallmentId,
169
+ amount: nextAmount,
170
+ description: '',
171
+ });
172
+ }, [settleCandidates, settleForm]);
173
+
174
+ const canalBadge = {
175
+ boleto: {
176
+ label: t('channels.boleto'),
177
+ className: 'bg-blue-100 text-blue-700',
178
+ },
179
+ pix: { label: 'PIX', className: 'bg-green-100 text-green-700' },
180
+ cartao: {
181
+ label: t('channels.card'),
182
+ className: 'bg-purple-100 text-purple-700',
183
+ },
184
+ transferencia: {
185
+ label: t('channels.transfer'),
186
+ className: 'bg-orange-100 text-orange-700',
187
+ },
188
+ };
189
+
190
+ if (!titulo) {
191
+ return (
192
+ <div className="space-y-6">
193
+ <PageHeader
194
+ title={t('notFound.title')}
195
+ description={t('notFound.description')}
196
+ breadcrumbs={[
197
+ {
198
+ label: t('notFound.breadcrumbReceivables'),
199
+ href: '/finance/accounts-receivable/installments',
200
+ },
201
+ {
202
+ label: t('notFound.breadcrumbInstallments'),
203
+ href: '/finance/accounts-receivable/installments',
204
+ },
205
+ ]}
206
+ />
207
+ </div>
208
+ );
209
+ }
210
+
211
+ const getPessoaById = (personId?: string) =>
212
+ pessoas.find((p) => p.id === personId);
213
+ const getCategoriaById = (categoryId?: string) =>
214
+ categorias.find((c) => c.id === categoryId);
215
+ const getCentroCustoById = (costCenterId?: string) =>
216
+ centrosCusto.find((c) => c.id === costCenterId);
217
+ const getContaBancariaById = (bankId?: string) =>
218
+ contasBancarias.find((c) => c.id === bankId);
219
+
220
+ const cliente = getPessoaById(titulo.clienteId);
221
+ const categoria = getCategoriaById(titulo.categoriaId);
222
+ const centroCusto = getCentroCustoById(titulo.centroCustoId);
223
+
224
+ const tagOptions = availableTags.map((tag) => ({
225
+ id: String(tag.id),
226
+ name: String(tag.nome || ''),
227
+ color: tag.cor,
228
+ usageCount:
229
+ typeof tag.usageCount === 'number'
230
+ ? tag.usageCount
231
+ : typeof tag.usoCount === 'number'
232
+ ? tag.usoCount
233
+ : typeof tag.count === 'number'
234
+ ? tag.count
235
+ : undefined,
236
+ }));
237
+
238
+ const canal =
239
+ canalBadge[titulo.canal as keyof typeof canalBadge] ||
240
+ canalBadge.transferencia;
241
+
242
+ const auditEvents = logsAuditoria
243
+ .filter(
244
+ (log) => log.entidadeId === titulo.id && log.entidade === 'TituloReceber'
245
+ )
246
+ .map((log) => ({
247
+ id: log.id,
248
+ data: log.data,
249
+ usuarioId: log.usuarioId,
250
+ acao: log.acao,
251
+ detalhes: log.detalhes,
252
+ antes: log.antes,
253
+ depois: log.depois,
254
+ }));
255
+
256
+ const attachmentDetails = Array.isArray(titulo.anexosDetalhes)
257
+ ? titulo.anexosDetalhes
258
+ : (titulo.anexos || []).map((nome: string) => ({ nome }));
259
+
260
+ const handleOpenAttachment = async (fileId?: string) => {
261
+ if (!fileId) {
262
+ return;
263
+ }
264
+
265
+ const response = await request<{ url?: string }>({
266
+ url: `/file/open/${fileId}`,
267
+ method: 'PUT',
268
+ });
269
+
270
+ const url = response?.data?.url;
271
+ if (!url) {
272
+ return;
273
+ }
274
+
275
+ window.open(url, '_blank', 'noopener,noreferrer');
276
+ };
277
+
278
+ const toTagSlug = (value: string) => {
279
+ return value
280
+ .normalize('NFD')
281
+ .replace(/[\u0300-\u036f]/g, '')
282
+ .toLowerCase()
283
+ .trim()
284
+ .replace(/[^a-z0-9]+/g, '-')
285
+ .replace(/(^-|-$)+/g, '');
286
+ };
287
+
288
+ const handleCreateTag = async (name: string) => {
289
+ const slug = toTagSlug(name);
290
+
291
+ if (!slug) {
292
+ return null;
293
+ }
294
+
295
+ setIsCreatingTag(true);
296
+ try {
297
+ const response = await request<{
298
+ id?: string | number;
299
+ nome?: string;
300
+ cor?: string;
301
+ }>({
302
+ url: '/finance/tags',
303
+ method: 'POST',
304
+ data: {
305
+ name: slug,
306
+ color: '#000000',
307
+ },
308
+ });
309
+
310
+ const created = response?.data;
311
+ const newTag = {
312
+ id: String(created?.id || `temp-${Date.now()}`),
313
+ nome: created?.nome || slug,
314
+ cor: created?.cor || '#000000',
315
+ };
316
+
317
+ setAvailableTags((current) => {
318
+ if (current.some((tag) => String(tag.id) === newTag.id)) {
319
+ return current;
320
+ }
321
+
322
+ return [...current, newTag];
323
+ });
324
+
325
+ showToastHandler?.('success', t('tagSelector.messages.createSuccess'));
326
+
327
+ return {
328
+ id: newTag.id,
329
+ name: newTag.nome,
330
+ color: newTag.cor,
331
+ };
332
+ } catch {
333
+ showToastHandler?.('error', t('tagSelector.messages.createError'));
334
+ return null;
335
+ } finally {
336
+ setIsCreatingTag(false);
337
+ }
338
+ };
339
+
340
+ const handleChangeTags = async (nextTagIds: string[]) => {
341
+ if (!titulo?.id) {
342
+ return;
343
+ }
344
+
345
+ try {
346
+ const response = await request<{ tags?: string[] }>({
347
+ url: `/finance/accounts-receivable/installments/${titulo.id}/tags`,
348
+ method: 'PATCH',
349
+ data: {
350
+ tag_ids: nextTagIds.map((tagId) => Number(tagId)),
351
+ },
352
+ });
353
+
354
+ if (Array.isArray(response?.data?.tags)) {
355
+ setSelectedTagIds(response.data.tags);
356
+ } else {
357
+ setSelectedTagIds(nextTagIds);
358
+ }
359
+ } catch {
360
+ showToastHandler?.('error', t('tagSelector.messages.updateError'));
361
+ }
362
+ };
363
+
364
+ const canApprove = titulo.status === 'rascunho';
365
+ const canSettle = ['aberto', 'parcial', 'vencido'].includes(titulo.status);
366
+
367
+ const getErrorMessage = (error: any, fallback: string) => {
368
+ const message = error?.response?.data?.message;
369
+
370
+ if (Array.isArray(message)) {
371
+ return message.join(', ');
372
+ }
373
+
374
+ if (typeof message === 'string' && message.trim()) {
375
+ return message;
376
+ }
377
+
378
+ return fallback;
379
+ };
380
+
381
+ const handleApprove = async () => {
382
+ if (!canApprove || isApproving) {
383
+ return;
384
+ }
385
+
386
+ setIsApproving(true);
387
+ try {
388
+ await request({
389
+ url: `/finance/accounts-receivable/installments/${titulo.id}/approve`,
390
+ method: 'PATCH',
391
+ });
392
+
393
+ await refetch();
394
+ showToastHandler?.('success', t('messages.approveSuccess'));
395
+ } catch (error) {
396
+ showToastHandler?.(
397
+ 'error',
398
+ getErrorMessage(error, t('messages.approveError'))
399
+ );
400
+ } finally {
401
+ setIsApproving(false);
402
+ }
403
+ };
404
+
405
+ const handleSettle = async (values: SettleFormValues) => {
406
+ if (!canSettle || isSettling) {
407
+ return;
408
+ }
409
+
410
+ setIsSettling(true);
411
+ try {
412
+ await request({
413
+ url: `/finance/accounts-receivable/installments/${titulo.id}/settlements`,
414
+ method: 'POST',
415
+ data: {
416
+ installment_id: Number(values.installmentId),
417
+ amount: values.amount,
418
+ description: values.description?.trim() || undefined,
419
+ },
420
+ });
421
+
422
+ await refetch();
423
+ setIsSettleDialogOpen(false);
424
+ showToastHandler?.('success', t('messages.settleSuccess'));
425
+ } catch (error) {
426
+ showToastHandler?.(
427
+ 'error',
428
+ getErrorMessage(error, t('messages.settleError'))
429
+ );
430
+ } finally {
431
+ setIsSettling(false);
432
+ }
433
+ };
434
+
435
+ const handleReverseSettlement = async (
436
+ settlementId: string,
437
+ reasonOverride?: string
438
+ ) => {
439
+ if (!settlementId || reversingSettlementId) {
440
+ return;
441
+ }
442
+
443
+ setReversingSettlementId(settlementId);
444
+ try {
445
+ await request({
446
+ url: `/finance/settlements/${settlementId}/reverse`,
447
+ method: 'POST',
448
+ data: {
449
+ reason:
450
+ reasonOverride?.trim() ||
451
+ reverseReason?.trim() ||
452
+ t('messages.reverseDefaultReason'),
453
+ memo:
454
+ reasonOverride?.trim() ||
455
+ reverseReason?.trim() ||
456
+ t('messages.reverseDefaultReason'),
457
+ },
458
+ });
459
+
460
+ await refetch();
461
+ setReverseReason('');
462
+ showToastHandler?.('success', t('messages.reverseSuccess'));
463
+ } catch (error) {
464
+ showToastHandler?.(
465
+ 'error',
466
+ getErrorMessage(error, t('messages.reverseError'))
467
+ );
468
+ } finally {
469
+ setReversingSettlementId(null);
470
+ }
471
+ };
472
+
473
+ return (
474
+ <Page>
475
+ <PageHeader
476
+ title={titulo.documento}
477
+ description={titulo.descricao}
478
+ breadcrumbs={[
479
+ { label: t('breadcrumbs.home'), href: '/' },
480
+ { label: t('breadcrumbs.finance'), href: '/finance' },
481
+ {
482
+ label: t('breadcrumbs.receivables'),
483
+ href: '/finance/accounts-receivable/installments',
484
+ },
485
+ { label: titulo.documento },
486
+ ]}
487
+ actions={
488
+ <div className="flex items-center gap-2">
489
+ <DropdownMenu>
490
+ <DropdownMenuTrigger asChild>
491
+ <Button variant="outline">
492
+ <MoreHorizontal className="mr-2 h-4 w-4" />
493
+ {t('actions.title')}
494
+ </Button>
495
+ </DropdownMenuTrigger>
496
+ <DropdownMenuContent align="end">
497
+ <DropdownMenuItem>
498
+ <Edit className="mr-2 h-4 w-4" />
499
+ {t('actions.edit')}
500
+ </DropdownMenuItem>
501
+ <DropdownMenuItem
502
+ disabled={!canApprove || isApproving}
503
+ onClick={() => void handleApprove()}
504
+ >
505
+ <CheckCircle className="mr-2 h-4 w-4" />
506
+ {t('actions.approve')}
507
+ </DropdownMenuItem>
508
+ <DropdownMenuItem
509
+ disabled={!canSettle || settleCandidates.length === 0}
510
+ onClick={() => setIsSettleDialogOpen(true)}
511
+ >
512
+ <Download className="mr-2 h-4 w-4" />
513
+ {t('actions.registerReceipt')}
514
+ </DropdownMenuItem>
515
+ <DropdownMenuItem>
516
+ <Send className="mr-2 h-4 w-4" />
517
+ {t('actions.sendCollection')}
518
+ </DropdownMenuItem>
519
+ </DropdownMenuContent>
520
+ </DropdownMenu>
521
+
522
+ <Sheet
523
+ open={isSettleDialogOpen}
524
+ onOpenChange={setIsSettleDialogOpen}
525
+ >
526
+ <SheetContent className="w-full sm:max-w-lg overflow-y-auto">
527
+ <SheetHeader>
528
+ <SheetTitle>{t('settleSheet.title')}</SheetTitle>
529
+ <SheetDescription>
530
+ {t('settleSheet.description')}
531
+ </SheetDescription>
532
+ </SheetHeader>
533
+
534
+ <Form {...settleForm}>
535
+ <form
536
+ className="space-y-4 px-1"
537
+ onSubmit={settleForm.handleSubmit(handleSettle)}
538
+ >
539
+ <FormField
540
+ control={settleForm.control}
541
+ name="installmentId"
542
+ render={({ field }) => (
543
+ <FormItem>
544
+ <FormLabel>
545
+ {t('settleSheet.installmentLabel')}
546
+ </FormLabel>
547
+ <Select
548
+ value={field.value}
549
+ onValueChange={(value) => {
550
+ field.onChange(value);
551
+
552
+ const selected = settleCandidates.find(
553
+ (parcela: any) => parcela.id === value
554
+ );
555
+
556
+ if (selected) {
557
+ settleForm.setValue(
558
+ 'amount',
559
+ Number(selected.valorAberto || 0),
560
+ { shouldValidate: true }
561
+ );
562
+ }
563
+ }}
564
+ >
565
+ <FormControl>
566
+ <SelectTrigger>
567
+ <SelectValue
568
+ placeholder={t(
569
+ 'settleSheet.installmentPlaceholder'
570
+ )}
571
+ />
572
+ </SelectTrigger>
573
+ </FormControl>
574
+ <SelectContent>
575
+ {settleCandidates.map((parcela: any) => (
576
+ <SelectItem key={parcela.id} value={parcela.id}>
577
+ {t('settleSheet.installmentOption', {
578
+ number: parcela.numero,
579
+ amount: new Intl.NumberFormat('pt-BR', {
580
+ style: 'currency',
581
+ currency: 'BRL',
582
+ }).format(Number(parcela.valorAberto || 0)),
583
+ })}
584
+ </SelectItem>
585
+ ))}
586
+ </SelectContent>
587
+ </Select>
588
+ <FormMessage />
589
+ </FormItem>
590
+ )}
591
+ />
592
+
593
+ <FormField
594
+ control={settleForm.control}
595
+ name="amount"
596
+ render={({ field }) => (
597
+ <FormItem>
598
+ <FormLabel>{t('settleSheet.amountLabel')}</FormLabel>
599
+ <FormControl>
600
+ <InputMoney
601
+ value={Number(field.value || 0)}
602
+ onValueChange={(value) => {
603
+ field.onChange(Number(value || 0));
604
+ }}
605
+ />
606
+ </FormControl>
607
+ <FormMessage />
608
+ </FormItem>
609
+ )}
610
+ />
611
+
612
+ <FormField
613
+ control={settleForm.control}
614
+ name="description"
615
+ render={({ field }) => (
616
+ <FormItem>
617
+ <FormLabel>
618
+ {t('settleSheet.descriptionLabel')}
619
+ </FormLabel>
620
+ <FormControl>
621
+ <Input {...field} value={field.value || ''} />
622
+ </FormControl>
623
+ <FormMessage />
624
+ </FormItem>
625
+ )}
626
+ />
627
+
628
+ <div className="flex justify-end gap-2 pt-2">
629
+ <Button
630
+ type="button"
631
+ variant="outline"
632
+ disabled={isSettling}
633
+ onClick={() => setIsSettleDialogOpen(false)}
634
+ >
635
+ {t('common.cancel')}
636
+ </Button>
637
+ <Button type="submit" disabled={isSettling}>
638
+ {t('settleSheet.confirm')}
639
+ </Button>
640
+ </div>
641
+ </form>
642
+ </Form>
643
+ </SheetContent>
644
+ </Sheet>
645
+
646
+ <Sheet
647
+ open={isReverseSheetOpen}
648
+ onOpenChange={(open) => {
649
+ setIsReverseSheetOpen(open);
650
+
651
+ if (!open) {
652
+ setSelectedSettlementIdToReverse(null);
653
+ setReverseReason('');
654
+ }
655
+ }}
656
+ >
657
+ <SheetContent className="w-full sm:max-w-lg overflow-y-auto">
658
+ <SheetHeader>
659
+ <SheetTitle>{t('reverseSheet.title')}</SheetTitle>
660
+ <SheetDescription>
661
+ {t('reverseSheet.description')}
662
+ </SheetDescription>
663
+ </SheetHeader>
664
+
665
+ <div className="space-y-4 px-4">
666
+ <div className="space-y-2">
667
+ <Label>{t('reverseSheet.reasonLabel')}</Label>
668
+ <Input
669
+ value={reverseReason}
670
+ onChange={(event) => setReverseReason(event.target.value)}
671
+ placeholder={t('reverseSheet.reasonPlaceholder')}
672
+ maxLength={255}
673
+ disabled={!!reversingSettlementId}
674
+ />
675
+ </div>
676
+
677
+ <div className="flex flex-col gap-2">
678
+ <Button
679
+ disabled={
680
+ !!reversingSettlementId ||
681
+ !selectedSettlementIdToReverse
682
+ }
683
+ onClick={() => {
684
+ if (!selectedSettlementIdToReverse) {
685
+ return;
686
+ }
687
+
688
+ void handleReverseSettlement(
689
+ selectedSettlementIdToReverse
690
+ ).finally(() => {
691
+ setIsReverseSheetOpen(false);
692
+ setSelectedSettlementIdToReverse(null);
693
+ setReverseReason('');
694
+ });
695
+ }}
696
+ >
697
+ {t('reverseSheet.confirm')}
698
+ </Button>
699
+ <Button
700
+ variant="outline"
701
+ disabled={!!reversingSettlementId}
702
+ onClick={() => {
703
+ setIsReverseSheetOpen(false);
704
+ setSelectedSettlementIdToReverse(null);
705
+ setReverseReason('');
706
+ }}
707
+ >
708
+ {t('common.cancel')}
709
+ </Button>
710
+ </div>
711
+ </div>
712
+ </SheetContent>
713
+ </Sheet>
714
+ </div>
715
+ }
716
+ />
717
+
718
+ <div className="grid gap-6 lg:grid-cols-3">
719
+ <Card className="lg:col-span-2">
720
+ <CardHeader>
721
+ <CardTitle>{t('documentData.title')}</CardTitle>
722
+ </CardHeader>
723
+ <CardContent>
724
+ <dl className="grid gap-4 sm:grid-cols-2">
725
+ <div>
726
+ <dt className="text-sm font-medium text-muted-foreground">
727
+ {t('documentData.client')}
728
+ </dt>
729
+ <dd className="mt-1">
730
+ <Link
731
+ href={`/cadastros/pessoas/${cliente?.id}`}
732
+ className="hover:underline"
733
+ >
734
+ {cliente?.nome}
735
+ </Link>
736
+ </dd>
737
+ </div>
738
+ <div>
739
+ <dt className="text-sm font-medium text-muted-foreground">
740
+ {t('documentData.cnpjCpf')}
741
+ </dt>
742
+ <dd className="mt-1">{cliente?.documento}</dd>
743
+ </div>
744
+ <div>
745
+ <dt className="text-sm font-medium text-muted-foreground">
746
+ {t('documentData.competency')}
747
+ </dt>
748
+ <dd className="mt-1">{titulo.competencia}</dd>
749
+ </div>
750
+ <div>
751
+ <dt className="text-sm font-medium text-muted-foreground">
752
+ {t('documentData.totalValue')}
753
+ </dt>
754
+ <dd className="mt-1 text-lg font-semibold">
755
+ <Money value={titulo.valorTotal} />
756
+ </dd>
757
+ </div>
758
+ <div>
759
+ <dt className="text-sm font-medium text-muted-foreground">
760
+ {t('documentData.category')}
761
+ </dt>
762
+ <dd className="mt-1">{categoria?.nome}</dd>
763
+ </div>
764
+ <div>
765
+ <dt className="text-sm font-medium text-muted-foreground">
766
+ {t('documentData.costCenter')}
767
+ </dt>
768
+ <dd className="mt-1">{centroCusto?.nome}</dd>
769
+ </div>
770
+ <div>
771
+ <dt className="text-sm font-medium text-muted-foreground">
772
+ {t('documentData.channel')}
773
+ </dt>
774
+ <dd className="mt-1">
775
+ <Badge className={canal.className} variant="outline">
776
+ {canal.label}
777
+ </Badge>
778
+ </dd>
779
+ </div>
780
+ <div>
781
+ <dt className="text-sm font-medium text-muted-foreground">
782
+ {t('documentData.tags')}
783
+ </dt>
784
+ <dd className="mt-1">
785
+ <TagSelectorSheet
786
+ selectedTagIds={selectedTagIds}
787
+ tags={tagOptions}
788
+ onChange={handleChangeTags}
789
+ onCreateTag={handleCreateTag}
790
+ disabled={isCreatingTag}
791
+ emptyText={t('tagSelector.noTags')}
792
+ labels={{
793
+ addTag: t('tagSelector.addTag'),
794
+ sheetTitle: t('tagSelector.sheetTitle'),
795
+ sheetDescription: t('tagSelector.sheetDescription'),
796
+ createLabel: t('tagSelector.createLabel'),
797
+ createPlaceholder: t('tagSelector.createPlaceholder'),
798
+ createAction: t('tagSelector.createAction'),
799
+ popularTitle: t('tagSelector.popularTitle'),
800
+ selectedTitle: t('tagSelector.selectedTitle'),
801
+ noTags: t('tagSelector.noTags'),
802
+ cancel: t('tagSelector.cancel'),
803
+ apply: t('tagSelector.apply'),
804
+ removeTagAria: (tagName: string) =>
805
+ t('tagSelector.removeTagAria', { tag: tagName }),
806
+ }}
807
+ />
808
+ </dd>
809
+ </div>
810
+ </dl>
811
+ </CardContent>
812
+ </Card>
813
+
814
+ <Card>
815
+ <CardHeader>
816
+ <CardTitle>{t('attachments.title')}</CardTitle>
817
+ <CardDescription>{t('attachments.description')}</CardDescription>
818
+ </CardHeader>
819
+ <CardContent>
820
+ {attachmentDetails.length > 0 ? (
821
+ <ul className="space-y-2">
822
+ {attachmentDetails.map((anexo: any, i: number) => (
823
+ <li key={i}>
824
+ <Button
825
+ variant="ghost"
826
+ className="h-auto w-full justify-start p-2"
827
+ onClick={() => void handleOpenAttachment(anexo?.id)}
828
+ >
829
+ <FileText className="mr-2 h-4 w-4" />
830
+ {anexo?.nome}
831
+ </Button>
832
+ </li>
833
+ ))}
834
+ </ul>
835
+ ) : (
836
+ <p className="text-sm text-muted-foreground">
837
+ {t('attachments.none')}
838
+ </p>
839
+ )}
840
+ </CardContent>
841
+ </Card>
842
+ </div>
843
+
844
+ <Tabs defaultValue="parcelas">
845
+ <TabsList>
846
+ <TabsTrigger value="parcelas">{t('tabs.installments')}</TabsTrigger>
847
+ <TabsTrigger value="liquidacoes">{t('tabs.receipts')}</TabsTrigger>
848
+ <TabsTrigger value="auditoria">{t('tabs.audit')}</TabsTrigger>
849
+ </TabsList>
850
+
851
+ <TabsContent value="parcelas" className="mt-4">
852
+ <Card>
853
+ <CardContent className="pt-6">
854
+ <Table>
855
+ <TableHeader>
856
+ <TableRow>
857
+ <TableHead>{t('installmentsTable.installment')}</TableHead>
858
+ <TableHead>{t('installmentsTable.dueDate')}</TableHead>
859
+ <TableHead className="text-right">
860
+ {t('installmentsTable.value')}
861
+ </TableHead>
862
+ <TableHead>{t('installmentsTable.status')}</TableHead>
863
+ </TableRow>
864
+ </TableHeader>
865
+ <TableBody>
866
+ {titulo.parcelas.map((parcela: any) => (
867
+ <TableRow key={parcela.id}>
868
+ <TableCell>
869
+ {parcela.numero}/{titulo.parcelas.length}
870
+ </TableCell>
871
+ <TableCell>{formatarData(parcela.vencimento)}</TableCell>
872
+ <TableCell className="text-right">
873
+ <Money value={parcela.valor} />
874
+ </TableCell>
875
+ <TableCell>
876
+ <StatusBadge status={parcela.status} />
877
+ </TableCell>
878
+ </TableRow>
879
+ ))}
880
+ </TableBody>
881
+ </Table>
882
+ </CardContent>
883
+ </Card>
884
+ </TabsContent>
885
+
886
+ <TabsContent value="liquidacoes" className="mt-4">
887
+ <Card>
888
+ <CardContent className="pt-6">
889
+ {titulo.parcelas.some((p: any) => p.liquidacoes.length > 0) ? (
890
+ <Table>
891
+ <TableHeader>
892
+ <TableRow>
893
+ <TableHead>{t('receiptsTable.date')}</TableHead>
894
+ <TableHead className="text-right">
895
+ {t('receiptsTable.value')}
896
+ </TableHead>
897
+ <TableHead className="text-right">
898
+ {t('receiptsTable.interest')}
899
+ </TableHead>
900
+ <TableHead className="text-right">
901
+ {t('receiptsTable.discount')}
902
+ </TableHead>
903
+ <TableHead>{t('receiptsTable.account')}</TableHead>
904
+ <TableHead>{t('receiptsTable.method')}</TableHead>
905
+ <TableHead className="text-right">
906
+ {t('receiptsTable.actions')}
907
+ </TableHead>
908
+ </TableRow>
909
+ </TableHeader>
910
+ <TableBody>
911
+ {titulo.parcelas.flatMap((parcela: any) =>
912
+ parcela.liquidacoes.map((liq: any) => {
913
+ const conta = getContaBancariaById(liq.contaBancariaId);
914
+ return (
915
+ <TableRow key={liq.id}>
916
+ <TableCell>{formatarData(liq.data)}</TableCell>
917
+ <TableCell className="text-right">
918
+ <Money value={liq.valor} />
919
+ </TableCell>
920
+ <TableCell className="text-right">
921
+ <Money value={liq.juros} />
922
+ </TableCell>
923
+ <TableCell className="text-right">
924
+ <Money value={liq.desconto} />
925
+ </TableCell>
926
+ <TableCell>{conta?.descricao}</TableCell>
927
+ <TableCell className="capitalize">
928
+ {liq.metodo}
929
+ </TableCell>
930
+ <TableCell className="text-right">
931
+ <Button
932
+ variant="outline"
933
+ size="sm"
934
+ disabled={
935
+ !liq.settlementId ||
936
+ liq.status === 'reversed' ||
937
+ !!reversingSettlementId
938
+ }
939
+ onClick={() => {
940
+ setSelectedSettlementIdToReverse(
941
+ String(liq.settlementId)
942
+ );
943
+ setReverseReason('');
944
+ setIsReverseSheetOpen(true);
945
+ }}
946
+ >
947
+ <Undo className="mr-2 h-4 w-4" />
948
+ {t('receiptsTable.reverseButton')}
949
+ </Button>
950
+ </TableCell>
951
+ </TableRow>
952
+ );
953
+ })
954
+ )}
955
+ </TableBody>
956
+ </Table>
957
+ ) : (
958
+ <p className="text-center text-muted-foreground py-8">
959
+ {t('receiptsTable.none')}
960
+ </p>
961
+ )}
962
+ </CardContent>
963
+ </Card>
964
+ </TabsContent>
965
+
966
+ <TabsContent value="auditoria" className="mt-4">
967
+ <Card>
968
+ <CardContent className="pt-6">
969
+ {auditEvents.length > 0 ? (
970
+ <AuditTimeline events={auditEvents} />
971
+ ) : (
972
+ <p className="text-center text-muted-foreground py-8">
973
+ {t('audit.none')}
974
+ </p>
975
+ )}
976
+ </CardContent>
977
+ </Card>
978
+ </TabsContent>
979
+ </Tabs>
980
+ </Page>
981
+ );
982
+ }