Compare commits
No commits in common. "0756b871f13a0af66edac180f3c562fa6504c9ca" and "3ee874ccee33fc2e16d1609f52ff4fa7748854f7" have entirely different histories.
0756b871f1
...
3ee874ccee
|
Before Width: | Height: | Size: 2.6 KiB After Width: | Height: | Size: 2.6 KiB |
|
Before Width: | Height: | Size: 1.2 KiB |
|
Before Width: | Height: | Size: 3.2 KiB |
|
|
@ -21,14 +21,8 @@ languages:
|
||||||
name: Norsk
|
name: Norsk
|
||||||
direction: ltr
|
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 ───────────────────────────────────────────────────────────────
|
# ── 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:
|
about:
|
||||||
en:
|
en:
|
||||||
title: "About"
|
title: "About"
|
||||||
|
|
@ -457,46 +451,11 @@ translations:
|
||||||
de: Zu zahlen
|
de: Zu zahlen
|
||||||
fr: À payer
|
fr: À payer
|
||||||
"no": Å betale
|
"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:
|
generate-invoice:
|
||||||
en: Download Invoice
|
en: Generate Invoice
|
||||||
de: Rechnung herunterladen
|
de: Rechnung erstellen
|
||||||
fr: Télécharger la facture
|
fr: Générer la facture
|
||||||
"no": Last ned faktura
|
"no": Generer faktura
|
||||||
other:
|
other:
|
||||||
en: Other
|
en: Other
|
||||||
de: Andere
|
de: Andere
|
||||||
|
|
|
||||||
|
Before Width: | Height: | Size: 455 B After Width: | Height: | Size: 455 B |
|
Before Width: | Height: | Size: 786 B After Width: | Height: | Size: 786 B |
|
Before Width: | Height: | Size: 1 KiB After Width: | Height: | Size: 1 KiB |
|
Before Width: | Height: | Size: 684 B After Width: | Height: | Size: 684 B |
|
Before Width: | Height: | Size: 11 KiB After Width: | Height: | Size: 11 KiB |
353
app/index.html
|
|
@ -5,13 +5,12 @@
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
<meta name="format-detection" content="telephone=no">
|
<meta name="format-detection" content="telephone=no">
|
||||||
<title>Invoice</title>
|
<title>Invoice</title>
|
||||||
<link rel="icon" href="assets/favicon.svg" type="image/svg+xml">
|
<link rel="icon" href="favicon.svg" type="image/svg+xml">
|
||||||
<link rel="icon" href="assets/favicon.ico" sizes="32x32">
|
<link rel="icon" href="favicon-48.png" sizes="48x48" type="image/png">
|
||||||
<link rel="icon" href="assets/favicon-48.png" sizes="48x48" type="image/png">
|
<link rel="icon" href="favicon-32.png" sizes="32x32" type="image/png">
|
||||||
<link rel="icon" href="assets/favicon-32.png" sizes="32x32" type="image/png">
|
<link rel="icon" href="favicon-16.png" sizes="16x16" type="image/png">
|
||||||
<link rel="icon" href="assets/favicon-16.png" sizes="16x16" type="image/png">
|
<link rel="apple-touch-icon" href="apple-touch-icon.png">
|
||||||
<link rel="apple-touch-icon" href="assets/apple-touch-icon.png">
|
<link rel="manifest" href="site.webmanifest">
|
||||||
<link rel="manifest" href="assets/site.webmanifest">
|
|
||||||
<meta name="theme-color" content="#2f6fed">
|
<meta name="theme-color" content="#2f6fed">
|
||||||
<script>
|
<script>
|
||||||
/* Restore persisted theme before first paint */
|
/* Restore persisted theme before first paint */
|
||||||
|
|
@ -123,7 +122,7 @@
|
||||||
.kb-mono { font-family: var(--font-mono); font-variant-numeric: tabular-nums; }
|
.kb-mono { font-family: var(--font-mono); font-variant-numeric: tabular-nums; }
|
||||||
|
|
||||||
/* ── Page shell ─────────────────────────────────────────────────────────── */
|
/* ── 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 ────────────────────────────────────────────────────────────── */
|
/* ── Toolbar ────────────────────────────────────────────────────────────── */
|
||||||
.kb-toolbar {
|
.kb-toolbar {
|
||||||
|
|
@ -153,25 +152,19 @@
|
||||||
|
|
||||||
/* ── Document header ────────────────────────────────────────────────────── */
|
/* ── Document header ────────────────────────────────────────────────────── */
|
||||||
.kb-header {
|
.kb-header {
|
||||||
display: flex; justify-content: space-between; align-items: flex-start;
|
display: flex; justify-content: space-between; align-items: center;
|
||||||
gap: 24px; padding-bottom: 20px; margin-bottom: 20px;
|
gap: 16px; padding-bottom: 14px; margin-bottom: 20px;
|
||||||
border-bottom: 1px solid var(--border);
|
border-bottom: 1px solid var(--border);
|
||||||
}
|
}
|
||||||
.kb-brand { display: flex; align-items: center; gap: 14px; min-width: 0; }
|
.app-wordmark {
|
||||||
.kb-brand .logo {
|
display: inline-flex; align-items: center; gap: 8px;
|
||||||
height: 46px; width: 46px; flex: 0 0 46px; border-radius: 10px;
|
font: 700 var(--fs-h1)/1 var(--font-sans);
|
||||||
display: grid; place-items: center; overflow: hidden;
|
color: var(--text); letter-spacing: -0.01em; user-select: none;
|
||||||
}
|
}
|
||||||
.kb-brand .logo svg { width: 100%; height: 100%; display: block; }
|
.app-wordmark svg { flex-shrink: 0; }
|
||||||
.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; }
|
|
||||||
.kb-doctitle { text-align: right; }
|
.kb-doctitle { text-align: right; }
|
||||||
.kb-doctitle h1 {
|
.kb-doctitle h1 { margin: 0; font-size: var(--fs-h1); font-weight: 700; letter-spacing: -0.01em; color: var(--text); }
|
||||||
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); }
|
|
||||||
|
|
||||||
/* ── Cards ──────────────────────────────────────────────────────────────── */
|
/* ── Cards ──────────────────────────────────────────────────────────────── */
|
||||||
.kb-card {
|
.kb-card {
|
||||||
|
|
@ -337,7 +330,7 @@
|
||||||
|
|
||||||
/* ── Footer ─────────────────────────────────────────────────────────────── */
|
/* ── Footer ─────────────────────────────────────────────────────────────── */
|
||||||
.kb-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);
|
font-size: var(--fs-small); color: var(--text-muted);
|
||||||
display: flex; align-items: center; gap: 8px; flex-wrap: wrap;
|
display: flex; align-items: center; gap: 8px; flex-wrap: wrap;
|
||||||
border-top: 1px solid var(--border);
|
border-top: 1px solid var(--border);
|
||||||
|
|
@ -371,23 +364,28 @@
|
||||||
</div>
|
</div>
|
||||||
<span id="sz-label" style="font-size:12px;color:var(--text-muted);font-family:var(--font-mono)">100%</span>
|
<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">
|
<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">
|
<svg viewBox="0 0 16 16" width="15" height="15" fill="currentColor">
|
||||||
<path d="M12 3a6 6 0 0 0 9 9 9 9 0 1 1-9-9Z"/>
|
<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"/>
|
||||||
</svg>
|
<path d="M8 1.5V14.5A6.5 6.5 0 0 1 8 1.5z"/>
|
||||||
</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>
|
</svg>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Document header (brand + title built by buildHeader() after config loads) -->
|
<!-- Document header -->
|
||||||
<header class="kb-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">
|
<div class="kb-doctitle">
|
||||||
<h1 id="inv-title"></h1>
|
<h1 id="inv-title">Invoice</h1>
|
||||||
<div class="meta" id="inv-meta"></div>
|
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
|
|
@ -594,54 +592,17 @@ async function loadCfg() {
|
||||||
// ── Boot ──────────────────────────────────────────────────────────────────────
|
// ── Boot ──────────────────────────────────────────────────────────────────────
|
||||||
function boot() {
|
function boot() {
|
||||||
buildLangBar();
|
buildLangBar();
|
||||||
buildHeader();
|
|
||||||
buildForm();
|
buildForm();
|
||||||
buildFooter();
|
buildFooter();
|
||||||
restoreStorage();
|
restoreStorage();
|
||||||
updateInvMeta();
|
|
||||||
if (!loadLines()) addLine();
|
if (!loadLines()) addLine();
|
||||||
document.getElementById("loading").style.display = "none";
|
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 ────────────────────────────────────────────────────
|
// ── 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 ZOOMS = [0.8, 0.85, 0.9, 0.95, 1.0, 1.1, 1.2, 1.3];
|
||||||
const ZOOM_LABELS = ["50%","60%","70%","80%","90%","100%","110%","120%","130%","140%","150%"];
|
const ZOOM_LABELS = ["80%", "85%", "90%", "95%", "100%", "110%", "120%", "130%"];
|
||||||
let zoomIdx = +(localStorage.getItem("zoomIdx") ?? 5);
|
let zoomIdx = +(localStorage.getItem("zoomIdx") ?? 4);
|
||||||
|
|
||||||
function applyZoom() {
|
function applyZoom() {
|
||||||
const fr = document.getElementById("form-root");
|
const fr = document.getElementById("form-root");
|
||||||
|
|
@ -661,10 +622,6 @@ function bumpZoom(dir) {
|
||||||
// ── Language bar ──────────────────────────────────────────────────────────────
|
// ── Language bar ──────────────────────────────────────────────────────────────
|
||||||
function buildLangBar() {
|
function buildLangBar() {
|
||||||
applyZoom();
|
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"] }];
|
const langs = cfg.languages || [{ code: cfg["default-code"], name: cfg["default-name"] }];
|
||||||
if (langs.length < 2) return;
|
if (langs.length < 2) return;
|
||||||
const part = document.getElementById("lang-part");
|
const part = document.getElementById("lang-part");
|
||||||
|
|
@ -684,6 +641,8 @@ function buildLangBar() {
|
||||||
|
|
||||||
// ── Build form ────────────────────────────────────────────────────────────────
|
// ── Build form ────────────────────────────────────────────────────────────────
|
||||||
function buildForm() {
|
function buildForm() {
|
||||||
|
document.getElementById("inv-title").textContent = t("invoice");
|
||||||
|
|
||||||
const today = new Date();
|
const today = new Date();
|
||||||
const dateDef = `${today.getFullYear()}-${String(today.getMonth() + 1).padStart(2, "0")}-${String(today.getDate()).padStart(2, "0")}`;
|
const dateDef = `${today.getFullYear()}-${String(today.getMonth() + 1).padStart(2, "0")}-${String(today.getDate()).padStart(2, "0")}`;
|
||||||
const curOpts = (cfg.currencies || []).map((c, i) =>
|
const curOpts = (cfg.currencies || []).map((c, i) =>
|
||||||
|
|
@ -827,54 +786,46 @@ function buildForm() {
|
||||||
</select>
|
</select>
|
||||||
</h2>
|
</h2>
|
||||||
<div id="ct-fields" class="locked">
|
<div id="ct-fields" class="locked">
|
||||||
<div class="kb-grid cols-2" style="align-items:start">
|
<div class="kb-grid cols-2">
|
||||||
<!-- Left: address -->
|
<div class="kb-field">
|
||||||
<div class="kb-grid">
|
<label class="kb-label" id="lbl-ctn" for="ctn">${t("charge-to-name")}</label>
|
||||||
<div class="kb-field">
|
<input id="ctn" type="text" class="kb-input">
|
||||||
<label class="kb-label" id="lbl-ctn" for="ctn">${t("charge-to-name")}</label>
|
|
||||||
<input id="ctn" type="text" class="kb-input">
|
|
||||||
</div>
|
|
||||||
<div class="kb-field">
|
|
||||||
<label class="kb-label" id="lbl-ca1" for="ca1">${t("charge-to-address1")}</label>
|
|
||||||
<input id="ca1" type="text" class="kb-input">
|
|
||||||
</div>
|
|
||||||
<div class="kb-field">
|
|
||||||
<label class="kb-label" id="lbl-ca2" for="ca2">${t("charge-to-address2")}</label>
|
|
||||||
<input id="ca2" type="text" class="kb-input">
|
|
||||||
</div>
|
|
||||||
<div class="kb-field">
|
|
||||||
<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">
|
|
||||||
</div>
|
|
||||||
<div class="kb-field">
|
|
||||||
<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>
|
</div>
|
||||||
<!-- Right: contact + IDs -->
|
<div class="kb-field">
|
||||||
<div class="kb-grid">
|
<label class="kb-label" id="lbl-ca1" for="ca1">${t("charge-to-address1")}</label>
|
||||||
<div class="kb-field">
|
<input id="ca1" type="text" class="kb-input">
|
||||||
<label class="kb-label" id="lbl-cph" for="cph">${t("charge-to-phone")}</label>
|
</div>
|
||||||
<input id="cph" type="tel" class="kb-input">
|
<div class="kb-field">
|
||||||
</div>
|
<label class="kb-label" id="lbl-ca2" for="ca2">${t("charge-to-address2")}</label>
|
||||||
<div class="kb-field">
|
<input id="ca2" type="text" class="kb-input">
|
||||||
<label class="kb-label" id="lbl-cem" for="cem">${t("charge-to-email")}</label>
|
</div>
|
||||||
<input id="cem" type="email" class="kb-input">
|
<div class="kb-field">
|
||||||
</div>
|
<label class="kb-label" id="lbl-ca3" for="ca3">${t("charge-to-address3")}</label>
|
||||||
<div class="kb-field">
|
<input id="ca3" type="text" class="kb-input">
|
||||||
<label class="kb-label" id="lbl-cvat" for="cvat">${t("vat-id")}</label>
|
</div>
|
||||||
<input id="cvat" type="text" class="kb-input">
|
<div class="kb-field">
|
||||||
</div>
|
<label class="kb-label" id="lbl-ca4" for="ca4">${t("charge-to-address4")}</label>
|
||||||
<div class="kb-field">
|
<input id="ca4" type="text" class="kb-input">
|
||||||
<label class="kb-label" id="lbl-creg" for="creg">${t("registration-no")}</label>
|
</div>
|
||||||
<input id="creg" type="text" class="kb-input">
|
<div class="kb-field">
|
||||||
</div>
|
<label class="kb-label" id="lbl-cc" for="cc">${t("charge-to-country")}</label>
|
||||||
|
<select id="cc" class="kb-select">${countryOpts("")}</select>
|
||||||
|
</div>
|
||||||
|
<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">
|
||||||
|
</div>
|
||||||
|
<div class="kb-field">
|
||||||
|
<label class="kb-label" id="lbl-cem" for="cem">${t("charge-to-email")}</label>
|
||||||
|
<input id="cem" type="email" class="kb-input">
|
||||||
|
</div>
|
||||||
|
<div class="kb-field">
|
||||||
|
<label class="kb-label" id="lbl-cvat" for="cvat">${t("vat-id")}</label>
|
||||||
|
<input id="cvat" type="text" class="kb-input">
|
||||||
|
</div>
|
||||||
|
<div class="kb-field">
|
||||||
|
<label class="kb-label" id="lbl-creg" for="creg">${t("registration-no")}</label>
|
||||||
|
<input id="creg" type="text" class="kb-input">
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -940,24 +891,19 @@ function buildForm() {
|
||||||
</section>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Action row -->
|
<!-- Generate -->
|
||||||
<div id="action-row" style="display:flex;gap:10px;margin-top:8px">
|
<button type="submit" id="btn-generate" class="kb-btn kb-btn--primary kb-btn--lg kb-btn--block">
|
||||||
<button type="button" id="btn-save" class="kb-btn kb-btn--ghost kb-btn--lg" style="flex:2" onclick="saveInvoice()">${t("save")}</button>
|
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="2"
|
||||||
<button type="button" id="btn-validate" class="kb-btn kb-btn--ghost kb-btn--lg" style="flex:2" onclick="validateInvoice()">${t("validate")}</button>
|
stroke-linecap="round" stroke-linejoin="round" width="16" height="16">
|
||||||
<button type="submit" id="btn-generate" class="kb-btn kb-btn--primary kb-btn--lg" style="flex:6">
|
<path d="M8 2v9m0 0 4-4M8 11 4 7"/><line x1="2" y1="14" x2="14" y2="14"/>
|
||||||
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="2"
|
</svg>
|
||||||
stroke-linecap="round" stroke-linejoin="round" width="16" height="16">
|
${t("generate-invoice")}
|
||||||
<path d="M8 2v9m0 0 4-4M8 11 4 7"/><line x1="2" y1="14" x2="14" y2="14"/>
|
</button>
|
||||||
</svg>
|
|
||||||
<span id="btn-generate-lbl">${t("generate-invoice")}</span>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</form>`;
|
</form>`;
|
||||||
|
|
||||||
document.getElementById("pcode").addEventListener("change", function () {
|
document.getElementById("pcode").addEventListener("change", function () {
|
||||||
document.getElementById("pcode-other-wrap").style.display = this.value === "__other__" ? "block" : "none";
|
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("ct-pick").addEventListener("change", e => fillChargeTo(e.target.value));
|
||||||
document.getElementById("idate").addEventListener("change", calcPayBy);
|
document.getElementById("idate").addEventListener("change", calcPayBy);
|
||||||
document.getElementById("paid-inp").addEventListener("input", calcTotals);
|
document.getElementById("paid-inp").addEventListener("input", calcTotals);
|
||||||
|
|
@ -1358,7 +1304,7 @@ function bumpNum(s) {
|
||||||
// ── Relabel on language switch ────────────────────────────────────────────────
|
// ── Relabel on language switch ────────────────────────────────────────────────
|
||||||
function relabel() {
|
function relabel() {
|
||||||
const lm = {
|
const lm = {
|
||||||
"lbl-language":"language",
|
"inv-title":"invoice","lbl-language":"language",
|
||||||
"sec-sender":"sender-section","sec-invdet":"invoice-details-section",
|
"sec-sender":"sender-section","sec-invdet":"invoice-details-section",
|
||||||
"sec-ct":"charge-to","sec-lines":"invoice-lines",
|
"sec-ct":"charge-to","sec-lines":"invoice-lines",
|
||||||
"lbl-sn":"sender-name","lbl-sa1":"sender-address1","lbl-sa2":"sender-address2",
|
"lbl-sn":"sender-name","lbl-sa1":"sender-address1","lbl-sa2":"sender-address2",
|
||||||
|
|
@ -1403,12 +1349,8 @@ function relabel() {
|
||||||
|
|
||||||
const alBtn = document.getElementById("btn-al");
|
const alBtn = document.getElementById("btn-al");
|
||||||
if (alBtn) alBtn.textContent = t("add-line");
|
if (alBtn) alBtn.textContent = t("add-line");
|
||||||
const saveBtn = document.getElementById("btn-save");
|
const genBtn = document.getElementById("btn-generate");
|
||||||
if (saveBtn) saveBtn.textContent = t("save");
|
if (genBtn) genBtn.textContent = t("generate-invoice");
|
||||||
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");
|
|
||||||
|
|
||||||
Object.keys(tLines).forEach(i => {
|
Object.keys(tLines).forEach(i => {
|
||||||
const ttEl = document.getElementById(`tt-${i}`);
|
const ttEl = document.getElementById(`tt-${i}`);
|
||||||
|
|
@ -1437,46 +1379,6 @@ function relabel() {
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Generate invoice ──────────────────────────────────────────────────────────
|
// ── 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() {
|
function generateInvoice() {
|
||||||
saveStorage();
|
saveStorage();
|
||||||
localStorage.setItem(LS_GEN, "true");
|
localStorage.setItem(LS_GEN, "true");
|
||||||
|
|
@ -1613,27 +1515,19 @@ function buildPDF() {
|
||||||
let y = MT;
|
let y = MT;
|
||||||
let ly = y, ry = y;
|
let ly = y, ry = y;
|
||||||
|
|
||||||
// Accent: #2f6fed = rgb(47,111,237) Muted text: rgb(107,114,128) Body: rgb(17,24,39)
|
if (sName) { fb(13); tc(30,45,69); tL(sName, ML, ly); ly += 6; }
|
||||||
const ACCENT = [47,111,237];
|
fn(8.5); tc(75,85,99);
|
||||||
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);
|
|
||||||
[...sAddr, sCntry].filter(Boolean).forEach(l => { tL(l, ML, ly); ly += 4.5; });
|
[...sAddr, sCntry].filter(Boolean).forEach(l => { tL(l, ML, ly); ly += 4.5; });
|
||||||
if (sPh || sEm || sTax) {
|
if (sPh || sEm || sTax) {
|
||||||
const parts = [];
|
const parts = [];
|
||||||
if (sPh) parts.push(`${td("sender-phone")}: ${sPh}`);
|
if (sPh) parts.push(`${td("sender-phone")}: ${sPh}`);
|
||||||
if (sEm) parts.push(`${td("sender-email")}: ${sEm}`);
|
if (sEm) parts.push(`${td("sender-email")}: ${sEm}`);
|
||||||
if (sTax) parts.push(`${td("vat-id")}: ${sTax}`);
|
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;
|
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 = [
|
const metaRows = [
|
||||||
iNo ? [td("invoice-no"), iNo] : null,
|
iNo ? [td("invoice-no"), iNo] : null,
|
||||||
iDate ? [td("invoice-date"), fmtDate(iDate)] : null,
|
iDate ? [td("invoice-date"), fmtDate(iDate)] : null,
|
||||||
|
|
@ -1641,20 +1535,20 @@ function buildPDF() {
|
||||||
iCur ? [td("invoice-currency"), iCur] : null,
|
iCur ? [td("invoice-currency"), iCur] : null,
|
||||||
].filter(Boolean);
|
].filter(Boolean);
|
||||||
metaRows.forEach(([lbl, val]) => {
|
metaRows.forEach(([lbl, val]) => {
|
||||||
fn(8.5); tc(...MUTED); tR(lbl + ":", XR - 42, ry);
|
fn(8.5); tc(107,114,128); tR(lbl + ":", XR - 42, ry);
|
||||||
fb(8.5); tc(...BODY); tR(val, XR, ry);
|
fb(8.5); tc(17,24,39); tR(val, XR, ry);
|
||||||
ry += 5;
|
ry += 5;
|
||||||
});
|
});
|
||||||
|
|
||||||
const row1Y = Math.max(ly, ry) + 4;
|
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);
|
doc.line(ML, row1Y, XR, row1Y);
|
||||||
|
|
||||||
let ly2 = row1Y + 5, ry2 = row1Y + 5;
|
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) {
|
if (ctName) {
|
||||||
fb(10); tc(...ACCENT); tL(ctName, ML, ly2); ly2 += 5.5;
|
fb(10); tc(30,45,69); tL(ctName, ML, ly2); ly2 += 5.5;
|
||||||
fn(8.5); tc(...BODY);
|
fn(8.5); tc(17,24,39);
|
||||||
[...ctAddr, ctCntry].filter(Boolean).forEach(l => { tL(l, ML, ly2); ly2 += 4.5; });
|
[...ctAddr, ctCntry].filter(Boolean).forEach(l => { tL(l, ML, ly2); ly2 += 4.5; });
|
||||||
const ctParts = [];
|
const ctParts = [];
|
||||||
if (ctPh) ctParts.push(`${td("charge-to-phone")}: ${ctPh}`);
|
if (ctPh) ctParts.push(`${td("charge-to-phone")}: ${ctPh}`);
|
||||||
|
|
@ -1662,16 +1556,16 @@ function buildPDF() {
|
||||||
if (ctVat) ctParts.push(`${td("vat-id")}: ${ctVat}`);
|
if (ctVat) ctParts.push(`${td("vat-id")}: ${ctVat}`);
|
||||||
if (ctReg) ctParts.push(`${td("registration-no")}: ${ctReg}`);
|
if (ctReg) ctParts.push(`${td("registration-no")}: ${ctReg}`);
|
||||||
if (ctParts.length) {
|
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;
|
sp(ctParts.join(" "), LW).forEach(l => { tL(l, ML, ly2); ly2 += 4; }); ly2 += 1;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (pTerm > 0 || showBank) {
|
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) {
|
if (pTerm > 0) {
|
||||||
const ts = `${td("payment-terms")}: ${pTerm} ${td("payment-days")}${pPayBy ? ` ${td("pay-by")}: ${pPayBy}` : ""}`;
|
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) {
|
if (showBank) {
|
||||||
const LLBL = 46;
|
const LLBL = 46;
|
||||||
|
|
@ -1682,22 +1576,22 @@ function buildPDF() {
|
||||||
(pBadr1||pBadr2) ? [td("bank-address"), [pBadr1,pBadr2].filter(Boolean).join(", ")] : null,
|
(pBadr1||pBadr2) ? [td("bank-address"), [pBadr1,pBadr2].filter(Boolean).join(", ")] : null,
|
||||||
].filter(Boolean);
|
].filter(Boolean);
|
||||||
payRows.forEach(([lbl, val]) => {
|
payRows.forEach(([lbl, val]) => {
|
||||||
fn(8); tc(...MUTED); tL(lbl + ":", XM_L, ry2);
|
fn(8); tc(107,114,128); tL(lbl + ":", XM_L, ry2);
|
||||||
fn(8.5); tc(...BODY);
|
fn(8.5); tc(17,24,39);
|
||||||
const wrapped = sp(val, LW - LLBL - 2);
|
const wrapped = sp(val, LW - LLBL - 2);
|
||||||
wrapped.forEach((line, i) => tL(line, XM_L + LLBL, ry2 + i * 4));
|
wrapped.forEach((line, i) => tL(line, XM_L + LLBL, ry2 + i * 4));
|
||||||
ry2 += Math.max(4.5, wrapped.length * 4);
|
ry2 += Math.max(4.5, wrapped.length * 4);
|
||||||
});
|
});
|
||||||
if (pRef) {
|
if (pRef) {
|
||||||
fn(8); tc(...MUTED); tL(td("payment-ref") + ":", XM_L, ry2);
|
fn(8); tc(107,114,128); tL(td("payment-ref") + ":", XM_L, ry2);
|
||||||
fb(8.5); tc(...BODY); tL(pRef, XM_L + LLBL, ry2); ry2 += 5;
|
fb(8.5); tc(17,24,39); tL(pRef, XM_L + LLBL, ry2); ry2 += 5;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
y = Math.max(ly2, 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;
|
doc.line(ML, y, XR, y); y += 6;
|
||||||
|
|
||||||
// ── LINE ITEMS TABLE ──────────────────────────────────────────────────────
|
// ── LINE ITEMS TABLE ──────────────────────────────────────────────────────
|
||||||
|
|
@ -1708,9 +1602,9 @@ function buildPDF() {
|
||||||
|
|
||||||
if (y + TH > PH - 40) { doc.addPage(); y = MT; }
|
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");
|
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("qty"), xQ+2, y+4.8);
|
||||||
tL(td("uom"), xU+2, y+4.8);
|
tL(td("uom"), xU+2, y+4.8);
|
||||||
tL(td("description"), xD+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 (y + rh > PH - 30) { doc.addPage(); y = MT; }
|
||||||
|
|
||||||
if (idx % 2 === 1) {
|
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");
|
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);
|
doc.line(ML, y+rh, XR, y+rh);
|
||||||
|
|
||||||
const yt = y + 5;
|
const yt = y + 5;
|
||||||
const qStr = row.qty % 1 === 0 ? String(row.qty) : fmt(row.qty);
|
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(qStr, xQ+2, yt);
|
||||||
tL(row.uomLbl, xU+2, yt);
|
tL(row.uomLbl, xU+2, yt);
|
||||||
dLines.forEach((dline, li) => tL(dline, xD+2, yt + li*3.8));
|
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);
|
fn(8.5); tc(17,24,39); tR(fmt(row.price), xP+CP-2, yt);
|
||||||
fb(8.5); tc(...BODY); tR(fmt(row.tot), XR-2, yt);
|
fb(8.5); tc(17,24,39); tR(fmt(row.tot), XR-2, yt);
|
||||||
|
|
||||||
if (row.fxNote) {
|
if (row.fxNote) {
|
||||||
const fxStr = `${td("per-item")}: ${row.fxNote.cur} ${fmt(row.fxNote.per)} `
|
const fxStr = `${td("per-item")}: ${row.fxNote.cur} ${fmt(row.fxNote.per)} `
|
||||||
+ `(${(+row.fxNote.rate).toFixed(5)} ${row.fxNote.cur} = 1 ${iCur})`;
|
+ `(${(+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);
|
const fxLines = sp(fxStr, CD + CP - 4);
|
||||||
fxLines.forEach((fl, fi) => tL(fl, xD+2, y + ROW_H + descH + 3.2 + fi * 3.5));
|
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]) => {
|
totRows.forEach(([lbl, val]) => {
|
||||||
if (y + TRH > PH - 20) { doc.addPage(); y = MT; }
|
if (y + TRH > PH - 20) { doc.addPage(); y = MT; }
|
||||||
fn(8.5); tc(...MUTED); tR(lbl, TLBX, y+4.5);
|
fn(8.5); tc(107,114,128); tR(lbl, TLBX, y+4.5);
|
||||||
fn(8.5); tc(...BODY); tR(val, XR-2, y+4.5);
|
fn(8.5); tc(17,24,39); tR(val, XR-2, y+4.5);
|
||||||
dc(...BORDER); doc.setLineWidth(0.1);
|
dc(209,213,219); doc.setLineWidth(0.1);
|
||||||
doc.line(TX, y+TRH, XR, y+TRH);
|
doc.line(TX, y+TRH, XR, y+TRH);
|
||||||
y += TRH;
|
y += TRH;
|
||||||
});
|
});
|
||||||
|
|
||||||
if (y + 9 > PH - 10) { doc.addPage(); y = MT; }
|
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");
|
doc.rect(TX, y, TW, 9, "F");
|
||||||
fn(9); tc(180,210,255); tR(td("to-pay"), TLBX, y+5.8);
|
fn(9); tc(180,195,215); tR(td("to-pay"), TLBX, y+5.8);
|
||||||
fb(11); tc(...WHITE); tR(fmt(toPay), XR-2, y+5.8);
|
fb(11); tc(255,255,255); tR(fmt(toPay), XR-2, y+5.8);
|
||||||
y += 9;
|
y += 9;
|
||||||
|
|
||||||
const safeName = s => (s || "").replace(/[^a-zA-Z0-9_\-]/g, "_").replace(/_+/g, "_").replace(/^_|_$/g, "");
|
doc.save(iNo ? `invoice-${iNo}.pdf` : "invoice.pdf");
|
||||||
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");
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Update FX labels when currency or invoice currency changes ────────────────
|
// ── Update FX labels when currency or invoice currency changes ────────────────
|
||||||
|
|
|
||||||
|
|
@ -11,12 +11,6 @@
|
||||||
"type": "image/png",
|
"type": "image/png",
|
||||||
"purpose": "any maskable"
|
"purpose": "any maskable"
|
||||||
},
|
},
|
||||||
{
|
|
||||||
"src": "icon-192.png",
|
|
||||||
"sizes": "192x192",
|
|
||||||
"type": "image/png",
|
|
||||||
"purpose": "any maskable"
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
"src": "apple-touch-icon.png",
|
"src": "apple-touch-icon.png",
|
||||||
"sizes": "180x180",
|
"sizes": "180x180",
|
||||||