192 lines
5.9 KiB
TypeScript
192 lines
5.9 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
|
|
name: string;
|
|
createdAt: string;
|
|
totalPrice: string;
|
|
currency: string;
|
|
hasInvoice: boolean;
|
|
invoiceNumber?: string;
|
|
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 } }
|
|
}
|
|
}
|
|
}
|
|
`;
|
|
|
|
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";
|
|
|
|
// Recent orders from Shopify (first 25).
|
|
let orders: RecentOrder[] = [];
|
|
try {
|
|
const res = await admin.graphql(RECENT_ORDERS_QUERY, { variables: { first: 25 } });
|
|
const json = (await res.json()) as {
|
|
data?: {
|
|
orders?: {
|
|
nodes?: Array<{
|
|
id: string;
|
|
name: string;
|
|
createdAt: string;
|
|
totalPriceSet?: { shopMoney: { amount: string; currencyCode: string } };
|
|
}>;
|
|
};
|
|
};
|
|
};
|
|
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,
|
|
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);
|
|
return {
|
|
id: n.id,
|
|
name: n.name,
|
|
createdAt: n.createdAt,
|
|
totalPrice: n.totalPriceSet?.shopMoney.amount ?? "",
|
|
currency: n.totalPriceSet?.shopMoney.currencyCode ?? "EUR",
|
|
hasInvoice: !!inv && !inv.cancelledAt,
|
|
invoiceNumber: inv?.invoiceNumber,
|
|
invoiceSent: !!inv?.sentAt,
|
|
invoiceCancelled: !!inv?.cancelledAt,
|
|
pdfUrl: inv?.pdfUrl,
|
|
};
|
|
});
|
|
} catch (err) {
|
|
console.warn("Failed to load recent orders:", err);
|
|
}
|
|
|
|
if (filter === "missing") orders = orders.filter((o) => !o.hasInvoice);
|
|
if (filter === "with") orders = orders.filter((o) => o.hasInvoice);
|
|
|
|
return { orders, filter };
|
|
};
|
|
|
|
export default function InvoicesPage() {
|
|
const { orders, filter } = 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-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>
|
|
|
|
{isLoading ? (
|
|
<s-paragraph>Loading…</s-paragraph>
|
|
) : orders.length === 0 ? (
|
|
<s-paragraph>No orders match the current filter.</s-paragraph>
|
|
) : (
|
|
<s-unordered-list>
|
|
{orders.map((o) => (
|
|
<OrderRow key={o.id} order={o} />
|
|
))}
|
|
</s-unordered-list>
|
|
)}
|
|
</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>
|
|
</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";
|
|
|
|
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>
|
|
);
|
|
}
|