feat(orders): issued-order PDF on the shared invoice/confirmation template
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -256,7 +256,7 @@ describe("renderIssuedOrderHtml", () => {
|
|||||||
const items = [
|
const items = [
|
||||||
{
|
{
|
||||||
description: "Materiál",
|
description: "Materiál",
|
||||||
item_description: null,
|
item_description: "Detailní popis položky",
|
||||||
quantity: 2,
|
quantity: 2,
|
||||||
unit: "ks",
|
unit: "ks",
|
||||||
unit_price: 100,
|
unit_price: 100,
|
||||||
@@ -264,18 +264,24 @@ describe("renderIssuedOrderHtml", () => {
|
|||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
it("renders the PO number, items, and both party names", () => {
|
it("renders the PO number, items, and both party names (PO direction)", () => {
|
||||||
const html = renderIssuedOrderHtml(
|
const html = renderIssuedOrderHtml(
|
||||||
order,
|
order,
|
||||||
items,
|
items,
|
||||||
{ name: "Dodavatel" },
|
{ name: "Dodavatel s.r.o." },
|
||||||
{ company_name: "Naše firma" },
|
{ company_name: "Naše firma" },
|
||||||
"cs",
|
"cs",
|
||||||
);
|
);
|
||||||
expect(html).toContain("26720001");
|
expect(html).toContain("26720001");
|
||||||
expect(html).toContain("Materiál");
|
expect(html).toContain("Materiál");
|
||||||
expect(html).toContain("Dodavatel");
|
// Optional item sub-line.
|
||||||
|
expect(html).toContain("Detailní popis položky");
|
||||||
|
// PO direction: the customer record is the Dodavatel (supplier), our
|
||||||
|
// company is the Odběratel (buyer).
|
||||||
|
expect(html).toContain("Dodavatel s.r.o.");
|
||||||
expect(html).toContain("Naše firma");
|
expect(html).toContain("Naše firma");
|
||||||
|
expect(html).toContain("Dodavatel");
|
||||||
|
expect(html).toContain("Odběratel");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("strips script tags from notes", () => {
|
it("strips script tags from notes", () => {
|
||||||
|
|||||||
@@ -10,23 +10,33 @@ import { JSDOM } from "jsdom";
|
|||||||
const window = new JSDOM("").window;
|
const window = new JSDOM("").window;
|
||||||
const DOMPurify = createDOMPurify(window);
|
const DOMPurify = createDOMPurify(window);
|
||||||
|
|
||||||
function escapeHtml(s: unknown): string {
|
/* ── Helpers (copied from orders-pdf.ts — the shared confirmation template) ── */
|
||||||
return String(s ?? "")
|
|
||||||
.replace(/&/g, "&")
|
function formatDate(date: Date | string | null | undefined): string {
|
||||||
.replace(/</g, "<")
|
if (!date) return "";
|
||||||
.replace(/>/g, ">")
|
const d = new Date(date);
|
||||||
.replace(/"/g, """)
|
if (isNaN(d.getTime())) return String(date);
|
||||||
.replace(/'/g, "'");
|
return localDateCzStr(d);
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatNum(n: number, decimals = 2): string {
|
function formatNum(n: number, decimals = 2): string {
|
||||||
return new Intl.NumberFormat("cs-CZ", {
|
const abs = Math.abs(n);
|
||||||
minimumFractionDigits: decimals,
|
const fixed = abs.toFixed(decimals);
|
||||||
maximumFractionDigits: decimals,
|
const [intPart, decPart] = fixed.split(".");
|
||||||
}).format(n);
|
const withSep = intPart.replace(/\B(?=(\d{3})+(?!\d))/g, " ");
|
||||||
|
const result = decPart ? `${withSep},${decPart}` : withSep;
|
||||||
|
return n < 0 ? `-${result}` : result;
|
||||||
|
}
|
||||||
|
|
||||||
|
function escapeHtml(str: string | null | undefined): string {
|
||||||
|
if (!str) return "";
|
||||||
|
return str
|
||||||
|
.replace(/&/g, "&")
|
||||||
|
.replace(/</g, "<")
|
||||||
|
.replace(/>/g, ">")
|
||||||
|
.replace(/"/g, """);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Copied from invoices-pdf.ts: strips scripts/handlers/inline styles from Quill HTML.
|
|
||||||
function cleanQuillHtml(html: string | null | undefined): string {
|
function cleanQuillHtml(html: string | null | undefined): string {
|
||||||
if (!html) return "";
|
if (!html) return "";
|
||||||
let s = html;
|
let s = html;
|
||||||
@@ -40,62 +50,175 @@ function cleanQuillHtml(html: string | null | undefined): string {
|
|||||||
);
|
);
|
||||||
s = s.replace(/\s+on\w+\s*=\s*("[^"]*"|'[^']*'|[^\s>]*)/gi, "");
|
s = s.replace(/\s+on\w+\s*=\s*("[^"]*"|'[^']*'|[^\s>]*)/gi, "");
|
||||||
s = s.replace(/\s+on\w+\s*=\s*[^\s>]*/gi, "");
|
s = s.replace(/\s+on\w+\s*=\s*[^\s>]*/gi, "");
|
||||||
s = s.replace(
|
s = s.replace(/href\s*=\s*["']?\s*javascript\s*:[^"'>\s]*/gi, 'href="#"');
|
||||||
/href\s*=\s*["']?\s*(javascript|data|vbscript)\s*:[^"'>\s]*/gi,
|
|
||||||
'href="#"',
|
|
||||||
);
|
|
||||||
s = s.replace(
|
|
||||||
/src\s*=\s*["']?\s*(javascript|data|vbscript)\s*:[^"'>\s]*/gi,
|
|
||||||
'src=""',
|
|
||||||
);
|
|
||||||
s = s.replace(/( )/g, " ");
|
s = s.replace(/( )/g, " ");
|
||||||
|
s = s.replace(/\s+style\s*=\s*("[^"]*"|'[^']*'|[^\s>]*)/gi, "");
|
||||||
|
let prev = "";
|
||||||
|
while (prev !== s) {
|
||||||
|
prev = s;
|
||||||
|
s = s.replace(/<span([^>]*)>(.*?)<\/span>\s*<span\1>/gs, "<span$1>$2");
|
||||||
|
}
|
||||||
return s;
|
return s;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface AddressResult {
|
||||||
|
name: string;
|
||||||
|
lines: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildAddressLines(
|
||||||
|
entity: Record<string, unknown> | null,
|
||||||
|
isCompany: boolean,
|
||||||
|
tObj: Record<string, string>,
|
||||||
|
): AddressResult {
|
||||||
|
if (!entity) return { name: "", lines: [] };
|
||||||
|
|
||||||
|
const nameKey = isCompany ? "company_name" : "name";
|
||||||
|
const name = String(entity[nameKey] || "");
|
||||||
|
|
||||||
|
let cfData: Array<{ name?: string; value?: string; showLabel?: boolean }> =
|
||||||
|
[];
|
||||||
|
let fieldOrder: string[] | null = null;
|
||||||
|
const raw = entity.custom_fields;
|
||||||
|
if (raw) {
|
||||||
|
let parsed: unknown;
|
||||||
|
try {
|
||||||
|
parsed = typeof raw === "string" ? JSON.parse(raw) : raw;
|
||||||
|
} catch {
|
||||||
|
parsed = null;
|
||||||
|
}
|
||||||
|
if (parsed && typeof parsed === "object") {
|
||||||
|
if ((parsed as Record<string, unknown>).fields) {
|
||||||
|
cfData =
|
||||||
|
((parsed as Record<string, unknown>).fields as typeof cfData) || [];
|
||||||
|
fieldOrder = ((parsed as Record<string, unknown>).field_order ||
|
||||||
|
(parsed as Record<string, unknown>).fieldOrder) as string[] | null;
|
||||||
|
} else if (Array.isArray(parsed)) {
|
||||||
|
cfData = parsed;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Array.isArray(fieldOrder)) {
|
||||||
|
const legacyMap: Record<string, string> = {
|
||||||
|
Name: "name",
|
||||||
|
CompanyName: "company_name",
|
||||||
|
Street: "street",
|
||||||
|
CityPostal: "city_postal",
|
||||||
|
Country: "country",
|
||||||
|
CompanyId: "company_id",
|
||||||
|
VatId: "vat_id",
|
||||||
|
};
|
||||||
|
fieldOrder = fieldOrder.map((k) => legacyMap[k] || k);
|
||||||
|
}
|
||||||
|
|
||||||
|
const fieldMap: Record<string, string> = {};
|
||||||
|
if (name) fieldMap[nameKey] = name;
|
||||||
|
if (entity.street) fieldMap.street = String(entity.street);
|
||||||
|
const cityParts = [entity.city || "", entity.postal_code || ""]
|
||||||
|
.filter(Boolean)
|
||||||
|
.map(String);
|
||||||
|
const cityPostal = cityParts.join(" ").trim();
|
||||||
|
if (cityPostal) fieldMap.city_postal = cityPostal;
|
||||||
|
if (entity.country) fieldMap.country = String(entity.country);
|
||||||
|
if (entity.company_id)
|
||||||
|
fieldMap.company_id = `${tObj.ico}${entity.company_id}`;
|
||||||
|
if (entity.vat_id) fieldMap.vat_id = `${tObj.dic}${entity.vat_id}`;
|
||||||
|
|
||||||
|
cfData.forEach((cf, i) => {
|
||||||
|
const cfName = (cf.name || "").trim();
|
||||||
|
const cfValue = (cf.value || "").trim();
|
||||||
|
const showLabel = cf.showLabel !== false;
|
||||||
|
if (cfValue) {
|
||||||
|
fieldMap[`custom_${i}`] =
|
||||||
|
showLabel && cfName ? `${cfName}: ${cfValue}` : cfValue;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const lines: string[] = [];
|
||||||
|
if (Array.isArray(fieldOrder) && fieldOrder.length) {
|
||||||
|
for (const key of fieldOrder) {
|
||||||
|
if (key === nameKey) continue;
|
||||||
|
if (fieldMap[key]) lines.push(fieldMap[key]);
|
||||||
|
}
|
||||||
|
for (const [key, line] of Object.entries(fieldMap)) {
|
||||||
|
if (key === nameKey) continue;
|
||||||
|
if (!fieldOrder!.includes(key)) lines.push(line);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
for (const [key, line] of Object.entries(fieldMap)) {
|
||||||
|
if (key === nameKey) continue;
|
||||||
|
lines.push(line);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { name, lines };
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Translations ────────────────────────────────────────────────── */
|
||||||
|
|
||||||
type Lang = "cs" | "en";
|
type Lang = "cs" | "en";
|
||||||
|
|
||||||
const T = {
|
const translations: Record<Lang, Record<string, string>> = {
|
||||||
cs: {
|
cs: {
|
||||||
title: "Objednávka",
|
title: "OBJEDNÁVKA",
|
||||||
|
heading: "OBJEDNÁVKA č.",
|
||||||
|
// PO direction: WE (company) are the buyer (Odběratel), the
|
||||||
|
// selected customer record is the supplier (Dodavatel).
|
||||||
supplier: "Dodavatel",
|
supplier: "Dodavatel",
|
||||||
recipient: "Odběratel",
|
buyer: "Odběratel",
|
||||||
desc: "Popis",
|
issue_date: "Datum vystavení:",
|
||||||
qty: "Množství",
|
delivery_date: "Požadované dodání:",
|
||||||
unitPrice: "Cena/MJ",
|
billing: "Objednáváme u Vás:",
|
||||||
base: "Bez DPH",
|
col_no: "Č.",
|
||||||
vat: "DPH",
|
col_desc: "Popis",
|
||||||
lineTotal: "Celkem",
|
col_qty: "Množství",
|
||||||
subtotal: "Základ",
|
col_unit_price: "Jedn. cena",
|
||||||
vatTotal: "DPH celkem",
|
col_price: "Cena",
|
||||||
|
col_vat_pct: "%DPH",
|
||||||
|
col_vat: "DPH",
|
||||||
|
col_total: "Celkem",
|
||||||
|
subtotal: "Mezisoučet:",
|
||||||
|
vat_label: "DPH",
|
||||||
total: "Celkem",
|
total: "Celkem",
|
||||||
orderDate: "Datum vystavení",
|
amounts_in: "Částky jsou uvedeny v",
|
||||||
deliveryDate: "Požadované dodání",
|
notes: "Poznámky",
|
||||||
notes: "Poznámka",
|
delivery_terms: "Dodací podmínky:",
|
||||||
deliveryTerms: "Dodací podmínky",
|
payment_terms: "Platební podmínky:",
|
||||||
paymentTerms: "Platební podmínky",
|
issued_by: "Vystavil:",
|
||||||
issuedBy: "Vystavil",
|
approved_by: "Schválil:",
|
||||||
|
ico: "IČ: ",
|
||||||
|
dic: "DIČ: ",
|
||||||
},
|
},
|
||||||
en: {
|
en: {
|
||||||
title: "Purchase Order",
|
title: "PURCHASE ORDER",
|
||||||
|
heading: "PURCHASE ORDER No.",
|
||||||
supplier: "Supplier",
|
supplier: "Supplier",
|
||||||
recipient: "Buyer",
|
buyer: "Buyer",
|
||||||
desc: "Description",
|
issue_date: "Issue date:",
|
||||||
qty: "Quantity",
|
delivery_date: "Requested delivery:",
|
||||||
unitPrice: "Unit price",
|
billing: "We order from you:",
|
||||||
base: "Net",
|
col_no: "No.",
|
||||||
vat: "VAT",
|
col_desc: "Description",
|
||||||
lineTotal: "Total",
|
col_qty: "Quantity",
|
||||||
subtotal: "Subtotal",
|
col_unit_price: "Unit price",
|
||||||
vatTotal: "VAT total",
|
col_price: "Price",
|
||||||
|
col_vat_pct: "VAT%",
|
||||||
|
col_vat: "VAT",
|
||||||
|
col_total: "Total",
|
||||||
|
subtotal: "Subtotal:",
|
||||||
|
vat_label: "VAT",
|
||||||
total: "Total",
|
total: "Total",
|
||||||
orderDate: "Order date",
|
amounts_in: "Amounts are in",
|
||||||
deliveryDate: "Requested delivery",
|
notes: "Notes",
|
||||||
notes: "Note",
|
delivery_terms: "Delivery terms:",
|
||||||
deliveryTerms: "Delivery terms",
|
payment_terms: "Payment terms:",
|
||||||
paymentTerms: "Payment terms",
|
issued_by: "Issued by:",
|
||||||
issuedBy: "Issued by",
|
approved_by: "Approved by:",
|
||||||
|
ico: "Reg. No.: ",
|
||||||
|
dic: "Tax ID: ",
|
||||||
},
|
},
|
||||||
} as const;
|
};
|
||||||
|
|
||||||
interface IssuedOrderPdfData {
|
interface IssuedOrderPdfData {
|
||||||
po_number: string | null;
|
po_number: string | null;
|
||||||
@@ -126,114 +249,572 @@ export function renderIssuedOrderHtml(
|
|||||||
settings: Record<string, unknown> | null,
|
settings: Record<string, unknown> | null,
|
||||||
lang: Lang,
|
lang: Lang,
|
||||||
): string {
|
): string {
|
||||||
const t = T[lang];
|
const t = translations[lang];
|
||||||
const applyVat = order.apply_vat !== false;
|
const applyVat = order.apply_vat !== false;
|
||||||
const currency = escapeHtml(order.currency || "CZK");
|
const currency = order.currency || "CZK";
|
||||||
const docRate = order.vat_rate != null ? Number(order.vat_rate) : 21;
|
const docRate = order.vat_rate != null ? Number(order.vat_rate) : 21;
|
||||||
|
const poNumber = escapeHtml(order.po_number || "");
|
||||||
|
|
||||||
|
// Logo embedding (same logic as the confirmation template).
|
||||||
|
let logoImg = "";
|
||||||
|
if (settings?.logo_data) {
|
||||||
|
const buf = Buffer.from(settings.logo_data as Buffer);
|
||||||
|
let mime = "image/png";
|
||||||
|
if (buf[0] === 0xff && buf[1] === 0xd8) mime = "image/jpeg";
|
||||||
|
else if (buf[0] === 0x47 && buf[1] === 0x49) mime = "image/gif";
|
||||||
|
else if (buf[0] === 0x52 && buf[1] === 0x49) mime = "image/webp";
|
||||||
|
const b64 = buf.toString("base64");
|
||||||
|
logoImg = `<img src="data:${escapeHtml(mime)};base64,${b64}" class="logo" />`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// PO direction: our company (settings) = Odběratel (buyer);
|
||||||
|
// the customer record = Dodavatel (supplier).
|
||||||
|
const buyer = buildAddressLines(settings, true, t); // company → Odběratel
|
||||||
|
const supplier = buildAddressLines(customer, false, t); // customer → Dodavatel
|
||||||
|
|
||||||
|
const buyerLinesHtml = buyer.lines
|
||||||
|
.map((l) => `<div class="address-line">${escapeHtml(l)}</div>`)
|
||||||
|
.join("");
|
||||||
|
const supplierLinesHtml = supplier.lines
|
||||||
|
.map((l) => `<div class="address-line">${escapeHtml(l)}</div>`)
|
||||||
|
.join("");
|
||||||
|
|
||||||
|
// Items — NET + per-line VAT-on-top, rounded per line (same math the
|
||||||
|
// service computeIssuedOrderTotals uses; mirrors the confirmation loop).
|
||||||
let subtotal = 0;
|
let subtotal = 0;
|
||||||
let vatTotal = 0;
|
let totalVat = 0;
|
||||||
const rows = items
|
const vatSummary: Record<string, { base: number; vat: number }> = {};
|
||||||
.map((it) => {
|
const itemsHtml = items
|
||||||
|
.map((it, i) => {
|
||||||
const qty = Number(it.quantity) || 0;
|
const qty = Number(it.quantity) || 0;
|
||||||
const unitPrice = Number(it.unit_price) || 0;
|
const unitPrice = Number(it.unit_price) || 0;
|
||||||
const base = qty * unitPrice;
|
|
||||||
const rate =
|
const rate =
|
||||||
it.vat_rate != null && it.vat_rate !== ""
|
it.vat_rate != null && it.vat_rate !== ""
|
||||||
? Number(it.vat_rate)
|
? Number(it.vat_rate)
|
||||||
: docRate;
|
: docRate;
|
||||||
|
const lineSubtotal = qty * unitPrice;
|
||||||
const lineVat = applyVat
|
const lineVat = applyVat
|
||||||
? Math.round(base * (rate / 100) * 100) / 100
|
? Math.round(lineSubtotal * (rate / 100) * 100) / 100
|
||||||
: 0;
|
: 0;
|
||||||
subtotal += base;
|
const lineTotal = lineSubtotal + lineVat;
|
||||||
vatTotal += lineVat;
|
|
||||||
|
subtotal += lineSubtotal;
|
||||||
|
totalVat += lineVat;
|
||||||
|
const key = String(rate);
|
||||||
|
if (!vatSummary[key]) vatSummary[key] = { base: 0, vat: 0 };
|
||||||
|
vatSummary[key].base += lineSubtotal;
|
||||||
|
vatSummary[key].vat += lineVat;
|
||||||
|
|
||||||
|
const qtyDecimals = Math.floor(qty) === qty ? 0 : 2;
|
||||||
|
const descHtml = `${escapeHtml(it.description)}${
|
||||||
|
it.item_description
|
||||||
|
? `<div class="item-sub">${escapeHtml(it.item_description)}</div>`
|
||||||
|
: ""
|
||||||
|
}`;
|
||||||
return `<tr>
|
return `<tr>
|
||||||
<td>${escapeHtml(it.description)}${it.item_description ? `<div class="sub">${escapeHtml(it.item_description)}</div>` : ""}</td>
|
<td class="row-num">${i + 1}</td>
|
||||||
<td class="r">${formatNum(qty, Number.isInteger(qty) ? 0 : 2)}${it.unit ? " " + escapeHtml(it.unit) : ""}</td>
|
<td class="desc">${descHtml}</td>
|
||||||
<td class="r">${formatNum(unitPrice)}</td>
|
<td class="center">${formatNum(qty, qtyDecimals)}${it.unit ? ` / ${escapeHtml(it.unit)}` : ""}</td>
|
||||||
<td class="r">${formatNum(base)}</td>
|
<td class="right">${formatNum(unitPrice)}</td>
|
||||||
<td class="c">${applyVat ? Math.round(rate) + "%" : "0%"}</td>
|
<td class="right">${formatNum(lineSubtotal)}</td>
|
||||||
<td class="r">${formatNum(lineVat)}</td>
|
<td class="center">${applyVat ? Math.floor(rate) : 0}%</td>
|
||||||
<td class="r">${formatNum(base + lineVat)}</td>
|
<td class="right">${formatNum(lineVat)}</td>
|
||||||
</tr>`;
|
<td class="right total-cell">${formatNum(lineTotal)}</td>
|
||||||
|
</tr>`;
|
||||||
})
|
})
|
||||||
.join("");
|
.join("");
|
||||||
|
|
||||||
subtotal = Math.round(subtotal * 100) / 100;
|
subtotal = Math.round(subtotal * 100) / 100;
|
||||||
vatTotal = Math.round(vatTotal * 100) / 100;
|
totalVat = Math.round(totalVat * 100) / 100;
|
||||||
const total = Math.round((subtotal + vatTotal) * 100) / 100;
|
const totalToPay = Math.round((subtotal + totalVat) * 100) / 100;
|
||||||
|
|
||||||
const company = (k: string) => escapeHtml(settings?.[k] ?? "");
|
let vatDetailHtml = "";
|
||||||
const cust = (k: string) => escapeHtml(customer?.[k] ?? "");
|
if (applyVat) {
|
||||||
|
for (const [rate, data] of Object.entries(vatSummary)) {
|
||||||
|
if (data.vat > 0) {
|
||||||
|
vatDetailHtml += `
|
||||||
|
<div class="row">
|
||||||
|
<span class="label">${escapeHtml(t.vat_label)} ${Math.floor(Number(rate))}%:</span>
|
||||||
|
<span class="value">${formatNum(data.vat)} ${escapeHtml(currency)}</span>
|
||||||
|
</div>`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const notesRaw = order.notes ?? "";
|
const notesRaw = order.notes ?? "";
|
||||||
const notesHtml = notesRaw.replace(/<[^>]*>/g, "").trim()
|
const notesStripped = notesRaw.replace(/<[^>]*>/g, "").trim();
|
||||||
? `<div class="notes"><div class="label">${escapeHtml(t.notes)}</div><div>${cleanQuillHtml(DOMPurify.sanitize(notesRaw))}</div></div>`
|
const notesHtml = notesStripped
|
||||||
|
? `
|
||||||
|
<div class="invoice-notes">
|
||||||
|
<div class="invoice-notes-label">${escapeHtml(t.notes)}</div>
|
||||||
|
<div class="invoice-notes-content">${cleanQuillHtml(DOMPurify.sanitize(notesRaw))}</div>
|
||||||
|
</div>
|
||||||
|
`
|
||||||
: "";
|
: "";
|
||||||
|
|
||||||
return `<!DOCTYPE html><html lang="${lang}"><head><meta charset="utf-8"/>
|
const issueDateStr = formatDate(order.order_date);
|
||||||
<title>${escapeHtml(t.title)} ${escapeHtml(order.po_number)}</title>
|
const deliveryDateStr = formatDate(order.delivery_date);
|
||||||
|
|
||||||
|
// Order-specific terms (purchase-order addition; not on invoices).
|
||||||
|
let termsHtml = "";
|
||||||
|
if (order.delivery_terms) {
|
||||||
|
termsHtml += `<div class="terms-row"><span class="lbl">${escapeHtml(t.delivery_terms)}</span> ${escapeHtml(order.delivery_terms)}</div>`;
|
||||||
|
}
|
||||||
|
if (order.payment_terms) {
|
||||||
|
termsHtml += `<div class="terms-row"><span class="lbl">${escapeHtml(t.payment_terms)}</span> ${escapeHtml(order.payment_terms)}</div>`;
|
||||||
|
}
|
||||||
|
const termsBlock = termsHtml ? `<div class="terms">${termsHtml}</div>` : "";
|
||||||
|
|
||||||
|
// Quill indent CSS
|
||||||
|
let indentCSS = "";
|
||||||
|
for (let n = 1; n <= 9; n++) {
|
||||||
|
const pad = n * 3;
|
||||||
|
const liPad = n * 3 + 1.5;
|
||||||
|
indentCSS += ` .ql-indent-${n} { padding-left: ${pad}em; }\n`;
|
||||||
|
indentCSS += ` li.ql-indent-${n} { padding-left: ${liPad}em; }\n`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return `<!DOCTYPE html>
|
||||||
|
<html lang="${escapeHtml(lang)}">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8" />
|
||||||
|
<title>${escapeHtml(t.heading)} ${poNumber}</title>
|
||||||
<style>
|
<style>
|
||||||
@page { size: A4; margin: 14mm; }
|
@page {
|
||||||
body { font-family: Arial, Helvetica, sans-serif; font-size: 11px; color: #222; }
|
size: A4;
|
||||||
h1 { font-size: 20px; margin: 0 0 4px; }
|
margin: 8mm 12mm 10mm 12mm;
|
||||||
.meta { color: #666; margin-bottom: 12px; }
|
}
|
||||||
.parties { display: flex; justify-content: space-between; margin: 14px 0; }
|
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||||
.party { width: 48%; }
|
html, body {
|
||||||
.label { font-weight: bold; text-transform: uppercase; font-size: 9px; color: #888; }
|
font-family: "Segoe UI", Tahoma, Arial, sans-serif;
|
||||||
table.items { width: 100%; border-collapse: collapse; margin-top: 10px; }
|
font-size: 10pt;
|
||||||
table.items th, table.items td { border-bottom: 1px solid #ddd; padding: 5px 6px; text-align: left; }
|
color: #1a1a1a;
|
||||||
table.items th { background: #f4f4f4; font-size: 9px; text-transform: uppercase; }
|
width: 186mm;
|
||||||
.r { text-align: right; } .c { text-align: center; }
|
}
|
||||||
.sub { color: #888; font-size: 9px; }
|
|
||||||
.totals { margin-top: 12px; width: 42%; margin-left: auto; border-collapse: collapse; }
|
.invoice-page {
|
||||||
.totals td { padding: 3px 6px; }
|
display: flex;
|
||||||
.totals .grand { font-weight: bold; border-top: 2px solid #333; font-size: 13px; }
|
flex-direction: column;
|
||||||
.notes { margin-top: 16px; }
|
min-height: calc(297mm - 27mm);
|
||||||
.terms { margin-top: 8px; color: #555; }
|
}
|
||||||
</style></head><body>
|
.invoice-content { flex: 1 1 auto; }
|
||||||
<h1>${escapeHtml(t.title)} ${escapeHtml(order.po_number)}</h1>
|
.invoice-footer {
|
||||||
<div class="meta">${escapeHtml(t.orderDate)}: ${order.order_date ? localDateCzStr(order.order_date) : "—"}${order.delivery_date ? ` · ${escapeHtml(t.deliveryDate)}: ${localDateCzStr(order.delivery_date)}` : ""}</div>
|
flex-shrink: 0;
|
||||||
<div class="parties">
|
}
|
||||||
<div class="party">
|
|
||||||
<div class="label">${escapeHtml(t.supplier)}</div>
|
.accent { color: #de3a3a; }
|
||||||
<div><strong>${cust("name")}</strong></div>
|
|
||||||
<div>${cust("street")}</div>
|
/* ── Hlavicka ── */
|
||||||
<div>${cust("postal_code")} ${cust("city")}</div>
|
.invoice-header {
|
||||||
${cust("company_id") ? `<div>IČO: ${cust("company_id")}</div>` : ""}
|
display: flex;
|
||||||
${cust("vat_id") ? `<div>DIČ: ${cust("vat_id")}</div>` : ""}
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: 1mm;
|
||||||
|
padding-bottom: 1mm;
|
||||||
|
border-bottom: 2pt solid #de3a3a;
|
||||||
|
}
|
||||||
|
.invoice-header .left {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 3mm;
|
||||||
|
}
|
||||||
|
.logo-header { text-align: left; }
|
||||||
|
.company-title {
|
||||||
|
font-size: 12pt;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
.invoice-title {
|
||||||
|
font-size: 13pt;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #de3a3a;
|
||||||
|
text-align: right;
|
||||||
|
letter-spacing: 0.03em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.logo {
|
||||||
|
max-width: 42mm;
|
||||||
|
max-height: 22mm;
|
||||||
|
object-fit: contain;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Adresy ── */
|
||||||
|
.header-grid {
|
||||||
|
border: 0.5pt solid #d0d0d0;
|
||||||
|
border-collapse: collapse;
|
||||||
|
width: 100%;
|
||||||
|
margin-bottom: 1mm;
|
||||||
|
}
|
||||||
|
.header-grid td {
|
||||||
|
padding: 2mm 3mm;
|
||||||
|
border: 0.5pt solid #d0d0d0;
|
||||||
|
vertical-align: top;
|
||||||
|
width: 50%;
|
||||||
|
}
|
||||||
|
.header-grid td.addr-customer {
|
||||||
|
background: #f5f5f5;
|
||||||
|
}
|
||||||
|
.header-grid td.details-bank {
|
||||||
|
background: #f5f5f5;
|
||||||
|
}
|
||||||
|
.address-label {
|
||||||
|
font-size: 8pt;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #de3a3a;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.08em;
|
||||||
|
margin-bottom: 1mm;
|
||||||
|
}
|
||||||
|
.address-name {
|
||||||
|
font-size: 10pt;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #1a1a1a;
|
||||||
|
line-height: 1.3;
|
||||||
|
margin-bottom: 1mm;
|
||||||
|
}
|
||||||
|
.address-line {
|
||||||
|
font-size: 9pt;
|
||||||
|
color: #444;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Detaily (banka + datumy) — inside header-grid ── */
|
||||||
|
|
||||||
|
.info-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: baseline;
|
||||||
|
font-size: 9pt;
|
||||||
|
padding: 1mm 0;
|
||||||
|
border-bottom: 0.5pt solid #f0f0f0;
|
||||||
|
}
|
||||||
|
.info-row:last-child { border-bottom: none; }
|
||||||
|
.info-row .lbl {
|
||||||
|
color: #666;
|
||||||
|
font-weight: 400;
|
||||||
|
flex-shrink: 0;
|
||||||
|
white-space: nowrap;
|
||||||
|
margin-right: 3mm;
|
||||||
|
}
|
||||||
|
.info-row .val {
|
||||||
|
font-weight: 600;
|
||||||
|
color: #1a1a1a;
|
||||||
|
text-align: right;
|
||||||
|
margin-left: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* VS/KS blok */
|
||||||
|
.vs-block {
|
||||||
|
font-size: 9pt;
|
||||||
|
line-height: 1.4;
|
||||||
|
padding-top: 2mm;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Polozky ── */
|
||||||
|
.billing-label {
|
||||||
|
font-weight: 700;
|
||||||
|
color: #1a1a1a;
|
||||||
|
font-size: 10pt;
|
||||||
|
padding: 2mm 0 1mm 0;
|
||||||
|
border-bottom: 1.5pt solid #de3a3a;
|
||||||
|
margin-bottom: 0;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.03em;
|
||||||
|
}
|
||||||
|
|
||||||
|
table.items {
|
||||||
|
width: 100%;
|
||||||
|
table-layout: fixed;
|
||||||
|
border-collapse: collapse;
|
||||||
|
font-size: 9pt;
|
||||||
|
margin-bottom: 2mm;
|
||||||
|
}
|
||||||
|
table.items thead th {
|
||||||
|
font-size: 8.5pt;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #646464;
|
||||||
|
padding: 4px 4px;
|
||||||
|
text-align: left;
|
||||||
|
border-bottom: 0.5pt solid #d0d0d0;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
table.items thead th.center { text-align: center; }
|
||||||
|
table.items thead th.right { text-align: right; }
|
||||||
|
table.items tbody td {
|
||||||
|
padding: 4px 4px;
|
||||||
|
border-bottom: 0.5pt solid #e0e0e0;
|
||||||
|
vertical-align: middle;
|
||||||
|
color: #1a1a1a;
|
||||||
|
}
|
||||||
|
table.items tbody tr:nth-child(even) { background: #f8f9fa; }
|
||||||
|
table.items tbody td.center { text-align: center; white-space: nowrap; }
|
||||||
|
table.items tbody td.right { text-align: right; }
|
||||||
|
table.items tbody td.row-num {
|
||||||
|
text-align: center;
|
||||||
|
color: #969696;
|
||||||
|
font-size: 9pt;
|
||||||
|
}
|
||||||
|
table.items tbody td.desc {
|
||||||
|
font-size: 9pt;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #1a1a1a;
|
||||||
|
}
|
||||||
|
table.items tbody td.desc .item-sub {
|
||||||
|
font-weight: 400;
|
||||||
|
font-size: 8.5pt;
|
||||||
|
color: #666;
|
||||||
|
margin-top: 0.5mm;
|
||||||
|
}
|
||||||
|
table.items tbody td.total-cell { font-weight: 700; }
|
||||||
|
|
||||||
|
/* Soucet + total - styl z nabidek */
|
||||||
|
.totals-wrapper {
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
margin-top: 2mm;
|
||||||
|
}
|
||||||
|
.totals {
|
||||||
|
width: 80mm;
|
||||||
|
}
|
||||||
|
.totals .detail-rows {
|
||||||
|
margin-bottom: 3mm;
|
||||||
|
}
|
||||||
|
.totals .row {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: baseline;
|
||||||
|
font-size: 9.5pt;
|
||||||
|
color: #1a1a1a;
|
||||||
|
margin-bottom: 2mm;
|
||||||
|
}
|
||||||
|
.totals .grand {
|
||||||
|
border-top: 0.5pt solid #e0e0e0;
|
||||||
|
padding-top: 4mm;
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: baseline;
|
||||||
|
}
|
||||||
|
.totals .grand .label {
|
||||||
|
font-size: 10.5pt;
|
||||||
|
font-weight: 400;
|
||||||
|
color: #1a1a1a;
|
||||||
|
align-self: center;
|
||||||
|
}
|
||||||
|
.totals .grand .value {
|
||||||
|
font-size: 14pt;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #1a1a1a;
|
||||||
|
border-bottom: 2.5pt solid #de3a3a;
|
||||||
|
padding-bottom: 1mm;
|
||||||
|
}
|
||||||
|
.totals .currency-note {
|
||||||
|
text-align: right;
|
||||||
|
font-size: 8pt;
|
||||||
|
color: #1a1a1a;
|
||||||
|
margin-top: 2mm;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Dodaci / platebni podminky (PO-specificke) */
|
||||||
|
.terms {
|
||||||
|
margin-top: 4mm;
|
||||||
|
font-size: 9pt;
|
||||||
|
line-height: 1.5;
|
||||||
|
color: #1a1a1a;
|
||||||
|
}
|
||||||
|
.terms-row { margin-bottom: 1mm; }
|
||||||
|
.terms-row .lbl {
|
||||||
|
font-weight: 600;
|
||||||
|
color: #555;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Vystavil */
|
||||||
|
.issued-by {
|
||||||
|
font-size: 9pt;
|
||||||
|
margin: 2mm 0;
|
||||||
|
line-height: 1.4;
|
||||||
|
}
|
||||||
|
.issued-by .lbl { font-weight: 600; }
|
||||||
|
|
||||||
|
/* Upozorneni */
|
||||||
|
.notice {
|
||||||
|
font-size: 8pt;
|
||||||
|
color: #1a1a1a;
|
||||||
|
margin: 2mm 0;
|
||||||
|
line-height: 1.3;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Prevzal / razitko */
|
||||||
|
.footer-row {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
margin-top: 4mm;
|
||||||
|
font-size: 9pt;
|
||||||
|
border-top: 0.5pt solid #aaa;
|
||||||
|
padding-top: 2mm;
|
||||||
|
min-height: 15mm;
|
||||||
|
}
|
||||||
|
.footer-row .col {
|
||||||
|
font-weight: 600;
|
||||||
|
color: #555;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Poznamky */
|
||||||
|
.invoice-notes {
|
||||||
|
margin-top: 4mm;
|
||||||
|
font-size: 10pt;
|
||||||
|
line-height: 1.5;
|
||||||
|
color: #1a1a1a;
|
||||||
|
}
|
||||||
|
.invoice-notes-label {
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 9pt;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: #555;
|
||||||
|
margin-bottom: 1mm;
|
||||||
|
}
|
||||||
|
.invoice-notes-content p { margin: 0 0 0.4em 0; }
|
||||||
|
.invoice-notes-content ul, .invoice-notes-content ol { margin: 0 0 0.4em 1.5em; }
|
||||||
|
.invoice-notes-content li { margin-bottom: 0.2em; }
|
||||||
|
.invoice-notes-content,
|
||||||
|
.invoice-notes-content * {
|
||||||
|
font-family: Tahoma, sans-serif !important;
|
||||||
|
}
|
||||||
|
.invoice-notes-content { font-size: 14px; }
|
||||||
|
.invoice-notes-content h1 { font-size: 20px; }
|
||||||
|
.invoice-notes-content h2 { font-size: 18px; }
|
||||||
|
.invoice-notes-content h3 { font-size: 16px; }
|
||||||
|
.invoice-notes-content h4 { font-size: 15px; }
|
||||||
|
|
||||||
|
/* Quill fonty – v PDF vynuceno Tahoma */
|
||||||
|
[class*="ql-font-"] { font-family: Tahoma, sans-serif !important; }
|
||||||
|
.ql-align-center { text-align: center; }
|
||||||
|
.ql-align-right { text-align: right; }
|
||||||
|
.ql-align-justify { text-align: justify; }
|
||||||
|
${indentCSS}
|
||||||
|
|
||||||
|
@media print {
|
||||||
|
body { -webkit-print-color-adjust: exact; print-color-adjust: exact; }
|
||||||
|
}
|
||||||
|
@media screen {
|
||||||
|
html { background: #525659; }
|
||||||
|
body {
|
||||||
|
width: 100vw !important;
|
||||||
|
margin: 0;
|
||||||
|
padding: 30px 0;
|
||||||
|
background: transparent;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
min-height: 100vh;
|
||||||
|
overflow-x: hidden;
|
||||||
|
}
|
||||||
|
.invoice-page {
|
||||||
|
width: 210mm;
|
||||||
|
min-height: 297mm;
|
||||||
|
padding: 15mm;
|
||||||
|
background: white;
|
||||||
|
box-shadow: 0 4px 20px rgba(0,0,0,0.3);
|
||||||
|
box-sizing: border-box;
|
||||||
|
border-radius: 2px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="invoice-page">
|
||||||
|
<div class="invoice-content">
|
||||||
|
|
||||||
|
<!-- Hlavicka -->
|
||||||
|
<div class="invoice-header">
|
||||||
|
<div class="left">
|
||||||
|
${logoImg ? `<div class="logo-header">${logoImg}</div>` : ""}
|
||||||
</div>
|
</div>
|
||||||
<div class="party">
|
<div class="invoice-title">${escapeHtml(t.heading)} ${poNumber}</div>
|
||||||
<div class="label">${escapeHtml(t.recipient)}</div>
|
</div>
|
||||||
<div><strong>${company("company_name")}</strong></div>
|
|
||||||
<div>${company("street")}</div>
|
<!-- Odberatel (nase firma) / Dodavatel (zakaznik) + Detaily -->
|
||||||
<div>${company("postal_code")} ${company("city")}</div>
|
<table class="header-grid" cellspacing="0">
|
||||||
${company("company_id") ? `<div>IČO: ${company("company_id")}</div>` : ""}
|
<tr>
|
||||||
${company("vat_id") ? `<div>DIČ: ${company("vat_id")}</div>` : ""}
|
<td>
|
||||||
|
<div class="address-label">${escapeHtml(t.buyer)}</div>
|
||||||
|
<div class="address-name">${escapeHtml(buyer.name)}</div>
|
||||||
|
${buyerLinesHtml}
|
||||||
|
</td>
|
||||||
|
<td class="addr-customer">
|
||||||
|
<div class="address-label">${escapeHtml(t.supplier)}</div>
|
||||||
|
<div class="address-name">${escapeHtml(supplier.name)}</div>
|
||||||
|
${supplierLinesHtml}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td class="details-bank">
|
||||||
|
<div class="info-row"><span class="lbl">${escapeHtml(t.issue_date)}</span> <span class="val">${escapeHtml(issueDateStr) || "—"}</span></div>
|
||||||
|
${deliveryDateStr ? `<div class="info-row"><span class="lbl">${escapeHtml(t.delivery_date)}</span> <span class="val">${escapeHtml(deliveryDateStr)}</span></div>` : ""}
|
||||||
|
</td>
|
||||||
|
<td></td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
<!-- Polozky -->
|
||||||
|
<div class="billing-label">${escapeHtml(t.billing)}</div>
|
||||||
|
<table class="items">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th class="center" style="width:3%">${escapeHtml(t.col_no)}</th>
|
||||||
|
<th style="width:36%">${escapeHtml(t.col_desc)}</th>
|
||||||
|
<th class="center" style="width:10%">${escapeHtml(t.col_qty)}</th>
|
||||||
|
<th class="right" style="width:10%">${escapeHtml(t.col_unit_price)}</th>
|
||||||
|
<th class="right" style="width:10%">${escapeHtml(t.col_price)}</th>
|
||||||
|
<th class="center" style="width:5%">${escapeHtml(t.col_vat_pct)}</th>
|
||||||
|
<th class="right" style="width:10%">${escapeHtml(t.col_vat)}</th>
|
||||||
|
<th class="right" style="width:16%">${escapeHtml(t.col_total)}</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
${itemsHtml}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
<!-- Soucty -->
|
||||||
|
<div class="totals-wrapper">
|
||||||
|
<div class="totals">
|
||||||
|
<div class="detail-rows">
|
||||||
|
<div class="row">
|
||||||
|
<span class="label">${escapeHtml(t.subtotal)}</span>
|
||||||
|
<span class="value">${formatNum(subtotal)} ${escapeHtml(currency)}</span>
|
||||||
|
</div>${vatDetailHtml}
|
||||||
|
</div>
|
||||||
|
<div class="grand">
|
||||||
|
<span class="label">${escapeHtml(t.total)}</span>
|
||||||
|
<span class="value">${formatNum(totalToPay)} ${escapeHtml(currency)}</span>
|
||||||
|
</div>
|
||||||
|
<div class="currency-note">${escapeHtml(t.amounts_in)} ${escapeHtml(currency)}</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<table class="items">
|
|
||||||
<thead><tr>
|
|
||||||
<th>${escapeHtml(t.desc)}</th>
|
|
||||||
<th class="r">${escapeHtml(t.qty)}</th>
|
|
||||||
<th class="r">${escapeHtml(t.unitPrice)}</th>
|
|
||||||
<th class="r">${escapeHtml(t.base)}</th>
|
|
||||||
<th class="c">${escapeHtml(t.vat)}</th>
|
|
||||||
<th class="r">${escapeHtml(t.vat)}</th>
|
|
||||||
<th class="r">${escapeHtml(t.lineTotal)}</th>
|
|
||||||
</tr></thead>
|
|
||||||
<tbody>${rows}</tbody>
|
|
||||||
</table>
|
|
||||||
<table class="totals">
|
|
||||||
<tr><td>${escapeHtml(t.subtotal)}</td><td class="r">${formatNum(subtotal)} ${currency}</td></tr>
|
|
||||||
<tr><td>${escapeHtml(t.vatTotal)}</td><td class="r">${formatNum(vatTotal)} ${currency}</td></tr>
|
|
||||||
<tr class="grand"><td>${escapeHtml(t.total)}</td><td class="r">${formatNum(total)} ${currency}</td></tr>
|
|
||||||
</table>
|
|
||||||
${notesHtml}
|
${notesHtml}
|
||||||
${order.delivery_terms ? `<div class="terms"><span class="label">${escapeHtml(t.deliveryTerms)}:</span> ${escapeHtml(order.delivery_terms)}</div>` : ""}
|
|
||||||
${order.payment_terms ? `<div class="terms"><span class="label">${escapeHtml(t.paymentTerms)}:</span> ${escapeHtml(order.payment_terms)}</div>` : ""}
|
${termsBlock}
|
||||||
${order.issued_by ? `<div class="terms"><span class="label">${escapeHtml(t.issuedBy)}:</span> ${escapeHtml(order.issued_by)}</div>` : ""}
|
|
||||||
</body></html>`;
|
</div><!-- /.invoice-content -->
|
||||||
|
<div class="invoice-footer">
|
||||||
|
|
||||||
|
<!-- Vystavil -->
|
||||||
|
${
|
||||||
|
order.issued_by
|
||||||
|
? `<div class="issued-by"><span class="lbl">${escapeHtml(t.issued_by)}</span> ${escapeHtml(order.issued_by)}</div>`
|
||||||
|
: ""
|
||||||
|
}
|
||||||
|
|
||||||
|
<!-- Vystavil / Schvalil -->
|
||||||
|
<div class="footer-row">
|
||||||
|
<div class="col">${escapeHtml(t.issued_by)}</div>
|
||||||
|
<div class="col" style="text-align:right">${escapeHtml(t.approved_by)}</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div><!-- /.invoice-footer -->
|
||||||
|
</div><!-- /.invoice-page -->
|
||||||
|
|
||||||
|
</body>
|
||||||
|
</html>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default async function issuedOrdersPdfRoutes(fastify: FastifyInstance) {
|
export default async function issuedOrdersPdfRoutes(fastify: FastifyInstance) {
|
||||||
|
|||||||
Reference in New Issue
Block a user