import type { LoaderFunctionArgs } from "react-router"; import { Link, useLoaderData, useNavigation, useFetcher } from "react-router"; import { authenticate } from "../shopify.server"; 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; } const RECENT_ORDERS_QUERY = `#graphql query RecentOrders($first: Int!) { orders(first: $first, sortKey: CREATED_AT, reverse: true) { nodes { id name 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 filterParam = (url.searchParams.get("filter") ?? "all") as Filter; const filter: Filter = ["all", "missing", "with"].includes(filterParam) ? filterParam : "all"; let orders: RecentOrder[] = []; try { const res = await admin.graphql(RECENT_ORDERS_QUERY, { variables: { first: 50 } }); const json = (await res.json()) as { data?: { orders?: { nodes?: Array<{ id: string; name: string; createdAt: string; totalPriceSet?: { shopMoney: { amount: string; currencyCode: string } }; customer?: { firstName: string | null; lastName: string | null } | null; }>; }; }; }; const nodes = json.data?.orders?.nodes ?? []; const orderIds = nodes.map((n) => n.id); const invoices = await db.invoice.findMany({ where: { shopDomain: session.shop, orderId: { in: orderIds }, kind: "invoice", }, orderBy: [{ version: "desc" }, { createdAt: "desc" }], }); const latestByOrder = new Map(); for (const inv of invoices) { if (!latestByOrder.has(inv.orderId)) latestByOrder.set(inv.orderId, inv); } 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, }; }); } catch (err) { 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, 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, counts } = useLoaderData(); const navigation = useNavigation(); const isLoading = navigation.state !== "idle"; return ( Generate the invoice for an order, regenerate an unsent draft, or cancel-and-reissue a sent one. Newest orders appear first. All Missing invoice Has invoice {isLoading ? ( Loading… ) : orders.length === 0 ? ( No orders match this filter Try a different filter or wait for new orders. ) : ( Order Customer Date Total Invoice Actions {orders.map((order) => ( ))} )} 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. Tip: filter by Missing invoice to find orders that still need one. ); } function FilterChip({ to, active, count, children, }: { to: string; active: boolean; count: number; children: React.ReactNode; }) { return ( {children} ({count}) ); } 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 ( {order.name} {order.customerName} {dateFmt.format(new Date(order.createdAt))} {formatMoney(order.totalPrice, order.currency)} {order.hasInvoice ? ( {order.invoiceNumber} {order.invoiceCancelled ? ( Cancelled ) : order.invoiceSent ? ( Sent ) : ( Issued )} {order.invoiceVersion && order.invoiceVersion > 1 ? ( v{order.invoiceVersion} ) : null} {fetcher.data?.error ? ( {fetcher.data.error} ) : null} ) : ( )} {order.pdfUrl ? ( PDF ) : null} {isCancelReissue ? ( ) : null} {isBusy ? "Working…" : buttonLabel} ); }