feat(offers): generate Angebot/Offer PDFs for draft orders
This commit is contained in:
+173
-1
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user