first version

This commit is contained in:
Gerhard Scheikl
2026-04-28 21:56:11 +02:00
parent 0f75dbaccb
commit 5b2aa5d62b
50 changed files with 5514 additions and 481 deletions
+191
View File
@@ -0,0 +1,191 @@
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>
);
}