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) && (
}
loading={discard.isPending}
onClick={() => discard.mutate()}
>
Discard
)}
{/* 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) && (
}
onClick={() => setReverseOpen(true)}
>
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
#AccountNameDescription
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.
>
);
}