Files
linumiq-invoice/app/routes/app._index.tsx
T
Gerhard Scheikl 770c6fd16a many updates :-)
2026-05-08 10:40:19 +02:00

219 lines
7.2 KiB
TypeScript

import type { LoaderFunctionArgs } from "react-router";
import { Link, useLoaderData } from "react-router";
import { authenticate } from "../shopify.server";
import db from "../db.server";
export const loader = async ({ request }: LoaderFunctionArgs) => {
const { session } = await authenticate.admin(request);
const [settings, recent, counts] = await Promise.all([
db.shopSettings.findUnique({ where: { shopDomain: session.shop } }),
db.invoice.findMany({
where: { shopDomain: session.shop },
orderBy: [{ issuedAt: "desc" }],
take: 8,
}),
db.invoice.groupBy({
by: ["status"],
where: { shopDomain: session.shop },
_count: { _all: true },
}),
]);
const total = counts.reduce((acc, row) => acc + row._count._all, 0);
const issuedCount = counts.find((c) => c.status === "issued")?._count._all ?? 0;
const sentCount = counts.find((c) => c.status === "sent")?._count._all ?? 0;
const cancelledCount = counts.find((c) => c.status === "cancelled")?._count._all ?? 0;
const settingsConfigured = !!(
settings &&
settings.companyName &&
settings.addressLine1 &&
settings.iban
);
return {
settingsConfigured,
metrics: { total, issuedCount, sentCount, cancelledCount },
recent: recent.map((i) => ({
id: i.id,
number: i.invoiceNumber,
kind: i.kind,
orderName: i.orderName,
version: i.version,
status: i.status,
sentAt: i.sentAt?.toISOString() ?? null,
cancelledAt: i.cancelledAt?.toISOString() ?? null,
issuedAt: i.issuedAt.toISOString(),
pdfUrl: i.pdfUrl,
})),
};
};
const dateFmt = new Intl.DateTimeFormat("en-GB", {
day: "2-digit",
month: "short",
year: "numeric",
});
function formatDate(iso: string | null): string {
if (!iso) return "—";
return dateFmt.format(new Date(iso));
}
interface RecentInvoice {
id: string;
number: string;
kind: string;
orderName: string;
version: number;
status: string;
sentAt: string | null;
cancelledAt: string | null;
issuedAt: string;
pdfUrl: string;
}
function statusBadge(invoice: RecentInvoice): {
tone: "success" | "info" | "critical" | "warning";
label: string;
} {
if (invoice.cancelledAt) return { tone: "critical", label: "Cancelled" };
if (invoice.kind === "storno") return { tone: "warning", label: "Storno" };
if (invoice.sentAt) return { tone: "success", label: "Sent" };
return { tone: "info", label: "Issued" };
}
export default function Index() {
const { settingsConfigured, metrics, recent } = useLoaderData<typeof loader>();
return (
<s-page heading="LinumIQ Invoice">
{!settingsConfigured && (
<s-banner tone="warning" heading="Configure your invoice settings">
<s-paragraph>
Complete your company, bank and numbering details so generated
invoices are legally compliant.
</s-paragraph>
<s-stack direction="inline" gap="base">
<s-link href="/app/settings">Open settings </s-link>
</s-stack>
</s-banner>
)}
<s-section heading="Overview">
<s-grid gridTemplateColumns="repeat(4, minmax(0, 1fr))" gap="base">
<Metric label="Total invoices" value={metrics.total} />
<Metric label="Issued" value={metrics.issuedCount} tone="info" />
<Metric label="Sent" value={metrics.sentCount} tone="success" />
<Metric label="Cancelled" value={metrics.cancelledCount} tone="critical" />
</s-grid>
</s-section>
<s-section
heading="Recent invoices"
padding="none"
accessibilityLabel="Recent invoices table"
>
{recent.length === 0 ? (
<s-box padding="base">
<s-stack direction="block" gap="base" alignItems="center">
<s-text type="strong">No invoices yet</s-text>
<s-paragraph tone="neutral">
Generate your first invoice from the Invoices page or directly
from a Shopify order.
</s-paragraph>
<s-link href="/app/invoices">Open invoices </s-link>
</s-stack>
</s-box>
) : (
<s-table>
<s-table-header-row>
<s-table-header listSlot="primary">Invoice</s-table-header>
<s-table-header>Order</s-table-header>
<s-table-header>Issued</s-table-header>
<s-table-header listSlot="secondary">Status</s-table-header>
<s-table-header listSlot="labeled">PDF</s-table-header>
</s-table-header-row>
<s-table-body>
{recent.map((invoice) => {
const badge = statusBadge(invoice);
return (
<s-table-row key={invoice.id}>
<s-table-cell>
<s-stack direction="block" gap="none">
<s-text type="strong">{invoice.number}</s-text>
{invoice.version > 1 ? (
<s-text tone="neutral">v{invoice.version}</s-text>
) : null}
</s-stack>
</s-table-cell>
<s-table-cell>{invoice.orderName}</s-table-cell>
<s-table-cell>{formatDate(invoice.issuedAt)}</s-table-cell>
<s-table-cell>
<s-badge tone={badge.tone}>{badge.label}</s-badge>
</s-table-cell>
<s-table-cell>
<s-stack direction="inline" gap="small" justifyContent="end">
{invoice.pdfUrl ? (
<s-link href={invoice.pdfUrl} target="_blank">
Open
</s-link>
) : (
<s-text tone="neutral"></s-text>
)}
</s-stack>
</s-table-cell>
</s-table-row>
);
})}
</s-table-body>
</s-table>
)}
<s-box padding="base">
<s-stack direction="inline" justifyContent="end">
<Link to="/app/invoices">View all invoices </Link>
</s-stack>
</s-box>
</s-section>
<s-section heading="How it works">
<s-stack direction="block" gap="base">
<s-paragraph>
LinumIQ Invoice generates Austrian-compliant PDF invoices for your
Shopify orders. PDFs are stored on Shopify Files and linked to
each order via metafields.
</s-paragraph>
<s-paragraph tone="neutral">
Trigger generation from the order page (Generate invoice action),
via Shopify Flow, or in bulk from the Invoices page.
</s-paragraph>
</s-stack>
</s-section>
</s-page>
);
}
function Metric({
label,
value,
tone,
}: {
label: string;
value: number;
tone?: "info" | "success" | "critical";
}) {
const valueTone = tone ?? "neutral";
return (
<s-box padding="base" background="subdued" border="base" borderRadius="base">
<s-stack direction="block" gap="small">
<s-text tone="neutral">{label}</s-text>
<s-heading>
<s-text tone={valueTone}>{value}</s-text>
</s-heading>
</s-stack>
</s-box>
);
}