mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-28 05:30:55 +00:00
336 lines
10 KiB
TypeScript
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>
|
|
</>
|
|
);
|
|
}
|