many updates :-)
This commit is contained in:
+161
-35
@@ -7,15 +7,25 @@ import db from "../db.server";
|
||||
export const loader = async ({ request }: LoaderFunctionArgs) => {
|
||||
const { session } = await authenticate.admin(request);
|
||||
|
||||
const [settings, recent] = await Promise.all([
|
||||
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: 10,
|
||||
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 &&
|
||||
@@ -25,12 +35,14 @@ export const loader = async ({ request }: LoaderFunctionArgs) => {
|
||||
|
||||
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(),
|
||||
@@ -39,54 +51,168 @@ export const loader = async ({ request }: LoaderFunctionArgs) => {
|
||||
};
|
||||
};
|
||||
|
||||
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, recent } = useLoaderData<typeof loader>();
|
||||
const { settingsConfigured, metrics, recent } = useLoaderData<typeof loader>();
|
||||
|
||||
return (
|
||||
<s-page heading="LinumIQ Invoice">
|
||||
{!settingsConfigured && (
|
||||
<s-banner tone="warning" heading="Configure your invoice settings">
|
||||
Complete your company, bank and numbering details so generated
|
||||
invoices are legally compliant.{" "}
|
||||
<Link to="/app/settings">Open settings</Link>
|
||||
<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="What this app does">
|
||||
<s-paragraph>
|
||||
Generates Austrian-compliant PDF invoices for your Shopify orders.
|
||||
Trigger from the order page (Generate invoice action), via Shopify
|
||||
Flow, or in bulk from the Invoices page. PDFs are stored on
|
||||
Shopify Files and linked to each order via metafields.
|
||||
</s-paragraph>
|
||||
<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">
|
||||
<s-section
|
||||
heading="Recent invoices"
|
||||
padding="none"
|
||||
accessibilityLabel="Recent invoices table"
|
||||
>
|
||||
{recent.length === 0 ? (
|
||||
<s-paragraph>No invoices generated yet.</s-paragraph>
|
||||
<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-unordered-list>
|
||||
{recent.map((i) => (
|
||||
<s-list-item key={i.id}>
|
||||
{i.kind === "storno" ? "Storno " : ""}
|
||||
{i.number} — order {i.orderName} (v{i.version})
|
||||
{i.cancelledAt
|
||||
? " — cancelled"
|
||||
: i.sentAt
|
||||
? " — sent"
|
||||
: ""}
|
||||
{i.pdfUrl ? (
|
||||
<>
|
||||
{" "}
|
||||
[<a href={i.pdfUrl} target="_blank" rel="noreferrer">PDF</a>]
|
||||
</>
|
||||
) : null}
|
||||
</s-list-item>
|
||||
))}
|
||||
</s-unordered-list>
|
||||
<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>
|
||||
)}
|
||||
<Link to="/app/invoices">Open invoices page</Link>
|
||||
<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>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user