import { useState } from "react"; import { Alert, Badge, Button, Card, Group, Loader, Modal, SimpleGrid, Stack, Table, Text, Textarea, Title, Tooltip, } from "@mantine/core"; import { IconArrowBackUp, IconInfoCircle, IconTrash } from "@tabler/icons-react"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { useNavigate, useParams } from "react-router-dom"; import { useAuth } from "@/auth/AuthContext"; import { FINANCE_PERMS } from "@/auth/permissions"; import { PageHeader } from "@/shared/components/PageHeader"; import { ApiErrorAlert } from "@/shared/components/ApiErrorAlert"; import { formatMoney } from "@/shared/lib/formatMoney"; import { discardJournal, fetchJournal, postJournal, reverseJournal } from "./api"; import { STATUS_COLOR } from "./types"; function Field({ label, value }: { label: string; value: React.ReactNode }) { return ( {label} {value ?? "—"} ); } export function JournalDetailPage() { const { id = "" } = useParams(); const navigate = useNavigate(); const queryClient = useQueryClient(); const { can } = useAuth(); const [reverseOpen, setReverseOpen] = useState(false); const [reason, setReason] = useState(""); const [actionError, setActionError] = useState(null); const entry = useQuery({ queryKey: ["journals", id], queryFn: () => fetchJournal(id), enabled: Boolean(id), }); const invalidate = () => { void queryClient.invalidateQueries({ queryKey: ["journals"] }); }; const post = useMutation({ mutationFn: () => postJournal(id), onMutate: () => setActionError(null), onSuccess: invalidate, onError: setActionError, }); const reverse = useMutation({ mutationFn: () => reverseJournal(id, { reason }), onMutate: () => setActionError(null), onSuccess: (created) => { invalidate(); setReverseOpen(false); setReason(""); navigate(`/journals/${created.id}`); }, onError: setActionError, }); const discard = useMutation({ mutationFn: () => discardJournal(id), onMutate: () => setActionError(null), onSuccess: () => { invalidate(); navigate("/journals"); }, onError: setActionError, }); if (entry.isLoading) { return ( ); } if (entry.error || !entry.data) { return ; } const data = entry.data; const isDraft = data.status === "DRAFT"; const isPosted = data.status === "POSTED"; return ( <> {isDraft && can(FINANCE_PERMS.journal.create) && ( )} {/* Posting is a separate permission from preparing — a segregation of duty, so the reason for a missing button is spelled out rather than left as an absence. */} {isDraft && (can(FINANCE_PERMS.journal.post) ? ( ) : ( ))} {isPosted && can(FINANCE_PERMS.journal.reverse) && ( )} } /> {data.status === "REVERSED" && ( } color="orange" mb="md" title="This entry has been reversed" > Its effect has been undone by a mirrored entry. Both remain in the ledger — that is the audit trail.{" "} {data.reversedByEntryId && ( navigate(`/journals/${data.reversedByEntryId}`)} > Open the reversal )} )} {data.reversesEntryId && ( } color="blue" mb="md"> This is a reversing entry.{" "} navigate(`/journals/${data.reversesEntryId}`)} > Open the entry it reverses {data.reversalReason ? ` — reason: ${data.reversalReason}` : ""} )} {isPosted && ( Posted to the ledger. It can no longer be edited or deleted — a correction is a reversing entry. )} {data.status} } /> Lines # Account Name Description Debit Credit {data.lines.map((line) => ( {line.lineNumber} {line.accountCode} {line.accountName?.en} {line.description ?? "—"} {/* A ledger shows the amount in one column and leaves the other blank — never a negative number in both. */} {Number(line.debit) > 0 ? formatMoney(line.debit) : ""} {Number(line.credit) > 0 ? formatMoney(line.credit) : ""} ))} Total {formatMoney(data.totalDebit)} {formatMoney(data.totalCredit)}
setReverseOpen(false)} title={`Reverse ${data.entryNumber}`} > This writes a NEW entry with the debits and credits swapped, dated today. The original stays in the ledger exactly as it is — that pair is the audit trail.