many updates :-)
This commit is contained in:
+161
-35
@@ -7,15 +7,25 @@ import db from "../db.server";
|
||||
export const loader = async ({ request }: LoaderFunctionArgs) => {
|
||||
const { session } = await authenticate.admin(request);
|
||||
|
||||
const [settings, recent] = await Promise.all([
|
||||
const [settings, recent, counts] = await Promise.all([
|
||||
db.shopSettings.findUnique({ where: { shopDomain: session.shop } }),
|
||||
db.invoice.findMany({
|
||||
where: { shopDomain: session.shop },
|
||||
orderBy: [{ issuedAt: "desc" }],
|
||||
take: 10,
|
||||
take: 8,
|
||||
}),
|
||||
db.invoice.groupBy({
|
||||
by: ["status"],
|
||||
where: { shopDomain: session.shop },
|
||||
_count: { _all: true },
|
||||
}),
|
||||
]);
|
||||
|
||||
const total = counts.reduce((acc, row) => acc + row._count._all, 0);
|
||||
const issuedCount = counts.find((c) => c.status === "issued")?._count._all ?? 0;
|
||||
const sentCount = counts.find((c) => c.status === "sent")?._count._all ?? 0;
|
||||
const cancelledCount = counts.find((c) => c.status === "cancelled")?._count._all ?? 0;
|
||||
|
||||
const settingsConfigured = !!(
|
||||
settings &&
|
||||
settings.companyName &&
|
||||
@@ -25,12 +35,14 @@ export const loader = async ({ request }: LoaderFunctionArgs) => {
|
||||
|
||||
return {
|
||||
settingsConfigured,
|
||||
metrics: { total, issuedCount, sentCount, cancelledCount },
|
||||
recent: recent.map((i) => ({
|
||||
id: i.id,
|
||||
number: i.invoiceNumber,
|
||||
kind: i.kind,
|
||||
orderName: i.orderName,
|
||||
version: i.version,
|
||||
status: i.status,
|
||||
sentAt: i.sentAt?.toISOString() ?? null,
|
||||
cancelledAt: i.cancelledAt?.toISOString() ?? null,
|
||||
issuedAt: i.issuedAt.toISOString(),
|
||||
@@ -39,54 +51,168 @@ export const loader = async ({ request }: LoaderFunctionArgs) => {
|
||||
};
|
||||
};
|
||||
|
||||
const dateFmt = new Intl.DateTimeFormat("en-GB", {
|
||||
day: "2-digit",
|
||||
month: "short",
|
||||
year: "numeric",
|
||||
});
|
||||
|
||||
function formatDate(iso: string | null): string {
|
||||
if (!iso) return "—";
|
||||
return dateFmt.format(new Date(iso));
|
||||
}
|
||||
|
||||
interface RecentInvoice {
|
||||
id: string;
|
||||
number: string;
|
||||
kind: string;
|
||||
orderName: string;
|
||||
version: number;
|
||||
status: string;
|
||||
sentAt: string | null;
|
||||
cancelledAt: string | null;
|
||||
issuedAt: string;
|
||||
pdfUrl: string;
|
||||
}
|
||||
|
||||
function statusBadge(invoice: RecentInvoice): {
|
||||
tone: "success" | "info" | "critical" | "warning";
|
||||
label: string;
|
||||
} {
|
||||
if (invoice.cancelledAt) return { tone: "critical", label: "Cancelled" };
|
||||
if (invoice.kind === "storno") return { tone: "warning", label: "Storno" };
|
||||
if (invoice.sentAt) return { tone: "success", label: "Sent" };
|
||||
return { tone: "info", label: "Issued" };
|
||||
}
|
||||
|
||||
export default function Index() {
|
||||
const { settingsConfigured, recent } = useLoaderData<typeof loader>();
|
||||
const { settingsConfigured, metrics, recent } = useLoaderData<typeof loader>();
|
||||
|
||||
return (
|
||||
<s-page heading="LinumIQ Invoice">
|
||||
{!settingsConfigured && (
|
||||
<s-banner tone="warning" heading="Configure your invoice settings">
|
||||
Complete your company, bank and numbering details so generated
|
||||
invoices are legally compliant.{" "}
|
||||
<Link to="/app/settings">Open settings</Link>
|
||||
<s-paragraph>
|
||||
Complete your company, bank and numbering details so generated
|
||||
invoices are legally compliant.
|
||||
</s-paragraph>
|
||||
<s-stack direction="inline" gap="base">
|
||||
<s-link href="/app/settings">Open settings →</s-link>
|
||||
</s-stack>
|
||||
</s-banner>
|
||||
)}
|
||||
|
||||
<s-section heading="What this app does">
|
||||
<s-paragraph>
|
||||
Generates Austrian-compliant PDF invoices for your Shopify orders.
|
||||
Trigger from the order page (Generate invoice action), via Shopify
|
||||
Flow, or in bulk from the Invoices page. PDFs are stored on
|
||||
Shopify Files and linked to each order via metafields.
|
||||
</s-paragraph>
|
||||
<s-section heading="Overview">
|
||||
<s-grid gridTemplateColumns="repeat(4, minmax(0, 1fr))" gap="base">
|
||||
<Metric label="Total invoices" value={metrics.total} />
|
||||
<Metric label="Issued" value={metrics.issuedCount} tone="info" />
|
||||
<Metric label="Sent" value={metrics.sentCount} tone="success" />
|
||||
<Metric label="Cancelled" value={metrics.cancelledCount} tone="critical" />
|
||||
</s-grid>
|
||||
</s-section>
|
||||
|
||||
<s-section heading="Recent invoices">
|
||||
<s-section
|
||||
heading="Recent invoices"
|
||||
padding="none"
|
||||
accessibilityLabel="Recent invoices table"
|
||||
>
|
||||
{recent.length === 0 ? (
|
||||
<s-paragraph>No invoices generated yet.</s-paragraph>
|
||||
<s-box padding="base">
|
||||
<s-stack direction="block" gap="base" alignItems="center">
|
||||
<s-text type="strong">No invoices yet</s-text>
|
||||
<s-paragraph tone="neutral">
|
||||
Generate your first invoice from the Invoices page or directly
|
||||
from a Shopify order.
|
||||
</s-paragraph>
|
||||
<s-link href="/app/invoices">Open invoices →</s-link>
|
||||
</s-stack>
|
||||
</s-box>
|
||||
) : (
|
||||
<s-unordered-list>
|
||||
{recent.map((i) => (
|
||||
<s-list-item key={i.id}>
|
||||
{i.kind === "storno" ? "Storno " : ""}
|
||||
{i.number} — order {i.orderName} (v{i.version})
|
||||
{i.cancelledAt
|
||||
? " — cancelled"
|
||||
: i.sentAt
|
||||
? " — sent"
|
||||
: ""}
|
||||
{i.pdfUrl ? (
|
||||
<>
|
||||
{" "}
|
||||
[<a href={i.pdfUrl} target="_blank" rel="noreferrer">PDF</a>]
|
||||
</>
|
||||
) : null}
|
||||
</s-list-item>
|
||||
))}
|
||||
</s-unordered-list>
|
||||
<s-table>
|
||||
<s-table-header-row>
|
||||
<s-table-header listSlot="primary">Invoice</s-table-header>
|
||||
<s-table-header>Order</s-table-header>
|
||||
<s-table-header>Issued</s-table-header>
|
||||
<s-table-header listSlot="secondary">Status</s-table-header>
|
||||
<s-table-header listSlot="labeled">PDF</s-table-header>
|
||||
</s-table-header-row>
|
||||
<s-table-body>
|
||||
{recent.map((invoice) => {
|
||||
const badge = statusBadge(invoice);
|
||||
return (
|
||||
<s-table-row key={invoice.id}>
|
||||
<s-table-cell>
|
||||
<s-stack direction="block" gap="none">
|
||||
<s-text type="strong">{invoice.number}</s-text>
|
||||
{invoice.version > 1 ? (
|
||||
<s-text tone="neutral">v{invoice.version}</s-text>
|
||||
) : null}
|
||||
</s-stack>
|
||||
</s-table-cell>
|
||||
<s-table-cell>{invoice.orderName}</s-table-cell>
|
||||
<s-table-cell>{formatDate(invoice.issuedAt)}</s-table-cell>
|
||||
<s-table-cell>
|
||||
<s-badge tone={badge.tone}>{badge.label}</s-badge>
|
||||
</s-table-cell>
|
||||
<s-table-cell>
|
||||
<s-stack direction="inline" gap="small" justifyContent="end">
|
||||
{invoice.pdfUrl ? (
|
||||
<s-link href={invoice.pdfUrl} target="_blank">
|
||||
Open
|
||||
</s-link>
|
||||
) : (
|
||||
<s-text tone="neutral">—</s-text>
|
||||
)}
|
||||
</s-stack>
|
||||
</s-table-cell>
|
||||
</s-table-row>
|
||||
);
|
||||
})}
|
||||
</s-table-body>
|
||||
</s-table>
|
||||
)}
|
||||
<Link to="/app/invoices">Open invoices page</Link>
|
||||
<s-box padding="base">
|
||||
<s-stack direction="inline" justifyContent="end">
|
||||
<Link to="/app/invoices">View all invoices →</Link>
|
||||
</s-stack>
|
||||
</s-box>
|
||||
</s-section>
|
||||
|
||||
<s-section heading="How it works">
|
||||
<s-stack direction="block" gap="base">
|
||||
<s-paragraph>
|
||||
LinumIQ Invoice generates Austrian-compliant PDF invoices for your
|
||||
Shopify orders. PDFs are stored on Shopify Files and linked to
|
||||
each order via metafields.
|
||||
</s-paragraph>
|
||||
<s-paragraph tone="neutral">
|
||||
Trigger generation from the order page (Generate invoice action),
|
||||
via Shopify Flow, or in bulk from the Invoices page.
|
||||
</s-paragraph>
|
||||
</s-stack>
|
||||
</s-section>
|
||||
</s-page>
|
||||
);
|
||||
}
|
||||
|
||||
function Metric({
|
||||
label,
|
||||
value,
|
||||
tone,
|
||||
}: {
|
||||
label: string;
|
||||
value: number;
|
||||
tone?: "info" | "success" | "critical";
|
||||
}) {
|
||||
const valueTone = tone ?? "neutral";
|
||||
return (
|
||||
<s-box padding="base" background="subdued" border="base" borderRadius="base">
|
||||
<s-stack direction="block" gap="small">
|
||||
<s-text tone="neutral">{label}</s-text>
|
||||
<s-heading>
|
||||
<s-text tone={valueTone}>{value}</s-text>
|
||||
</s-heading>
|
||||
</s-stack>
|
||||
</s-box>
|
||||
);
|
||||
}
|
||||
|
||||
+196
-78
@@ -6,12 +6,15 @@ 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;
|
||||
@@ -26,21 +29,26 @@ const RECENT_ORDERS_QUERY = `#graphql
|
||||
createdAt
|
||||
displayFinancialStatus
|
||||
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 filter = url.searchParams.get("filter") ?? "all";
|
||||
const filterParam = (url.searchParams.get("filter") ?? "all") as Filter;
|
||||
const filter: Filter = ["all", "missing", "with"].includes(filterParam)
|
||||
? filterParam
|
||||
: "all";
|
||||
|
||||
// Recent orders from Shopify (first 25).
|
||||
let orders: RecentOrder[] = [];
|
||||
try {
|
||||
const res = await admin.graphql(RECENT_ORDERS_QUERY, { variables: { first: 25 } });
|
||||
const res = await admin.graphql(RECENT_ORDERS_QUERY, { variables: { first: 50 } });
|
||||
const json = (await res.json()) as {
|
||||
data?: {
|
||||
orders?: {
|
||||
@@ -49,6 +57,7 @@ export const loader = async ({ request }: LoaderFunctionArgs) => {
|
||||
name: string;
|
||||
createdAt: string;
|
||||
totalPriceSet?: { shopMoney: { amount: string; currencyCode: string } };
|
||||
customer?: { firstName: string | null; lastName: string | null } | null;
|
||||
}>;
|
||||
};
|
||||
};
|
||||
@@ -56,7 +65,6 @@ export const loader = async ({ request }: LoaderFunctionArgs) => {
|
||||
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,
|
||||
@@ -72,14 +80,20 @@ export const loader = async ({ request }: LoaderFunctionArgs) => {
|
||||
|
||||
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,
|
||||
@@ -89,103 +103,207 @@ export const loader = async ({ request }: LoaderFunctionArgs) => {
|
||||
console.warn("Failed to load recent 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, filter };
|
||||
return {
|
||||
orders,
|
||||
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, filter } = useLoaderData<typeof loader>();
|
||||
const { orders, filter, counts } = 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-section heading="Recent orders" padding="none">
|
||||
<s-box padding="base">
|
||||
<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="base">
|
||||
<Link to="?filter=all">All</Link>
|
||||
<Link to="?filter=missing">Missing invoice</Link>
|
||||
<Link to="?filter=with">Has invoice</Link>
|
||||
</s-stack>
|
||||
<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>
|
||||
</s-box>
|
||||
|
||||
{isLoading ? (
|
||||
<s-paragraph>Loading…</s-paragraph>
|
||||
<s-box padding="base">
|
||||
<s-stack direction="inline" gap="small" alignItems="center">
|
||||
<s-spinner size="small" accessibilityLabel="Loading orders" />
|
||||
<s-text tone="neutral">Loading…</s-text>
|
||||
</s-stack>
|
||||
</s-box>
|
||||
) : orders.length === 0 ? (
|
||||
<s-paragraph>No orders match the current filter.</s-paragraph>
|
||||
<s-box padding="base">
|
||||
<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-box>
|
||||
) : (
|
||||
<s-unordered-list>
|
||||
{orders.map((o) => (
|
||||
<OrderRow key={o.id} order={o} />
|
||||
))}
|
||||
</s-unordered-list>
|
||||
<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="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 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 OrderRow({ order }: { order: RecentOrder }) {
|
||||
const numericId = order.id.replace(/^.*\//, "");
|
||||
const fetcher = useFetcher<{ ok: boolean; error?: string; invoiceNumber?: string }>();
|
||||
const isBusy = fetcher.state !== "idle";
|
||||
|
||||
function FilterChip({
|
||||
to,
|
||||
active,
|
||||
count,
|
||||
children,
|
||||
}: {
|
||||
to: string;
|
||||
active: boolean;
|
||||
count: number;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
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>
|
||||
<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 isBusy = fetcher.state !== "idle";
|
||||
const isCancelReissue = order.hasInvoice && order.invoiceSent;
|
||||
const buttonLabel = !order.hasInvoice
|
||||
? "Generate"
|
||||
: order.invoiceSent
|
||||
? "Cancel & reissue"
|
||||
: "Regenerate";
|
||||
|
||||
return (
|
||||
<s-table-row>
|
||||
<s-table-cell>
|
||||
<s-stack direction="block" gap="none">
|
||||
<s-text type="strong">{order.name}</s-text>
|
||||
</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}
|
||||
</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}
|
||||
variant={order.hasInvoice ? "secondary" : "primary"}
|
||||
tone={isCancelReissue ? "critical" : "auto"}
|
||||
>
|
||||
{isBusy ? "Working…" : buttonLabel}
|
||||
</s-button>
|
||||
</fetcher.Form>
|
||||
</s-stack>
|
||||
</s-table-cell>
|
||||
</s-table-row>
|
||||
);
|
||||
}
|
||||
|
||||
+99
-19
@@ -8,6 +8,11 @@ import {
|
||||
isValidIban,
|
||||
normaliseIban,
|
||||
} from "../services/invoice/validation";
|
||||
import {
|
||||
STORED_LOGO_SENTINEL,
|
||||
deleteStoredLogo,
|
||||
storeUploadedLogo,
|
||||
} from "../services/invoice/logoCache.server";
|
||||
|
||||
interface SettingsFieldErrors {
|
||||
vatId?: string;
|
||||
@@ -16,6 +21,7 @@ interface SettingsFieldErrors {
|
||||
smtpPort?: string;
|
||||
paymentTermDays?: string;
|
||||
invoiceSeed?: string;
|
||||
logo?: string;
|
||||
}
|
||||
|
||||
export const loader = async ({ request }: LoaderFunctionArgs) => {
|
||||
@@ -25,7 +31,14 @@ export const loader = async ({ request }: LoaderFunctionArgs) => {
|
||||
update: {},
|
||||
create: { shopDomain: session.shop },
|
||||
});
|
||||
return { settings };
|
||||
let logoPreviewDataUrl: string | null = null;
|
||||
if (settings.logoUrl === STORED_LOGO_SENTINEL) {
|
||||
const cached = await db.logoCache.findUnique({ where: { shopDomain: session.shop } });
|
||||
if (cached) {
|
||||
logoPreviewDataUrl = `data:${cached.contentType};base64,${Buffer.from(cached.bytes).toString("base64")}`;
|
||||
}
|
||||
}
|
||||
return { settings, logoPreviewDataUrl };
|
||||
};
|
||||
|
||||
export const action = async ({ request }: ActionFunctionArgs) => {
|
||||
@@ -72,6 +85,32 @@ export const action = async ({ request }: ActionFunctionArgs) => {
|
||||
errors.invoiceSeed = "Must be a non-negative number.";
|
||||
}
|
||||
|
||||
// --- Logo handling --------------------------------------------------------
|
||||
// The settings page allows three actions on the logo:
|
||||
// 1. Upload a new file (multipart `logoFile`) — stored in `LogoCache`.
|
||||
// 2. Remove the current logo (`removeLogo=on`).
|
||||
// 3. Provide an external URL via the `logoUrl` field.
|
||||
// If a file is uploaded it wins over a manually-entered URL.
|
||||
let resolvedLogoUrl = str("logoUrl");
|
||||
const removeLogo = bool("removeLogo");
|
||||
const logoFile = form.get("logoFile");
|
||||
const hasUpload =
|
||||
logoFile && typeof logoFile === "object" && "size" in logoFile && (logoFile as File).size > 0;
|
||||
|
||||
if (removeLogo && !hasUpload) {
|
||||
await deleteStoredLogo(session.shop);
|
||||
resolvedLogoUrl = "";
|
||||
} else if (hasUpload) {
|
||||
const file = logoFile as File;
|
||||
const buf = Buffer.from(await file.arrayBuffer());
|
||||
const stored = await storeUploadedLogo(session.shop, buf, file.type);
|
||||
if (!stored.ok) {
|
||||
errors.logo = stored.error ?? "Failed to store uploaded logo.";
|
||||
} else {
|
||||
resolvedLogoUrl = STORED_LOGO_SENTINEL;
|
||||
}
|
||||
}
|
||||
|
||||
if (Object.keys(errors).length > 0) {
|
||||
return { ok: false, errors, savedAt: null as string | null };
|
||||
}
|
||||
@@ -102,8 +141,9 @@ export const action = async ({ request }: ActionFunctionArgs) => {
|
||||
defaultLanguage: str("defaultLanguage", "de") === "en" ? "en" : "de",
|
||||
paymentTermDays: paymentTermDays ?? 14,
|
||||
footerNote: str("footerNote"),
|
||||
footerNoteEn: str("footerNoteEn"),
|
||||
kleinunternehmer: bool("kleinunternehmer"),
|
||||
logoUrl: str("logoUrl"),
|
||||
logoUrl: resolvedLogoUrl,
|
||||
smtpHost: str("smtpHost"),
|
||||
smtpPort: smtpPort ?? 587,
|
||||
smtpSecure: bool("smtpSecure"),
|
||||
@@ -124,30 +164,38 @@ export const action = async ({ request }: ActionFunctionArgs) => {
|
||||
};
|
||||
|
||||
export default function SettingsRoute() {
|
||||
const { settings } = useLoaderData<typeof loader>();
|
||||
const { settings, logoPreviewDataUrl } = useLoaderData<typeof loader>();
|
||||
const actionData = useActionData<typeof action>();
|
||||
const nav = useNavigation();
|
||||
const isSaving = nav.state === "submitting";
|
||||
const errors = actionData?.errors ?? {};
|
||||
const hasStoredLogo = settings.logoUrl === STORED_LOGO_SENTINEL;
|
||||
// Show the URL field only when not using a stored upload — keeps the UI
|
||||
// simpler and avoids the sentinel value leaking into the input.
|
||||
const visibleLogoUrl = hasStoredLogo ? "" : settings.logoUrl;
|
||||
|
||||
return (
|
||||
<s-page heading="Invoice settings">
|
||||
<s-paragraph>
|
||||
Configure issuer details, bank, numbering, language and SMTP. These
|
||||
values are written into every PDF invoice this app generates and must
|
||||
match your legal records.
|
||||
</s-paragraph>
|
||||
<s-section>
|
||||
<s-paragraph>
|
||||
Configure issuer details, bank, numbering, language and SMTP. These
|
||||
values are written into every PDF invoice this app generates and must
|
||||
match your legal records.
|
||||
</s-paragraph>
|
||||
</s-section>
|
||||
|
||||
{actionData?.ok && (
|
||||
<s-banner tone="success">Settings saved.</s-banner>
|
||||
<s-banner tone="success" heading="Settings saved">
|
||||
Your changes are now live and will be used for the next invoice.
|
||||
</s-banner>
|
||||
)}
|
||||
{actionData && !actionData.ok && (
|
||||
<s-banner tone="critical">
|
||||
Please fix the errors highlighted below.
|
||||
<s-banner tone="critical" heading="Please fix the highlighted errors">
|
||||
Some fields below need attention before settings can be saved.
|
||||
</s-banner>
|
||||
)}
|
||||
|
||||
<Form method="post">
|
||||
<Form method="post" encType="multipart/form-data">
|
||||
<s-section heading="Company">
|
||||
<s-stack direction="block" gap="base">
|
||||
<Field label="Company name" name="companyName" defaultValue={settings.companyName} />
|
||||
@@ -252,8 +300,37 @@ export default function SettingsRoute() {
|
||||
{ value: "en", label: "English (en)" },
|
||||
]}
|
||||
/>
|
||||
<Field label="Footer note (optional)" name="footerNote" defaultValue={settings.footerNote} />
|
||||
<Field label="Logo URL (PNG/JPG, served from Shopify Files or any HTTPS URL)" name="logoUrl" defaultValue={settings.logoUrl} />
|
||||
<Field label="Footer note (German)" name="footerNote" defaultValue={settings.footerNote} helpText="Shown at the bottom of every German PDF invoice (e.g. „Vielen Dank für Ihren Auftrag.“)." />
|
||||
<Field label="Footer note (English)" name="footerNoteEn" defaultValue={settings.footerNoteEn} helpText="Shown on English PDF invoices. Falls back to the German note when empty." />
|
||||
</s-stack>
|
||||
</s-section>
|
||||
|
||||
<s-section heading="Logo">
|
||||
<s-stack direction="block" gap="base">
|
||||
<s-paragraph>
|
||||
The logo is rendered in the top-right corner of every invoice
|
||||
PDF. Upload an image (PNG, JPEG, WebP or GIF, max 5 MB) or
|
||||
provide a publicly reachable HTTPS URL — uploads take precedence.
|
||||
</s-paragraph>
|
||||
{errors.logo && <s-banner tone="critical">{errors.logo}</s-banner>}
|
||||
{logoPreviewDataUrl && (
|
||||
<s-stack direction="block" gap="small">
|
||||
<s-text>Current logo:</s-text>
|
||||
<img
|
||||
src={logoPreviewDataUrl}
|
||||
alt="Current invoice logo"
|
||||
style={{ maxWidth: "240px", maxHeight: "120px", border: "1px solid #ddd", padding: "8px", background: "#fff" }}
|
||||
/>
|
||||
<s-checkbox name="removeLogo" label="Remove current logo on save" />
|
||||
</s-stack>
|
||||
)}
|
||||
<s-text>Upload a new logo:</s-text>
|
||||
<input type="file" name="logoFile" accept="image/png,image/jpeg,image/webp,image/gif" />
|
||||
<Field
|
||||
label="Or external logo URL (PNG/JPG/WebP, served from Shopify Files or any HTTPS URL)"
|
||||
name="logoUrl"
|
||||
defaultValue={visibleLogoUrl}
|
||||
/>
|
||||
</s-stack>
|
||||
</s-section>
|
||||
|
||||
@@ -284,11 +361,14 @@ export default function SettingsRoute() {
|
||||
</s-stack>
|
||||
</s-section>
|
||||
|
||||
<s-stack direction="inline" gap="base">
|
||||
<s-button type="submit" variant="primary" {...(isSaving ? { loading: true } : {})}>
|
||||
Save settings
|
||||
</s-button>
|
||||
</s-stack>
|
||||
<s-section>
|
||||
<s-stack direction="inline" gap="base" justifyContent="end" alignItems="center">
|
||||
{isSaving ? <s-text tone="neutral">Saving…</s-text> : null}
|
||||
<s-button type="submit" variant="primary" {...(isSaving ? { loading: true } : {})}>
|
||||
Save settings
|
||||
</s-button>
|
||||
</s-stack>
|
||||
</s-section>
|
||||
</Form>
|
||||
</s-page>
|
||||
);
|
||||
|
||||
@@ -118,6 +118,7 @@ function mapIssuer(s: ShopSettings): IssuerData {
|
||||
iban: s.iban,
|
||||
bic: s.bic,
|
||||
footerNote: s.footerNote,
|
||||
footerNoteEn: s.footerNoteEn,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -181,6 +182,7 @@ function mapLinesAndTotals(order: RawOrderForInvoice): {
|
||||
quantity: qty,
|
||||
unitPriceNet: round2(unitNet),
|
||||
totalNet: round2(lineNet),
|
||||
imageUrl: li.imageUrl ?? undefined,
|
||||
});
|
||||
|
||||
netSum += lineNet;
|
||||
|
||||
@@ -175,26 +175,25 @@ function renderEmailBody({
|
||||
invoiceNumber: string;
|
||||
language: "de" | "en";
|
||||
}): { text: string; html: string } {
|
||||
const company = settings.companyName || "your supplier";
|
||||
if (language === "en") {
|
||||
const text =
|
||||
`Dear customer,\n\n` +
|
||||
`Please find attached invoice ${invoiceNumber}.\n\n` +
|
||||
`Kind regards,\n${company}`;
|
||||
`Thank you for your purchase.`;
|
||||
const html =
|
||||
`<p>Dear customer,</p>` +
|
||||
`<p>Please find attached invoice <strong>${escapeHtml(invoiceNumber)}</strong>.</p>` +
|
||||
`<p>Kind regards,<br/>${escapeHtml(company)}</p>`;
|
||||
`<p>Thank you for your purchase.</p>`;
|
||||
return { text, html };
|
||||
}
|
||||
const text =
|
||||
`Sehr geehrte Damen und Herren,\n\n` +
|
||||
`Hallo,\n\n` +
|
||||
`anbei finden Sie die Rechnung ${invoiceNumber}.\n\n` +
|
||||
`Mit freundlichen Grüßen,\n${company}`;
|
||||
`Danke für deinen Einkauf.`;
|
||||
const html =
|
||||
`<p>Sehr geehrte Damen und Herren,</p>` +
|
||||
`<p>Hallo,</p>` +
|
||||
`<p>anbei finden Sie die Rechnung <strong>${escapeHtml(invoiceNumber)}</strong>.</p>` +
|
||||
`<p>Mit freundlichen Grüßen,<br/>${escapeHtml(company)}</p>`;
|
||||
`<p>Danke für deinen Einkauf.</p>`;
|
||||
return { text, html };
|
||||
}
|
||||
|
||||
|
||||
@@ -7,6 +7,7 @@ import { composeInvoice } from "./composeInvoice";
|
||||
import { buildGiroCodeDataUrl } from "./girocode";
|
||||
import { loadOrderForInvoice } from "./loadOrderForInvoice.server";
|
||||
import { getLogoDataUrl } from "./logoCache.server";
|
||||
import { attachLineItemImages } from "./productImageCache.server";
|
||||
import { allocateInvoiceNumber } from "./numbering.server";
|
||||
import { InvoiceDocument } from "./pdf/InvoiceDocument";
|
||||
import type { InvoiceViewModel } from "./types";
|
||||
@@ -76,6 +77,9 @@ export async function generateInvoice(
|
||||
const logoDataUrl = await getLogoDataUrl(shopDomain, settings.logoUrl);
|
||||
if (logoDataUrl) viewModel.issuer.logoDataUrl = logoDataUrl;
|
||||
|
||||
// Product images for each line (best-effort, parallel, in-process cache).
|
||||
await attachLineItemImages(viewModel.lines);
|
||||
|
||||
// GiroCode (only for unpaid + IBAN configured + enabled).
|
||||
if (
|
||||
settings.giroCodeEnabled &&
|
||||
|
||||
@@ -66,7 +66,7 @@ const de: InvoiceStrings = {
|
||||
salutationGeneric: "Sehr geehrte Damen und Herren,",
|
||||
thankYouLine:
|
||||
"vielen Dank für Ihren Auftrag. Wir erlauben uns, Ihnen folgende Leistungen in Rechnung zu stellen:",
|
||||
closing: "Mit freundlichen Grüßen",
|
||||
closing: "Danke für deinen Einkauf",
|
||||
paymentTerms: (days, due) =>
|
||||
`Bitte überweisen Sie den Rechnungsbetrag innerhalb von ${days} Tagen, spätestens bis zum ${due}, auf das unten angegebene Konto. Bei Fragen zur Rechnung stehen wir Ihnen gerne zur Verfügung.`,
|
||||
paymentTermsImmediate:
|
||||
@@ -116,7 +116,7 @@ const en: InvoiceStrings = {
|
||||
salutationGeneric: "Dear Sir or Madam,",
|
||||
thankYouLine:
|
||||
"Thank you for your order. We hereby invoice you for the following:",
|
||||
closing: "Kind regards",
|
||||
closing: "Thank you for your purchase.",
|
||||
paymentTerms: (days, due) =>
|
||||
`Please transfer the invoice amount within ${days} days, no later than ${due}, to the bank account shown below.`,
|
||||
paymentTermsImmediate: "The invoice amount is due immediately upon receipt.",
|
||||
|
||||
@@ -57,6 +57,7 @@ export interface RawLineItem {
|
||||
quantity: number;
|
||||
originalUnitPriceSet: { shopMoney: RawMoney };
|
||||
taxLines: RawTaxLine[];
|
||||
imageUrl: string | null;
|
||||
}
|
||||
|
||||
export interface RawTaxLine {
|
||||
@@ -119,6 +120,7 @@ const QUERY = `#graphql
|
||||
sku
|
||||
quantity
|
||||
originalUnitPriceSet { shopMoney { amount currencyCode } }
|
||||
image { url altText }
|
||||
taxLines {
|
||||
title
|
||||
rate
|
||||
@@ -219,7 +221,17 @@ export async function loadOrderForInvoice(
|
||||
totalTaxSet: order.totalTaxSet,
|
||||
totalPriceSet: order.totalPriceSet,
|
||||
taxLines: order.taxLines || [],
|
||||
lineItems: (order.lineItems?.edges || []).map((e) => e.node),
|
||||
lineItems: (order.lineItems?.edges || []).map((e) => {
|
||||
const node = e.node as unknown as RawLineItem & { image?: { url: string | null } | null };
|
||||
return {
|
||||
title: node.title,
|
||||
sku: node.sku,
|
||||
quantity: node.quantity,
|
||||
originalUnitPriceSet: node.originalUnitPriceSet,
|
||||
taxLines: node.taxLines,
|
||||
imageUrl: node.image?.url ?? null,
|
||||
};
|
||||
}),
|
||||
purchasingEntity: { company: purchasingCompany },
|
||||
};
|
||||
}
|
||||
|
||||
@@ -3,6 +3,13 @@ import db from "../../db.server";
|
||||
const MAX_BYTES = 5 * 1024 * 1024; // 5 MB cap
|
||||
const STALE_AFTER_MS = 24 * 60 * 60 * 1000; // re-fetch once a day at most
|
||||
|
||||
/**
|
||||
* Sentinel value stored in `ShopSettings.logoUrl` when the logo was uploaded
|
||||
* directly through the settings UI (rather than fetched from a remote URL).
|
||||
* The actual bytes live in `LogoCache` for that shop.
|
||||
*/
|
||||
export const STORED_LOGO_SENTINEL = "stored://shop-logo";
|
||||
|
||||
/**
|
||||
* Returns a `data:` URL for the shop's logo bytes, fetching from the
|
||||
* configured URL on first use (or when stale) and persisting to the
|
||||
@@ -15,6 +22,12 @@ export async function getLogoDataUrl(
|
||||
if (!logoUrl) return undefined;
|
||||
|
||||
const cached = await db.logoCache.findUnique({ where: { shopDomain } });
|
||||
|
||||
// Locally uploaded logo: bytes live in LogoCache, no HTTP fetch.
|
||||
if (logoUrl === STORED_LOGO_SENTINEL) {
|
||||
return cached ? toDataUrl(cached.bytes, cached.contentType) : undefined;
|
||||
}
|
||||
|
||||
const isFresh =
|
||||
cached &&
|
||||
cached.sourceUrl === logoUrl &&
|
||||
@@ -65,3 +78,46 @@ function guessContentType(url: string): string {
|
||||
if (lower.endsWith(".webp")) return "image/webp";
|
||||
return "image/png";
|
||||
}
|
||||
|
||||
const ALLOWED_LOGO_MIME = new Set(["image/png", "image/jpeg", "image/webp", "image/gif"]);
|
||||
|
||||
export interface StoreUploadedLogoResult {
|
||||
ok: boolean;
|
||||
error?: string;
|
||||
contentType?: string;
|
||||
byteLength?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Persists an uploaded logo file directly into `LogoCache`. Caller is
|
||||
* responsible for setting `ShopSettings.logoUrl = STORED_LOGO_SENTINEL`.
|
||||
*/
|
||||
export async function storeUploadedLogo(
|
||||
shopDomain: string,
|
||||
bytes: Buffer,
|
||||
contentType: string,
|
||||
): Promise<StoreUploadedLogoResult> {
|
||||
const ct = (contentType || "").toLowerCase();
|
||||
if (!ALLOWED_LOGO_MIME.has(ct)) {
|
||||
return { ok: false, error: `Unsupported image type "${contentType || "unknown"}". Use PNG, JPEG, WebP or GIF.` };
|
||||
}
|
||||
if (bytes.byteLength === 0) {
|
||||
return { ok: false, error: "Uploaded file is empty." };
|
||||
}
|
||||
if (bytes.byteLength > MAX_BYTES) {
|
||||
return { ok: false, error: `File too large (${(bytes.byteLength / 1024 / 1024).toFixed(2)} MB). Max is ${MAX_BYTES / 1024 / 1024} MB.` };
|
||||
}
|
||||
|
||||
const bytesU8 = new Uint8Array(bytes);
|
||||
await db.logoCache.upsert({
|
||||
where: { shopDomain },
|
||||
create: { shopDomain, sourceUrl: STORED_LOGO_SENTINEL, bytes: bytesU8, contentType: ct, etag: "" },
|
||||
update: { sourceUrl: STORED_LOGO_SENTINEL, bytes: bytesU8, contentType: ct, etag: "", fetchedAt: new Date() },
|
||||
});
|
||||
|
||||
return { ok: true, contentType: ct, byteLength: bytes.byteLength };
|
||||
}
|
||||
|
||||
export async function deleteStoredLogo(shopDomain: string): Promise<void> {
|
||||
await db.logoCache.deleteMany({ where: { shopDomain } });
|
||||
}
|
||||
|
||||
@@ -111,6 +111,26 @@ const styles = StyleSheet.create({
|
||||
colQty: { width: "16%", textAlign: "right" },
|
||||
colUnit: { width: "16%", textAlign: "right" },
|
||||
colTotal: { width: "16%", textAlign: "right" },
|
||||
descriptionCell: {
|
||||
flexDirection: "row",
|
||||
alignItems: "flex-start",
|
||||
gap: 6,
|
||||
},
|
||||
productIcon: {
|
||||
width: 28,
|
||||
height: 28,
|
||||
objectFit: "contain",
|
||||
borderWidth: 0.5,
|
||||
borderColor: TABLE_BORDER,
|
||||
borderRadius: 2,
|
||||
},
|
||||
productIconPlaceholder: {
|
||||
width: 28,
|
||||
height: 28,
|
||||
},
|
||||
descriptionText: {
|
||||
flex: 1,
|
||||
},
|
||||
itemTitle: {
|
||||
fontFamily: "Helvetica-Bold",
|
||||
},
|
||||
@@ -123,6 +143,9 @@ const styles = StyleSheet.create({
|
||||
marginTop: 10,
|
||||
alignSelf: "flex-end",
|
||||
width: "50%",
|
||||
// Match the table rows' horizontal padding so the right-aligned amounts
|
||||
// line up perfectly with the "Total" column above.
|
||||
paddingHorizontal: 4,
|
||||
},
|
||||
totalRow: {
|
||||
flexDirection: "row",
|
||||
@@ -346,9 +369,6 @@ export function InvoiceDocument({ invoice }: DocProps) {
|
||||
|
||||
<View style={styles.closing}>
|
||||
<Text>{t.closing}</Text>
|
||||
<Text style={{ fontFamily: "Helvetica-Bold", marginTop: 4 }}>
|
||||
{invoice.issuer.ownerName || invoice.issuer.companyName}
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
<Footer issuer={invoice.issuer} language={invoice.language} />
|
||||
@@ -416,9 +436,16 @@ function LineRow({
|
||||
return (
|
||||
<View style={styles.tableRow}>
|
||||
<Text style={styles.colPos}>{line.position}</Text>
|
||||
<View style={styles.colDescription}>
|
||||
<Text style={styles.itemTitle}>{line.title}</Text>
|
||||
{line.sku ? <Text style={styles.itemSku}>SKU: {line.sku}</Text> : null}
|
||||
<View style={[styles.colDescription, styles.descriptionCell]}>
|
||||
{line.imageDataUrl ? (
|
||||
<Image src={line.imageDataUrl} style={styles.productIcon} />
|
||||
) : (
|
||||
<View style={styles.productIconPlaceholder} />
|
||||
)}
|
||||
<View style={styles.descriptionText}>
|
||||
<Text style={styles.itemTitle}>{line.title}</Text>
|
||||
{line.sku ? <Text style={styles.itemSku}>SKU: {line.sku}</Text> : null}
|
||||
</View>
|
||||
</View>
|
||||
<Text style={styles.colQty}>{formatQuantity(line.quantity, t.pieceUnit, language)}</Text>
|
||||
<Text style={styles.colUnit}>{formatMoney(line.unitPriceNet, currency, language)}</Text>
|
||||
@@ -460,8 +487,20 @@ function Footer({ issuer, language }: { issuer: IssuerData; language: InvoiceLan
|
||||
{issuer.bankName ? <Text>{t.bankLabel}: {issuer.bankName}</Text> : null}
|
||||
{issuer.iban ? <Text>{t.ibanLabel}: {issuer.iban}</Text> : null}
|
||||
{issuer.bic ? <Text>{t.bicLabel}: {issuer.bic}</Text> : null}
|
||||
{issuer.footerNote ? <Text>{issuer.footerNote}</Text> : null}
|
||||
{pickFooterNote(issuer, language) ? <Text>{pickFooterNote(issuer, language)}</Text> : null}
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Picks the footer note for the rendered language. English falls back to the
|
||||
* German `footerNote` when `footerNoteEn` is empty (so existing single-language
|
||||
* setups keep working). German always uses `footerNote`.
|
||||
*/
|
||||
function pickFooterNote(issuer: { footerNote: string; footerNoteEn: string }, language: InvoiceLanguage): string {
|
||||
if (language === "en") {
|
||||
return issuer.footerNoteEn?.trim() || issuer.footerNote || "";
|
||||
}
|
||||
return issuer.footerNote || "";
|
||||
}
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
/**
|
||||
* Fetches product images for invoice line items and returns them as
|
||||
* `data:` URLs ready to embed in the PDF.
|
||||
*
|
||||
* Uses a simple in-process LRU-ish Map keyed by URL. Images are typically
|
||||
* served from Shopify's CDN so re-fetching is cheap, but caching avoids
|
||||
* hammering the network when regenerating an invoice multiple times.
|
||||
*/
|
||||
const MAX_BYTES = 2 * 1024 * 1024; // 2 MB cap per image
|
||||
const CACHE_MAX_ENTRIES = 200;
|
||||
|
||||
const cache = new Map<string, string>(); // url -> data URL
|
||||
|
||||
function rememberInCache(url: string, dataUrl: string) {
|
||||
if (cache.size >= CACHE_MAX_ENTRIES) {
|
||||
// Drop the oldest entry (Map preserves insertion order).
|
||||
const oldest = cache.keys().next().value;
|
||||
if (oldest) cache.delete(oldest);
|
||||
}
|
||||
cache.set(url, dataUrl);
|
||||
}
|
||||
|
||||
function guessContentType(url: string, headerCt: string | null): string {
|
||||
if (headerCt && headerCt.startsWith("image/")) return headerCt;
|
||||
const lower = url.toLowerCase();
|
||||
if (lower.includes(".jpg") || lower.includes(".jpeg")) return "image/jpeg";
|
||||
if (lower.includes(".webp")) return "image/webp";
|
||||
if (lower.includes(".gif")) return "image/gif";
|
||||
return "image/png";
|
||||
}
|
||||
|
||||
export async function fetchProductImageDataUrl(url: string): Promise<string | undefined> {
|
||||
if (!url) return undefined;
|
||||
const hit = cache.get(url);
|
||||
if (hit) return hit;
|
||||
|
||||
// Request a small Shopify CDN variant when possible to keep the PDF lean.
|
||||
// Shopify image URLs accept a `width=` query param; fall back to the original URL.
|
||||
const requestUrl = url.includes("cdn.shopify.com") && !/[?&](width|height|crop)=/.test(url)
|
||||
? `${url}${url.includes("?") ? "&" : "?"}width=128`
|
||||
: url;
|
||||
|
||||
let res: Response;
|
||||
try {
|
||||
res = await fetch(requestUrl);
|
||||
} catch (err) {
|
||||
console.warn(`Product image fetch failed for ${url}:`, err);
|
||||
return undefined;
|
||||
}
|
||||
if (!res.ok) {
|
||||
console.warn(`Product image HTTP ${res.status} for ${url}`);
|
||||
return undefined;
|
||||
}
|
||||
const buf = await res.arrayBuffer();
|
||||
if (buf.byteLength === 0 || buf.byteLength > MAX_BYTES) return undefined;
|
||||
|
||||
const contentType = guessContentType(url, res.headers.get("content-type"));
|
||||
// @react-pdf supports png/jpeg natively; webp/gif are unreliable. Skip those.
|
||||
if (contentType !== "image/png" && contentType !== "image/jpeg") return undefined;
|
||||
|
||||
const b64 = Buffer.from(buf).toString("base64");
|
||||
const dataUrl = `data:${contentType};base64,${b64}`;
|
||||
rememberInCache(url, dataUrl);
|
||||
return dataUrl;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves images for every line in parallel, mutating `imageDataUrl` in place.
|
||||
* Failures are swallowed (the row simply renders without an icon).
|
||||
*/
|
||||
export async function attachLineItemImages(
|
||||
lines: { imageUrl?: string; imageDataUrl?: string }[],
|
||||
): Promise<void> {
|
||||
await Promise.all(
|
||||
lines.map(async (line) => {
|
||||
if (!line.imageUrl) return;
|
||||
const dataUrl = await fetchProductImageDataUrl(line.imageUrl);
|
||||
if (dataUrl) line.imageDataUrl = dataUrl;
|
||||
}),
|
||||
);
|
||||
}
|
||||
@@ -64,6 +64,7 @@ export interface IssuerData {
|
||||
/** Optional pre-fetched logo bytes as a data URL. */
|
||||
logoDataUrl?: string;
|
||||
footerNote: string;
|
||||
footerNoteEn: string;
|
||||
}
|
||||
|
||||
export interface RecipientData {
|
||||
@@ -87,6 +88,10 @@ export interface InvoiceLine {
|
||||
totalNet: number;
|
||||
/** Optional SKU for display under the title. */
|
||||
sku?: string;
|
||||
/** Source URL of the product/variant image (Shopify CDN). */
|
||||
imageUrl?: string;
|
||||
/** `data:` URL with base64-encoded image bytes — attached by the generator. */
|
||||
imageDataUrl?: string;
|
||||
}
|
||||
|
||||
export type NoticeKind = "reverseCharge" | "export" | "kleinunternehmer";
|
||||
|
||||
Reference in New Issue
Block a user