mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-09-07 16:35:42 +00:00
255 lines
7.5 KiB
TypeScript
255 lines
7.5 KiB
TypeScript
import {
|
|
ActionIcon,
|
|
Button,
|
|
Card,
|
|
Center,
|
|
Container,
|
|
Group,
|
|
Loader,
|
|
SimpleGrid,
|
|
Stack,
|
|
Table,
|
|
Text,
|
|
} from "@mantine/core";
|
|
import { useQuery } from "@tanstack/react-query";
|
|
import { ArrowLeft, Download } from "lucide-react";
|
|
import { useState } from "react";
|
|
import { useNavigate, useParams } from "react-router-dom";
|
|
|
|
import {
|
|
InvoiceStatusBadge,
|
|
formatDate,
|
|
formatMoney,
|
|
humanize,
|
|
} from "@/components/customers";
|
|
import { PageContainer, PageHeader } from "@/components/page";
|
|
import { api } from "@/services/api";
|
|
import { invoicesService } from "@/services/invoices.service";
|
|
|
|
function openPdfBlob(blob: Blob, filename: string) {
|
|
const url = URL.createObjectURL(blob);
|
|
const opened = window.open(url, "_blank");
|
|
if (!opened) {
|
|
const a = document.createElement("a");
|
|
a.href = url;
|
|
a.download = filename;
|
|
document.body.appendChild(a);
|
|
a.click();
|
|
a.remove();
|
|
}
|
|
setTimeout(() => URL.revokeObjectURL(url), 60_000);
|
|
}
|
|
|
|
function InfoField({ label, value }: { label: string; value?: string | null }) {
|
|
return (
|
|
<Stack gap={2}>
|
|
<Text
|
|
size="xs"
|
|
fw={600}
|
|
c="edr-muted"
|
|
tt="uppercase"
|
|
style={{ letterSpacing: "0.04em" }}
|
|
>
|
|
{label}
|
|
</Text>
|
|
<Text size="sm" c="edr-text">
|
|
{value && value.trim() ? value : "—"}
|
|
</Text>
|
|
</Stack>
|
|
);
|
|
}
|
|
|
|
export default function InvoiceDetailPage() {
|
|
const { id } = useParams<{ id: string }>();
|
|
const navigate = useNavigate();
|
|
const [downloading, setDownloading] = useState(false);
|
|
|
|
const { data: invoice, isLoading } = useQuery(
|
|
api.invoices.getById.queryOptions({
|
|
input: { id: id ?? "" },
|
|
enabled: Boolean(id),
|
|
}),
|
|
);
|
|
|
|
const downloadDocument = async () => {
|
|
if (!id) return;
|
|
setDownloading(true);
|
|
try {
|
|
const { data } = await invoicesService.downloadDocument(id);
|
|
openPdfBlob(data, `${invoice?.invoiceNumber ?? "invoice"}.pdf`);
|
|
} finally {
|
|
setDownloading(false);
|
|
}
|
|
};
|
|
|
|
if (isLoading) {
|
|
return (
|
|
<Center mih="60vh">
|
|
<Loader />
|
|
</Center>
|
|
);
|
|
}
|
|
|
|
if (!invoice) {
|
|
return (
|
|
<Container size="sm" py="xl">
|
|
<Stack align="center" gap="md">
|
|
<Text fw={700}>Invoice not found</Text>
|
|
<Button
|
|
variant="default"
|
|
leftSection={<ArrowLeft size={16} />}
|
|
onClick={() => navigate("/dashboard/invoices")}
|
|
>
|
|
Back to invoices
|
|
</Button>
|
|
</Stack>
|
|
</Container>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<PageContainer>
|
|
<PageHeader
|
|
breadcrumbs={[
|
|
{ label: "Invoices", href: "/dashboard/invoices" },
|
|
{ label: invoice.invoiceNumber },
|
|
]}
|
|
backTo="/dashboard/invoices"
|
|
title={invoice.invoiceNumber}
|
|
subtitle={`${humanize(invoice.source)} · ${invoice.sourceId}`}
|
|
meta={<InvoiceStatusBadge status={invoice.status} />}
|
|
action={
|
|
<ActionIcon
|
|
variant="default"
|
|
size="lg"
|
|
radius="md"
|
|
aria-label="Download invoice"
|
|
loading={downloading}
|
|
onClick={() => void downloadDocument()}
|
|
>
|
|
<Download size={16} />
|
|
</ActionIcon>
|
|
}
|
|
/>
|
|
|
|
<Stack gap="lg">
|
|
<Card>
|
|
<Stack gap="lg">
|
|
<Text fw={600} c="edr-text">
|
|
Summary
|
|
</Text>
|
|
<SimpleGrid cols={{ base: 1, sm: 2, lg: 4 }} spacing="lg">
|
|
<InfoField label="Billed to" value={invoice.company?.name} />
|
|
<InfoField
|
|
label="Profile"
|
|
value={invoice.companyProfile?.reference}
|
|
/>
|
|
<InfoField label="Type" value={humanize(invoice.type)} />
|
|
<InfoField label="Currency" value={invoice.currency} />
|
|
<InfoField label="Issued" value={formatDate(invoice.issuedAt)} />
|
|
<InfoField label="Due" value={formatDate(invoice.dueAt)} />
|
|
<InfoField
|
|
label="Total"
|
|
value={formatMoney(invoice.totalAmount, invoice.currency)}
|
|
/>
|
|
<InfoField
|
|
label="Balance"
|
|
value={formatMoney(invoice.balanceAmount, invoice.currency)}
|
|
/>
|
|
</SimpleGrid>
|
|
</Stack>
|
|
</Card>
|
|
|
|
<Card>
|
|
<Stack gap="md">
|
|
<Text fw={600} c="edr-text">
|
|
Line items
|
|
</Text>
|
|
<Table striped withRowBorders={false} verticalSpacing="xs">
|
|
<Table.Thead>
|
|
<Table.Tr>
|
|
<Table.Th>Description</Table.Th>
|
|
<Table.Th>Charge type</Table.Th>
|
|
<Table.Th ta="right">Quantity</Table.Th>
|
|
<Table.Th ta="right">Unit rate</Table.Th>
|
|
<Table.Th ta="right">Amount</Table.Th>
|
|
</Table.Tr>
|
|
</Table.Thead>
|
|
<Table.Tbody>
|
|
{(invoice.lines ?? []).map((line) => (
|
|
<Table.Tr key={line.id}>
|
|
<Table.Td>{line.description ?? line.chargeType}</Table.Td>
|
|
<Table.Td>
|
|
<Text size="sm" c="dimmed">
|
|
{humanize(line.chargeType)}
|
|
</Text>
|
|
</Table.Td>
|
|
<Table.Td ta="right">{line.quantity}</Table.Td>
|
|
<Table.Td ta="right">
|
|
{formatMoney(line.unitRate, line.currency)}
|
|
</Table.Td>
|
|
<Table.Td ta="right">
|
|
{formatMoney(line.amount, line.currency)}
|
|
</Table.Td>
|
|
</Table.Tr>
|
|
))}
|
|
{(invoice.lines ?? []).length === 0 && (
|
|
<Table.Tr>
|
|
<Table.Td colSpan={5}>
|
|
<Text size="sm" c="dimmed" ta="center" py="md">
|
|
No line items.
|
|
</Text>
|
|
</Table.Td>
|
|
</Table.Tr>
|
|
)}
|
|
</Table.Tbody>
|
|
</Table>
|
|
|
|
<Group
|
|
justify="flex-end"
|
|
gap="xl"
|
|
pt="sm"
|
|
style={{
|
|
borderTop: "1px solid var(--mantine-color-edr-border-0)",
|
|
}}
|
|
>
|
|
<Stack gap={2} align="flex-end">
|
|
<Text size="xs" c="edr-muted">
|
|
Subtotal
|
|
</Text>
|
|
<Text size="sm">
|
|
{formatMoney(invoice.subtotalAmount, invoice.currency)}
|
|
</Text>
|
|
</Stack>
|
|
<Stack gap={2} align="flex-end">
|
|
<Text size="xs" c="edr-muted">
|
|
Tax
|
|
</Text>
|
|
<Text size="sm">
|
|
{formatMoney(invoice.taxAmount, invoice.currency)}
|
|
</Text>
|
|
</Stack>
|
|
<Stack gap={2} align="flex-end">
|
|
<Text size="xs" c="edr-muted">
|
|
Paid
|
|
</Text>
|
|
<Text size="sm">
|
|
{formatMoney(invoice.paidAmount, invoice.currency)}
|
|
</Text>
|
|
</Stack>
|
|
<Stack gap={2} align="flex-end">
|
|
<Text size="xs" fw={700}>
|
|
Total
|
|
</Text>
|
|
<Text size="sm" fw={700}>
|
|
{formatMoney(invoice.totalAmount, invoice.currency)}
|
|
</Text>
|
|
</Stack>
|
|
</Group>
|
|
</Stack>
|
|
</Card>
|
|
</Stack>
|
|
</PageContainer>
|
|
);
|
|
}
|