501 lines
17 KiB
TypeScript
501 lines
17 KiB
TypeScript
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;
|
|
}
|
|
|
|
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) {
|
|
nodes {
|
|
id
|
|
name
|
|
createdAt
|
|
displayFinancialStatus
|
|
totalPriceSet { shopMoney { amount currencyCode } }
|
|
customer { firstName lastName }
|
|
}
|
|
}
|
|
}
|
|
`;
|
|
|
|
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) => {
|
|
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[] = [];
|
|
let drafts: DraftOrderRow[] = [];
|
|
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<string, (typeof invoices)[number]>();
|
|
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);
|
|
}
|
|
|
|
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;
|
|
}>;
|
|
};
|
|
};
|
|
errors?: Array<{ message: string }>;
|
|
};
|
|
if (json.errors?.length) {
|
|
console.warn(
|
|
"draftOrders query returned errors:",
|
|
json.errors.map((e) => e.message).join("; "),
|
|
);
|
|
}
|
|
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;
|
|
|
|
if (filter === "missing") orders = orders.filter((o) => !o.hasInvoice);
|
|
if (filter === "with") orders = orders.filter((o) => o.hasInvoice);
|
|
|
|
return {
|
|
orders,
|
|
drafts,
|
|
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, drafts, filter, counts } = useLoaderData<typeof loader>();
|
|
const navigation = useNavigation();
|
|
const isLoading = navigation.state !== "idle";
|
|
|
|
return (
|
|
<s-page heading="Invoices">
|
|
<s-section heading="Recent orders">
|
|
<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="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>
|
|
|
|
{isLoading ? (
|
|
<s-stack direction="inline" gap="base" alignItems="center">
|
|
<s-spinner size="base" accessibilityLabel="Loading orders" />
|
|
<s-text tone="neutral">Loading…</s-text>
|
|
</s-stack>
|
|
) : orders.length === 0 ? (
|
|
<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-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="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>
|
|
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 FilterChip({
|
|
to,
|
|
active,
|
|
count,
|
|
children,
|
|
}: {
|
|
to: string;
|
|
active: boolean;
|
|
count: number;
|
|
children: React.ReactNode;
|
|
}) {
|
|
return (
|
|
<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 sendFetcher = useFetcher<{ ok: boolean; error?: string }>();
|
|
const isBusy = fetcher.state !== "idle";
|
|
const isSending = sendFetcher.state !== "idle";
|
|
const isCancelReissue = order.hasInvoice && order.invoiceSent;
|
|
const buttonLabel = !order.hasInvoice
|
|
? "Generate"
|
|
: order.invoiceSent
|
|
? "Cancel & reissue"
|
|
: "Regenerate";
|
|
const sendLabel = order.invoiceSent ? "Re-send" : "Send";
|
|
|
|
return (
|
|
<s-table-row>
|
|
<s-table-cell>
|
|
<s-stack direction="block" gap="none">
|
|
<s-link href={`shopify://admin/orders/${order.numericId}`}>
|
|
<s-text type="strong">{order.name}</s-text>
|
|
</s-link>
|
|
</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}
|
|
{sendFetcher.data?.error ? (
|
|
<s-text tone="critical">{sendFetcher.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 || isSending}
|
|
variant={order.hasInvoice ? "secondary" : "primary"}
|
|
tone={isCancelReissue ? "critical" : "auto"}
|
|
>
|
|
{isBusy ? "Working…" : buttonLabel}
|
|
</s-button>
|
|
</fetcher.Form>
|
|
<sendFetcher.Form method="post" action={`/api/orders/${order.numericId}/invoice`}>
|
|
<input type="hidden" name="action" value="send" />
|
|
<s-button
|
|
type="submit"
|
|
disabled={isBusy || isSending}
|
|
variant={order.hasInvoice && !order.invoiceSent ? "primary" : "secondary"}
|
|
>
|
|
{isSending ? "Sending…" : sendLabel}
|
|
</s-button>
|
|
</sendFetcher.Form>
|
|
</s-stack>
|
|
</s-table-cell>
|
|
</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>
|
|
);
|
|
}
|