@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,1270 +1,1270 @@
1
- 'use client';
2
-
3
- import { FinanceTitleActionsMenu } from '@/app/(app)/(libraries)/finance/_components/finance-title-actions-menu';
4
- import { Page, PageHeader } from '@/components/entity-list';
5
- import { AuditTimeline } from '@/components/ui/audit-timeline';
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
- Form,
16
- FormControl,
17
- FormField,
18
- FormItem,
19
- FormLabel,
20
- FormMessage,
21
- } from '@/components/ui/form';
22
- import { Input } from '@/components/ui/input';
23
- import { InputMoney } from '@/components/ui/input-money';
24
- import { Label } from '@/components/ui/label';
25
- import { Money } from '@/components/ui/money';
26
- import {
27
- Select,
28
- SelectContent,
29
- SelectItem,
30
- SelectTrigger,
31
- SelectValue,
32
- } from '@/components/ui/select';
33
- import {
34
- Sheet,
35
- SheetContent,
36
- SheetDescription,
37
- SheetHeader,
38
- SheetTitle,
39
- } from '@/components/ui/sheet';
40
- import { StatusBadge } from '@/components/ui/status-badge';
41
- import {
42
- Table,
43
- TableBody,
44
- TableCell,
45
- TableHead,
46
- TableHeader,
47
- TableRow,
48
- } from '@/components/ui/table';
49
- import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
50
- import { TagSelectorSheet } from '@/components/ui/tag-selector-sheet';
51
- import { useApp, useQuery } from '@hed-hog/next-app-provider';
52
- import { zodResolver } from '@hookform/resolvers/zod';
53
- import { ChevronDown, ChevronUp, FileText, Loader2, Undo } from 'lucide-react';
54
- import { useTranslations } from 'next-intl';
55
- import Link from 'next/link';
56
- import { useParams, useRouter, useSearchParams } from 'next/navigation';
57
- import { Fragment, useEffect, useMemo, useState } from 'react';
58
- import { useForm } from 'react-hook-form';
59
- import { z } from 'zod';
60
- import { formatarData } from '../../../_lib/formatters';
61
- import {
62
- canApproveTitle,
63
- canCancelTitle,
64
- canEditTitle,
65
- canReverseTitle,
66
- canSettleTitle,
67
- } from '../../../_lib/title-action-rules';
68
- import { useFinanceData } from '../../../_lib/use-finance-data';
69
-
70
- type SettleFormValues = {
71
- installmentId: string;
72
- amount: number;
73
- description?: string;
74
- };
75
-
76
- type SettlementHistoryAllocation = {
77
- installmentId: string;
78
- installmentSeq: number;
79
- amountCents: number;
80
- };
81
-
82
- type SettlementHistoryItem = {
83
- normal: {
84
- id: string;
85
- paidAt: string | null;
86
- amountCents: number;
87
- type: 'NORMAL';
88
- method?: string | null;
89
- account?: string | null;
90
- accountId?: string | null;
91
- createdAt?: string | null;
92
- createdBy?: string | null;
93
- memo?: string | null;
94
- reconciled: boolean;
95
- reconciliationId?: string | null;
96
- };
97
- reversal?: {
98
- id: string;
99
- paidAt: string | null;
100
- amountCents: number;
101
- type: 'REVERSAL';
102
- createdAt?: string | null;
103
- createdBy?: string | null;
104
- memo?: string | null;
105
- } | null;
106
- allocations: SettlementHistoryAllocation[];
107
- statusLabel: 'ATIVO' | 'ESTORNADO';
108
- };
109
-
110
- export default function TituloDetalhePage() {
111
- const t = useTranslations('finance.PayableInstallmentDetailPage');
112
- const settleSchema = useMemo(
113
- () =>
114
- z.object({
115
- installmentId: z.string().min(1, t('validation.installmentRequired')),
116
- amount: z.number().min(0.01, t('validation.amountGreaterThanZero')),
117
- description: z.string().optional(),
118
- }),
119
- [t]
120
- );
121
- const { request, showToastHandler } = useApp();
122
- const router = useRouter();
123
- const searchParams = useSearchParams();
124
- const params = useParams<{ id: string }>();
125
- const id = params?.id;
126
- const { data, refetch } = useFinanceData();
127
- const {
128
- titulosPagar,
129
- pessoas,
130
- categorias,
131
- centrosCusto,
132
- logsAuditoria,
133
- tags,
134
- } = data;
135
-
136
- const titulo = titulosPagar.find((t) => t.id === id);
137
-
138
- const {
139
- data: settlementsHistory = [],
140
- refetch: refetchSettlementsHistory,
141
- isFetching: isFetchingSettlementsHistory,
142
- } = useQuery<SettlementHistoryItem[]>({
143
- queryKey: ['finance-title-settlements-history', id],
144
- enabled: !!id,
145
- queryFn: async () => {
146
- const response = await request<SettlementHistoryItem[]>({
147
- url: `/finance/titles/${id}/settlements-history`,
148
- method: 'GET',
149
- });
150
-
151
- return (response.data || []) as SettlementHistoryItem[];
152
- },
153
- placeholderData: (old) => old,
154
- });
155
-
156
- const canSettle = canSettleTitle(titulo?.status);
157
-
158
- const settleCandidates = (titulo?.parcelas || []).filter(
159
- (parcela: any) =>
160
- parcela.status === 'aberto' ||
161
- parcela.status === 'parcial' ||
162
- parcela.status === 'vencido'
163
- );
164
-
165
- const settleForm = useForm<SettleFormValues>({
166
- resolver: zodResolver(settleSchema),
167
- defaultValues: {
168
- installmentId: settleCandidates[0]?.id || '',
169
- amount: Number(settleCandidates[0]?.valorAberto || 0),
170
- description: '',
171
- },
172
- });
173
-
174
- const [availableTags, setAvailableTags] = useState<any[]>([]);
175
- const [selectedTagIds, setSelectedTagIds] = useState<string[]>([]);
176
- const [isCreatingTag, setIsCreatingTag] = useState(false);
177
- const [isApproving, setIsApproving] = useState(false);
178
- const [isSettling, setIsSettling] = useState(false);
179
- const [isCanceling, setIsCanceling] = useState(false);
180
- const [isSettleDialogOpen, setIsSettleDialogOpen] = useState(false);
181
- const [isReverseDialogOpen, setIsReverseDialogOpen] = useState(false);
182
- const [selectedSettlementIdToReverse, setSelectedSettlementIdToReverse] =
183
- useState<string | null>(null);
184
- const [reverseReason, setReverseReason] = useState('');
185
- const [reversingSettlementId, setReversingSettlementId] = useState<
186
- string | null
187
- >(null);
188
- const [unreconcilingId, setUnreconcilingId] = useState<string | null>(null);
189
- const [expandedSettlementRows, setExpandedSettlementRows] = useState<
190
- Record<string, boolean>
191
- >({});
192
-
193
- useEffect(() => {
194
- setAvailableTags(tags || []);
195
- }, [tags]);
196
-
197
- useEffect(() => {
198
- if (!titulo) {
199
- return;
200
- }
201
-
202
- setSelectedTagIds(Array.isArray(titulo.tags) ? titulo.tags : []);
203
- }, [titulo]);
204
-
205
- useEffect(() => {
206
- const firstCandidate = settleCandidates[0];
207
- const nextInstallmentId = firstCandidate?.id || '';
208
- const nextAmount = Number(firstCandidate?.valorAberto || 0);
209
- const currentInstallmentId = settleForm.getValues('installmentId') || '';
210
- const currentAmount = Number(settleForm.getValues('amount') || 0);
211
-
212
- if (
213
- currentInstallmentId === nextInstallmentId &&
214
- currentAmount === nextAmount
215
- ) {
216
- return;
217
- }
218
-
219
- settleForm.reset({
220
- installmentId: nextInstallmentId,
221
- amount: nextAmount,
222
- description: '',
223
- });
224
- }, [settleForm, settleCandidates]);
225
-
226
- useEffect(() => {
227
- const action = searchParams.get('action');
228
-
229
- if (action !== 'settle') {
230
- return;
231
- }
232
-
233
- if (canSettle && settleCandidates.length > 0) {
234
- setIsSettleDialogOpen(true);
235
- }
236
- }, [canSettle, searchParams, settleCandidates.length]);
237
-
238
- if (!titulo) {
239
- return (
240
- <div className="space-y-6">
241
- <PageHeader
242
- title={t('notFound.title')}
243
- description={t('notFound.description')}
244
- breadcrumbs={[
245
- {
246
- label: t('notFound.breadcrumbPayables'),
247
- href: '/finance/accounts-payable/installments',
248
- },
249
- {
250
- label: t('notFound.breadcrumbInstallments'),
251
- href: '/finance/accounts-payable/installments',
252
- },
253
- ]}
254
- />
255
- </div>
256
- );
257
- }
258
-
259
- const getPessoaById = (personId?: string) =>
260
- pessoas.find((p) => p.id === personId);
261
- const getCategoriaById = (categoryId?: string) =>
262
- categorias.find((c) => c.id === categoryId);
263
- const getCentroCustoById = (costCenterId?: string) =>
264
- centrosCusto.find((c) => c.id === costCenterId);
265
-
266
- const fornecedor = getPessoaById(titulo.fornecedorId);
267
- const categoria = getCategoriaById(titulo.categoriaId);
268
- const centroCusto = getCentroCustoById(titulo.centroCustoId);
269
-
270
- const tagOptions = availableTags.map((tag) => ({
271
- id: String(tag.id),
272
- name: String(tag.nome || ''),
273
- color: tag.cor,
274
- usageCount:
275
- typeof tag.usageCount === 'number'
276
- ? tag.usageCount
277
- : typeof tag.usoCount === 'number'
278
- ? tag.usoCount
279
- : typeof tag.count === 'number'
280
- ? tag.count
281
- : undefined,
282
- }));
283
-
284
- const auditEvents = logsAuditoria
285
- .filter(
286
- (log) => log.entidadeId === titulo.id && log.entidade === 'TituloPagar'
287
- )
288
- .map((log) => ({
289
- id: log.id,
290
- data: log.data,
291
- usuarioId: log.usuarioId,
292
- acao: log.acao,
293
- detalhes: log.detalhes,
294
- antes: log.antes,
295
- depois: log.depois,
296
- }));
297
-
298
- const attachmentDetails = Array.isArray(titulo.anexosDetalhes)
299
- ? titulo.anexosDetalhes
300
- : (titulo.anexos || []).map((nome: string) => ({ nome }));
301
-
302
- const handleOpenAttachment = async (fileId?: string) => {
303
- if (!fileId) {
304
- return;
305
- }
306
-
307
- const response = await request<{ url?: string }>({
308
- url: `/file/open/${fileId}`,
309
- method: 'PUT',
310
- });
311
-
312
- const url = response?.data?.url;
313
- if (!url) {
314
- return;
315
- }
316
-
317
- window.open(url, '_blank', 'noopener,noreferrer');
318
- };
319
-
320
- const toTagSlug = (value: string) => {
321
- return value
322
- .normalize('NFD')
323
- .replace(/[\u0300-\u036f]/g, '')
324
- .toLowerCase()
325
- .trim()
326
- .replace(/[^a-z0-9]+/g, '-')
327
- .replace(/(^-|-$)+/g, '');
328
- };
329
-
330
- const handleCreateTag = async (name: string) => {
331
- const slug = toTagSlug(name);
332
-
333
- if (!slug) {
334
- return null;
335
- }
336
-
337
- setIsCreatingTag(true);
338
- try {
339
- const response = await request<{
340
- id?: string | number;
341
- nome?: string;
342
- cor?: string;
343
- }>({
344
- url: '/finance/tags',
345
- method: 'POST',
346
- data: {
347
- name: slug,
348
- color: '#000000',
349
- },
350
- });
351
-
352
- const created = response?.data;
353
- const newTag = {
354
- id: String(created?.id || `temp-${Date.now()}`),
355
- nome: created?.nome || slug,
356
- cor: created?.cor || '#000000',
357
- };
358
-
359
- setAvailableTags((current) => {
360
- if (current.some((tag) => String(tag.id) === newTag.id)) {
361
- return current;
362
- }
363
-
364
- return [...current, newTag];
365
- });
366
-
367
- showToastHandler?.('success', t('tagSelector.messages.createSuccess'));
368
-
369
- return {
370
- id: newTag.id,
371
- name: newTag.nome,
372
- color: newTag.cor,
373
- };
374
- } catch {
375
- showToastHandler?.('error', t('tagSelector.messages.createError'));
376
- return null;
377
- } finally {
378
- setIsCreatingTag(false);
379
- }
380
- };
381
-
382
- const handleChangeTags = async (nextTagIds: string[]) => {
383
- if (!titulo?.id) {
384
- return;
385
- }
386
-
387
- try {
388
- const response = await request<{ tags?: string[] }>({
389
- url: `/finance/accounts-payable/installments/${titulo.id}/tags`,
390
- method: 'PATCH',
391
- data: {
392
- tag_ids: nextTagIds.map((tagId) => Number(tagId)),
393
- },
394
- });
395
-
396
- if (Array.isArray(response?.data?.tags)) {
397
- setSelectedTagIds(response.data.tags);
398
- } else {
399
- setSelectedTagIds(nextTagIds);
400
- }
401
- } catch {
402
- showToastHandler?.('error', t('tagSelector.messages.updateError'));
403
- }
404
- };
405
-
406
- const canApprove = canApproveTitle(titulo.status);
407
- const canEdit = canEditTitle(titulo.status);
408
- const canCancel = canCancelTitle(titulo.status);
409
- const reversibleSettlements = settlementsHistory.filter(
410
- (group) =>
411
- group.normal?.id &&
412
- group.statusLabel === 'ATIVO' &&
413
- !group.normal?.reconciled
414
- );
415
- const canReverse =
416
- canReverseTitle(titulo.status) && reversibleSettlements.length > 0;
417
-
418
- const getErrorMessage = (error: any, fallback: string) => {
419
- const message = error?.response?.data?.message;
420
-
421
- if (Array.isArray(message)) {
422
- return message.join(', ');
423
- }
424
-
425
- if (typeof message === 'string' && message.trim()) {
426
- return message;
427
- }
428
-
429
- return fallback;
430
- };
431
-
432
- const handleApprove = async () => {
433
- if (!canApprove || isApproving) {
434
- return;
435
- }
436
-
437
- setIsApproving(true);
438
- try {
439
- await request({
440
- url: `/finance/accounts-payable/installments/${titulo.id}/approve`,
441
- method: 'PATCH',
442
- });
443
-
444
- await refetch();
445
- showToastHandler?.('success', t('messages.approveSuccess'));
446
- } catch (error) {
447
- showToastHandler?.(
448
- 'error',
449
- getErrorMessage(error, t('messages.approveError'))
450
- );
451
- } finally {
452
- setIsApproving(false);
453
- }
454
- };
455
-
456
- const handleSettle = async (values: SettleFormValues) => {
457
- if (!canSettle || isSettling) {
458
- return;
459
- }
460
-
461
- setIsSettling(true);
462
- try {
463
- await request({
464
- url: `/finance/accounts-payable/installments/${titulo.id}/settlements`,
465
- method: 'POST',
466
- data: {
467
- installment_id: Number(values.installmentId),
468
- amount: values.amount,
469
- description: values.description?.trim() || undefined,
470
- },
471
- });
472
-
473
- await Promise.all([refetch(), refetchSettlementsHistory()]);
474
- setIsSettleDialogOpen(false);
475
- showToastHandler?.('success', t('messages.settleSuccess'));
476
- } catch (error) {
477
- showToastHandler?.(
478
- 'error',
479
- getErrorMessage(error, t('messages.settleError'))
480
- );
481
- } finally {
482
- setIsSettling(false);
483
- }
484
- };
485
-
486
- const handleReverseSettlement = async (
487
- settlementId: string,
488
- reasonOverride?: string
489
- ) => {
490
- if (!settlementId || reversingSettlementId) {
491
- return;
492
- }
493
-
494
- setReversingSettlementId(settlementId);
495
- try {
496
- await request({
497
- url: `/finance/settlements/${settlementId}/reverse`,
498
- method: 'POST',
499
- data: {
500
- reason: reasonOverride?.trim() || reverseReason?.trim() || undefined,
501
- memo: reasonOverride?.trim() || reverseReason?.trim() || undefined,
502
- },
503
- });
504
-
505
- await Promise.all([refetch(), refetchSettlementsHistory()]);
506
- setReverseReason('');
507
- showToastHandler?.('success', t('messages.reverseSuccess'));
508
- } catch (error) {
509
- showToastHandler?.(
510
- 'error',
511
- getErrorMessage(error, t('messages.reverseError'))
512
- );
513
- } finally {
514
- setReversingSettlementId(null);
515
- }
516
- };
517
-
518
- const handleUnreconcileSettlement = async (reconciliationId: string) => {
519
- if (!reconciliationId || unreconcilingId) {
520
- return;
521
- }
522
-
523
- setUnreconcilingId(reconciliationId);
524
- try {
525
- await request({
526
- url: `/finance/bank-reconciliations/${reconciliationId}`,
527
- method: 'DELETE',
528
- });
529
-
530
- await Promise.all([refetch(), refetchSettlementsHistory()]);
531
- showToastHandler?.('success', t('messages.unreconcileSuccess'));
532
- } catch (error) {
533
- showToastHandler?.(
534
- 'error',
535
- getErrorMessage(error, t('messages.unreconcileError'))
536
- );
537
- } finally {
538
- setUnreconcilingId(null);
539
- }
540
- };
541
-
542
- const toggleSettlementExpanded = (settlementId: string) => {
543
- setExpandedSettlementRows((current) => ({
544
- ...current,
545
- [settlementId]: !current[settlementId],
546
- }));
547
- };
548
-
549
- const handleCancel = async () => {
550
- if (!canCancel || isCanceling) {
551
- return;
552
- }
553
-
554
- setIsCanceling(true);
555
- try {
556
- await request({
557
- url: `/finance/accounts-payable/installments/${titulo.id}/cancel`,
558
- method: 'PATCH',
559
- data: {},
560
- });
561
-
562
- await refetch();
563
- showToastHandler?.('success', t('messages.cancelSuccess'));
564
- } catch (error) {
565
- showToastHandler?.(
566
- 'error',
567
- getErrorMessage(error, t('messages.cancelError'))
568
- );
569
- } finally {
570
- setIsCanceling(false);
571
- }
572
- };
573
-
574
- return (
575
- <Page>
576
- <PageHeader
577
- title={titulo.documento}
578
- description={titulo.descricao}
579
- breadcrumbs={[
580
- { label: t('breadcrumbs.home'), href: '/' },
581
- { label: t('breadcrumbs.finance'), href: '/finance' },
582
- {
583
- label: t('breadcrumbs.payables'),
584
- href: '/finance/accounts-payable/installments',
585
- },
586
- { label: titulo.documento },
587
- ]}
588
- actions={
589
- <div className="flex items-center gap-2">
590
- <FinanceTitleActionsMenu
591
- triggerVariant="outline"
592
- canEdit={canEdit}
593
- canApprove={canApprove}
594
- canSettle={canSettle && settleCandidates.length > 0}
595
- canReverse={canReverse}
596
- canCancel={canCancel}
597
- isApproving={isApproving}
598
- isReversing={!!reversingSettlementId}
599
- isCanceling={isCanceling}
600
- labels={{
601
- menu: t('actions.title'),
602
- srActions: t('actions.title'),
603
- edit: t('actions.edit'),
604
- approve: t('actions.approve'),
605
- settle: t('actions.settle'),
606
- reverse: t('actions.reverse'),
607
- cancel: t('actions.cancel'),
608
- }}
609
- dialogs={{
610
- cancelTitle: t('dialogs.cancel.title'),
611
- cancelDescription: t('dialogs.cancel.description'),
612
- cancelButton: t('dialogs.cancel.cancel'),
613
- confirmCancelButton: t('dialogs.cancel.confirm'),
614
- reverseTitle: t('dialogs.reverse.title'),
615
- reverseDescription: t('dialogs.reverse.description'),
616
- reverseReasonLabel: t('dialogs.reverse.reasonLabel'),
617
- reverseReasonPlaceholder: t(
618
- 'dialogs.reverse.reasonPlaceholder'
619
- ),
620
- reverseButton: t('dialogs.reverse.cancel'),
621
- confirmReverseButton: t('dialogs.reverse.confirm'),
622
- }}
623
- onEdit={() =>
624
- router.push(
625
- `/finance/accounts-payable/installments?editId=${titulo.id}`
626
- )
627
- }
628
- onApprove={() => void handleApprove()}
629
- onSettle={() => setIsSettleDialogOpen(true)}
630
- onReverse={(reason) => {
631
- const latestSettlementId = reversibleSettlements[0]?.normal?.id;
632
-
633
- if (!latestSettlementId) {
634
- return;
635
- }
636
-
637
- return handleReverseSettlement(
638
- String(latestSettlementId),
639
- reason
640
- );
641
- }}
642
- onCancel={() => handleCancel()}
643
- />
644
-
645
- <Sheet
646
- open={isReverseDialogOpen}
647
- onOpenChange={(open) => {
648
- setIsReverseDialogOpen(open);
649
-
650
- if (!open) {
651
- setSelectedSettlementIdToReverse(null);
652
- setReverseReason('');
653
- }
654
- }}
655
- >
656
- <SheetContent className="w-full sm:max-w-lg overflow-y-auto">
657
- <SheetHeader>
658
- <SheetTitle>{t('dialogs.reverse.title')}</SheetTitle>
659
- <SheetDescription>
660
- {t('dialogs.reverse.description')}
661
- </SheetDescription>
662
- </SheetHeader>
663
-
664
- <div className="space-y-4 px-4">
665
- <div className="space-y-2">
666
- <Label>{t('dialogs.reverse.reasonLabel')}</Label>
667
- <Input
668
- value={reverseReason}
669
- onChange={(event) => setReverseReason(event.target.value)}
670
- placeholder={t('dialogs.reverse.reasonPlaceholder')}
671
- maxLength={255}
672
- disabled={!!reversingSettlementId}
673
- />
674
- </div>
675
-
676
- <div className="flex flex-col gap-2">
677
- <Button
678
- disabled={
679
- !!reversingSettlementId ||
680
- !selectedSettlementIdToReverse
681
- }
682
- onClick={() => {
683
- if (!selectedSettlementIdToReverse) {
684
- return;
685
- }
686
-
687
- void handleReverseSettlement(
688
- selectedSettlementIdToReverse
689
- ).finally(() => {
690
- setIsReverseDialogOpen(false);
691
- setSelectedSettlementIdToReverse(null);
692
- setReverseReason('');
693
- });
694
- }}
695
- >
696
- {t('dialogs.reverse.confirm')}
697
- </Button>
698
- <Button
699
- variant="outline"
700
- disabled={!!reversingSettlementId}
701
- onClick={() => {
702
- setIsReverseDialogOpen(false);
703
- setSelectedSettlementIdToReverse(null);
704
- setReverseReason('');
705
- }}
706
- >
707
- {t('dialogs.reverse.cancel')}
708
- </Button>
709
- </div>
710
- </div>
711
- </SheetContent>
712
- </Sheet>
713
-
714
- <Sheet
715
- open={isSettleDialogOpen}
716
- onOpenChange={setIsSettleDialogOpen}
717
- >
718
- <SheetContent className="w-full sm:max-w-lg overflow-y-auto">
719
- <SheetHeader>
720
- <SheetTitle>{t('settleSheet.title')}</SheetTitle>
721
- <SheetDescription>
722
- {t('settleSheet.description')}
723
- </SheetDescription>
724
- </SheetHeader>
725
-
726
- <Form {...settleForm}>
727
- <form
728
- className="space-y-4 px-4"
729
- onSubmit={settleForm.handleSubmit(handleSettle)}
730
- >
731
- <FormField
732
- control={settleForm.control}
733
- name="installmentId"
734
- render={({ field }) => (
735
- <FormItem>
736
- <FormLabel>
737
- {t('settleSheet.installmentLabel')}
738
- </FormLabel>
739
- <Select
740
- value={field.value}
741
- onValueChange={(value) => {
742
- field.onChange(value);
743
-
744
- const selected = settleCandidates.find(
745
- (parcela: any) => parcela.id === value
746
- );
747
-
748
- if (selected) {
749
- settleForm.setValue(
750
- 'amount',
751
- Number(selected.valorAberto || 0),
752
- { shouldValidate: true }
753
- );
754
- }
755
- }}
756
- >
757
- <FormControl>
758
- <SelectTrigger className="w-full">
759
- <SelectValue
760
- placeholder={t(
761
- 'settleSheet.installmentPlaceholder'
762
- )}
763
- />
764
- </SelectTrigger>
765
- </FormControl>
766
- <SelectContent>
767
- {settleCandidates.map((parcela: any) => (
768
- <SelectItem key={parcela.id} value={parcela.id}>
769
- {t('settleSheet.installmentOption', {
770
- number: parcela.numero,
771
- amount: new Intl.NumberFormat('pt-BR', {
772
- style: 'currency',
773
- currency: 'BRL',
774
- }).format(Number(parcela.valorAberto || 0)),
775
- })}
776
- </SelectItem>
777
- ))}
778
- </SelectContent>
779
- </Select>
780
- <FormMessage />
781
- </FormItem>
782
- )}
783
- />
784
-
785
- <FormField
786
- control={settleForm.control}
787
- name="amount"
788
- render={({ field }) => (
789
- <FormItem>
790
- <FormLabel>{t('settleSheet.amountLabel')}</FormLabel>
791
- <FormControl>
792
- <InputMoney
793
- value={Number(field.value || 0)}
794
- onValueChange={(value) => {
795
- field.onChange(Number(value || 0));
796
- }}
797
- />
798
- </FormControl>
799
- <FormMessage />
800
- </FormItem>
801
- )}
802
- />
803
-
804
- <FormField
805
- control={settleForm.control}
806
- name="description"
807
- render={({ field }) => (
808
- <FormItem>
809
- <FormLabel>
810
- {t('settleSheet.descriptionLabel')}
811
- </FormLabel>
812
- <FormControl>
813
- <Input {...field} value={field.value || ''} />
814
- </FormControl>
815
- <FormMessage />
816
- </FormItem>
817
- )}
818
- />
819
-
820
- <div className="flex flex-col gap-4">
821
- <Button
822
- className="w-full"
823
- type="submit"
824
- disabled={isSettling}
825
- >
826
- {t('settleSheet.confirm')}
827
- </Button>
828
- </div>
829
- </form>
830
- </Form>
831
- </SheetContent>
832
- </Sheet>
833
- </div>
834
- }
835
- />
836
-
837
- <div className="grid gap-6 lg:grid-cols-3">
838
- <Card className="lg:col-span-2">
839
- <CardHeader>
840
- <CardTitle>{t('documentData.title')}</CardTitle>
841
- </CardHeader>
842
- <CardContent>
843
- <dl className="grid gap-4 sm:grid-cols-2">
844
- <div>
845
- <dt className="text-sm font-medium text-muted-foreground">
846
- {t('documentData.supplier')}
847
- </dt>
848
- <dd className="mt-1">
849
- <Link
850
- href={`/cadastros/pessoas/${fornecedor?.id}`}
851
- className="hover:underline"
852
- >
853
- {fornecedor?.nome}
854
- </Link>
855
- </dd>
856
- </div>
857
- <div>
858
- <dt className="text-sm font-medium text-muted-foreground">
859
- {t('documentData.cnpjCpf')}
860
- </dt>
861
- <dd className="mt-1">{fornecedor?.documento}</dd>
862
- </div>
863
- <div>
864
- <dt className="text-sm font-medium text-muted-foreground">
865
- {t('documentData.competency')}
866
- </dt>
867
- <dd className="mt-1">{titulo.competencia}</dd>
868
- </div>
869
- <div>
870
- <dt className="text-sm font-medium text-muted-foreground">
871
- {t('documentData.totalValue')}
872
- </dt>
873
- <dd className="mt-1 text-lg font-semibold">
874
- <Money value={titulo.valorTotal} />
875
- </dd>
876
- </div>
877
- <div>
878
- <dt className="text-sm font-medium text-muted-foreground">
879
- {t('documentData.status')}
880
- </dt>
881
- <dd className="mt-1">
882
- <StatusBadge status={titulo.status} />
883
- </dd>
884
- </div>
885
- <div>
886
- <dt className="text-sm font-medium text-muted-foreground">
887
- {t('documentData.category')}
888
- </dt>
889
- <dd className="mt-1">{categoria?.nome}</dd>
890
- </div>
891
- <div>
892
- <dt className="text-sm font-medium text-muted-foreground">
893
- {t('documentData.costCenter')}
894
- </dt>
895
- <dd className="mt-1">{centroCusto?.nome}</dd>
896
- </div>
897
- <div>
898
- <dt className="text-sm font-medium text-muted-foreground">
899
- {t('documentData.createdAt')}
900
- </dt>
901
- <dd className="mt-1">{formatarData(titulo.criadoEm)}</dd>
902
- </div>
903
- <div>
904
- <dt className="text-sm font-medium text-muted-foreground">
905
- {t('documentData.tags')}
906
- </dt>
907
- <dd className="mt-1">
908
- <TagSelectorSheet
909
- selectedTagIds={selectedTagIds}
910
- tags={tagOptions}
911
- onChange={handleChangeTags}
912
- onCreateTag={handleCreateTag}
913
- disabled={isCreatingTag}
914
- emptyText={t('tagSelector.noTags')}
915
- labels={{
916
- addTag: t('tagSelector.addTag'),
917
- sheetTitle: t('tagSelector.sheetTitle'),
918
- sheetDescription: t('tagSelector.sheetDescription'),
919
- createLabel: t('tagSelector.createLabel'),
920
- createPlaceholder: t('tagSelector.createPlaceholder'),
921
- createAction: t('tagSelector.createAction'),
922
- popularTitle: t('tagSelector.popularTitle'),
923
- selectedTitle: t('tagSelector.selectedTitle'),
924
- noTags: t('tagSelector.noTags'),
925
- cancel: t('tagSelector.cancel'),
926
- apply: t('tagSelector.apply'),
927
- removeTagAria: (tagName: string) =>
928
- t('tagSelector.removeTagAria', { tag: tagName }),
929
- }}
930
- />
931
- </dd>
932
- </div>
933
- </dl>
934
- </CardContent>
935
- </Card>
936
-
937
- <Card>
938
- <CardHeader>
939
- <CardTitle>{t('attachments.title')}</CardTitle>
940
- <CardDescription>{t('attachments.description')}</CardDescription>
941
- </CardHeader>
942
- <CardContent>
943
- {attachmentDetails.length > 0 ? (
944
- <ul className="space-y-2">
945
- {attachmentDetails.map((anexo: any, i: number) => (
946
- <li key={i}>
947
- <Button
948
- variant="ghost"
949
- className="h-auto w-full justify-start p-2"
950
- onClick={() => void handleOpenAttachment(anexo?.id)}
951
- >
952
- <FileText className="mr-2 h-4 w-4" />
953
- {anexo?.nome}
954
- </Button>
955
- </li>
956
- ))}
957
- </ul>
958
- ) : (
959
- <p className="text-sm text-muted-foreground">
960
- {t('attachments.none')}
961
- </p>
962
- )}
963
- </CardContent>
964
- </Card>
965
- </div>
966
-
967
- <Tabs defaultValue="parcelas">
968
- <TabsList>
969
- <TabsTrigger value="parcelas">{t('tabs.installments')}</TabsTrigger>
970
- <TabsTrigger value="liquidacoes">{t('tabs.settlements')}</TabsTrigger>
971
- <TabsTrigger value="auditoria">{t('tabs.audit')}</TabsTrigger>
972
- </TabsList>
973
-
974
- <TabsContent value="parcelas" className="mt-4">
975
- <Card>
976
- <CardContent className="pt-6">
977
- <Table>
978
- <TableHeader>
979
- <TableRow>
980
- <TableHead>{t('installmentsTable.installment')}</TableHead>
981
- <TableHead>{t('installmentsTable.dueDate')}</TableHead>
982
- <TableHead className="text-right">
983
- {t('installmentsTable.value')}
984
- </TableHead>
985
- <TableHead>{t('installmentsTable.method')}</TableHead>
986
- <TableHead>{t('installmentsTable.status')}</TableHead>
987
- </TableRow>
988
- </TableHeader>
989
- <TableBody>
990
- {titulo.parcelas.map((parcela: any) => (
991
- <TableRow key={parcela.id}>
992
- <TableCell>
993
- {parcela.numero}/{titulo.parcelas.length}
994
- </TableCell>
995
- <TableCell>{formatarData(parcela.vencimento)}</TableCell>
996
- <TableCell className="text-right">
997
- <Money value={parcela.valor} />
998
- </TableCell>
999
- <TableCell className="capitalize">
1000
- {parcela.metodoPagamento}
1001
- </TableCell>
1002
- <TableCell>
1003
- <StatusBadge status={parcela.status} />
1004
- </TableCell>
1005
- </TableRow>
1006
- ))}
1007
- </TableBody>
1008
- </Table>
1009
- </CardContent>
1010
- </Card>
1011
- </TabsContent>
1012
-
1013
- <TabsContent value="liquidacoes" className="mt-4">
1014
- <Card>
1015
- <CardContent className="pt-6">
1016
- {isFetchingSettlementsHistory ? (
1017
- <div className="flex items-center justify-center py-8 text-muted-foreground">
1018
- <Loader2 className="mr-2 h-4 w-4 animate-spin" />
1019
- {t('settlementsHistory.loading')}
1020
- </div>
1021
- ) : settlementsHistory.length > 0 ? (
1022
- <Table>
1023
- <TableHeader>
1024
- <TableRow>
1025
- <TableHead>
1026
- {t('settlementsHistory.headers.date')}
1027
- </TableHead>
1028
- <TableHead>
1029
- {t('settlementsHistory.headers.type')}
1030
- </TableHead>
1031
- <TableHead>
1032
- {t('settlementsHistory.headers.status')}
1033
- </TableHead>
1034
- <TableHead className="text-right">
1035
- {t('settlementsHistory.headers.netAmount')}
1036
- </TableHead>
1037
- <TableHead>
1038
- {t('settlementsHistory.headers.method')}
1039
- </TableHead>
1040
- <TableHead>
1041
- {t('settlementsHistory.headers.account')}
1042
- </TableHead>
1043
- <TableHead>
1044
- {t('settlementsHistory.headers.reconciled')}
1045
- </TableHead>
1046
- <TableHead className="text-right">
1047
- {t('settlementsHistory.headers.actions')}
1048
- </TableHead>
1049
- </TableRow>
1050
- </TableHeader>
1051
- <TableBody>
1052
- {settlementsHistory.map((group) => {
1053
- const normalId = group.normal.id;
1054
- const isExpanded = !!expandedSettlementRows[normalId];
1055
- const canReverseNormal =
1056
- group.statusLabel === 'ATIVO' &&
1057
- !group.normal.reconciled &&
1058
- !reversingSettlementId;
1059
-
1060
- return (
1061
- <Fragment key={normalId}>
1062
- <TableRow>
1063
- <TableCell>
1064
- {formatarData(group.normal.paidAt)}
1065
- </TableCell>
1066
- <TableCell>
1067
- <span className="rounded-md border px-2 py-1 text-xs font-medium">
1068
- {t('settlementsHistory.normalType')}
1069
- </span>
1070
- </TableCell>
1071
- <TableCell>
1072
- <span
1073
- className={`rounded-md px-2 py-1 text-xs font-medium ${
1074
- group.statusLabel === 'ESTORNADO'
1075
- ? 'bg-muted text-muted-foreground'
1076
- : 'bg-primary/10 text-primary'
1077
- }`}
1078
- >
1079
- {group.statusLabel}
1080
- </span>
1081
- </TableCell>
1082
- <TableCell className="text-right font-medium">
1083
- +
1084
- <Money
1085
- value={
1086
- Math.abs(group.normal.amountCents || 0) / 100
1087
- }
1088
- />
1089
- </TableCell>
1090
- <TableCell className="capitalize">
1091
- {group.normal.method || '-'}
1092
- </TableCell>
1093
- <TableCell>{group.normal.account || '-'}</TableCell>
1094
- <TableCell>
1095
- <span
1096
- className={`rounded-md px-2 py-1 text-xs font-medium ${
1097
- group.normal.reconciled
1098
- ? 'bg-primary/10 text-primary'
1099
- : 'bg-muted text-muted-foreground'
1100
- }`}
1101
- >
1102
- {group.normal.reconciled
1103
- ? t('settlementsHistory.reconciled.yes')
1104
- : t('settlementsHistory.reconciled.no')}
1105
- </span>
1106
- </TableCell>
1107
- <TableCell className="text-right">
1108
- <div className="flex items-center justify-end gap-2">
1109
- {group.normal.reconciled &&
1110
- group.normal.reconciliationId ? (
1111
- <Button
1112
- variant="outline"
1113
- size="sm"
1114
- disabled={
1115
- unreconcilingId ===
1116
- group.normal.reconciliationId
1117
- }
1118
- onClick={() =>
1119
- void handleUnreconcileSettlement(
1120
- String(group.normal.reconciliationId)
1121
- )
1122
- }
1123
- >
1124
- {unreconcilingId ===
1125
- group.normal.reconciliationId
1126
- ? t(
1127
- 'settlementsHistory.unreconcileLoading'
1128
- )
1129
- : t('settlementsHistory.unreconcile')}
1130
- </Button>
1131
- ) : null}
1132
-
1133
- <Button
1134
- variant="outline"
1135
- size="sm"
1136
- title={
1137
- group.normal.reconciled
1138
- ? t('settlementsHistory.unreconcileFirst')
1139
- : undefined
1140
- }
1141
- disabled={!canReverseNormal}
1142
- onClick={() => {
1143
- setSelectedSettlementIdToReverse(normalId);
1144
- setReverseReason('');
1145
- setIsReverseDialogOpen(true);
1146
- }}
1147
- >
1148
- <Undo className="mr-2 h-4 w-4" />
1149
- {t('settlementsHistory.reverse')}
1150
- </Button>
1151
-
1152
- <Button
1153
- variant="ghost"
1154
- size="sm"
1155
- onClick={() =>
1156
- toggleSettlementExpanded(normalId)
1157
- }
1158
- >
1159
- {isExpanded ? (
1160
- <ChevronUp className="h-4 w-4" />
1161
- ) : (
1162
- <ChevronDown className="h-4 w-4" />
1163
- )}
1164
- </Button>
1165
- </div>
1166
- </TableCell>
1167
- </TableRow>
1168
-
1169
- {group.reversal ? (
1170
- <TableRow>
1171
- <TableCell className="pl-8">
1172
- {formatarData(group.reversal.paidAt)}
1173
- </TableCell>
1174
- <TableCell>
1175
- <span className="rounded-md border px-2 py-1 text-xs font-medium">
1176
- {t('settlementsHistory.reversalType')}
1177
- </span>
1178
- </TableCell>
1179
- <TableCell>
1180
- <span className="rounded-md bg-muted px-2 py-1 text-xs font-medium text-muted-foreground">
1181
- {t('settlementsHistory.reversedStatus')}
1182
- </span>
1183
- </TableCell>
1184
- <TableCell className="text-right font-medium text-destructive">
1185
- -
1186
- <Money
1187
- value={
1188
- Math.abs(group.reversal.amountCents || 0) /
1189
- 100
1190
- }
1191
- />
1192
- </TableCell>
1193
- <TableCell>-</TableCell>
1194
- <TableCell>-</TableCell>
1195
- <TableCell>-</TableCell>
1196
- <TableCell className="text-right text-xs text-muted-foreground">
1197
- {group.reversal.memo || '-'}
1198
- </TableCell>
1199
- </TableRow>
1200
- ) : null}
1201
-
1202
- {isExpanded ? (
1203
- <TableRow>
1204
- <TableCell colSpan={8}>
1205
- <div className="rounded-md border bg-muted/20 p-3">
1206
- <p className="mb-2 text-xs font-medium text-muted-foreground">
1207
- {t('settlementsHistory.allocationsTitle')}
1208
- </p>
1209
- <div className="space-y-1">
1210
- {group.allocations.map((allocation) => (
1211
- <div
1212
- key={`${normalId}-${allocation.installmentId}`}
1213
- className="flex items-center justify-between text-sm"
1214
- >
1215
- <span>
1216
- {t(
1217
- 'settlementsHistory.allocationInstallment'
1218
- )}{' '}
1219
- #{allocation.installmentSeq}
1220
- </span>
1221
- <span className="font-medium">
1222
- +
1223
- <Money
1224
- value={
1225
- Math.abs(
1226
- Number(
1227
- allocation.amountCents || 0
1228
- )
1229
- ) / 100
1230
- }
1231
- />
1232
- </span>
1233
- </div>
1234
- ))}
1235
- </div>
1236
- </div>
1237
- </TableCell>
1238
- </TableRow>
1239
- ) : null}
1240
- </Fragment>
1241
- );
1242
- })}
1243
- </TableBody>
1244
- </Table>
1245
- ) : (
1246
- <p className="text-center text-muted-foreground py-8">
1247
- {t('settlementsTable.none')}
1248
- </p>
1249
- )}
1250
- </CardContent>
1251
- </Card>
1252
- </TabsContent>
1253
-
1254
- <TabsContent value="auditoria" className="mt-4">
1255
- <Card>
1256
- <CardContent className="pt-6">
1257
- {auditEvents.length > 0 ? (
1258
- <AuditTimeline events={auditEvents} />
1259
- ) : (
1260
- <p className="text-center text-muted-foreground py-8">
1261
- {t('audit.none')}
1262
- </p>
1263
- )}
1264
- </CardContent>
1265
- </Card>
1266
- </TabsContent>
1267
- </Tabs>
1268
- </Page>
1269
- );
1270
- }
1
+ 'use client';
2
+
3
+ import { FinanceTitleActionsMenu } from '@/app/(app)/(libraries)/finance/_components/finance-title-actions-menu';
4
+ import { Page, PageHeader } from '@/components/entity-list';
5
+ import { AuditTimeline } from '@/components/ui/audit-timeline';
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
+ Form,
16
+ FormControl,
17
+ FormField,
18
+ FormItem,
19
+ FormLabel,
20
+ FormMessage,
21
+ } from '@/components/ui/form';
22
+ import { Input } from '@/components/ui/input';
23
+ import { InputMoney } from '@/components/ui/input-money';
24
+ import { Label } from '@/components/ui/label';
25
+ import { Money } from '@/components/ui/money';
26
+ import {
27
+ Select,
28
+ SelectContent,
29
+ SelectItem,
30
+ SelectTrigger,
31
+ SelectValue,
32
+ } from '@/components/ui/select';
33
+ import {
34
+ Sheet,
35
+ SheetContent,
36
+ SheetDescription,
37
+ SheetHeader,
38
+ SheetTitle,
39
+ } from '@/components/ui/sheet';
40
+ import { StatusBadge } from '@/components/ui/status-badge';
41
+ import {
42
+ Table,
43
+ TableBody,
44
+ TableCell,
45
+ TableHead,
46
+ TableHeader,
47
+ TableRow,
48
+ } from '@/components/ui/table';
49
+ import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
50
+ import { TagSelectorSheet } from '@/components/ui/tag-selector-sheet';
51
+ import { useApp, useQuery } from '@hed-hog/next-app-provider';
52
+ import { zodResolver } from '@hookform/resolvers/zod';
53
+ import { ChevronDown, ChevronUp, FileText, Loader2, Undo } from 'lucide-react';
54
+ import { useTranslations } from 'next-intl';
55
+ import Link from 'next/link';
56
+ import { useParams, useRouter, useSearchParams } from 'next/navigation';
57
+ import { Fragment, useEffect, useMemo, useState } from 'react';
58
+ import { useForm } from 'react-hook-form';
59
+ import { z } from 'zod';
60
+ import { formatarData } from '../../../_lib/formatters';
61
+ import {
62
+ canApproveTitle,
63
+ canCancelTitle,
64
+ canEditTitle,
65
+ canReverseTitle,
66
+ canSettleTitle,
67
+ } from '../../../_lib/title-action-rules';
68
+ import { useFinanceData } from '../../../_lib/use-finance-data';
69
+
70
+ type SettleFormValues = {
71
+ installmentId: string;
72
+ amount: number;
73
+ description?: string;
74
+ };
75
+
76
+ type SettlementHistoryAllocation = {
77
+ installmentId: string;
78
+ installmentSeq: number;
79
+ amountCents: number;
80
+ };
81
+
82
+ type SettlementHistoryItem = {
83
+ normal: {
84
+ id: string;
85
+ paidAt: string | null;
86
+ amountCents: number;
87
+ type: 'NORMAL';
88
+ method?: string | null;
89
+ account?: string | null;
90
+ accountId?: string | null;
91
+ createdAt?: string | null;
92
+ createdBy?: string | null;
93
+ memo?: string | null;
94
+ reconciled: boolean;
95
+ reconciliationId?: string | null;
96
+ };
97
+ reversal?: {
98
+ id: string;
99
+ paidAt: string | null;
100
+ amountCents: number;
101
+ type: 'REVERSAL';
102
+ createdAt?: string | null;
103
+ createdBy?: string | null;
104
+ memo?: string | null;
105
+ } | null;
106
+ allocations: SettlementHistoryAllocation[];
107
+ statusLabel: 'ATIVO' | 'ESTORNADO';
108
+ };
109
+
110
+ export default function TituloDetalhePage() {
111
+ const t = useTranslations('finance.PayableInstallmentDetailPage');
112
+ const settleSchema = useMemo(
113
+ () =>
114
+ z.object({
115
+ installmentId: z.string().min(1, t('validation.installmentRequired')),
116
+ amount: z.number().min(0.01, t('validation.amountGreaterThanZero')),
117
+ description: z.string().optional(),
118
+ }),
119
+ [t]
120
+ );
121
+ const { request, showToastHandler } = useApp();
122
+ const router = useRouter();
123
+ const searchParams = useSearchParams();
124
+ const params = useParams<{ id: string }>();
125
+ const id = params?.id;
126
+ const { data, refetch } = useFinanceData();
127
+ const {
128
+ titulosPagar,
129
+ pessoas,
130
+ categorias,
131
+ centrosCusto,
132
+ logsAuditoria,
133
+ tags,
134
+ } = data;
135
+
136
+ const titulo = titulosPagar.find((t) => t.id === id);
137
+
138
+ const {
139
+ data: settlementsHistory = [],
140
+ refetch: refetchSettlementsHistory,
141
+ isFetching: isFetchingSettlementsHistory,
142
+ } = useQuery<SettlementHistoryItem[]>({
143
+ queryKey: ['finance-title-settlements-history', id],
144
+ enabled: !!id,
145
+ queryFn: async () => {
146
+ const response = await request<SettlementHistoryItem[]>({
147
+ url: `/finance/titles/${id}/settlements-history`,
148
+ method: 'GET',
149
+ });
150
+
151
+ return (response.data || []) as SettlementHistoryItem[];
152
+ },
153
+ placeholderData: (old) => old,
154
+ });
155
+
156
+ const canSettle = canSettleTitle(titulo?.status);
157
+
158
+ const settleCandidates = (titulo?.parcelas || []).filter(
159
+ (parcela: any) =>
160
+ parcela.status === 'aberto' ||
161
+ parcela.status === 'parcial' ||
162
+ parcela.status === 'vencido'
163
+ );
164
+
165
+ const settleForm = useForm<SettleFormValues>({
166
+ resolver: zodResolver(settleSchema),
167
+ defaultValues: {
168
+ installmentId: settleCandidates[0]?.id || '',
169
+ amount: Number(settleCandidates[0]?.valorAberto || 0),
170
+ description: '',
171
+ },
172
+ });
173
+
174
+ const [availableTags, setAvailableTags] = useState<any[]>([]);
175
+ const [selectedTagIds, setSelectedTagIds] = useState<string[]>([]);
176
+ const [isCreatingTag, setIsCreatingTag] = useState(false);
177
+ const [isApproving, setIsApproving] = useState(false);
178
+ const [isSettling, setIsSettling] = useState(false);
179
+ const [isCanceling, setIsCanceling] = useState(false);
180
+ const [isSettleDialogOpen, setIsSettleDialogOpen] = useState(false);
181
+ const [isReverseDialogOpen, setIsReverseDialogOpen] = useState(false);
182
+ const [selectedSettlementIdToReverse, setSelectedSettlementIdToReverse] =
183
+ useState<string | null>(null);
184
+ const [reverseReason, setReverseReason] = useState('');
185
+ const [reversingSettlementId, setReversingSettlementId] = useState<
186
+ string | null
187
+ >(null);
188
+ const [unreconcilingId, setUnreconcilingId] = useState<string | null>(null);
189
+ const [expandedSettlementRows, setExpandedSettlementRows] = useState<
190
+ Record<string, boolean>
191
+ >({});
192
+
193
+ useEffect(() => {
194
+ setAvailableTags(tags || []);
195
+ }, [tags]);
196
+
197
+ useEffect(() => {
198
+ if (!titulo) {
199
+ return;
200
+ }
201
+
202
+ setSelectedTagIds(Array.isArray(titulo.tags) ? titulo.tags : []);
203
+ }, [titulo]);
204
+
205
+ useEffect(() => {
206
+ const firstCandidate = settleCandidates[0];
207
+ const nextInstallmentId = firstCandidate?.id || '';
208
+ const nextAmount = Number(firstCandidate?.valorAberto || 0);
209
+ const currentInstallmentId = settleForm.getValues('installmentId') || '';
210
+ const currentAmount = Number(settleForm.getValues('amount') || 0);
211
+
212
+ if (
213
+ currentInstallmentId === nextInstallmentId &&
214
+ currentAmount === nextAmount
215
+ ) {
216
+ return;
217
+ }
218
+
219
+ settleForm.reset({
220
+ installmentId: nextInstallmentId,
221
+ amount: nextAmount,
222
+ description: '',
223
+ });
224
+ }, [settleForm, settleCandidates]);
225
+
226
+ useEffect(() => {
227
+ const action = searchParams.get('action');
228
+
229
+ if (action !== 'settle') {
230
+ return;
231
+ }
232
+
233
+ if (canSettle && settleCandidates.length > 0) {
234
+ setIsSettleDialogOpen(true);
235
+ }
236
+ }, [canSettle, searchParams, settleCandidates.length]);
237
+
238
+ if (!titulo) {
239
+ return (
240
+ <div className="space-y-6">
241
+ <PageHeader
242
+ title={t('notFound.title')}
243
+ description={t('notFound.description')}
244
+ breadcrumbs={[
245
+ {
246
+ label: t('notFound.breadcrumbPayables'),
247
+ href: '/finance/accounts-payable/installments',
248
+ },
249
+ {
250
+ label: t('notFound.breadcrumbInstallments'),
251
+ href: '/finance/accounts-payable/installments',
252
+ },
253
+ ]}
254
+ />
255
+ </div>
256
+ );
257
+ }
258
+
259
+ const getPessoaById = (personId?: string) =>
260
+ pessoas.find((p) => p.id === personId);
261
+ const getCategoriaById = (categoryId?: string) =>
262
+ categorias.find((c) => c.id === categoryId);
263
+ const getCentroCustoById = (costCenterId?: string) =>
264
+ centrosCusto.find((c) => c.id === costCenterId);
265
+
266
+ const fornecedor = getPessoaById(titulo.fornecedorId);
267
+ const categoria = getCategoriaById(titulo.categoriaId);
268
+ const centroCusto = getCentroCustoById(titulo.centroCustoId);
269
+
270
+ const tagOptions = availableTags.map((tag) => ({
271
+ id: String(tag.id),
272
+ name: String(tag.nome || ''),
273
+ color: tag.cor,
274
+ usageCount:
275
+ typeof tag.usageCount === 'number'
276
+ ? tag.usageCount
277
+ : typeof tag.usoCount === 'number'
278
+ ? tag.usoCount
279
+ : typeof tag.count === 'number'
280
+ ? tag.count
281
+ : undefined,
282
+ }));
283
+
284
+ const auditEvents = logsAuditoria
285
+ .filter(
286
+ (log) => log.entidadeId === titulo.id && log.entidade === 'TituloPagar'
287
+ )
288
+ .map((log) => ({
289
+ id: log.id,
290
+ data: log.data,
291
+ usuarioId: log.usuarioId,
292
+ acao: log.acao,
293
+ detalhes: log.detalhes,
294
+ antes: log.antes,
295
+ depois: log.depois,
296
+ }));
297
+
298
+ const attachmentDetails = Array.isArray(titulo.anexosDetalhes)
299
+ ? titulo.anexosDetalhes
300
+ : (titulo.anexos || []).map((nome: string) => ({ nome }));
301
+
302
+ const handleOpenAttachment = async (fileId?: string) => {
303
+ if (!fileId) {
304
+ return;
305
+ }
306
+
307
+ const response = await request<{ url?: string }>({
308
+ url: `/file/open/${fileId}`,
309
+ method: 'PUT',
310
+ });
311
+
312
+ const url = response?.data?.url;
313
+ if (!url) {
314
+ return;
315
+ }
316
+
317
+ window.open(url, '_blank', 'noopener,noreferrer');
318
+ };
319
+
320
+ const toTagSlug = (value: string) => {
321
+ return value
322
+ .normalize('NFD')
323
+ .replace(/[\u0300-\u036f]/g, '')
324
+ .toLowerCase()
325
+ .trim()
326
+ .replace(/[^a-z0-9]+/g, '-')
327
+ .replace(/(^-|-$)+/g, '');
328
+ };
329
+
330
+ const handleCreateTag = async (name: string) => {
331
+ const slug = toTagSlug(name);
332
+
333
+ if (!slug) {
334
+ return null;
335
+ }
336
+
337
+ setIsCreatingTag(true);
338
+ try {
339
+ const response = await request<{
340
+ id?: string | number;
341
+ nome?: string;
342
+ cor?: string;
343
+ }>({
344
+ url: '/finance/tags',
345
+ method: 'POST',
346
+ data: {
347
+ name: slug,
348
+ color: '#000000',
349
+ },
350
+ });
351
+
352
+ const created = response?.data;
353
+ const newTag = {
354
+ id: String(created?.id || `temp-${Date.now()}`),
355
+ nome: created?.nome || slug,
356
+ cor: created?.cor || '#000000',
357
+ };
358
+
359
+ setAvailableTags((current) => {
360
+ if (current.some((tag) => String(tag.id) === newTag.id)) {
361
+ return current;
362
+ }
363
+
364
+ return [...current, newTag];
365
+ });
366
+
367
+ showToastHandler?.('success', t('tagSelector.messages.createSuccess'));
368
+
369
+ return {
370
+ id: newTag.id,
371
+ name: newTag.nome,
372
+ color: newTag.cor,
373
+ };
374
+ } catch {
375
+ showToastHandler?.('error', t('tagSelector.messages.createError'));
376
+ return null;
377
+ } finally {
378
+ setIsCreatingTag(false);
379
+ }
380
+ };
381
+
382
+ const handleChangeTags = async (nextTagIds: string[]) => {
383
+ if (!titulo?.id) {
384
+ return;
385
+ }
386
+
387
+ try {
388
+ const response = await request<{ tags?: string[] }>({
389
+ url: `/finance/accounts-payable/installments/${titulo.id}/tags`,
390
+ method: 'PATCH',
391
+ data: {
392
+ tag_ids: nextTagIds.map((tagId) => Number(tagId)),
393
+ },
394
+ });
395
+
396
+ if (Array.isArray(response?.data?.tags)) {
397
+ setSelectedTagIds(response.data.tags);
398
+ } else {
399
+ setSelectedTagIds(nextTagIds);
400
+ }
401
+ } catch {
402
+ showToastHandler?.('error', t('tagSelector.messages.updateError'));
403
+ }
404
+ };
405
+
406
+ const canApprove = canApproveTitle(titulo.status);
407
+ const canEdit = canEditTitle(titulo.status);
408
+ const canCancel = canCancelTitle(titulo.status);
409
+ const reversibleSettlements = settlementsHistory.filter(
410
+ (group) =>
411
+ group.normal?.id &&
412
+ group.statusLabel === 'ATIVO' &&
413
+ !group.normal?.reconciled
414
+ );
415
+ const canReverse =
416
+ canReverseTitle(titulo.status) && reversibleSettlements.length > 0;
417
+
418
+ const getErrorMessage = (error: any, fallback: string) => {
419
+ const message = error?.response?.data?.message;
420
+
421
+ if (Array.isArray(message)) {
422
+ return message.join(', ');
423
+ }
424
+
425
+ if (typeof message === 'string' && message.trim()) {
426
+ return message;
427
+ }
428
+
429
+ return fallback;
430
+ };
431
+
432
+ const handleApprove = async () => {
433
+ if (!canApprove || isApproving) {
434
+ return;
435
+ }
436
+
437
+ setIsApproving(true);
438
+ try {
439
+ await request({
440
+ url: `/finance/accounts-payable/installments/${titulo.id}/approve`,
441
+ method: 'PATCH',
442
+ });
443
+
444
+ await refetch();
445
+ showToastHandler?.('success', t('messages.approveSuccess'));
446
+ } catch (error) {
447
+ showToastHandler?.(
448
+ 'error',
449
+ getErrorMessage(error, t('messages.approveError'))
450
+ );
451
+ } finally {
452
+ setIsApproving(false);
453
+ }
454
+ };
455
+
456
+ const handleSettle = async (values: SettleFormValues) => {
457
+ if (!canSettle || isSettling) {
458
+ return;
459
+ }
460
+
461
+ setIsSettling(true);
462
+ try {
463
+ await request({
464
+ url: `/finance/accounts-payable/installments/${titulo.id}/settlements`,
465
+ method: 'POST',
466
+ data: {
467
+ installment_id: Number(values.installmentId),
468
+ amount: values.amount,
469
+ description: values.description?.trim() || undefined,
470
+ },
471
+ });
472
+
473
+ await Promise.all([refetch(), refetchSettlementsHistory()]);
474
+ setIsSettleDialogOpen(false);
475
+ showToastHandler?.('success', t('messages.settleSuccess'));
476
+ } catch (error) {
477
+ showToastHandler?.(
478
+ 'error',
479
+ getErrorMessage(error, t('messages.settleError'))
480
+ );
481
+ } finally {
482
+ setIsSettling(false);
483
+ }
484
+ };
485
+
486
+ const handleReverseSettlement = async (
487
+ settlementId: string,
488
+ reasonOverride?: string
489
+ ) => {
490
+ if (!settlementId || reversingSettlementId) {
491
+ return;
492
+ }
493
+
494
+ setReversingSettlementId(settlementId);
495
+ try {
496
+ await request({
497
+ url: `/finance/settlements/${settlementId}/reverse`,
498
+ method: 'POST',
499
+ data: {
500
+ reason: reasonOverride?.trim() || reverseReason?.trim() || undefined,
501
+ memo: reasonOverride?.trim() || reverseReason?.trim() || undefined,
502
+ },
503
+ });
504
+
505
+ await Promise.all([refetch(), refetchSettlementsHistory()]);
506
+ setReverseReason('');
507
+ showToastHandler?.('success', t('messages.reverseSuccess'));
508
+ } catch (error) {
509
+ showToastHandler?.(
510
+ 'error',
511
+ getErrorMessage(error, t('messages.reverseError'))
512
+ );
513
+ } finally {
514
+ setReversingSettlementId(null);
515
+ }
516
+ };
517
+
518
+ const handleUnreconcileSettlement = async (reconciliationId: string) => {
519
+ if (!reconciliationId || unreconcilingId) {
520
+ return;
521
+ }
522
+
523
+ setUnreconcilingId(reconciliationId);
524
+ try {
525
+ await request({
526
+ url: `/finance/bank-reconciliations/${reconciliationId}`,
527
+ method: 'DELETE',
528
+ });
529
+
530
+ await Promise.all([refetch(), refetchSettlementsHistory()]);
531
+ showToastHandler?.('success', t('messages.unreconcileSuccess'));
532
+ } catch (error) {
533
+ showToastHandler?.(
534
+ 'error',
535
+ getErrorMessage(error, t('messages.unreconcileError'))
536
+ );
537
+ } finally {
538
+ setUnreconcilingId(null);
539
+ }
540
+ };
541
+
542
+ const toggleSettlementExpanded = (settlementId: string) => {
543
+ setExpandedSettlementRows((current) => ({
544
+ ...current,
545
+ [settlementId]: !current[settlementId],
546
+ }));
547
+ };
548
+
549
+ const handleCancel = async () => {
550
+ if (!canCancel || isCanceling) {
551
+ return;
552
+ }
553
+
554
+ setIsCanceling(true);
555
+ try {
556
+ await request({
557
+ url: `/finance/accounts-payable/installments/${titulo.id}/cancel`,
558
+ method: 'PATCH',
559
+ data: {},
560
+ });
561
+
562
+ await refetch();
563
+ showToastHandler?.('success', t('messages.cancelSuccess'));
564
+ } catch (error) {
565
+ showToastHandler?.(
566
+ 'error',
567
+ getErrorMessage(error, t('messages.cancelError'))
568
+ );
569
+ } finally {
570
+ setIsCanceling(false);
571
+ }
572
+ };
573
+
574
+ return (
575
+ <Page>
576
+ <PageHeader
577
+ title={titulo.documento}
578
+ description={titulo.descricao}
579
+ breadcrumbs={[
580
+ { label: t('breadcrumbs.home'), href: '/' },
581
+ { label: t('breadcrumbs.finance'), href: '/finance' },
582
+ {
583
+ label: t('breadcrumbs.payables'),
584
+ href: '/finance/accounts-payable/installments',
585
+ },
586
+ { label: titulo.documento },
587
+ ]}
588
+ actions={
589
+ <div className="flex items-center gap-2">
590
+ <FinanceTitleActionsMenu
591
+ triggerVariant="outline"
592
+ canEdit={canEdit}
593
+ canApprove={canApprove}
594
+ canSettle={canSettle && settleCandidates.length > 0}
595
+ canReverse={canReverse}
596
+ canCancel={canCancel}
597
+ isApproving={isApproving}
598
+ isReversing={!!reversingSettlementId}
599
+ isCanceling={isCanceling}
600
+ labels={{
601
+ menu: t('actions.title'),
602
+ srActions: t('actions.title'),
603
+ edit: t('actions.edit'),
604
+ approve: t('actions.approve'),
605
+ settle: t('actions.settle'),
606
+ reverse: t('actions.reverse'),
607
+ cancel: t('actions.cancel'),
608
+ }}
609
+ dialogs={{
610
+ cancelTitle: t('dialogs.cancel.title'),
611
+ cancelDescription: t('dialogs.cancel.description'),
612
+ cancelButton: t('dialogs.cancel.cancel'),
613
+ confirmCancelButton: t('dialogs.cancel.confirm'),
614
+ reverseTitle: t('dialogs.reverse.title'),
615
+ reverseDescription: t('dialogs.reverse.description'),
616
+ reverseReasonLabel: t('dialogs.reverse.reasonLabel'),
617
+ reverseReasonPlaceholder: t(
618
+ 'dialogs.reverse.reasonPlaceholder'
619
+ ),
620
+ reverseButton: t('dialogs.reverse.cancel'),
621
+ confirmReverseButton: t('dialogs.reverse.confirm'),
622
+ }}
623
+ onEdit={() =>
624
+ router.push(
625
+ `/finance/accounts-payable/installments?editId=${titulo.id}`
626
+ )
627
+ }
628
+ onApprove={() => void handleApprove()}
629
+ onSettle={() => setIsSettleDialogOpen(true)}
630
+ onReverse={(reason) => {
631
+ const latestSettlementId = reversibleSettlements[0]?.normal?.id;
632
+
633
+ if (!latestSettlementId) {
634
+ return;
635
+ }
636
+
637
+ return handleReverseSettlement(
638
+ String(latestSettlementId),
639
+ reason
640
+ );
641
+ }}
642
+ onCancel={() => handleCancel()}
643
+ />
644
+
645
+ <Sheet
646
+ open={isReverseDialogOpen}
647
+ onOpenChange={(open) => {
648
+ setIsReverseDialogOpen(open);
649
+
650
+ if (!open) {
651
+ setSelectedSettlementIdToReverse(null);
652
+ setReverseReason('');
653
+ }
654
+ }}
655
+ >
656
+ <SheetContent className="w-full sm:max-w-lg overflow-y-auto">
657
+ <SheetHeader>
658
+ <SheetTitle>{t('dialogs.reverse.title')}</SheetTitle>
659
+ <SheetDescription>
660
+ {t('dialogs.reverse.description')}
661
+ </SheetDescription>
662
+ </SheetHeader>
663
+
664
+ <div className="space-y-4 px-4">
665
+ <div className="space-y-2">
666
+ <Label>{t('dialogs.reverse.reasonLabel')}</Label>
667
+ <Input
668
+ value={reverseReason}
669
+ onChange={(event) => setReverseReason(event.target.value)}
670
+ placeholder={t('dialogs.reverse.reasonPlaceholder')}
671
+ maxLength={255}
672
+ disabled={!!reversingSettlementId}
673
+ />
674
+ </div>
675
+
676
+ <div className="flex flex-col gap-2">
677
+ <Button
678
+ disabled={
679
+ !!reversingSettlementId ||
680
+ !selectedSettlementIdToReverse
681
+ }
682
+ onClick={() => {
683
+ if (!selectedSettlementIdToReverse) {
684
+ return;
685
+ }
686
+
687
+ void handleReverseSettlement(
688
+ selectedSettlementIdToReverse
689
+ ).finally(() => {
690
+ setIsReverseDialogOpen(false);
691
+ setSelectedSettlementIdToReverse(null);
692
+ setReverseReason('');
693
+ });
694
+ }}
695
+ >
696
+ {t('dialogs.reverse.confirm')}
697
+ </Button>
698
+ <Button
699
+ variant="outline"
700
+ disabled={!!reversingSettlementId}
701
+ onClick={() => {
702
+ setIsReverseDialogOpen(false);
703
+ setSelectedSettlementIdToReverse(null);
704
+ setReverseReason('');
705
+ }}
706
+ >
707
+ {t('dialogs.reverse.cancel')}
708
+ </Button>
709
+ </div>
710
+ </div>
711
+ </SheetContent>
712
+ </Sheet>
713
+
714
+ <Sheet
715
+ open={isSettleDialogOpen}
716
+ onOpenChange={setIsSettleDialogOpen}
717
+ >
718
+ <SheetContent className="w-full sm:max-w-lg overflow-y-auto">
719
+ <SheetHeader>
720
+ <SheetTitle>{t('settleSheet.title')}</SheetTitle>
721
+ <SheetDescription>
722
+ {t('settleSheet.description')}
723
+ </SheetDescription>
724
+ </SheetHeader>
725
+
726
+ <Form {...settleForm}>
727
+ <form
728
+ className="space-y-4 px-4"
729
+ onSubmit={settleForm.handleSubmit(handleSettle)}
730
+ >
731
+ <FormField
732
+ control={settleForm.control}
733
+ name="installmentId"
734
+ render={({ field }) => (
735
+ <FormItem>
736
+ <FormLabel>
737
+ {t('settleSheet.installmentLabel')}
738
+ </FormLabel>
739
+ <Select
740
+ value={field.value}
741
+ onValueChange={(value) => {
742
+ field.onChange(value);
743
+
744
+ const selected = settleCandidates.find(
745
+ (parcela: any) => parcela.id === value
746
+ );
747
+
748
+ if (selected) {
749
+ settleForm.setValue(
750
+ 'amount',
751
+ Number(selected.valorAberto || 0),
752
+ { shouldValidate: true }
753
+ );
754
+ }
755
+ }}
756
+ >
757
+ <FormControl>
758
+ <SelectTrigger className="w-full">
759
+ <SelectValue
760
+ placeholder={t(
761
+ 'settleSheet.installmentPlaceholder'
762
+ )}
763
+ />
764
+ </SelectTrigger>
765
+ </FormControl>
766
+ <SelectContent>
767
+ {settleCandidates.map((parcela: any) => (
768
+ <SelectItem key={parcela.id} value={parcela.id}>
769
+ {t('settleSheet.installmentOption', {
770
+ number: parcela.numero,
771
+ amount: new Intl.NumberFormat('pt-BR', {
772
+ style: 'currency',
773
+ currency: 'BRL',
774
+ }).format(Number(parcela.valorAberto || 0)),
775
+ })}
776
+ </SelectItem>
777
+ ))}
778
+ </SelectContent>
779
+ </Select>
780
+ <FormMessage />
781
+ </FormItem>
782
+ )}
783
+ />
784
+
785
+ <FormField
786
+ control={settleForm.control}
787
+ name="amount"
788
+ render={({ field }) => (
789
+ <FormItem>
790
+ <FormLabel>{t('settleSheet.amountLabel')}</FormLabel>
791
+ <FormControl>
792
+ <InputMoney
793
+ value={Number(field.value || 0)}
794
+ onValueChange={(value) => {
795
+ field.onChange(Number(value || 0));
796
+ }}
797
+ />
798
+ </FormControl>
799
+ <FormMessage />
800
+ </FormItem>
801
+ )}
802
+ />
803
+
804
+ <FormField
805
+ control={settleForm.control}
806
+ name="description"
807
+ render={({ field }) => (
808
+ <FormItem>
809
+ <FormLabel>
810
+ {t('settleSheet.descriptionLabel')}
811
+ </FormLabel>
812
+ <FormControl>
813
+ <Input {...field} value={field.value || ''} />
814
+ </FormControl>
815
+ <FormMessage />
816
+ </FormItem>
817
+ )}
818
+ />
819
+
820
+ <div className="flex flex-col gap-4">
821
+ <Button
822
+ className="w-full"
823
+ type="submit"
824
+ disabled={isSettling}
825
+ >
826
+ {t('settleSheet.confirm')}
827
+ </Button>
828
+ </div>
829
+ </form>
830
+ </Form>
831
+ </SheetContent>
832
+ </Sheet>
833
+ </div>
834
+ }
835
+ />
836
+
837
+ <div className="grid gap-6 lg:grid-cols-3">
838
+ <Card className="lg:col-span-2">
839
+ <CardHeader>
840
+ <CardTitle>{t('documentData.title')}</CardTitle>
841
+ </CardHeader>
842
+ <CardContent>
843
+ <dl className="grid gap-4 sm:grid-cols-2">
844
+ <div>
845
+ <dt className="text-sm font-medium text-muted-foreground">
846
+ {t('documentData.supplier')}
847
+ </dt>
848
+ <dd className="mt-1">
849
+ <Link
850
+ href={`/cadastros/pessoas/${fornecedor?.id}`}
851
+ className="hover:underline"
852
+ >
853
+ {fornecedor?.nome}
854
+ </Link>
855
+ </dd>
856
+ </div>
857
+ <div>
858
+ <dt className="text-sm font-medium text-muted-foreground">
859
+ {t('documentData.cnpjCpf')}
860
+ </dt>
861
+ <dd className="mt-1">{fornecedor?.documento}</dd>
862
+ </div>
863
+ <div>
864
+ <dt className="text-sm font-medium text-muted-foreground">
865
+ {t('documentData.competency')}
866
+ </dt>
867
+ <dd className="mt-1">{titulo.competencia}</dd>
868
+ </div>
869
+ <div>
870
+ <dt className="text-sm font-medium text-muted-foreground">
871
+ {t('documentData.totalValue')}
872
+ </dt>
873
+ <dd className="mt-1 text-lg font-semibold">
874
+ <Money value={titulo.valorTotal} />
875
+ </dd>
876
+ </div>
877
+ <div>
878
+ <dt className="text-sm font-medium text-muted-foreground">
879
+ {t('documentData.status')}
880
+ </dt>
881
+ <dd className="mt-1">
882
+ <StatusBadge status={titulo.status} />
883
+ </dd>
884
+ </div>
885
+ <div>
886
+ <dt className="text-sm font-medium text-muted-foreground">
887
+ {t('documentData.category')}
888
+ </dt>
889
+ <dd className="mt-1">{categoria?.nome}</dd>
890
+ </div>
891
+ <div>
892
+ <dt className="text-sm font-medium text-muted-foreground">
893
+ {t('documentData.costCenter')}
894
+ </dt>
895
+ <dd className="mt-1">{centroCusto?.nome}</dd>
896
+ </div>
897
+ <div>
898
+ <dt className="text-sm font-medium text-muted-foreground">
899
+ {t('documentData.createdAt')}
900
+ </dt>
901
+ <dd className="mt-1">{formatarData(titulo.criadoEm)}</dd>
902
+ </div>
903
+ <div>
904
+ <dt className="text-sm font-medium text-muted-foreground">
905
+ {t('documentData.tags')}
906
+ </dt>
907
+ <dd className="mt-1">
908
+ <TagSelectorSheet
909
+ selectedTagIds={selectedTagIds}
910
+ tags={tagOptions}
911
+ onChange={handleChangeTags}
912
+ onCreateTag={handleCreateTag}
913
+ disabled={isCreatingTag}
914
+ emptyText={t('tagSelector.noTags')}
915
+ labels={{
916
+ addTag: t('tagSelector.addTag'),
917
+ sheetTitle: t('tagSelector.sheetTitle'),
918
+ sheetDescription: t('tagSelector.sheetDescription'),
919
+ createLabel: t('tagSelector.createLabel'),
920
+ createPlaceholder: t('tagSelector.createPlaceholder'),
921
+ createAction: t('tagSelector.createAction'),
922
+ popularTitle: t('tagSelector.popularTitle'),
923
+ selectedTitle: t('tagSelector.selectedTitle'),
924
+ noTags: t('tagSelector.noTags'),
925
+ cancel: t('tagSelector.cancel'),
926
+ apply: t('tagSelector.apply'),
927
+ removeTagAria: (tagName: string) =>
928
+ t('tagSelector.removeTagAria', { tag: tagName }),
929
+ }}
930
+ />
931
+ </dd>
932
+ </div>
933
+ </dl>
934
+ </CardContent>
935
+ </Card>
936
+
937
+ <Card>
938
+ <CardHeader>
939
+ <CardTitle>{t('attachments.title')}</CardTitle>
940
+ <CardDescription>{t('attachments.description')}</CardDescription>
941
+ </CardHeader>
942
+ <CardContent>
943
+ {attachmentDetails.length > 0 ? (
944
+ <ul className="space-y-2">
945
+ {attachmentDetails.map((anexo: any, i: number) => (
946
+ <li key={i}>
947
+ <Button
948
+ variant="ghost"
949
+ className="h-auto w-full justify-start p-2"
950
+ onClick={() => void handleOpenAttachment(anexo?.id)}
951
+ >
952
+ <FileText className="mr-2 h-4 w-4" />
953
+ {anexo?.nome}
954
+ </Button>
955
+ </li>
956
+ ))}
957
+ </ul>
958
+ ) : (
959
+ <p className="text-sm text-muted-foreground">
960
+ {t('attachments.none')}
961
+ </p>
962
+ )}
963
+ </CardContent>
964
+ </Card>
965
+ </div>
966
+
967
+ <Tabs defaultValue="parcelas">
968
+ <TabsList>
969
+ <TabsTrigger value="parcelas">{t('tabs.installments')}</TabsTrigger>
970
+ <TabsTrigger value="liquidacoes">{t('tabs.settlements')}</TabsTrigger>
971
+ <TabsTrigger value="auditoria">{t('tabs.audit')}</TabsTrigger>
972
+ </TabsList>
973
+
974
+ <TabsContent value="parcelas" className="mt-4">
975
+ <Card>
976
+ <CardContent className="pt-6">
977
+ <Table>
978
+ <TableHeader>
979
+ <TableRow>
980
+ <TableHead>{t('installmentsTable.installment')}</TableHead>
981
+ <TableHead>{t('installmentsTable.dueDate')}</TableHead>
982
+ <TableHead className="text-right">
983
+ {t('installmentsTable.value')}
984
+ </TableHead>
985
+ <TableHead>{t('installmentsTable.method')}</TableHead>
986
+ <TableHead>{t('installmentsTable.status')}</TableHead>
987
+ </TableRow>
988
+ </TableHeader>
989
+ <TableBody>
990
+ {titulo.parcelas.map((parcela: any) => (
991
+ <TableRow key={parcela.id}>
992
+ <TableCell>
993
+ {parcela.numero}/{titulo.parcelas.length}
994
+ </TableCell>
995
+ <TableCell>{formatarData(parcela.vencimento)}</TableCell>
996
+ <TableCell className="text-right">
997
+ <Money value={parcela.valor} />
998
+ </TableCell>
999
+ <TableCell className="capitalize">
1000
+ {parcela.metodoPagamento}
1001
+ </TableCell>
1002
+ <TableCell>
1003
+ <StatusBadge status={parcela.status} />
1004
+ </TableCell>
1005
+ </TableRow>
1006
+ ))}
1007
+ </TableBody>
1008
+ </Table>
1009
+ </CardContent>
1010
+ </Card>
1011
+ </TabsContent>
1012
+
1013
+ <TabsContent value="liquidacoes" className="mt-4">
1014
+ <Card>
1015
+ <CardContent className="pt-6">
1016
+ {isFetchingSettlementsHistory ? (
1017
+ <div className="flex items-center justify-center py-8 text-muted-foreground">
1018
+ <Loader2 className="mr-2 h-4 w-4 animate-spin" />
1019
+ {t('settlementsHistory.loading')}
1020
+ </div>
1021
+ ) : settlementsHistory.length > 0 ? (
1022
+ <Table>
1023
+ <TableHeader>
1024
+ <TableRow>
1025
+ <TableHead>
1026
+ {t('settlementsHistory.headers.date')}
1027
+ </TableHead>
1028
+ <TableHead>
1029
+ {t('settlementsHistory.headers.type')}
1030
+ </TableHead>
1031
+ <TableHead>
1032
+ {t('settlementsHistory.headers.status')}
1033
+ </TableHead>
1034
+ <TableHead className="text-right">
1035
+ {t('settlementsHistory.headers.netAmount')}
1036
+ </TableHead>
1037
+ <TableHead>
1038
+ {t('settlementsHistory.headers.method')}
1039
+ </TableHead>
1040
+ <TableHead>
1041
+ {t('settlementsHistory.headers.account')}
1042
+ </TableHead>
1043
+ <TableHead>
1044
+ {t('settlementsHistory.headers.reconciled')}
1045
+ </TableHead>
1046
+ <TableHead className="text-right">
1047
+ {t('settlementsHistory.headers.actions')}
1048
+ </TableHead>
1049
+ </TableRow>
1050
+ </TableHeader>
1051
+ <TableBody>
1052
+ {settlementsHistory.map((group) => {
1053
+ const normalId = group.normal.id;
1054
+ const isExpanded = !!expandedSettlementRows[normalId];
1055
+ const canReverseNormal =
1056
+ group.statusLabel === 'ATIVO' &&
1057
+ !group.normal.reconciled &&
1058
+ !reversingSettlementId;
1059
+
1060
+ return (
1061
+ <Fragment key={normalId}>
1062
+ <TableRow>
1063
+ <TableCell>
1064
+ {formatarData(group.normal.paidAt)}
1065
+ </TableCell>
1066
+ <TableCell>
1067
+ <span className="rounded-md border px-2 py-1 text-xs font-medium">
1068
+ {t('settlementsHistory.normalType')}
1069
+ </span>
1070
+ </TableCell>
1071
+ <TableCell>
1072
+ <span
1073
+ className={`rounded-md px-2 py-1 text-xs font-medium ${
1074
+ group.statusLabel === 'ESTORNADO'
1075
+ ? 'bg-muted text-muted-foreground'
1076
+ : 'bg-primary/10 text-primary'
1077
+ }`}
1078
+ >
1079
+ {group.statusLabel}
1080
+ </span>
1081
+ </TableCell>
1082
+ <TableCell className="text-right font-medium">
1083
+ +
1084
+ <Money
1085
+ value={
1086
+ Math.abs(group.normal.amountCents || 0) / 100
1087
+ }
1088
+ />
1089
+ </TableCell>
1090
+ <TableCell className="capitalize">
1091
+ {group.normal.method || '-'}
1092
+ </TableCell>
1093
+ <TableCell>{group.normal.account || '-'}</TableCell>
1094
+ <TableCell>
1095
+ <span
1096
+ className={`rounded-md px-2 py-1 text-xs font-medium ${
1097
+ group.normal.reconciled
1098
+ ? 'bg-primary/10 text-primary'
1099
+ : 'bg-muted text-muted-foreground'
1100
+ }`}
1101
+ >
1102
+ {group.normal.reconciled
1103
+ ? t('settlementsHistory.reconciled.yes')
1104
+ : t('settlementsHistory.reconciled.no')}
1105
+ </span>
1106
+ </TableCell>
1107
+ <TableCell className="text-right">
1108
+ <div className="flex items-center justify-end gap-2">
1109
+ {group.normal.reconciled &&
1110
+ group.normal.reconciliationId ? (
1111
+ <Button
1112
+ variant="outline"
1113
+ size="sm"
1114
+ disabled={
1115
+ unreconcilingId ===
1116
+ group.normal.reconciliationId
1117
+ }
1118
+ onClick={() =>
1119
+ void handleUnreconcileSettlement(
1120
+ String(group.normal.reconciliationId)
1121
+ )
1122
+ }
1123
+ >
1124
+ {unreconcilingId ===
1125
+ group.normal.reconciliationId
1126
+ ? t(
1127
+ 'settlementsHistory.unreconcileLoading'
1128
+ )
1129
+ : t('settlementsHistory.unreconcile')}
1130
+ </Button>
1131
+ ) : null}
1132
+
1133
+ <Button
1134
+ variant="outline"
1135
+ size="sm"
1136
+ title={
1137
+ group.normal.reconciled
1138
+ ? t('settlementsHistory.unreconcileFirst')
1139
+ : undefined
1140
+ }
1141
+ disabled={!canReverseNormal}
1142
+ onClick={() => {
1143
+ setSelectedSettlementIdToReverse(normalId);
1144
+ setReverseReason('');
1145
+ setIsReverseDialogOpen(true);
1146
+ }}
1147
+ >
1148
+ <Undo className="mr-2 h-4 w-4" />
1149
+ {t('settlementsHistory.reverse')}
1150
+ </Button>
1151
+
1152
+ <Button
1153
+ variant="ghost"
1154
+ size="sm"
1155
+ onClick={() =>
1156
+ toggleSettlementExpanded(normalId)
1157
+ }
1158
+ >
1159
+ {isExpanded ? (
1160
+ <ChevronUp className="h-4 w-4" />
1161
+ ) : (
1162
+ <ChevronDown className="h-4 w-4" />
1163
+ )}
1164
+ </Button>
1165
+ </div>
1166
+ </TableCell>
1167
+ </TableRow>
1168
+
1169
+ {group.reversal ? (
1170
+ <TableRow>
1171
+ <TableCell className="pl-8">
1172
+ {formatarData(group.reversal.paidAt)}
1173
+ </TableCell>
1174
+ <TableCell>
1175
+ <span className="rounded-md border px-2 py-1 text-xs font-medium">
1176
+ {t('settlementsHistory.reversalType')}
1177
+ </span>
1178
+ </TableCell>
1179
+ <TableCell>
1180
+ <span className="rounded-md bg-muted px-2 py-1 text-xs font-medium text-muted-foreground">
1181
+ {t('settlementsHistory.reversedStatus')}
1182
+ </span>
1183
+ </TableCell>
1184
+ <TableCell className="text-right font-medium text-destructive">
1185
+ -
1186
+ <Money
1187
+ value={
1188
+ Math.abs(group.reversal.amountCents || 0) /
1189
+ 100
1190
+ }
1191
+ />
1192
+ </TableCell>
1193
+ <TableCell>-</TableCell>
1194
+ <TableCell>-</TableCell>
1195
+ <TableCell>-</TableCell>
1196
+ <TableCell className="text-right text-xs text-muted-foreground">
1197
+ {group.reversal.memo || '-'}
1198
+ </TableCell>
1199
+ </TableRow>
1200
+ ) : null}
1201
+
1202
+ {isExpanded ? (
1203
+ <TableRow>
1204
+ <TableCell colSpan={8}>
1205
+ <div className="rounded-md border bg-muted/20 p-3">
1206
+ <p className="mb-2 text-xs font-medium text-muted-foreground">
1207
+ {t('settlementsHistory.allocationsTitle')}
1208
+ </p>
1209
+ <div className="space-y-1">
1210
+ {group.allocations.map((allocation) => (
1211
+ <div
1212
+ key={`${normalId}-${allocation.installmentId}`}
1213
+ className="flex items-center justify-between text-sm"
1214
+ >
1215
+ <span>
1216
+ {t(
1217
+ 'settlementsHistory.allocationInstallment'
1218
+ )}{' '}
1219
+ #{allocation.installmentSeq}
1220
+ </span>
1221
+ <span className="font-medium">
1222
+ +
1223
+ <Money
1224
+ value={
1225
+ Math.abs(
1226
+ Number(
1227
+ allocation.amountCents || 0
1228
+ )
1229
+ ) / 100
1230
+ }
1231
+ />
1232
+ </span>
1233
+ </div>
1234
+ ))}
1235
+ </div>
1236
+ </div>
1237
+ </TableCell>
1238
+ </TableRow>
1239
+ ) : null}
1240
+ </Fragment>
1241
+ );
1242
+ })}
1243
+ </TableBody>
1244
+ </Table>
1245
+ ) : (
1246
+ <p className="text-center text-muted-foreground py-8">
1247
+ {t('settlementsTable.none')}
1248
+ </p>
1249
+ )}
1250
+ </CardContent>
1251
+ </Card>
1252
+ </TabsContent>
1253
+
1254
+ <TabsContent value="auditoria" className="mt-4">
1255
+ <Card>
1256
+ <CardContent className="pt-6">
1257
+ {auditEvents.length > 0 ? (
1258
+ <AuditTimeline events={auditEvents} />
1259
+ ) : (
1260
+ <p className="text-center text-muted-foreground py-8">
1261
+ {t('audit.none')}
1262
+ </p>
1263
+ )}
1264
+ </CardContent>
1265
+ </Card>
1266
+ </TabsContent>
1267
+ </Tabs>
1268
+ </Page>
1269
+ );
1270
+ }