many updates :-)

This commit is contained in:
Gerhard Scheikl
2026-05-08 10:40:19 +02:00
parent 5b2aa5d62b
commit 770c6fd16a
16 changed files with 876 additions and 151 deletions
+196 -78
View File
@@ -6,12 +6,15 @@ import db from "../db.server";
interface RecentOrder {
id: string; // gid
numericId: string;
name: string;
createdAt: string;
totalPrice: string;
currency: string;
customerName: string;
hasInvoice: boolean;
invoiceNumber?: string;
invoiceVersion?: number;
invoiceSent?: boolean;
invoiceCancelled?: boolean;
pdfUrl?: string;
@@ -26,21 +29,26 @@ const RECENT_ORDERS_QUERY = `#graphql
createdAt
displayFinancialStatus
totalPriceSet { shopMoney { amount currencyCode } }
customer { firstName lastName }
}
}
}
`;
type Filter = "all" | "missing" | "with";
export const loader = async ({ request }: LoaderFunctionArgs) => {
const { admin, session } = await authenticate.admin(request);
const url = new URL(request.url);
const filter = url.searchParams.get("filter") ?? "all";
const filterParam = (url.searchParams.get("filter") ?? "all") as Filter;
const filter: Filter = ["all", "missing", "with"].includes(filterParam)
? filterParam
: "all";
// Recent orders from Shopify (first 25).
let orders: RecentOrder[] = [];
try {
const res = await admin.graphql(RECENT_ORDERS_QUERY, { variables: { first: 25 } });
const res = await admin.graphql(RECENT_ORDERS_QUERY, { variables: { first: 50 } });
const json = (await res.json()) as {
data?: {
orders?: {
@@ -49,6 +57,7 @@ export const loader = async ({ request }: LoaderFunctionArgs) => {
name: string;
createdAt: string;
totalPriceSet?: { shopMoney: { amount: string; currencyCode: string } };
customer?: { firstName: string | null; lastName: string | null } | null;
}>;
};
};
@@ -56,7 +65,6 @@ export const loader = async ({ request }: LoaderFunctionArgs) => {
const nodes = json.data?.orders?.nodes ?? [];
const orderIds = nodes.map((n) => n.id);
// Look up which of these orders already have a non-cancelled invoice.
const invoices = await db.invoice.findMany({
where: {
shopDomain: session.shop,
@@ -72,14 +80,20 @@ export const loader = async ({ request }: LoaderFunctionArgs) => {
orders = nodes.map((n) => {
const inv = latestByOrder.get(n.id);
const customer = n.customer
? [n.customer.firstName, n.customer.lastName].filter(Boolean).join(" ").trim()
: "";
return {
id: n.id,
numericId: n.id.replace(/^.*\//, ""),
name: n.name,
createdAt: n.createdAt,
totalPrice: n.totalPriceSet?.shopMoney.amount ?? "",
currency: n.totalPriceSet?.shopMoney.currencyCode ?? "EUR",
customerName: customer || "Guest",
hasInvoice: !!inv && !inv.cancelledAt,
invoiceNumber: inv?.invoiceNumber,
invoiceVersion: inv?.version,
invoiceSent: !!inv?.sentAt,
invoiceCancelled: !!inv?.cancelledAt,
pdfUrl: inv?.pdfUrl,
@@ -89,103 +103,207 @@ export const loader = async ({ request }: LoaderFunctionArgs) => {
console.warn("Failed to load recent orders:", err);
}
const allCount = orders.length;
const withCount = orders.filter((o) => o.hasInvoice).length;
const missingCount = allCount - withCount;
if (filter === "missing") orders = orders.filter((o) => !o.hasInvoice);
if (filter === "with") orders = orders.filter((o) => o.hasInvoice);
return { orders, filter };
return {
orders,
filter,
counts: { all: allCount, with: withCount, missing: missingCount },
};
};
const dateFmt = new Intl.DateTimeFormat("en-GB", {
day: "2-digit",
month: "short",
year: "numeric",
});
const moneyFmt = new Intl.NumberFormat("de-AT", {
minimumFractionDigits: 2,
maximumFractionDigits: 2,
});
function formatMoney(amount: string, currency: string): string {
const n = Number(amount);
if (!Number.isFinite(n)) return `${amount} ${currency}`;
return `${moneyFmt.format(n)} ${currency}`;
}
export default function InvoicesPage() {
const { orders, filter } = useLoaderData<typeof loader>();
const { orders, filter, counts } = useLoaderData<typeof loader>();
const navigation = useNavigation();
const isLoading = navigation.state !== "idle";
return (
<s-page heading="Invoices">
<s-section heading="Recent orders">
<s-paragraph>
Generate or view the latest invoice for each order. For sent
invoices, use Cancel & reissue to issue a Stornorechnung followed
by a fresh invoice.
</s-paragraph>
<s-section heading="Recent orders" padding="none">
<s-box padding="base">
<s-stack direction="block" gap="base">
<s-paragraph>
Generate the invoice for an order, regenerate an unsent draft,
or cancel-and-reissue a sent one. Newest orders appear first.
</s-paragraph>
<s-stack direction="inline" gap="base">
<Link to="?filter=all">All</Link>
<Link to="?filter=missing">Missing invoice</Link>
<Link to="?filter=with">Has invoice</Link>
</s-stack>
<s-stack direction="inline" gap="small" alignItems="center">
<FilterChip to="?filter=all" active={filter === "all"} count={counts.all}>
All
</FilterChip>
<FilterChip
to="?filter=missing"
active={filter === "missing"}
count={counts.missing}
>
Missing invoice
</FilterChip>
<FilterChip to="?filter=with" active={filter === "with"} count={counts.with}>
Has invoice
</FilterChip>
</s-stack>
</s-stack>
</s-box>
{isLoading ? (
<s-paragraph>Loading</s-paragraph>
<s-box padding="base">
<s-stack direction="inline" gap="small" alignItems="center">
<s-spinner size="small" accessibilityLabel="Loading orders" />
<s-text tone="neutral">Loading</s-text>
</s-stack>
</s-box>
) : orders.length === 0 ? (
<s-paragraph>No orders match the current filter.</s-paragraph>
<s-box padding="base">
<s-stack direction="block" gap="base" alignItems="center">
<s-text type="strong">No orders match this filter</s-text>
<s-paragraph tone="neutral">
Try a different filter or wait for new orders.
</s-paragraph>
</s-stack>
</s-box>
) : (
<s-unordered-list>
{orders.map((o) => (
<OrderRow key={o.id} order={o} />
))}
</s-unordered-list>
<s-table>
<s-table-header-row>
<s-table-header listSlot="primary">Order</s-table-header>
<s-table-header>Customer</s-table-header>
<s-table-header>Date</s-table-header>
<s-table-header format="numeric">Total</s-table-header>
<s-table-header listSlot="secondary">Invoice</s-table-header>
<s-table-header listSlot="labeled">Actions</s-table-header>
</s-table-header-row>
<s-table-body>
{orders.map((order) => (
<OrderRow key={order.id} order={order} />
))}
</s-table-body>
</s-table>
)}
</s-section>
<s-section heading="Bulk generate">
<s-paragraph>
Use the buttons next to each order to generate one at a time. (A
true multi-select bulk run will be added once the admin block
extension is installed.)
</s-paragraph>
<s-paragraph>
Tip: filter by <em>Missing invoice</em> to see orders that still
need one.
</s-paragraph>
<s-section heading="About this page">
<s-stack direction="block" gap="small">
<s-paragraph>
Buttons trigger the same generation pipeline used by the order
page action and Shopify Flow. PDFs are uploaded to Shopify Files
and linked back to the order via metafields.
</s-paragraph>
<s-paragraph tone="neutral">
Tip: filter by <em>Missing invoice</em> to find orders that still
need one.
</s-paragraph>
</s-stack>
</s-section>
</s-page>
);
}
function OrderRow({ order }: { order: RecentOrder }) {
const numericId = order.id.replace(/^.*\//, "");
const fetcher = useFetcher<{ ok: boolean; error?: string; invoiceNumber?: string }>();
const isBusy = fetcher.state !== "idle";
function FilterChip({
to,
active,
count,
children,
}: {
to: string;
active: boolean;
count: number;
children: React.ReactNode;
}) {
return (
<s-list-item>
<s-stack direction="inline" gap="base" align-items="center">
<strong>{order.name}</strong>
<span>{order.totalPrice} {order.currency}</span>
{order.hasInvoice ? (
<span>
{order.invoiceNumber}
{order.invoiceCancelled
? " (cancelled)"
: order.invoiceSent
? " (sent)"
: ""}
{order.pdfUrl ? (
<>
{" — "}
<a href={order.pdfUrl} target="_blank" rel="noreferrer">PDF</a>
</>
) : null}
</span>
) : (
<span> no invoice yet</span>
)}
<fetcher.Form method="post" action={`/api/orders/${numericId}/invoice`}>
<s-button type="submit" disabled={isBusy} variant="primary">
{order.hasInvoice
? order.invoiceSent
? "Cancel & reissue"
: "Regenerate"
: "Generate"}
</s-button>
{order.hasInvoice && order.invoiceSent && (
<input type="hidden" name="action" value="cancel_reissue" />
)}
</fetcher.Form>
{fetcher.data?.error && (
<span style={{ color: "red" }}>{fetcher.data.error}</span>
)}
</s-stack>
</s-list-item>
<Link to={to} style={{ textDecoration: "none" }}>
<s-clickable-chip color={active ? "strong" : "base"} accessibilityLabel={String(children)}>
{children} ({count})
</s-clickable-chip>
</Link>
);
}
function OrderRow({ order }: { order: RecentOrder }) {
const fetcher = useFetcher<{ ok: boolean; error?: string; invoiceNumber?: string }>();
const isBusy = fetcher.state !== "idle";
const isCancelReissue = order.hasInvoice && order.invoiceSent;
const buttonLabel = !order.hasInvoice
? "Generate"
: order.invoiceSent
? "Cancel & reissue"
: "Regenerate";
return (
<s-table-row>
<s-table-cell>
<s-stack direction="block" gap="none">
<s-text type="strong">{order.name}</s-text>
</s-stack>
</s-table-cell>
<s-table-cell>{order.customerName}</s-table-cell>
<s-table-cell>{dateFmt.format(new Date(order.createdAt))}</s-table-cell>
<s-table-cell>{formatMoney(order.totalPrice, order.currency)}</s-table-cell>
<s-table-cell>
{order.hasInvoice ? (
<s-stack direction="block" gap="none">
<s-stack direction="inline" gap="small" alignItems="center">
<s-text type="strong">{order.invoiceNumber}</s-text>
{order.invoiceCancelled ? (
<s-badge tone="critical">Cancelled</s-badge>
) : order.invoiceSent ? (
<s-badge tone="success">Sent</s-badge>
) : (
<s-badge tone="info">Issued</s-badge>
)}
{order.invoiceVersion && order.invoiceVersion > 1 ? (
<s-text tone="neutral">v{order.invoiceVersion}</s-text>
) : null}
</s-stack>
{fetcher.data?.error ? (
<s-text tone="critical">{fetcher.data.error}</s-text>
) : null}
</s-stack>
) : (
<s-text tone="neutral"></s-text>
)}
</s-table-cell>
<s-table-cell>
<s-stack direction="inline" gap="small" justifyContent="end" alignItems="center">
{order.pdfUrl ? (
<s-link href={order.pdfUrl} target="_blank">
PDF
</s-link>
) : null}
<fetcher.Form method="post" action={`/api/orders/${order.numericId}/invoice`}>
{isCancelReissue ? (
<input type="hidden" name="action" value="cancel_reissue" />
) : null}
<s-button
type="submit"
disabled={isBusy}
variant={order.hasInvoice ? "secondary" : "primary"}
tone={isCancelReissue ? "critical" : "auto"}
>
{isBusy ? "Working…" : buttonLabel}
</s-button>
</fetcher.Form>
</s-stack>
</s-table-cell>
</s-table-row>
);
}