feat(offers): generate Angebot/Offer PDFs for draft orders

This commit is contained in:
Gerhard Scheikl
2026-05-09 19:26:33 +02:00
parent 1ec4faaac5
commit 6224597497
8 changed files with 465 additions and 41 deletions
+12 -6
View File
@@ -15,15 +15,17 @@ import { sendInvoiceEmail } from "../services/invoice/email.server";
export const loader = async ({ request, params }: LoaderFunctionArgs) => {
const { session, cors } = await authenticate.admin(request);
const orderId = requireOrderId(params);
const url = new URL(request.url);
const kind = (url.searchParams.get("kind") === "offer" ? "offer" : "invoice") as "invoice" | "offer";
const orderGid = orderId.startsWith("gid://")
? orderId
: `gid://shopify/Order/${orderId}`;
: `gid://shopify/${kind === "offer" ? "DraftOrder" : "Order"}/${orderId}`;
const invoices = await db.invoice.findMany({
where: { shopDomain: session.shop, orderId: orderGid },
orderBy: [{ issuedAt: "desc" }],
});
const latest = invoices.find((i) => i.kind === "invoice" && !i.cancelledAt);
const latest = invoices.find((i) => i.kind === kind && !i.cancelledAt);
return cors(
Response.json({
@@ -41,15 +43,18 @@ export const action = async ({ request, params }: ActionFunctionArgs) => {
const orderId = requireOrderId(params);
const url = new URL(request.url);
let op = url.searchParams.get("action");
if (!op) {
// Also accept the action from the form body (used by the in-app fetcher).
let kindParam = url.searchParams.get("kind");
if (!op || !kindParam) {
// Also accept the action / kind from the form body (used by the in-app fetcher).
const ct = request.headers.get("content-type") || "";
if (ct.includes("application/x-www-form-urlencoded") || ct.includes("multipart/form-data")) {
const form = await request.formData();
op = (form.get("action") as string | null) ?? null;
op = op ?? ((form.get("action") as string | null) ?? null);
kindParam = kindParam ?? ((form.get("kind") as string | null) ?? null);
}
}
op = op ?? "generate";
const kind: "invoice" | "offer" = kindParam === "offer" ? "offer" : "invoice";
try {
if (op === "cancel_reissue") {
@@ -109,8 +114,9 @@ export const action = async ({ request, params }: ActionFunctionArgs) => {
shopDomain: session.shop,
admin,
orderId,
kind,
});
return cors(Response.json({ ok: true, op: "generate", ...result }));
return cors(Response.json({ ok: true, op: kind === "offer" ? "generate_offer" : "generate", ...result }));
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
console.error("invoice action failed:", err);
+173 -1
View File
@@ -20,6 +20,20 @@ interface RecentOrder {
pdfUrl?: string;
}
interface DraftOrderRow {
id: string; // gid
numericId: string;
name: string;
createdAt: string;
totalPrice: string;
currency: string;
customerName: string;
hasOffer: boolean;
offerNumber?: string;
offerVersion?: number;
pdfUrl?: string;
}
const RECENT_ORDERS_QUERY = `#graphql
query RecentOrders($first: Int!) {
orders(first: $first, sortKey: CREATED_AT, reverse: true) {
@@ -35,6 +49,20 @@ const RECENT_ORDERS_QUERY = `#graphql
}
`;
const RECENT_DRAFTS_QUERY = `#graphql
query RecentDrafts($first: Int!) {
draftOrders(first: $first, sortKey: UPDATED_AT, reverse: true, query: "status:open") {
nodes {
id
name
createdAt
totalPriceSet { shopMoney { amount currencyCode } }
customer { firstName lastName }
}
}
}
`;
type Filter = "all" | "missing" | "with";
export const loader = async ({ request }: LoaderFunctionArgs) => {
@@ -47,6 +75,7 @@ export const loader = async ({ request }: LoaderFunctionArgs) => {
: "all";
let orders: RecentOrder[] = [];
let drafts: DraftOrderRow[] = [];
try {
const res = await admin.graphql(RECENT_ORDERS_QUERY, { variables: { first: 50 } });
const json = (await res.json()) as {
@@ -103,6 +132,56 @@ export const loader = async ({ request }: LoaderFunctionArgs) => {
console.warn("Failed to load recent orders:", err);
}
try {
const res = await admin.graphql(RECENT_DRAFTS_QUERY, { variables: { first: 50 } });
const json = (await res.json()) as {
data?: {
draftOrders?: {
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?.draftOrders?.nodes ?? [];
const draftIds = nodes.map((n) => n.id);
const offers = await db.invoice.findMany({
where: { shopDomain: session.shop, orderId: { in: draftIds }, kind: "offer" },
orderBy: [{ version: "desc" }, { createdAt: "desc" }],
});
const latestByDraft = new Map<string, (typeof offers)[number]>();
for (const off of offers) {
if (!latestByDraft.has(off.orderId)) latestByDraft.set(off.orderId, off);
}
drafts = nodes.map((n) => {
const off = latestByDraft.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",
hasOffer: !!off && !off.cancelledAt,
offerNumber: off?.invoiceNumber,
offerVersion: off?.version,
pdfUrl: off?.pdfUrl,
};
});
} catch (err) {
console.warn("Failed to load draft orders:", err);
}
const allCount = orders.length;
const withCount = orders.filter((o) => o.hasInvoice).length;
const missingCount = allCount - withCount;
@@ -112,6 +191,7 @@ export const loader = async ({ request }: LoaderFunctionArgs) => {
return {
orders,
drafts,
filter,
counts: { all: allCount, with: withCount, missing: missingCount },
};
@@ -134,7 +214,7 @@ function formatMoney(amount: string, currency: string): string {
}
export default function InvoicesPage() {
const { orders, filter, counts } = useLoaderData<typeof loader>();
const { orders, drafts, filter, counts } = useLoaderData<typeof loader>();
const navigation = useNavigation();
const isLoading = navigation.state !== "idle";
@@ -195,6 +275,40 @@ export default function InvoicesPage() {
)}
</s-section>
<s-section heading="Draft orders (offers)">
<s-stack direction="block" gap="base">
<s-paragraph>
Generate a PDF offer (Angebot) for any open draft order. The
offer's number is the draft order name (e.g. <em>D1</em>).
</s-paragraph>
</s-stack>
{drafts.length === 0 ? (
<s-stack direction="block" gap="base" alignItems="center">
<s-text type="strong">No open draft orders</s-text>
<s-paragraph tone="neutral">
Create a draft order in Shopify and refresh this page.
</s-paragraph>
</s-stack>
) : (
<s-table>
<s-table-header-row>
<s-table-header listSlot="primary">Draft</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">Offer</s-table-header>
<s-table-header listSlot="labeled">Actions</s-table-header>
</s-table-header-row>
<s-table-body>
{drafts.map((d) => (
<DraftRow key={d.id} draft={d} />
))}
</s-table-body>
</s-table>
)}
</s-section>
<s-section heading="About this page">
<s-stack direction="block" gap="small">
<s-paragraph>
@@ -319,3 +433,61 @@ function OrderRow({ order }: { order: RecentOrder }) {
</s-table-row>
);
}
function DraftRow({ draft }: { draft: DraftOrderRow }) {
const fetcher = useFetcher<{ ok: boolean; error?: string }>();
const isBusy = fetcher.state !== "idle";
const buttonLabel = draft.hasOffer ? "Regenerate offer" : "Generate offer";
return (
<s-table-row>
<s-table-cell>
<s-stack direction="block" gap="none">
<s-link href={`shopify://admin/draft_orders/${draft.numericId}`}>
<s-text type="strong">{draft.name}</s-text>
</s-link>
</s-stack>
</s-table-cell>
<s-table-cell>{draft.customerName}</s-table-cell>
<s-table-cell>{dateFmt.format(new Date(draft.createdAt))}</s-table-cell>
<s-table-cell>{formatMoney(draft.totalPrice, draft.currency)}</s-table-cell>
<s-table-cell>
{draft.hasOffer ? (
<s-stack direction="block" gap="none">
<s-stack direction="inline" gap="small" alignItems="center">
<s-text type="strong">{draft.offerNumber}</s-text>
<s-badge tone="info">Issued</s-badge>
{draft.offerVersion && draft.offerVersion > 1 ? (
<s-text tone="neutral">v{draft.offerVersion}</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">
{draft.pdfUrl ? (
<s-link href={draft.pdfUrl} target="_blank">
PDF
</s-link>
) : null}
<fetcher.Form method="post" action={`/api/orders/${draft.numericId}/invoice`}>
<input type="hidden" name="kind" value="offer" />
<s-button
type="submit"
disabled={isBusy}
variant={draft.hasOffer ? "secondary" : "primary"}
>
{isBusy ? "Working…" : buttonLabel}
</s-button>
</fetcher.Form>
</s-stack>
</s-table-cell>
</s-table-row>
);
}