Files
edr-platform/apps/finance-web/src/features/journals/JournalDetailPage.tsx
2026-08-25 00:11:39 +03:00

336 lines
10 KiB
TypeScript

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 (
<Stack gap={2}>
<Text size="xs" c="dimmed" tt="uppercase">
{label}
</Text>
<Text size="sm">{value ?? "—"}</Text>
</Stack>
);
}
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<unknown>(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 (
<Group justify="center" py="xl">
<Loader />
</Group>
);
}
if (entry.error || !entry.data) {
return <ApiErrorAlert error={entry.error} title="Could not load the entry" />;
}
const data = entry.data;
const isDraft = data.status === "DRAFT";
const isPosted = data.status === "POSTED";
return (
<>
<PageHeader
title={data.entryNumber}
description={data.memo}
actions={
<Group gap="sm">
<Button variant="default" onClick={() => navigate("/journals")}>
Back
</Button>
{isDraft && can(FINANCE_PERMS.journal.create) && (
<Button
variant="default"
color="red"
leftSection={<IconTrash size={16} />}
loading={discard.isPending}
onClick={() => discard.mutate()}
>
Discard
</Button>
)}
{/* 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) ? (
<Button loading={post.isPending} onClick={() => post.mutate()}>
Post to ledger
</Button>
) : (
<Tooltip label="Posting is a separate approval from preparing. Ask someone who holds it.">
<Button disabled>Post to ledger</Button>
</Tooltip>
))}
{isPosted && can(FINANCE_PERMS.journal.reverse) && (
<Button
variant="light"
color="orange"
leftSection={<IconArrowBackUp size={16} />}
onClick={() => setReverseOpen(true)}
>
Reverse
</Button>
)}
</Group>
}
/>
<ApiErrorAlert error={actionError} title="The ledger refused that" />
{data.status === "REVERSED" && (
<Alert
icon={<IconInfoCircle size={18} />}
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 && (
<Text
component="span"
td="underline"
style={{ cursor: "pointer" }}
onClick={() => navigate(`/journals/${data.reversedByEntryId}`)}
>
Open the reversal
</Text>
)}
</Alert>
)}
{data.reversesEntryId && (
<Alert icon={<IconInfoCircle size={18} />} color="blue" mb="md">
This is a reversing entry.{" "}
<Text
component="span"
td="underline"
style={{ cursor: "pointer" }}
onClick={() => navigate(`/journals/${data.reversesEntryId}`)}
>
Open the entry it reverses
</Text>
{data.reversalReason ? ` — reason: ${data.reversalReason}` : ""}
</Alert>
)}
{isPosted && (
<Alert color="green" mb="md" variant="light">
Posted to the ledger. It can no longer be edited or deleted a
correction is a reversing entry.
</Alert>
)}
<Card withBorder mb="md" padding="lg">
<SimpleGrid cols={{ base: 2, sm: 3, md: 5 }} spacing="lg">
<Field
label="Status"
value={
<Badge size="sm" variant="light" color={STATUS_COLOR[data.status]}>
{data.status}
</Badge>
}
/>
<Field label="Entry date" value={data.entryDate} />
<Field label="Type" value={data.journalType} />
<Field label="Reference" value={data.reference} />
<Field label="Currency" value={data.currency} />
<Field
label="Posted at"
value={data.postedAt ? new Date(data.postedAt).toLocaleString() : "—"}
/>
<Field
label="Source"
value={
data.sourceModule ? `${data.sourceModule}:${data.sourceId}` : "Manual"
}
/>
</SimpleGrid>
</Card>
<Title order={4} mb="xs">
Lines
</Title>
<Table.ScrollContainer minWidth={760}>
<Table striped withTableBorder>
<Table.Thead>
<Table.Tr>
<Table.Th w={60}>#</Table.Th>
<Table.Th w={120}>Account</Table.Th>
<Table.Th>Name</Table.Th>
<Table.Th>Description</Table.Th>
<Table.Th w={150} ta="right">
Debit
</Table.Th>
<Table.Th w={150} ta="right">
Credit
</Table.Th>
</Table.Tr>
</Table.Thead>
<Table.Tbody>
{data.lines.map((line) => (
<Table.Tr key={line.id}>
<Table.Td>{line.lineNumber}</Table.Td>
<Table.Td>
<Text ff="monospace" size="sm">
{line.accountCode}
</Text>
</Table.Td>
<Table.Td>{line.accountName?.en}</Table.Td>
<Table.Td>
<Text size="sm" c="dimmed">
{line.description ?? "—"}
</Text>
</Table.Td>
{/* A ledger shows the amount in one column and leaves the other
blank — never a negative number in both. */}
<Table.Td ta="right">
<Text ff="monospace">
{Number(line.debit) > 0 ? formatMoney(line.debit) : ""}
</Text>
</Table.Td>
<Table.Td ta="right">
<Text ff="monospace">
{Number(line.credit) > 0 ? formatMoney(line.credit) : ""}
</Text>
</Table.Td>
</Table.Tr>
))}
</Table.Tbody>
<Table.Tfoot>
<Table.Tr>
<Table.Td colSpan={4}>
<Text fw={600}>Total</Text>
</Table.Td>
<Table.Td ta="right">
<Text ff="monospace" fw={700}>
{formatMoney(data.totalDebit)}
</Text>
</Table.Td>
<Table.Td ta="right">
<Text ff="monospace" fw={700}>
{formatMoney(data.totalCredit)}
</Text>
</Table.Td>
</Table.Tr>
</Table.Tfoot>
</Table>
</Table.ScrollContainer>
<Modal
opened={reverseOpen}
onClose={() => setReverseOpen(false)}
title={`Reverse ${data.entryNumber}`}
>
<Stack>
<Text size="sm" c="dimmed">
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.
</Text>
<Textarea
label="Reason"
description="Recorded permanently on both entries."
required
autosize
minRows={3}
value={reason}
onChange={(event) => setReason(event.currentTarget.value)}
/>
<Group justify="flex-end">
<Button variant="default" onClick={() => setReverseOpen(false)}>
Cancel
</Button>
<Button
color="orange"
disabled={!reason.trim()}
loading={reverse.isPending}
onClick={() => reverse.mutate()}
>
Reverse
</Button>
</Group>
</Stack>
</Modal>
</>
);
}