Compare commits

..

No commits in common. "0756b871f13a0af66edac180f3c562fa6504c9ca" and "3ee874ccee33fc2e16d1609f52ff4fa7748854f7" have entirely different histories.

11 changed files with 126 additions and 284 deletions

View file

Before

Width:  |  Height:  |  Size: 2.6 KiB

After

Width:  |  Height:  |  Size: 2.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.2 KiB

View file

@ -21,14 +21,8 @@ languages:
name: Norsk
direction: ltr
# ── Organisation brand ────────────────────────────────────────────────────────
# Optional. Set org-name to show a branded header (org logo + name + subheading).
# Leave org-name absent or empty to show the plain "invoice" wordmark instead.
# org-name: "Acme Corporation"
# org-subheading: "Freelance invoicing"
# ── About modal ───────────────────────────────────────────────────────────────
# Optional. Remove this section entirely to hide the ⓘ button and footer link.
# Optional. Remove this section entirely to hide the About link in the footer.
about:
en:
title: "About"
@ -457,46 +451,11 @@ translations:
de: Zu zahlen
fr: À payer
"no": Å betale
save:
en: Save
de: Speichern
fr: Enregistrer
"no": Lagre
validate:
en: Validate
de: Prüfen
fr: Valider
"no": Valider
val-ok:
en: All good. The form is ready to download.
de: Alles in Ordnung. Das Formular ist bereit zum Herunterladen.
fr: Tout est bon. Le formulaire est prêt à télécharger.
"no": Alt er i orden. Skjemaet er klart til nedlasting.
val-invoice-no:
en: Invoice number is required
de: Rechnungsnummer ist erforderlich
fr: Le numéro de facture est requis
"no": Fakturanummer er påkrevd
val-from-name:
en: Sender name is required
de: Absendername ist erforderlich
fr: Le nom de l'expéditeur est requis
"no": Avsendernavn er påkrevd
val-charge-to:
en: Charge-to is required
de: Empfänger ist erforderlich
fr: Le destinataire est requis
"no": Mottaker er påkrevd
val-lines:
en: At least one line item is required
de: Mindestens eine Zeile ist erforderlich
fr: Au moins une ligne est requise
"no": Minst én linje er påkrevd
generate-invoice:
en: Download Invoice
de: Rechnung herunterladen
fr: Télécharger la facture
"no": Last ned faktura
en: Generate Invoice
de: Rechnung erstellen
fr: Générer la facture
"no": Generer faktura
other:
en: Other
de: Andere

View file

Before

Width:  |  Height:  |  Size: 455 B

After

Width:  |  Height:  |  Size: 455 B

View file

Before

Width:  |  Height:  |  Size: 786 B

After

Width:  |  Height:  |  Size: 786 B

View file

Before

Width:  |  Height:  |  Size: 1 KiB

After

Width:  |  Height:  |  Size: 1 KiB

View file

Before

Width:  |  Height:  |  Size: 684 B

After

Width:  |  Height:  |  Size: 684 B

View file

Before

Width:  |  Height:  |  Size: 11 KiB

After

Width:  |  Height:  |  Size: 11 KiB

View file

@ -5,13 +5,12 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta name="format-detection" content="telephone=no">
<title>Invoice</title>
<link rel="icon" href="assets/favicon.svg" type="image/svg+xml">
<link rel="icon" href="assets/favicon.ico" sizes="32x32">
<link rel="icon" href="assets/favicon-48.png" sizes="48x48" type="image/png">
<link rel="icon" href="assets/favicon-32.png" sizes="32x32" type="image/png">
<link rel="icon" href="assets/favicon-16.png" sizes="16x16" type="image/png">
<link rel="apple-touch-icon" href="assets/apple-touch-icon.png">
<link rel="manifest" href="assets/site.webmanifest">
<link rel="icon" href="favicon.svg" type="image/svg+xml">
<link rel="icon" href="favicon-48.png" sizes="48x48" type="image/png">
<link rel="icon" href="favicon-32.png" sizes="32x32" type="image/png">
<link rel="icon" href="favicon-16.png" sizes="16x16" type="image/png">
<link rel="apple-touch-icon" href="apple-touch-icon.png">
<link rel="manifest" href="site.webmanifest">
<meta name="theme-color" content="#2f6fed">
<script>
/* Restore persisted theme before first paint */
@ -123,7 +122,7 @@
.kb-mono { font-family: var(--font-mono); font-variant-numeric: tabular-nums; }
/* ── Page shell ─────────────────────────────────────────────────────────── */
.kb-wrap { max-width: 980px; margin: 0 auto; padding: 22px 20px 56px; }
.kb-wrap { max-width: 960px; margin: 0 auto; padding: 22px 20px 56px; }
/* ── Toolbar ────────────────────────────────────────────────────────────── */
.kb-toolbar {
@ -153,25 +152,19 @@
/* ── Document header ────────────────────────────────────────────────────── */
.kb-header {
display: flex; justify-content: space-between; align-items: flex-start;
gap: 24px; padding-bottom: 20px; margin-bottom: 20px;
display: flex; justify-content: space-between; align-items: center;
gap: 16px; padding-bottom: 14px; margin-bottom: 20px;
border-bottom: 1px solid var(--border);
}
.kb-brand { display: flex; align-items: center; gap: 14px; min-width: 0; }
.kb-brand .logo {
height: 46px; width: 46px; flex: 0 0 46px; border-radius: 10px;
display: grid; place-items: center; overflow: hidden;
.app-wordmark {
display: inline-flex; align-items: center; gap: 8px;
font: 700 var(--fs-h1)/1 var(--font-sans);
color: var(--text); letter-spacing: -0.01em; user-select: none;
}
.kb-brand .logo svg { width: 100%; height: 100%; display: block; }
.kb-brand .org { font-size: 17px; font-weight: 700; color: var(--text); letter-spacing: -0.01em; }
.kb-brand .org small { display: block; font-size: var(--fs-small); font-weight: 500; color: var(--text-muted); letter-spacing: 0; }
.app-wordmark svg { flex-shrink: 0; }
.kb-doctitle { text-align: right; }
.kb-doctitle h1 {
margin: 0; font-size: var(--fs-h1); font-weight: 700; letter-spacing: -0.01em;
color: var(--text); display: inline-flex; align-items: center; gap: 9px;
}
.kb-doctitle h1 svg { flex-shrink: 0; }
.kb-doctitle .meta { margin-top: 4px; font-size: var(--fs-small); color: var(--text-muted); font-family: var(--font-mono); }
.kb-doctitle h1 { margin: 0; font-size: var(--fs-h1); font-weight: 700; letter-spacing: -0.01em; color: var(--text); }
/* ── Cards ──────────────────────────────────────────────────────────────── */
.kb-card {
@ -337,7 +330,7 @@
/* ── Footer ─────────────────────────────────────────────────────────────── */
.kb-footer {
max-width: 980px; margin: 0 auto; padding: 16px 20px 12px;
max-width: 960px; margin: 0 auto; padding: 16px 20px 12px;
font-size: var(--fs-small); color: var(--text-muted);
display: flex; align-items: center; gap: 8px; flex-wrap: wrap;
border-top: 1px solid var(--border);
@ -371,23 +364,28 @@
</div>
<span id="sz-label" style="font-size:12px;color:var(--text-muted);font-family:var(--font-mono)">100%</span>
<button class="kb-iconbtn" id="btn-theme" onclick="toggleTheme()" title="Toggle light/dark" aria-label="Toggle dark mode">
<svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M12 3a6 6 0 0 0 9 9 9 9 0 1 1-9-9Z"/>
</svg>
</button>
<button class="kb-iconbtn" id="btn-about" onclick="openAbout()" aria-label="About" style="display:none">
<svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<circle cx="12" cy="12" r="10"/><path d="M12 16v-4M12 8h.01"/>
<svg viewBox="0 0 16 16" width="15" height="15" fill="currentColor">
<path d="M8 1.5a6.5 6.5 0 1 0 0 13A6.5 6.5 0 0 0 8 1.5zM0 8a8 8 0 1 1 16 0A8 8 0 0 1 0 8z"/>
<path d="M8 1.5V14.5A6.5 6.5 0 0 1 8 1.5z"/>
</svg>
</button>
</div>
<!-- Document header (brand + title built by buildHeader() after config loads) -->
<!-- Document header -->
<header class="kb-header">
<div id="hdr-brand"></div>
<div class="app-wordmark">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 48 48" width="28" height="28" role="img" aria-label="Invoice">
<rect width="48" height="48" rx="12" fill="var(--accent)"/>
<path d="M14 9 H29 L34 14 V39 H14 Z" fill="none" stroke="#fff" stroke-width="3" stroke-linejoin="round"/>
<path d="M29 9 V14 H34" fill="none" stroke="#fff" stroke-width="3" stroke-linejoin="round"/>
<path d="M18.5 20 H27" stroke="#fff" stroke-width="2.6" stroke-linecap="round"/>
<path d="M18.5 25 H29.5" stroke="#fff" stroke-width="2.6" stroke-linecap="round" opacity=".55"/>
<path d="M18.5 33 H29.5" stroke="#fff" stroke-width="3.2" stroke-linecap="round"/>
</svg>
invoice
</div>
<div class="kb-doctitle">
<h1 id="inv-title"></h1>
<div class="meta" id="inv-meta"></div>
<h1 id="inv-title">Invoice</h1>
</div>
</header>
@ -594,54 +592,17 @@ async function loadCfg() {
// ── Boot ──────────────────────────────────────────────────────────────────────
function boot() {
buildLangBar();
buildHeader();
buildForm();
buildFooter();
restoreStorage();
updateInvMeta();
if (!loadLines()) addLine();
document.getElementById("loading").style.display = "none";
}
// ── Document header ───────────────────────────────────────────────────────────
const LOGO_SVG = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 48 48" aria-hidden="true">
<rect width="48" height="48" rx="12" fill="var(--accent)"/>
<path d="M14 9 H29 L34 14 V39 H14 Z" fill="none" stroke="#fff" stroke-width="3" stroke-linejoin="round"/>
<path d="M29 9 V14 H34" fill="none" stroke="#fff" stroke-width="3" stroke-linejoin="round"/>
<path d="M18.5 20 H27" stroke="#fff" stroke-width="2.6" stroke-linecap="round"/>
<path d="M18.5 25 H29.5" stroke="#fff" stroke-width="2.6" stroke-linecap="round" opacity=".55"/>
<path d="M18.5 33 H29.5" stroke="#fff" stroke-width="3.2" stroke-linecap="round"/>
</svg>`;
function buildHeader() {
const orgName = cfg["org-name"] || "kbenestad.invoice";
const orgSub = cfg["org-subheading"] || "Invoice Generator";
// Left brand — always kb-brand structure
const brandEl = document.getElementById("hdr-brand");
brandEl.className = "kb-brand";
brandEl.innerHTML = `<span class="logo">${LOGO_SVG}</span>
<span class="org">${h(orgName)}<small>${h(orgSub)}</small></span>`;
// Right: app name h1 (icon + lowercase app name)
const titleEl = document.getElementById("inv-title");
// Right h1: 24×24 icon inline + lowercase app name (mirrors reimburse/timesheet)
titleEl.innerHTML = LOGO_SVG.replace('<svg ', '<svg width="24" height="24" ') + "invoice";
// Meta: invoice number, non-breaking space when empty to preserve row height
updateInvMeta();
}
function updateInvMeta() {
const el = document.getElementById("inv-meta");
const ino = (document.getElementById("ino")?.value || "").trim();
if (el) el.textContent = ino || " ";
}
// ── Font-size accessibility ────────────────────────────────────────────────────
const ZOOMS = [0.5,0.6,0.7,0.8,0.9,1.0,1.1,1.2,1.3,1.4,1.5];
const ZOOM_LABELS = ["50%","60%","70%","80%","90%","100%","110%","120%","130%","140%","150%"];
let zoomIdx = +(localStorage.getItem("zoomIdx") ?? 5);
const ZOOMS = [0.8, 0.85, 0.9, 0.95, 1.0, 1.1, 1.2, 1.3];
const ZOOM_LABELS = ["80%", "85%", "90%", "95%", "100%", "110%", "120%", "130%"];
let zoomIdx = +(localStorage.getItem("zoomIdx") ?? 4);
function applyZoom() {
const fr = document.getElementById("form-root");
@ -661,10 +622,6 @@ function bumpZoom(dir) {
// ── Language bar ──────────────────────────────────────────────────────────────
function buildLangBar() {
applyZoom();
if (cfg.about) {
const btn = document.getElementById("btn-about");
if (btn) btn.style.display = "";
}
const langs = cfg.languages || [{ code: cfg["default-code"], name: cfg["default-name"] }];
if (langs.length < 2) return;
const part = document.getElementById("lang-part");
@ -684,6 +641,8 @@ function buildLangBar() {
// ── Build form ────────────────────────────────────────────────────────────────
function buildForm() {
document.getElementById("inv-title").textContent = t("invoice");
const today = new Date();
const dateDef = `${today.getFullYear()}-${String(today.getMonth() + 1).padStart(2, "0")}-${String(today.getDate()).padStart(2, "0")}`;
const curOpts = (cfg.currencies || []).map((c, i) =>
@ -827,9 +786,7 @@ function buildForm() {
</select>
</h2>
<div id="ct-fields" class="locked">
<div class="kb-grid cols-2" style="align-items:start">
<!-- Left: address -->
<div class="kb-grid">
<div class="kb-grid cols-2">
<div class="kb-field">
<label class="kb-label" id="lbl-ctn" for="ctn">${t("charge-to-name")}</label>
<input id="ctn" type="text" class="kb-input">
@ -846,7 +803,6 @@ function buildForm() {
<label class="kb-label" id="lbl-ca3" for="ca3">${t("charge-to-address3")}</label>
<input id="ca3" type="text" class="kb-input">
</div>
<div class="kb-grid cols-2" style="gap:10px">
<div class="kb-field">
<label class="kb-label" id="lbl-ca4" for="ca4">${t("charge-to-address4")}</label>
<input id="ca4" type="text" class="kb-input">
@ -855,10 +811,6 @@ function buildForm() {
<label class="kb-label" id="lbl-cc" for="cc">${t("charge-to-country")}</label>
<select id="cc" class="kb-select">${countryOpts("")}</select>
</div>
</div>
</div>
<!-- Right: contact + IDs -->
<div class="kb-grid">
<div class="kb-field">
<label class="kb-label" id="lbl-cph" for="cph">${t("charge-to-phone")}</label>
<input id="cph" type="tel" class="kb-input">
@ -877,7 +829,6 @@ function buildForm() {
</div>
</div>
</div>
</div>
</section>
<!-- Row 3: Line items -->
@ -940,24 +891,19 @@ function buildForm() {
</section>
</div>
<!-- Action row -->
<div id="action-row" style="display:flex;gap:10px;margin-top:8px">
<button type="button" id="btn-save" class="kb-btn kb-btn--ghost kb-btn--lg" style="flex:2" onclick="saveInvoice()">${t("save")}</button>
<button type="button" id="btn-validate" class="kb-btn kb-btn--ghost kb-btn--lg" style="flex:2" onclick="validateInvoice()">${t("validate")}</button>
<button type="submit" id="btn-generate" class="kb-btn kb-btn--primary kb-btn--lg" style="flex:6">
<!-- Generate -->
<button type="submit" id="btn-generate" class="kb-btn kb-btn--primary kb-btn--lg kb-btn--block">
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="2"
stroke-linecap="round" stroke-linejoin="round" width="16" height="16">
<path d="M8 2v9m0 0 4-4M8 11 4 7"/><line x1="2" y1="14" x2="14" y2="14"/>
</svg>
<span id="btn-generate-lbl">${t("generate-invoice")}</span>
${t("generate-invoice")}
</button>
</div>
</form>`;
document.getElementById("pcode").addEventListener("change", function () {
document.getElementById("pcode-other-wrap").style.display = this.value === "__other__" ? "block" : "none";
});
document.getElementById("ino").addEventListener("input", updateInvMeta);
document.getElementById("ct-pick").addEventListener("change", e => fillChargeTo(e.target.value));
document.getElementById("idate").addEventListener("change", calcPayBy);
document.getElementById("paid-inp").addEventListener("input", calcTotals);
@ -1358,7 +1304,7 @@ function bumpNum(s) {
// ── Relabel on language switch ────────────────────────────────────────────────
function relabel() {
const lm = {
"lbl-language":"language",
"inv-title":"invoice","lbl-language":"language",
"sec-sender":"sender-section","sec-invdet":"invoice-details-section",
"sec-ct":"charge-to","sec-lines":"invoice-lines",
"lbl-sn":"sender-name","lbl-sa1":"sender-address1","lbl-sa2":"sender-address2",
@ -1403,12 +1349,8 @@ function relabel() {
const alBtn = document.getElementById("btn-al");
if (alBtn) alBtn.textContent = t("add-line");
const saveBtn = document.getElementById("btn-save");
if (saveBtn) saveBtn.textContent = t("save");
const valBtn = document.getElementById("btn-validate");
if (valBtn) valBtn.textContent = t("validate");
const genLbl = document.getElementById("btn-generate-lbl");
if (genLbl) genLbl.textContent = t("generate-invoice");
const genBtn = document.getElementById("btn-generate");
if (genBtn) genBtn.textContent = t("generate-invoice");
Object.keys(tLines).forEach(i => {
const ttEl = document.getElementById(`tt-${i}`);
@ -1437,46 +1379,6 @@ function relabel() {
}
// ── Generate invoice ──────────────────────────────────────────────────────────
function saveInvoice() {
saveStorage();
const btn = document.getElementById("btn-save");
if (!btn) return;
const orig = btn.textContent;
btn.textContent = "✓ " + orig;
btn.disabled = true;
setTimeout(() => { btn.textContent = orig; btn.disabled = false; }, 1500);
}
function validateInvoice() {
const errors = [];
const ino = (document.getElementById("ino")?.value || "").trim();
if (!ino) errors.push(t("val-invoice-no") || "Invoice number is required");
const fromName = (document.getElementById("sn")?.value || "").trim();
if (!fromName) errors.push(t("val-from-name") || "Sender name is required");
const ctPick = document.getElementById("ct-pick");
if (!ctPick || !ctPick.value) errors.push(t("val-charge-to") || "Charge-to is required");
if (Object.keys(lines).length === 0) errors.push(t("val-lines") || "At least one line item is required");
const btn = document.getElementById("btn-validate");
const existing = document.getElementById("validate-msg");
if (existing) existing.remove();
const msg = document.createElement("div");
msg.id = "validate-msg";
msg.style.cssText = "margin-top:8px;padding:10px 14px;border-radius:8px;font-size:var(--fs-small);display:flex;align-items:center;gap:10px";
if (errors.length === 0) {
msg.style.background = "#d1fae5";
msg.style.color = "#166534";
msg.innerHTML = `<svg width="18" height="18" viewBox="0 0 18 18" fill="none" style="flex-shrink:0"><circle cx="9" cy="9" r="9" fill="#22c55e"/><path d="M5 9l3 3 5-5" stroke="#fff" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"/></svg>${h(t("val-ok") || "All good. The form is ready to download.")}`;
} else {
msg.style.background = "color-mix(in srgb, #e74c3c 12%, transparent)";
msg.style.color = "#e74c3c";
msg.innerHTML = errors.map(e => `• ${e}`).join("<br>");
}
const row = document.getElementById("action-row");
if (row) row.after(msg);
setTimeout(() => msg.remove(), 5000);
}
function generateInvoice() {
saveStorage();
localStorage.setItem(LS_GEN, "true");
@ -1613,27 +1515,19 @@ function buildPDF() {
let y = MT;
let ly = y, ry = y;
// Accent: #2f6fed = rgb(47,111,237) Muted text: rgb(107,114,128) Body: rgb(17,24,39)
const ACCENT = [47,111,237];
const BODY = [17,24,39];
const MUTED = [107,114,128];
const BORDER = [209,213,219];
const WHITE = [255,255,255];
const STRIPE = [249,250,251];
if (sName) { fb(13); tc(...ACCENT); tL(sName, ML, ly); ly += 6; }
fn(8.5); tc(...MUTED);
if (sName) { fb(13); tc(30,45,69); tL(sName, ML, ly); ly += 6; }
fn(8.5); tc(75,85,99);
[...sAddr, sCntry].filter(Boolean).forEach(l => { tL(l, ML, ly); ly += 4.5; });
if (sPh || sEm || sTax) {
const parts = [];
if (sPh) parts.push(`${td("sender-phone")}: ${sPh}`);
if (sEm) parts.push(`${td("sender-email")}: ${sEm}`);
if (sTax) parts.push(`${td("vat-id")}: ${sTax}`);
fn(8); tc(...MUTED);
fn(8); tc(107,114,128);
sp(parts.join(" "), LW).forEach(l => { tL(l, ML, ly); ly += 4; }); ly += 1;
}
fb(24); tc(...ACCENT); tR(td("invoice"), XR, ry); ry += 10;
fb(24); tc(30,45,69); tR(td("invoice"), XR, ry); ry += 10;
const metaRows = [
iNo ? [td("invoice-no"), iNo] : null,
iDate ? [td("invoice-date"), fmtDate(iDate)] : null,
@ -1641,20 +1535,20 @@ function buildPDF() {
iCur ? [td("invoice-currency"), iCur] : null,
].filter(Boolean);
metaRows.forEach(([lbl, val]) => {
fn(8.5); tc(...MUTED); tR(lbl + ":", XR - 42, ry);
fb(8.5); tc(...BODY); tR(val, XR, ry);
fn(8.5); tc(107,114,128); tR(lbl + ":", XR - 42, ry);
fb(8.5); tc(17,24,39); tR(val, XR, ry);
ry += 5;
});
const row1Y = Math.max(ly, ry) + 4;
dc(...BORDER); doc.setLineWidth(0.3);
dc(209,213,219); doc.setLineWidth(0.3);
doc.line(ML, row1Y, XR, row1Y);
let ly2 = row1Y + 5, ry2 = row1Y + 5;
fb(7); tc(...MUTED); tL(td("charge-to").toUpperCase(), ML, ly2); ly2 += 5;
fb(7); tc(107,114,128); tL(td("charge-to").toUpperCase(), ML, ly2); ly2 += 5;
if (ctName) {
fb(10); tc(...ACCENT); tL(ctName, ML, ly2); ly2 += 5.5;
fn(8.5); tc(...BODY);
fb(10); tc(30,45,69); tL(ctName, ML, ly2); ly2 += 5.5;
fn(8.5); tc(17,24,39);
[...ctAddr, ctCntry].filter(Boolean).forEach(l => { tL(l, ML, ly2); ly2 += 4.5; });
const ctParts = [];
if (ctPh) ctParts.push(`${td("charge-to-phone")}: ${ctPh}`);
@ -1662,16 +1556,16 @@ function buildPDF() {
if (ctVat) ctParts.push(`${td("vat-id")}: ${ctVat}`);
if (ctReg) ctParts.push(`${td("registration-no")}: ${ctReg}`);
if (ctParts.length) {
fn(8); tc(...MUTED);
fn(8); tc(107,114,128);
sp(ctParts.join(" "), LW).forEach(l => { tL(l, ML, ly2); ly2 += 4; }); ly2 += 1;
}
}
if (pTerm > 0 || showBank) {
fb(7); tc(...MUTED); tL(td("payment").toUpperCase(), XM_L, ry2); ry2 += 5;
fb(7); tc(107,114,128); tL(td("payment").toUpperCase(), XM_L, ry2); ry2 += 5;
if (pTerm > 0) {
const ts = `${td("payment-terms")}: ${pTerm} ${td("payment-days")}${pPayBy ? ` ${td("pay-by")}: ${pPayBy}` : ""}`;
fn(8.5); tc(...BODY); tL(ts, XM_L, ry2); ry2 += 5;
fn(8.5); tc(17,24,39); tL(ts, XM_L, ry2); ry2 += 5;
}
if (showBank) {
const LLBL = 46;
@ -1682,22 +1576,22 @@ function buildPDF() {
(pBadr1||pBadr2) ? [td("bank-address"), [pBadr1,pBadr2].filter(Boolean).join(", ")] : null,
].filter(Boolean);
payRows.forEach(([lbl, val]) => {
fn(8); tc(...MUTED); tL(lbl + ":", XM_L, ry2);
fn(8.5); tc(...BODY);
fn(8); tc(107,114,128); tL(lbl + ":", XM_L, ry2);
fn(8.5); tc(17,24,39);
const wrapped = sp(val, LW - LLBL - 2);
wrapped.forEach((line, i) => tL(line, XM_L + LLBL, ry2 + i * 4));
ry2 += Math.max(4.5, wrapped.length * 4);
});
if (pRef) {
fn(8); tc(...MUTED); tL(td("payment-ref") + ":", XM_L, ry2);
fb(8.5); tc(...BODY); tL(pRef, XM_L + LLBL, ry2); ry2 += 5;
fn(8); tc(107,114,128); tL(td("payment-ref") + ":", XM_L, ry2);
fb(8.5); tc(17,24,39); tL(pRef, XM_L + LLBL, ry2); ry2 += 5;
}
}
}
y = Math.max(ly2, ry2) + 5;
dc(...ACCENT); doc.setLineWidth(0.6);
dc(30,45,69); doc.setLineWidth(0.6);
doc.line(ML, y, XR, y); y += 6;
// ── LINE ITEMS TABLE ──────────────────────────────────────────────────────
@ -1708,9 +1602,9 @@ function buildPDF() {
if (y + TH > PH - 40) { doc.addPage(); y = MT; }
fc(...ACCENT); dc(...WHITE); doc.setLineWidth(0);
fc(30,45,69); dc(255,255,255); doc.setLineWidth(0);
doc.rect(ML, y, CW, TH, "F");
fb(8); tc(...WHITE);
fb(8); tc(255,255,255);
tL(td("qty"), xQ+2, y+4.8);
tL(td("uom"), xU+2, y+4.8);
tL(td("description"), xD+2, y+4.8);
@ -1735,27 +1629,27 @@ function buildPDF() {
if (y + rh > PH - 30) { doc.addPage(); y = MT; }
if (idx % 2 === 1) {
fc(...STRIPE); dc(...WHITE); doc.setLineWidth(0);
fc(249,250,251); dc(255,255,255); doc.setLineWidth(0);
doc.rect(ML, y, CW, rh, "F");
}
dc(...BORDER); doc.setLineWidth(0.1);
dc(209,213,219); doc.setLineWidth(0.1);
doc.line(ML, y+rh, XR, y+rh);
const yt = y + 5;
const qStr = row.qty % 1 === 0 ? String(row.qty) : fmt(row.qty);
fn(8.5); tc(...BODY);
fn(8.5); tc(17,24,39);
tL(qStr, xQ+2, yt);
tL(row.uomLbl, xU+2, yt);
dLines.forEach((dline, li) => tL(dline, xD+2, yt + li*3.8));
fn(8.5); tc(...BODY); tR(fmt(row.price), xP+CP-2, yt);
fb(8.5); tc(...BODY); tR(fmt(row.tot), XR-2, yt);
fn(8.5); tc(17,24,39); tR(fmt(row.price), xP+CP-2, yt);
fb(8.5); tc(17,24,39); tR(fmt(row.tot), XR-2, yt);
if (row.fxNote) {
const fxStr = `${td("per-item")}: ${row.fxNote.cur} ${fmt(row.fxNote.per)} `
+ `(${(+row.fxNote.rate).toFixed(5)} ${row.fxNote.cur} = 1 ${iCur})`;
fn(7); tc(...MUTED);
fn(7); tc(107,114,128);
const fxLines = sp(fxStr, CD + CP - 4);
fxLines.forEach((fl, fi) => tL(fl, xD+2, y + ROW_H + descH + 3.2 + fi * 3.5));
}
@ -1779,26 +1673,21 @@ function buildPDF() {
totRows.forEach(([lbl, val]) => {
if (y + TRH > PH - 20) { doc.addPage(); y = MT; }
fn(8.5); tc(...MUTED); tR(lbl, TLBX, y+4.5);
fn(8.5); tc(...BODY); tR(val, XR-2, y+4.5);
dc(...BORDER); doc.setLineWidth(0.1);
fn(8.5); tc(107,114,128); tR(lbl, TLBX, y+4.5);
fn(8.5); tc(17,24,39); tR(val, XR-2, y+4.5);
dc(209,213,219); doc.setLineWidth(0.1);
doc.line(TX, y+TRH, XR, y+TRH);
y += TRH;
});
if (y + 9 > PH - 10) { doc.addPage(); y = MT; }
fc(...ACCENT); dc(...WHITE); doc.setLineWidth(0);
fc(30,45,69); dc(255,255,255); doc.setLineWidth(0);
doc.rect(TX, y, TW, 9, "F");
fn(9); tc(180,210,255); tR(td("to-pay"), TLBX, y+5.8);
fb(11); tc(...WHITE); tR(fmt(toPay), XR-2, y+5.8);
fn(9); tc(180,195,215); tR(td("to-pay"), TLBX, y+5.8);
fb(11); tc(255,255,255); tR(fmt(toPay), XR-2, y+5.8);
y += 9;
const safeName = s => (s || "").replace(/[^a-zA-Z0-9_\-]/g, "_").replace(/_+/g, "_").replace(/^_|_$/g, "");
const fnIssuer = safeName(sName);
const fnDate = iDate || "";
const fnNo = safeName(iNo);
const parts = [fnIssuer, fnDate, fnNo].filter(Boolean);
doc.save(parts.length ? parts.join("_") + ".pdf" : "invoice.pdf");
doc.save(iNo ? `invoice-${iNo}.pdf` : "invoice.pdf");
}
// ── Update FX labels when currency or invoice currency changes ────────────────

View file

@ -11,12 +11,6 @@
"type": "image/png",
"purpose": "any maskable"
},
{
"src": "icon-192.png",
"sizes": "192x192",
"type": "image/png",
"purpose": "any maskable"
},
{
"src": "apple-touch-icon.png",
"sizes": "180x180",