mirror of
https://github.com/kbenestad/invoice.git
synced 2026-06-18 08:04:32 +00:00
- Add reg-no key to charge-to entries in config.yml - Add registration-no translation key (en/de/fr/no) - Add Registration no. input below VAT ID in the charge-to form - fillChargeTo() populates and clears creg; field locks when predefined recipient is selected - relabel() maps lbl-creg to registration-no translation - gatherData() collects ctReg; buildPreviewHTML() and buildPDF() display it in the bill-to block https://claude.ai/code/session_015iyCBgoTXNNqaErR287U1u
1333 lines
59 KiB
HTML
1333 lines
59 KiB
HTML
<!DOCTYPE html>
|
||
<html lang="en" dir="ltr">
|
||
<head>
|
||
<meta charset="UTF-8">
|
||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||
<title>Invoice</title>
|
||
<style>
|
||
/* ── Variables ──────────────────────────────────────────────────────────── */
|
||
:root {
|
||
--navy: #1e2d45;
|
||
--slate: #64748b;
|
||
--border: #d1d5db;
|
||
--border-light:#e5e7eb;
|
||
--bg: #f3f4f6;
|
||
--white: #ffffff;
|
||
--accent: #1d4ed8;
|
||
--accent-hover:#1e40af;
|
||
--text: #111827;
|
||
--text-muted: #6b7280;
|
||
--danger: #dc2626;
|
||
--success: #15803d;
|
||
--radius: 4px;
|
||
}
|
||
|
||
/* ── Reset / Base ───────────────────────────────────────────────────────── */
|
||
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
||
|
||
body {
|
||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto,
|
||
"Helvetica Neue", Arial, sans-serif;
|
||
font-size: 14px;
|
||
color: var(--text);
|
||
background: var(--bg);
|
||
line-height: 1.5;
|
||
}
|
||
|
||
input, select, textarea, button { font-family: inherit; }
|
||
|
||
input, select {
|
||
font-size: 13px;
|
||
color: var(--text);
|
||
border: 1px solid var(--border);
|
||
border-radius: var(--radius);
|
||
padding: 5px 8px;
|
||
background: var(--white);
|
||
width: 100%;
|
||
outline: none;
|
||
transition: border-color .15s;
|
||
}
|
||
input:focus, select:focus { border-color: var(--accent); box-shadow: 0 0 0 2px #dbeafe; }
|
||
input[type="number"] { text-align: right; }
|
||
|
||
button { cursor: pointer; border: none; border-radius: var(--radius); font-size: 13px; }
|
||
|
||
/* ── Layout ─────────────────────────────────────────────────────────────── */
|
||
.wrap { max-width: 920px; margin: 0 auto; padding: 20px 16px 48px; }
|
||
|
||
/* ── Language bar ───────────────────────────────────────────────────────── */
|
||
#lang-bar {
|
||
display: flex;
|
||
justify-content: flex-end;
|
||
align-items: center;
|
||
gap: 8px;
|
||
margin-bottom: 12px;
|
||
padding: 7px 12px;
|
||
background: var(--white);
|
||
border: 1px solid var(--border);
|
||
border-radius: var(--radius);
|
||
}
|
||
#lang-bar label { font-size: 12px; color: var(--text-muted); white-space: nowrap; }
|
||
#lang-bar select { width: auto; }
|
||
|
||
/* ── Invoice banner ─────────────────────────────────────────────────────── */
|
||
#inv-banner {
|
||
background: var(--navy);
|
||
color: var(--white);
|
||
padding: 18px 24px;
|
||
border-radius: var(--radius);
|
||
margin-bottom: 14px;
|
||
}
|
||
#inv-banner h1 { font-size: 26px; font-weight: 800; letter-spacing: 5px; line-height: 1; }
|
||
|
||
/* ── Card ───────────────────────────────────────────────────────────────── */
|
||
.card {
|
||
background: var(--white);
|
||
border: 1px solid var(--border);
|
||
border-radius: var(--radius);
|
||
padding: 16px 20px;
|
||
margin-bottom: 12px;
|
||
}
|
||
.card-title {
|
||
font-size: 10px;
|
||
font-weight: 700;
|
||
text-transform: uppercase;
|
||
letter-spacing: 1.2px;
|
||
color: var(--slate);
|
||
margin-bottom: 12px;
|
||
padding-bottom: 8px;
|
||
border-bottom: 1px solid var(--border-light);
|
||
}
|
||
|
||
/* ── Two-column grid ────────────────────────────────────────────────────── */
|
||
.two-col { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; }
|
||
@media (max-width: 640px) { .two-col { grid-template-columns: 1fr; } }
|
||
|
||
/* ── Form groups ────────────────────────────────────────────────────────── */
|
||
.fg { margin-bottom: 8px; }
|
||
.fg label { display: block; font-size: 11px; color: var(--text-muted); font-weight: 500; margin-bottom: 3px; }
|
||
.fi { display: grid; grid-template-columns: 80px 1fr; gap: 8px; align-items: center; margin-bottom: 8px; }
|
||
.fi label { font-size: 12px; color: var(--text-muted); white-space: nowrap; }
|
||
|
||
/* ── Line items table ───────────────────────────────────────────────────── */
|
||
#lines-card {
|
||
background: var(--white);
|
||
border: 1px solid var(--border);
|
||
border-radius: var(--radius);
|
||
margin-bottom: 12px;
|
||
overflow: hidden;
|
||
}
|
||
#lines-card .card-title { padding: 11px 16px; margin: 0; border-bottom: 1px solid var(--border); }
|
||
|
||
.line-tbl { width: 100%; border-collapse: collapse; }
|
||
.line-tbl thead th {
|
||
background: #f8f9fb;
|
||
padding: 7px 10px;
|
||
font-size: 10px; font-weight: 700;
|
||
text-transform: uppercase; letter-spacing: .5px;
|
||
color: var(--slate);
|
||
border-bottom: 1px solid var(--border);
|
||
text-align: left;
|
||
}
|
||
.line-tbl thead th.r { text-align: right; }
|
||
|
||
.line-tbl .lr td { padding: 8px 10px; border-bottom: 1px solid var(--border-light); vertical-align: top; }
|
||
.line-tbl .lr.open td { border-bottom: none; }
|
||
.line-tbl .fx td { padding: 4px 10px 10px; border-bottom: 1px solid var(--border-light); background: #f9fafb; }
|
||
.line-tbl .al td { padding: 10px; }
|
||
|
||
.col-qty { width: 72px; } .col-uom { width: 100px; }
|
||
.col-price { width: 110px; } .col-tot { width: 110px; } .col-act { width: 36px; }
|
||
|
||
.line-total-val { font-size: 13px; font-weight: 600; text-align: right; padding-top: 6px; }
|
||
|
||
.fx-grid { display: grid; grid-template-columns: 1fr 1fr 1fr; gap: 8px; margin-bottom: 6px; }
|
||
.fx-label { font-size: 11px; color: var(--text-muted); margin-bottom: 3px; }
|
||
.fx-note { font-size: 11px; color: var(--text-muted); }
|
||
.fx-note strong { color: var(--text); }
|
||
|
||
.btn-remove { background: none; color: var(--danger); font-size: 18px; line-height: 1; padding: 2px 5px; border-radius: 3px; }
|
||
.btn-remove:hover { background: #fef2f2; }
|
||
.btn-add-line { background: none; color: var(--accent); font-size: 13px; font-weight: 500; padding: 5px 10px; border: 1px dashed var(--accent); border-radius: var(--radius); }
|
||
.btn-add-line:hover { background: #eff6ff; }
|
||
|
||
/* ── Totals ─────────────────────────────────────────────────────────────── */
|
||
#totals-card { background: var(--white); border: 1px solid var(--border); border-radius: var(--radius); margin-bottom: 14px; }
|
||
.tot-tbl { width: 100%; border-collapse: collapse; }
|
||
.tot-tbl td { padding: 8px 16px; }
|
||
.tot-tbl tr:not(:last-child) td { border-bottom: 1px solid var(--border-light); }
|
||
.tot-tbl .lbl { text-align: right; color: var(--text-muted); font-size: 13px; }
|
||
.tot-tbl .val { text-align: right; width: 150px; font-size: 13px; font-weight: 600; }
|
||
.tot-tbl .final td { background: var(--navy); color: var(--white); font-size: 15px; font-weight: 700; }
|
||
.tot-tbl .final .lbl { color: rgba(255,255,255,.75); }
|
||
.paid-inp { width: 120px !important; text-align: right; }
|
||
.tax-inputs { display: flex; align-items: center; gap: 5px; justify-content: flex-end; }
|
||
.tax-inputs input[type="number"] { width: 68px; }
|
||
.tax-inputs select { width: auto; }
|
||
.btn-rm-tax { background: none; color: var(--danger); font-size: 16px; line-height: 1; padding: 1px 4px; border-radius: 3px; }
|
||
.btn-rm-tax:hover { background: #fef2f2; }
|
||
.tax-add-cell { text-align: right; padding: 5px 16px !important; }
|
||
.btn-add-tax { background: none; color: var(--accent); font-size: 12px; font-weight: 500; padding: 3px 8px; border: 1px dashed var(--accent); border-radius: var(--radius); }
|
||
.btn-add-tax:hover { background: #eff6ff; }
|
||
|
||
/* ── Generate button ────────────────────────────────────────────────────── */
|
||
#btn-generate {
|
||
display: block; width: 100%;
|
||
padding: 14px;
|
||
background: var(--accent); color: var(--white);
|
||
font-size: 15px; font-weight: 700; letter-spacing: 1px;
|
||
border-radius: var(--radius);
|
||
margin-bottom: 20px;
|
||
transition: background .15s;
|
||
}
|
||
#btn-generate:hover { background: var(--accent-hover); }
|
||
|
||
/* ── Invoice overlay ────────────────────────────────────────────────────── */
|
||
#overlay {
|
||
display: none;
|
||
position: fixed; inset: 0;
|
||
background: rgba(15,23,42,.65);
|
||
z-index: 900;
|
||
overflow-y: auto;
|
||
padding: 32px 16px 48px;
|
||
align-items: flex-start;
|
||
justify-content: center;
|
||
flex-direction: column;
|
||
}
|
||
#overlay.on { display: flex; }
|
||
|
||
#overlay-actions {
|
||
display: flex; gap: 10px;
|
||
margin: 0 auto 16px;
|
||
width: 794px; max-width: 100%;
|
||
}
|
||
.btn-dl {
|
||
background: var(--success); color: var(--white);
|
||
padding: 10px 20px; font-size: 14px; font-weight: 600;
|
||
border-radius: var(--radius);
|
||
}
|
||
.btn-dl:hover { background: #166534; }
|
||
.btn-close {
|
||
background: var(--white); color: var(--text);
|
||
padding: 10px 20px; font-size: 14px;
|
||
border: 1px solid var(--border); border-radius: var(--radius);
|
||
}
|
||
.btn-close:hover { background: var(--bg); }
|
||
|
||
/* ── Invoice preview (screen only) ──────────────────────────────────────── */
|
||
#inv-doc {
|
||
background: var(--white);
|
||
width: 794px; max-width: 100%; min-height: 1100px;
|
||
padding: 52px 60px 60px;
|
||
box-shadow: 0 4px 24px rgba(0,0,0,.25);
|
||
margin: 0 auto;
|
||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto,
|
||
"Helvetica Neue", Arial, sans-serif;
|
||
font-size: 11.5px; color: #111827;
|
||
}
|
||
|
||
.d-hdr {
|
||
display: flex; justify-content: space-between; align-items: flex-start;
|
||
padding-bottom: 20px; margin-bottom: 24px;
|
||
border-bottom: 3px solid var(--navy);
|
||
}
|
||
.d-sender .name { font-size: 17px; font-weight: 700; color: var(--navy); margin-bottom: 6px; }
|
||
.d-sender p { font-size: 10.5px; color: #4b5563; margin-bottom: 2px; }
|
||
.d-title { text-align: right; }
|
||
.d-title h1 { font-size: 30px; font-weight: 800; letter-spacing: 5px; color: var(--navy); margin-bottom: 14px; }
|
||
.d-meta { border-collapse: collapse; margin-left: auto; }
|
||
.d-meta td { font-size: 10.5px; padding: 2px 0 2px 12px; }
|
||
.d-meta .ml { color: #6b7280; text-align: right; }
|
||
.d-meta .mv { font-weight: 600; text-align: right; }
|
||
|
||
.d-bill {
|
||
padding: 12px 16px; margin-bottom: 22px;
|
||
background: #f8f9fa;
|
||
border-left: 4px solid var(--navy);
|
||
border-radius: 0 var(--radius) var(--radius) 0;
|
||
}
|
||
.d-bill .bt-lbl { font-size: 9px; font-weight: 700; text-transform: uppercase; letter-spacing: 1.2px; color: #6b7280; margin-bottom: 5px; }
|
||
.d-bill .bt-name { font-size: 13px; font-weight: 700; color: var(--navy); margin-bottom: 3px; }
|
||
.d-bill p { font-size: 10.5px; color: #4b5563; margin-bottom: 2px; }
|
||
.d-bill .bt-meta { display: flex; flex-wrap: wrap; gap: 16px; margin-top: 6px; }
|
||
.d-bill .bt-meta span { font-size: 10px; color: #4b5563; }
|
||
.d-bill .bt-meta strong { color: #111827; }
|
||
|
||
.d-lines { width: 100%; border-collapse: collapse; margin-bottom: 20px; }
|
||
.d-lines thead tr { background: var(--navy); color: white; }
|
||
.d-lines thead th { padding: 7px 10px; font-size: 9.5px; font-weight: 700; text-transform: uppercase; letter-spacing: .5px; text-align: left; }
|
||
.d-lines thead th.r { text-align: right; }
|
||
.d-lines tbody tr { border-bottom: 1px solid #e5e7eb; }
|
||
.d-lines tbody tr:nth-child(even) { background: #f9fafb; }
|
||
.d-lines tbody td { padding: 7px 10px; font-size: 10.5px; vertical-align: top; }
|
||
.d-lines tbody td.r { text-align: right; }
|
||
.d-lines tbody td.b { font-weight: 600; }
|
||
.d-fx-note { font-size: 9.5px; color: #6b7280; margin-top: 3px; }
|
||
|
||
.d-tots { width: 100%; border-collapse: collapse; }
|
||
.d-tots td { padding: 5px 10px; font-size: 11.5px; }
|
||
.d-tots .sp { width: 55%; }
|
||
.d-tots .tl { text-align: right; color: #6b7280; padding-right: 20px; }
|
||
.d-tots .tv { text-align: right; width: 130px; font-weight: 600; }
|
||
.d-tots .sub td { border-top: 1px solid #e5e7eb; }
|
||
.d-tots .fin td { background: var(--navy); color: white; font-size: 14px; font-weight: 700; border-top: 2px solid var(--navy); }
|
||
.d-tots .fin .tl { color: rgba(255,255,255,.75); }
|
||
|
||
/* ── Locked charge-to fields ────────────────────────────────────────────── */
|
||
#ct-fields.locked input,
|
||
#ct-fields.locked select {
|
||
background: #f8f9fa;
|
||
color: var(--text-muted);
|
||
border-color: var(--border-light);
|
||
pointer-events: none;
|
||
cursor: not-allowed;
|
||
}
|
||
|
||
/* ── Error / loading ────────────────────────────────────────────────────── */
|
||
#loading { padding: 48px; text-align: center; color: var(--text-muted); font-size: 14px; }
|
||
.error-box { background: #fef2f2; border: 1px solid #fca5a5; color: #991b1b; padding: 16px 20px; border-radius: var(--radius); margin: 20px 0; font-size: 13px; }
|
||
</style>
|
||
</head>
|
||
<body>
|
||
<div class="wrap">
|
||
|
||
<!-- Language bar -->
|
||
<div id="lang-bar" style="display:none">
|
||
<span>🌐</span>
|
||
<label id="lbl-language" for="lang-sel">Language</label>
|
||
<select id="lang-sel"></select>
|
||
</div>
|
||
|
||
<!-- Banner -->
|
||
<div id="inv-banner"><h1 id="inv-title">INVOICE</h1></div>
|
||
|
||
<!-- Loading / error placeholder -->
|
||
<div id="loading">Loading configuration…</div>
|
||
|
||
<!-- Main form (injected by JS) -->
|
||
<div id="form-root"></div>
|
||
|
||
</div><!-- /.wrap -->
|
||
|
||
<!-- ── Invoice preview overlay ───────────────────────────────────────────────── -->
|
||
<div id="overlay">
|
||
<div id="overlay-actions">
|
||
<button class="btn-dl" id="btn-dl" onclick="buildPDF()">⬇ Download PDF</button>
|
||
<button class="btn-close" id="btn-close" onclick="closeOverlay()">✕ Close</button>
|
||
</div>
|
||
<div id="inv-doc"></div>
|
||
</div>
|
||
|
||
<!-- js-yaml -->
|
||
<script src="https://cdnjs.cloudflare.com/ajax/libs/js-yaml/4.1.0/js-yaml.min.js"
|
||
crossorigin="anonymous"></script>
|
||
<!-- jsPDF -->
|
||
<script src="https://cdnjs.cloudflare.com/ajax/libs/jspdf/2.5.1/jspdf.umd.min.js"
|
||
crossorigin="anonymous"></script>
|
||
|
||
<script>
|
||
"use strict";
|
||
|
||
// ── State ─────────────────────────────────────────────────────────────────────
|
||
let cfg = null;
|
||
let lang = "en";
|
||
let lid = 0;
|
||
const lines = {};
|
||
let tlid = 0;
|
||
const tLines = {};
|
||
|
||
// ── i18n ──────────────────────────────────────────────────────────────────────
|
||
function t(key) {
|
||
const e = cfg?.translations?.[key];
|
||
if (!e) return key;
|
||
return e[lang] ?? e[cfg["default-code"]] ?? key;
|
||
}
|
||
|
||
// ── Numbers ───────────────────────────────────────────────────────────────────
|
||
function fmt(n) {
|
||
if (n === "" || n == null || isNaN(+n)) return "0.00";
|
||
return (+n).toLocaleString("en-US", { minimumFractionDigits: 2, maximumFractionDigits: 2 });
|
||
}
|
||
function pn(s) { return parseFloat(String(s ?? 0).replace(/,/g, "")) || 0; }
|
||
|
||
// ── Date ─────────────────────────────────────────────────────────────────────
|
||
const MONTHS_FULL = ["January","February","March","April","May","June",
|
||
"July","August","September","October","November","December"];
|
||
const MONTHS_SHORT = ["Jan","Feb","Mar","Apr","May","Jun",
|
||
"Jul","Aug","Sep","Oct","Nov","Dec"];
|
||
|
||
function fmtDate(v) {
|
||
if (!v) return "";
|
||
const [yr, mo, dy] = v.split("-").map(Number);
|
||
const pattern = cfg?.["date-format"] || "d MMMM YYYY";
|
||
return pattern.replace(/YYYY|YY|MMMM|MMM|MM|M|dd|d/g, tok => {
|
||
switch (tok) {
|
||
case "YYYY": return yr;
|
||
case "YY": return String(yr).slice(-2);
|
||
case "MMMM": return MONTHS_FULL[mo - 1];
|
||
case "MMM": return MONTHS_SHORT[mo - 1];
|
||
case "MM": return String(mo).padStart(2, "0");
|
||
case "M": return mo;
|
||
case "dd": return String(dy).padStart(2, "0");
|
||
case "d": return dy;
|
||
default: return tok;
|
||
}
|
||
});
|
||
}
|
||
|
||
// ── HTML escape ───────────────────────────────────────────────────────────────
|
||
function h(s) {
|
||
if (s == null) return "";
|
||
return String(s).replace(/&/g,"&").replace(/</g,"<").replace(/>/g,">").replace(/"/g,""");
|
||
}
|
||
|
||
// ── Countries ─────────────────────────────────────────────────────────────────
|
||
const COUNTRIES = [
|
||
["","— Select country —"],
|
||
["AF","Afghanistan"],["AL","Albania"],["DZ","Algeria"],["AR","Argentina"],
|
||
["AU","Australia"],["AT","Austria"],["BE","Belgium"],["BR","Brazil"],
|
||
["CA","Canada"],["CL","Chile"],["CN","China"],["CO","Colombia"],
|
||
["HR","Croatia"],["CZ","Czech Republic"],["DK","Denmark"],["EG","Egypt"],
|
||
["FI","Finland"],["FR","France"],["DE","Germany"],["GH","Ghana"],
|
||
["GR","Greece"],["HK","Hong Kong"],["HU","Hungary"],["IN","India"],
|
||
["ID","Indonesia"],["IE","Ireland"],["IL","Israel"],["IT","Italy"],
|
||
["JP","Japan"],["KE","Kenya"],["KR","South Korea"],["MY","Malaysia"],
|
||
["MX","Mexico"],["NL","Netherlands"],["NZ","New Zealand"],["NG","Nigeria"],
|
||
["NO","Norway"],["PK","Pakistan"],["PE","Peru"],["PH","Philippines"],
|
||
["PL","Poland"],["PT","Portugal"],["RO","Romania"],["RU","Russia"],
|
||
["SA","Saudi Arabia"],["ZA","South Africa"],["ES","Spain"],["SE","Sweden"],
|
||
["CH","Switzerland"],["TW","Taiwan"],["TH","Thailand"],["TR","Turkey"],
|
||
["UA","Ukraine"],["AE","United Arab Emirates"],["GB","United Kingdom"],
|
||
["US","United States"],["VN","Vietnam"]
|
||
];
|
||
const COUNTRY_MAP = Object.fromEntries(COUNTRIES.slice(1));
|
||
|
||
function countryOpts(sel) {
|
||
return COUNTRIES.map(([c, n]) =>
|
||
`<option value="${h(c)}" ${c === sel ? "selected" : ""}>${h(n)}</option>`
|
||
).join("");
|
||
}
|
||
|
||
// ── Load config ───────────────────────────────────────────────────────────────
|
||
async function loadCfg() {
|
||
try {
|
||
const r = await fetch("config.yml");
|
||
if (!r.ok) throw new Error(`HTTP ${r.status}`);
|
||
cfg = jsyaml.load(await r.text());
|
||
lang = cfg["default-code"] || "en";
|
||
boot();
|
||
} catch (err) {
|
||
document.getElementById("loading").innerHTML =
|
||
`<div class="error-box"><strong>Could not load config.yml:</strong> ${h(err.message)}<br>
|
||
Serve the <code>app/</code> folder from a web server (e.g. <code>npx serve .</code>).</div>`;
|
||
}
|
||
}
|
||
|
||
// ── Boot ──────────────────────────────────────────────────────────────────────
|
||
function boot() {
|
||
buildLangBar();
|
||
buildForm();
|
||
restoreStorage();
|
||
addLine();
|
||
document.getElementById("loading").style.display = "none";
|
||
}
|
||
|
||
// ── Language bar ──────────────────────────────────────────────────────────────
|
||
function buildLangBar() {
|
||
const langs = cfg.languages || [{ code: cfg["default-code"], name: cfg["default-name"] }];
|
||
if (langs.length < 2) return;
|
||
const bar = document.getElementById("lang-bar");
|
||
bar.style.display = "flex";
|
||
document.getElementById("lbl-language").textContent = t("language");
|
||
const sel = document.getElementById("lang-sel");
|
||
sel.innerHTML = langs.map(l =>
|
||
`<option value="${h(l.code)}" ${l.code === lang ? "selected" : ""}>${h(l.name)}</option>`
|
||
).join("");
|
||
sel.addEventListener("change", e => {
|
||
lang = e.target.value;
|
||
const ld = langs.find(l => l.code === lang);
|
||
document.documentElement.lang = lang;
|
||
document.documentElement.dir = ld?.direction || "ltr";
|
||
relabel();
|
||
});
|
||
}
|
||
|
||
// ── 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) =>
|
||
`<option value="${h(c)}" ${i === 0 ? "selected" : ""}>${h(c)}</option>`).join("");
|
||
|
||
const pcOpts = (cfg["project-codes"] || []).map(pc => `<option value="${h(pc)}">${h(pc)}</option>`).join("");
|
||
const ctOpts = (cfg["charge-to"] || []).map((ct, i) => `<option value="${i}">${h(ct.display || ct.name)}</option>`).join("");
|
||
|
||
document.getElementById("form-root").innerHTML = `
|
||
<form id="the-form" novalidate>
|
||
<div class="two-col">
|
||
<div class="card">
|
||
<div class="card-title" id="sec-sender">${t("sender-section")}</div>
|
||
<div class="fg"><label id="lbl-sn" for="sn">${t("sender-name")}</label>
|
||
<input id="sn" type="text" data-ls="sn" autocomplete="name"></div>
|
||
<div class="fg"><label id="lbl-sa1" for="sa1">${t("sender-address1")}</label>
|
||
<input id="sa1" type="text" data-ls="sa1" autocomplete="address-line1"></div>
|
||
<div class="fg"><label id="lbl-sa2" for="sa2">${t("sender-address2")}</label>
|
||
<input id="sa2" type="text" data-ls="sa2" autocomplete="address-line2"></div>
|
||
<div class="fg"><label id="lbl-sa3" for="sa3">${t("sender-address3")}</label>
|
||
<input id="sa3" type="text" data-ls="sa3"></div>
|
||
<div class="fg"><label id="lbl-sa4" for="sa4">${t("sender-address4")}</label>
|
||
<input id="sa4" type="text" data-ls="sa4"></div>
|
||
<div class="fg"><label id="lbl-sc" for="sc">${t("sender-country")}</label>
|
||
<select id="sc" data-ls="sc">${countryOpts("")}</select></div>
|
||
<div class="fi"><label id="lbl-sp">${t("sender-phone")}:</label>
|
||
<input id="sp" type="tel" data-ls="sp" autocomplete="tel"></div>
|
||
<div class="fi"><label id="lbl-se">${t("sender-email")}:</label>
|
||
<input id="se" type="email" data-ls="se" autocomplete="email"></div>
|
||
</div>
|
||
<div class="card">
|
||
<div class="card-title" id="sec-invdet">${t("invoice-details-section")}</div>
|
||
<div class="fg"><label id="lbl-idate" for="idate">${t("invoice-date")}</label>
|
||
<input id="idate" type="date" value="${dateDef}"></div>
|
||
<div class="fg"><label id="lbl-icur" for="icur">${t("invoice-currency")}</label>
|
||
<select id="icur" data-ls="icur">${curOpts}</select></div>
|
||
<div class="fg"><label id="lbl-pcode" for="pcode">${t("project-code")}</label>
|
||
<select id="pcode">
|
||
<option value="">${t("select")}</option>
|
||
${pcOpts}
|
||
<option value="__other__">${t("other")}</option>
|
||
</select></div>
|
||
<div class="fg" id="pcode-other-wrap" style="display:none">
|
||
<label id="lbl-pcode-other" for="pcode-other">${t("project-code")} (${t("other")})</label>
|
||
<input id="pcode-other" type="text">
|
||
</div>
|
||
<div class="fg"><label id="lbl-ino" for="ino">${t("invoice-no")}</label>
|
||
<input id="ino" type="text" data-ls="ino"></div>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="card">
|
||
<div class="card-title" style="display:flex;align-items:center;gap:8px;flex-wrap:wrap">
|
||
<span id="sec-ct">${t("charge-to")}:</span>
|
||
<select id="ct-pick" style="width:auto;font-size:12px">
|
||
<option value="">${t("select")}</option>
|
||
${ctOpts}
|
||
<option value="__other__">${t("other")}</option>
|
||
</select>
|
||
</div>
|
||
<div id="ct-fields">
|
||
<div class="fg"><label id="lbl-ctn" for="ctn">${t("charge-to-name")}</label>
|
||
<input id="ctn" type="text"></div>
|
||
<div class="fg"><label id="lbl-ca1" for="ca1">${t("charge-to-address1")}</label>
|
||
<input id="ca1" type="text"></div>
|
||
<div class="fg"><label id="lbl-ca2" for="ca2">${t("charge-to-address2")}</label>
|
||
<input id="ca2" type="text"></div>
|
||
<div class="fg"><label id="lbl-ca3" for="ca3">${t("charge-to-address3")}</label>
|
||
<input id="ca3" type="text"></div>
|
||
<div class="fg"><label id="lbl-ca4" for="ca4">${t("charge-to-address4")}</label>
|
||
<input id="ca4" type="text"></div>
|
||
<div class="fg"><label id="lbl-cc" for="cc">${t("charge-to-country")}</label>
|
||
<select id="cc">${countryOpts("")}</select></div>
|
||
<div class="fi"><label id="lbl-cph">${t("charge-to-phone")}:</label>
|
||
<input id="cph" type="tel"></div>
|
||
<div class="fi"><label id="lbl-cem">${t("charge-to-email")}:</label>
|
||
<input id="cem" type="email"></div>
|
||
<div class="fi"><label id="lbl-cvat">${t("vat-id")}:</label>
|
||
<input id="cvat" type="text"></div>
|
||
<div class="fi"><label id="lbl-creg">${t("registration-no")}:</label>
|
||
<input id="creg" type="text"></div>
|
||
</div>
|
||
</div>
|
||
|
||
<div id="lines-card">
|
||
<div class="card-title" id="sec-lines">${t("invoice-lines")}</div>
|
||
<table class="line-tbl">
|
||
<thead>
|
||
<tr>
|
||
<th class="col-qty" id="th-qty">${t("qty")}</th>
|
||
<th class="col-uom" id="th-uom">${t("uom")}</th>
|
||
<th class="col-desc" id="th-desc">${t("description")}</th>
|
||
<th class="col-price r" id="th-price">${t("price")}</th>
|
||
<th class="col-tot r" id="th-tot">${t("line-total")}</th>
|
||
<th class="col-act"></th>
|
||
</tr>
|
||
</thead>
|
||
<tbody id="tbody"></tbody>
|
||
</table>
|
||
</div>
|
||
|
||
<div id="totals-card">
|
||
<table class="tot-tbl">
|
||
<tbody id="tot-pre">
|
||
<tr>
|
||
<td class="lbl" id="lbl-sub">${t("subtotal")}</td>
|
||
<td class="val" id="v-sub">0.00</td>
|
||
</tr>
|
||
</tbody>
|
||
<tbody id="tax-tbody"></tbody>
|
||
<tbody id="tot-post">
|
||
<tr>
|
||
<td colspan="2" class="tax-add-cell">
|
||
<button type="button" class="btn-add-tax" id="btn-add-tax" onclick="addTaxLine()">${t("add-tax")}</button>
|
||
</td>
|
||
</tr>
|
||
<tr>
|
||
<td class="lbl" id="lbl-paid">${t("paid")}</td>
|
||
<td class="val"><input id="paid-inp" class="paid-inp" type="number" value="0" min="0" step="0.01"></td>
|
||
</tr>
|
||
<tr class="final">
|
||
<td class="lbl" id="lbl-topay">${t("to-pay")}</td>
|
||
<td class="val" id="v-topay">0.00</td>
|
||
</tr>
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
|
||
<button type="submit" id="btn-generate">${t("generate-invoice")}</button>
|
||
</form>`;
|
||
|
||
document.getElementById("pcode").addEventListener("change", function () {
|
||
document.getElementById("pcode-other-wrap").style.display = this.value === "__other__" ? "block" : "none";
|
||
});
|
||
document.getElementById("ct-pick").addEventListener("change", e => fillChargeTo(e.target.value));
|
||
document.getElementById("paid-inp").addEventListener("input", calcTotals);
|
||
document.getElementById("the-form").addEventListener("submit", e => { e.preventDefault(); generateInvoice(); });
|
||
document.querySelectorAll("[data-ls]").forEach(el => el.addEventListener("change", saveStorage));
|
||
}
|
||
|
||
// ── Fill charge-to ────────────────────────────────────────────────────────────
|
||
function fillChargeTo(v) {
|
||
const f = (id, val) => { const el = document.getElementById(id); if (el) el.value = val ?? ""; };
|
||
const fields = document.getElementById("ct-fields");
|
||
|
||
if (v === "" || v === "__other__") {
|
||
if (v === "") ["ctn","ca1","ca2","ca3","ca4","cc","cph","cem","cvat","creg"].forEach(id => f(id, ""));
|
||
fields?.classList.remove("locked");
|
||
return;
|
||
}
|
||
const ct = (cfg["charge-to"] || [])[+v];
|
||
if (!ct) return;
|
||
f("ctn", ct.name); f("ca1", ct.address1);
|
||
f("ca2", ct.address2); f("ca3", ct.address3);
|
||
f("ca4", ct.address4); f("cc", ct.country);
|
||
f("cph", ct.phone); f("cem", ct.email);
|
||
f("cvat", ct["vat-id"]); f("creg", ct["reg-no"]);
|
||
fields?.classList.add("locked");
|
||
|
||
// Auto-set invoice currency from recipient config
|
||
if (ct.currency) {
|
||
const icurEl = document.getElementById("icur");
|
||
if (icurEl) { icurEl.value = ct.currency; saveStorage(); }
|
||
}
|
||
}
|
||
|
||
// ── Select option helpers ─────────────────────────────────────────────────────
|
||
function uomOpts(sel) {
|
||
let o = `<option value="">${t("select")}</option>`;
|
||
(cfg.uom || []).forEach(u => {
|
||
const lbl = u.labels?.[lang] ?? u.labels?.[cfg["default-code"]] ?? u.code;
|
||
o += `<option value="${h(u.code)}" ${u.code === sel ? "selected" : ""}>${h(lbl)} (${h(u.code)})</option>`;
|
||
});
|
||
return o;
|
||
}
|
||
|
||
function prodOpts(sel) {
|
||
let o = `<option value="">${t("select")}</option>`;
|
||
(cfg.products || []).forEach((p, i) => {
|
||
const desc = p.description?.[lang] ?? p.description?.[cfg["default-code"]] ?? p.code;
|
||
o += `<option value="${i}" ${String(i) === String(sel) ? "selected" : ""}>${h(desc)}</option>`;
|
||
});
|
||
o += `<option value="__other__" ${"__other__" === sel ? "selected" : ""}>${t("other")}</option>`;
|
||
return o;
|
||
}
|
||
|
||
function currOpts(sel) {
|
||
return (cfg.currencies || ["USD","EUR","GBP"]).map(c =>
|
||
`<option value="${c}" ${c === sel ? "selected" : ""}>${c}</option>`
|
||
).join("");
|
||
}
|
||
|
||
function getTaxTypeOpts(sel) {
|
||
return (cfg["tax-types"] || []).map(tt => {
|
||
const lbl = tt.labels?.[lang] ?? tt.labels?.[cfg["default-code"]] ?? tt.key;
|
||
return `<option value="${h(tt.key)}" ${tt.key === sel ? "selected" : ""}>${h(lbl)}</option>`;
|
||
}).join("");
|
||
}
|
||
|
||
function addTaxLine() {
|
||
const i = tlid++;
|
||
tLines[i] = {};
|
||
const tbody = document.getElementById("tax-tbody");
|
||
const defaultKey = (cfg["tax-types"]||[])[0]?.key || "";
|
||
|
||
const tr = document.createElement("tr");
|
||
tr.className = "tax-row"; tr.id = `tlr-${i}`;
|
||
tr.innerHTML = `
|
||
<td class="lbl">
|
||
<div class="tax-inputs">
|
||
<input type="number" id="tv-${i}" value="" placeholder="0" min="0" step="any"
|
||
oninput="calcTotals()" style="width:68px">
|
||
<select id="tt-${i}" style="width:auto" onchange="calcTotals()">
|
||
<option value="pct">${t("tax-pct")}</option>
|
||
<option value="amt">${t("tax-amount")}</option>
|
||
</select>
|
||
<select id="tk-${i}" style="width:auto" onchange="calcTotals()">
|
||
${getTaxTypeOpts(defaultKey)}
|
||
</select>
|
||
<button type="button" class="btn-rm-tax" onclick="removeTaxLine(${i})">×</button>
|
||
</div>
|
||
</td>
|
||
<td class="val" id="ta-${i}">0.00</td>`;
|
||
tbody.appendChild(tr);
|
||
calcTotals();
|
||
}
|
||
|
||
function removeTaxLine(i) {
|
||
document.getElementById(`tlr-${i}`)?.remove();
|
||
delete tLines[i];
|
||
calcTotals();
|
||
}
|
||
|
||
// ── Add / remove line ─────────────────────────────────────────────────────────
|
||
function addLine() {
|
||
const i = lid++;
|
||
lines[i] = {};
|
||
const tbody = document.getElementById("tbody");
|
||
const alRow = document.getElementById("al-row");
|
||
if (alRow) alRow.remove();
|
||
|
||
const tr = document.createElement("tr");
|
||
tr.className = "lr"; tr.id = `lr-${i}`;
|
||
tr.innerHTML = `
|
||
<td class="col-qty"><input type="number" id="qty-${i}" value="1" min="0" step="any"
|
||
oninput="calcLine(${i})"></td>
|
||
<td class="col-uom"><select id="uom-${i}" onchange="calcLine(${i})">${uomOpts("")}</select></td>
|
||
<td class="col-desc">
|
||
<select id="dsel-${i}" onchange="pickProduct(${i})">${prodOpts("")}</select>
|
||
<input type="text" id="dtxt-${i}" placeholder="${h(t("description"))}"
|
||
style="margin-top:4px;display:none" oninput="calcLine(${i})">
|
||
<div style="display:flex;align-items:center;gap:6px;margin-top:5px">
|
||
<label style="font-size:11px;color:var(--text-muted);white-space:nowrap"
|
||
id="lbl-fx-${i}">${t("foreign-currency")}:</label>
|
||
<select id="fx-${i}" style="width:auto" onchange="toggleFx(${i})">
|
||
<option value="no">${t("no-option")}</option>
|
||
<option value="yes">${t("yes")}</option>
|
||
</select>
|
||
</div>
|
||
</td>
|
||
<td class="col-price"><input type="number" id="price-${i}" value="0" min="0" step="any" oninput="calcLine(${i})"></td>
|
||
<td class="col-tot"><div class="line-total-val" id="ltv-${i}">0.00</div></td>
|
||
<td class="col-act"><button type="button" class="btn-remove" onclick="removeLine(${i})">×</button></td>`;
|
||
tbody.appendChild(tr);
|
||
|
||
const alNew = document.createElement("tr");
|
||
alNew.id = "al-row"; alNew.className = "al";
|
||
alNew.innerHTML = `<td colspan="6">
|
||
<button type="button" class="btn-add-line" id="btn-al" onclick="addLine()">${t("add-line")}</button>
|
||
</td>`;
|
||
tbody.appendChild(alNew);
|
||
calcTotals();
|
||
}
|
||
|
||
function removeLine(i) {
|
||
document.getElementById(`lr-${i}`)?.remove();
|
||
document.getElementById(`fx-row-${i}`)?.remove();
|
||
delete lines[i];
|
||
calcTotals();
|
||
}
|
||
|
||
function pickProduct(i) {
|
||
const v = document.getElementById(`dsel-${i}`)?.value;
|
||
const txt = document.getElementById(`dtxt-${i}`);
|
||
txt.style.display = v === "__other__" ? "block" : "none";
|
||
if (v !== "" && v !== "__other__") {
|
||
const p = (cfg.products || [])[+v];
|
||
if (p) {
|
||
const uomEl = document.getElementById(`uom-${i}`);
|
||
const priceEl = document.getElementById(`price-${i}`);
|
||
if (uomEl && p.uom) uomEl.value = p.uom;
|
||
if (priceEl && p.price != null) priceEl.value = p.price;
|
||
}
|
||
}
|
||
calcLine(i);
|
||
}
|
||
|
||
// ── Foreign currency ──────────────────────────────────────────────────────────
|
||
function toggleFx(i) {
|
||
const on = document.getElementById(`fx-${i}`)?.value === "yes";
|
||
const lr = document.getElementById(`lr-${i}`);
|
||
document.getElementById(`fx-row-${i}`)?.remove();
|
||
if (!on) { lr?.classList.remove("open"); calcTotals(); return; }
|
||
lr?.classList.add("open");
|
||
|
||
const fxTr = document.createElement("tr");
|
||
fxTr.className = "fx"; fxTr.id = `fx-row-${i}`;
|
||
fxTr.innerHTML = `
|
||
<td colspan="6">
|
||
<div class="fx-grid">
|
||
<div>
|
||
<div class="fx-label">${t("currency-code")}</div>
|
||
<select id="fcur-${i}" onchange="calcLine(${i})">${currOpts("USD")}</select>
|
||
</div>
|
||
<div>
|
||
<div class="fx-label">${t("exchange-rate")}</div>
|
||
<input type="number" id="frate-${i}" value="1" min="0" step="any" oninput="calcLine(${i})">
|
||
</div>
|
||
<div>
|
||
<div class="fx-label">${t("per-item")}</div>
|
||
<input type="number" id="fper-${i}" value="0" min="0" step="any" oninput="calcFxFromPer(${i})">
|
||
</div>
|
||
</div>
|
||
<div class="fx-note">${t("total-local")}: <strong id="fltot-${i}">0.00</strong></div>
|
||
</td>`;
|
||
lr?.insertAdjacentElement("afterend", fxTr);
|
||
calcLine(i);
|
||
}
|
||
|
||
function calcFxFromPer(i) {
|
||
const per = pn(document.getElementById(`fper-${i}`)?.value);
|
||
const rate = pn(document.getElementById(`frate-${i}`)?.value) || 1;
|
||
const prEl = document.getElementById(`price-${i}`);
|
||
// rate = "1 foreign = rate local", so local price = per * rate
|
||
if (prEl) prEl.value = (per * rate).toFixed(6);
|
||
calcLine(i);
|
||
}
|
||
|
||
// ── Calculations ──────────────────────────────────────────────────────────────
|
||
function calcLine(i) {
|
||
const qty = pn(document.getElementById(`qty-${i}`)?.value);
|
||
const price = pn(document.getElementById(`price-${i}`)?.value);
|
||
const el = document.getElementById(`ltv-${i}`);
|
||
if (el) el.textContent = fmt(qty * price);
|
||
|
||
if (document.getElementById(`fx-${i}`)?.value === "yes") {
|
||
const per = pn(document.getElementById(`fper-${i}`)?.value);
|
||
const ltot = document.getElementById(`fltot-${i}`);
|
||
// Show foreign-currency line total (per * qty, still in foreign currency)
|
||
if (ltot) ltot.textContent = fmt(per * qty);
|
||
}
|
||
calcTotals();
|
||
}
|
||
|
||
function calcTotals() {
|
||
let sub = 0;
|
||
Object.keys(lines).forEach(i => {
|
||
sub += pn(document.getElementById(`qty-${i}`)?.value) *
|
||
pn(document.getElementById(`price-${i}`)?.value);
|
||
});
|
||
|
||
let totalTax = 0;
|
||
Object.keys(tLines).forEach(i => {
|
||
const val = pn(document.getElementById(`tv-${i}`)?.value);
|
||
const type = document.getElementById(`tt-${i}`)?.value || "pct";
|
||
const amt = type === "pct" ? sub * (val / 100) : val;
|
||
totalTax += amt;
|
||
const el = document.getElementById(`ta-${i}`);
|
||
if (el) el.textContent = fmt(amt);
|
||
});
|
||
|
||
const paid = pn(document.getElementById("paid-inp")?.value);
|
||
const toPay = sub + totalTax - paid;
|
||
|
||
const set = (id, v) => { const e = document.getElementById(id); if (e) e.textContent = fmt(v); };
|
||
set("v-sub", sub);
|
||
set("v-topay", toPay);
|
||
}
|
||
|
||
// ── LocalStorage ──────────────────────────────────────────────────────────────
|
||
const LS_DATA = "inv_data_v1";
|
||
const LS_GEN = "inv_generated_v1";
|
||
|
||
function saveStorage() {
|
||
const d = {};
|
||
document.querySelectorAll("[data-ls]").forEach(el => { d[el.dataset.ls] = el.value; });
|
||
localStorage.setItem(LS_DATA, JSON.stringify(d));
|
||
}
|
||
|
||
function restoreStorage() {
|
||
try {
|
||
const raw = localStorage.getItem(LS_DATA);
|
||
if (!raw) return;
|
||
const d = JSON.parse(raw);
|
||
const wasGen = localStorage.getItem(LS_GEN) === "true";
|
||
|
||
Object.entries(d).forEach(([k, v]) => {
|
||
const el = document.querySelector(`[data-ls="${k}"]`);
|
||
if (el) el.value = v;
|
||
});
|
||
|
||
if (wasGen && d.ino) {
|
||
const bumped = bumpNum(d.ino);
|
||
const el = document.getElementById("ino");
|
||
if (el) el.value = bumped;
|
||
d.ino = bumped;
|
||
localStorage.setItem(LS_DATA, JSON.stringify(d));
|
||
localStorage.setItem(LS_GEN, "false");
|
||
}
|
||
} catch (_) {}
|
||
}
|
||
|
||
function bumpNum(s) {
|
||
return String(s).replace(/(\d+)$/, m => String(+m + 1).padStart(m.length, "0"));
|
||
}
|
||
|
||
// ── Relabel on language switch ────────────────────────────────────────────────
|
||
function relabel() {
|
||
const lm = {
|
||
"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",
|
||
"lbl-sa3":"sender-address3","lbl-sa4":"sender-address4","lbl-sc":"sender-country",
|
||
"lbl-sp":"sender-phone","lbl-se":"sender-email",
|
||
"lbl-idate":"invoice-date","lbl-icur":"invoice-currency","lbl-pcode":"project-code","lbl-ino":"invoice-no",
|
||
"lbl-ctn":"charge-to-name","lbl-ca1":"charge-to-address1","lbl-ca2":"charge-to-address2",
|
||
"lbl-ca3":"charge-to-address3","lbl-ca4":"charge-to-address4","lbl-cc":"charge-to-country",
|
||
"lbl-cph":"charge-to-phone","lbl-cem":"charge-to-email","lbl-cvat":"vat-id","lbl-creg":"registration-no",
|
||
"th-qty":"qty","th-uom":"uom","th-desc":"description","th-price":"price","th-tot":"line-total",
|
||
"lbl-sub":"subtotal","lbl-paid":"paid","lbl-topay":"to-pay",
|
||
};
|
||
Object.entries(lm).forEach(([id, key]) => {
|
||
const el = document.getElementById(id);
|
||
if (el) el.textContent = t(key);
|
||
});
|
||
document.getElementById("inv-banner").querySelector("h1").textContent = t("invoice");
|
||
|
||
Object.keys(lines).forEach(i => {
|
||
const fl = document.getElementById(`lbl-fx-${i}`);
|
||
if (fl) fl.textContent = t("foreign-currency") + ":";
|
||
const dt = document.getElementById(`dtxt-${i}`);
|
||
if (dt) dt.placeholder = t("description");
|
||
const ue = document.getElementById(`uom-${i}`);
|
||
if (ue) { const s = ue.value; ue.innerHTML = uomOpts(s); }
|
||
const de = document.getElementById(`dsel-${i}`);
|
||
if (de) { const s = de.value; de.innerHTML = prodOpts(s); }
|
||
const fe = document.getElementById(`fx-${i}`);
|
||
if (fe) {
|
||
const s = fe.value;
|
||
fe.innerHTML = `<option value="no">${t("no-option")}</option><option value="yes">${t("yes")}</option>`;
|
||
fe.value = s;
|
||
}
|
||
});
|
||
|
||
const alBtn = document.getElementById("btn-al");
|
||
if (alBtn) alBtn.textContent = t("add-line");
|
||
const genBtn = document.getElementById("btn-generate");
|
||
if (genBtn) genBtn.textContent = t("generate-invoice");
|
||
|
||
// Rebuild dynamic tax line dropdowns
|
||
Object.keys(tLines).forEach(i => {
|
||
const ttEl = document.getElementById(`tt-${i}`);
|
||
if (ttEl) {
|
||
const sv = ttEl.value;
|
||
ttEl.innerHTML = `<option value="pct">${t("tax-pct")}</option><option value="amt">${t("tax-amount")}</option>`;
|
||
ttEl.value = sv;
|
||
}
|
||
const tkEl = document.getElementById(`tk-${i}`);
|
||
if (tkEl) { const sv = tkEl.value; tkEl.innerHTML = getTaxTypeOpts(sv); }
|
||
});
|
||
const atBtn = document.getElementById("btn-add-tax");
|
||
if (atBtn) atBtn.textContent = t("add-tax");
|
||
|
||
const ctPick = document.getElementById("ct-pick");
|
||
if (ctPick) {
|
||
const cur = ctPick.value;
|
||
const ctOpts = (cfg["charge-to"] || []).map((ct, i) =>
|
||
`<option value="${i}">${h(ct.display || ct.name)}</option>`).join("");
|
||
ctPick.innerHTML = `<option value="">${t("select")}</option>${ctOpts}<option value="__other__">${t("other")}</option>`;
|
||
ctPick.value = cur;
|
||
}
|
||
|
||
document.getElementById("btn-dl").textContent = "⬇ " + t("download-pdf");
|
||
document.getElementById("btn-close").textContent = "✕ " + t("close");
|
||
}
|
||
|
||
// ── Generate preview ──────────────────────────────────────────────────────────
|
||
function generateInvoice() {
|
||
saveStorage();
|
||
localStorage.setItem(LS_GEN, "true");
|
||
document.getElementById("inv-doc").innerHTML = buildPreviewHTML();
|
||
const ov = document.getElementById("overlay");
|
||
ov.classList.add("on");
|
||
ov.scrollTop = 0;
|
||
document.getElementById("btn-dl").textContent = "⬇ " + t("download-pdf");
|
||
document.getElementById("btn-close").textContent = "✕ " + t("close");
|
||
}
|
||
|
||
function closeOverlay() {
|
||
document.getElementById("overlay").classList.remove("on");
|
||
}
|
||
|
||
// ── Shared: gather invoice data ───────────────────────────────────────────────
|
||
function gatherData(renderLang) {
|
||
const dl = renderLang || cfg["default-code"] || "en";
|
||
const td = key => { const e = cfg?.translations?.[key]; return e?.[dl] ?? e?.["en"] ?? key; };
|
||
const g = id => (document.getElementById(id)?.value ?? "").trim();
|
||
|
||
const sName = g("sn");
|
||
const sAddr = [g("sa1"),g("sa2"),g("sa3"),g("sa4")].filter(Boolean);
|
||
const sCntry = COUNTRY_MAP[g("sc")] || "";
|
||
const sPh = g("sp"), sEm = g("se");
|
||
|
||
const iDate = g("idate");
|
||
const pCode = g("pcode") === "__other__" ? g("pcode-other") : g("pcode");
|
||
const iNo = g("ino");
|
||
const iCur = g("icur");
|
||
|
||
const ctName = g("ctn");
|
||
const ctAddr = [g("ca1"),g("ca2"),g("ca3"),g("ca4")].filter(Boolean);
|
||
const ctCntry= COUNTRY_MAP[g("cc")] || "";
|
||
const ctPh = g("cph"), ctEm = g("cem"), ctVat = g("cvat"), ctReg = g("creg");
|
||
|
||
let sub = 0;
|
||
const rows = [];
|
||
Object.keys(lines).sort((a,b)=>+a-+b).forEach(i => {
|
||
const qty = pn(document.getElementById(`qty-${i}`)?.value);
|
||
const price = pn(document.getElementById(`price-${i}`)?.value);
|
||
const tot = qty * price;
|
||
if (qty === 0 && price === 0) return;
|
||
sub += tot;
|
||
|
||
const uomCode = document.getElementById(`uom-${i}`)?.value || "";
|
||
const uomObj = (cfg.uom||[]).find(u=>u.code===uomCode);
|
||
const uomLbl = uomObj?.labels?.[dl] ?? uomObj?.labels?.[cfg["default-code"]] ?? uomCode;
|
||
|
||
const dv = document.getElementById(`dsel-${i}`)?.value;
|
||
let desc = "";
|
||
if (dv === "__other__") {
|
||
desc = (document.getElementById(`dtxt-${i}`)?.value || "").trim();
|
||
} else if (dv !== "" && dv != null) {
|
||
const p = (cfg.products||[])[+dv];
|
||
desc = p?.description?.[dl] ?? p?.description?.[cfg["default-code"]] ?? "";
|
||
}
|
||
|
||
const isFx = document.getElementById(`fx-${i}`)?.value === "yes";
|
||
let fxNote = null;
|
||
if (isFx) {
|
||
const cur = document.getElementById(`fcur-${i}`)?.value || "";
|
||
const rate = pn(document.getElementById(`frate-${i}`)?.value) || 1;
|
||
const per = pn(document.getElementById(`fper-${i}`)?.value);
|
||
const foreignTot = per * qty; // line total in foreign currency
|
||
fxNote = { cur, rate, per, foreignTot, td };
|
||
}
|
||
rows.push({ qty, uomLbl, desc, price, tot, fxNote });
|
||
});
|
||
|
||
let totalTax = 0;
|
||
const taxes = [];
|
||
Object.keys(tLines).sort((a,b)=>+a-+b).forEach(i => {
|
||
const val = pn(document.getElementById(`tv-${i}`)?.value);
|
||
const type = document.getElementById(`tt-${i}`)?.value || "pct";
|
||
const key = document.getElementById(`tk-${i}`)?.value || "";
|
||
const ttObj = (cfg["tax-types"]||[]).find(x => x.key === key);
|
||
const label = ttObj?.labels?.[dl] ?? ttObj?.labels?.[cfg["default-code"]] ?? key;
|
||
const amt = type === "pct" ? sub * (val / 100) : val;
|
||
totalTax += amt;
|
||
const lineLabel = type === "pct" ? `${label} ${val}%` : `${label} (${fmt(val)})`;
|
||
taxes.push({ val, type, key, label, lineLabel, amt });
|
||
});
|
||
|
||
const paid = pn(document.getElementById("paid-inp")?.value);
|
||
const toPay = sub + totalTax - paid;
|
||
|
||
return { dl, td, sName, sAddr, sCntry, sPh, sEm, iDate, pCode, iNo, iCur,
|
||
ctName, ctAddr, ctCntry, ctPh, ctEm, ctVat, ctReg,
|
||
rows, sub, taxes, totalTax, paid, toPay };
|
||
}
|
||
|
||
// ── Build HTML preview ────────────────────────────────────────────────────────
|
||
function buildPreviewHTML() {
|
||
const { td, sName, sAddr, sCntry, sPh, sEm, iDate, pCode, iNo, iCur,
|
||
ctName, ctAddr, ctCntry, ctPh, ctEm, ctVat, ctReg,
|
||
rows, sub, taxes, paid, toPay } = gatherData();
|
||
|
||
const linesHTML = rows.map(row => {
|
||
const fxLine = row.fxNote
|
||
? `<div class="d-fx-note">${h(row.fxNote.td("converted-from"))} ${h(row.fxNote.cur)}: `
|
||
+ `1 ${h(row.fxNote.cur)} = ${(+row.fxNote.rate).toFixed(5)} ${h(iCur)}. `
|
||
+ `${h(row.fxNote.td("per-item"))}: ${h(row.fxNote.cur)} ${fmt(row.fxNote.per)}, `
|
||
+ `${h(row.fxNote.td("line-total")).toLowerCase()}: ${h(row.fxNote.cur)} ${fmt(row.fxNote.foreignTot)}</div>`
|
||
: "";
|
||
const qStr = row.qty % 1 === 0 ? row.qty : fmt(row.qty);
|
||
return `<tr>
|
||
<td>${qStr}</td><td>${h(row.uomLbl)}</td>
|
||
<td>${h(row.desc)}${fxLine}</td>
|
||
<td class="r">${fmt(row.price)}</td>
|
||
<td class="r b">${fmt(row.tot)}</td>
|
||
</tr>`;
|
||
}).join("") || `<tr><td colspan="5" style="text-align:center;color:#9ca3af;padding:20px">—</td></tr>`;
|
||
|
||
return `
|
||
<div class="d-hdr">
|
||
<div class="d-sender">
|
||
${sName ? `<div class="name">${h(sName)}</div>` : ""}
|
||
${sAddr.map(a=>`<p>${h(a)}</p>`).join("")}
|
||
${sCntry ? `<p>${h(sCntry)}</p>` : ""}
|
||
${sPh ? `<p>${h(td("sender-phone"))}: ${h(sPh)}</p>` : ""}
|
||
${sEm ? `<p>${h(td("sender-email"))}: ${h(sEm)}</p>` : ""}
|
||
</div>
|
||
<div class="d-title">
|
||
<h1>${h(td("invoice"))}</h1>
|
||
<table class="d-meta">
|
||
${iNo ? `<tr><td class="ml">${h(td("invoice-no"))}</td><td class="mv">${h(iNo)}</td></tr>` : ""}
|
||
${iDate ? `<tr><td class="ml">${h(td("invoice-date"))}</td><td class="mv">${h(fmtDate(iDate))}</td></tr>` : ""}
|
||
${pCode ? `<tr><td class="ml">${h(td("project-code"))}</td><td class="mv">${h(pCode)}</td></tr>` : ""}
|
||
${iCur ? `<tr><td class="ml">${h(td("invoice-currency"))}</td><td class="mv">${h(iCur)}</td></tr>` : ""}
|
||
</table>
|
||
</div>
|
||
</div>
|
||
${ctName ? `
|
||
<div class="d-bill">
|
||
<div class="bt-lbl">${h(td("charge-to"))}</div>
|
||
<div class="bt-name">${h(ctName)}</div>
|
||
${ctAddr.map(a=>`<p>${h(a)}</p>`).join("")}
|
||
${ctCntry ? `<p>${h(ctCntry)}</p>` : ""}
|
||
<div class="bt-meta">
|
||
${ctPh ? `<span><strong>${h(td("charge-to-phone"))}:</strong> ${h(ctPh)}</span>` : ""}
|
||
${ctEm ? `<span><strong>${h(td("charge-to-email"))}:</strong> ${h(ctEm)}</span>` : ""}
|
||
${ctVat ? `<span><strong>${h(td("vat-id"))}:</strong> ${h(ctVat)}</span>` : ""}
|
||
${ctReg ? `<span><strong>${h(td("registration-no"))}:</strong> ${h(ctReg)}</span>` : ""}
|
||
</div>
|
||
</div>` : ""}
|
||
<table class="d-lines">
|
||
<thead><tr>
|
||
<th style="width:50px">${h(td("qty"))}</th>
|
||
<th style="width:70px">${h(td("uom"))}</th>
|
||
<th>${h(td("description"))}</th>
|
||
<th class="r" style="width:100px">${h(td("price"))}</th>
|
||
<th class="r" style="width:115px">${h(td("line-total"))}</th>
|
||
</tr></thead>
|
||
<tbody>${linesHTML}</tbody>
|
||
</table>
|
||
<table class="d-tots">
|
||
<tr class="sub">
|
||
<td class="sp"></td>
|
||
<td class="tl">${h(td("subtotal"))}</td>
|
||
<td class="tv">${fmt(sub)}</td>
|
||
</tr>
|
||
${taxes.map(tx => `<tr><td class="sp"></td><td class="tl">${h(tx.lineLabel)}</td><td class="tv">${fmt(tx.amt)}</td></tr>`).join("")}
|
||
${paid > 0 ? `<tr><td class="sp"></td><td class="tl">${h(td("paid"))}</td><td class="tv">−${fmt(paid)}</td></tr>` : ""}
|
||
<tr class="fin">
|
||
<td class="sp"></td>
|
||
<td class="tl">${h(td("to-pay"))}</td>
|
||
<td class="tv">${fmt(toPay)}</td>
|
||
</tr>
|
||
</table>`;
|
||
}
|
||
|
||
// ── Build PDF with jsPDF + Helvetica ─────────────────────────────────────────
|
||
function buildPDF() {
|
||
if (!window.jspdf) { alert("PDF library not loaded — check your internet connection."); return; }
|
||
|
||
const { jsPDF } = window.jspdf;
|
||
const paperFmt = (cfg["paper-format"] || "a4").toLowerCase();
|
||
const doc = new jsPDF({ unit: "mm", format: paperFmt });
|
||
|
||
// Page geometry
|
||
const PW = paperFmt === "letter" ? 215.9 : 210;
|
||
const PH = paperFmt === "letter" ? 279.4 : 297;
|
||
const ML = 15, MR = 15, MT = 15;
|
||
const CW = PW - ML - MR; // 180mm content width
|
||
const XR = PW - MR; // 195mm right edge
|
||
|
||
// Color helpers
|
||
const fc = (r,g,b) => doc.setFillColor(r,g,b);
|
||
const dc = (r,g,b) => doc.setDrawColor(r,g,b);
|
||
const tc = (r,g,b) => doc.setTextColor(r,g,b);
|
||
const fb = sz => { doc.setFont("helvetica","bold"); doc.setFontSize(sz); };
|
||
const fn = sz => { doc.setFont("helvetica","normal"); doc.setFontSize(sz); };
|
||
const tL = (s,x,y) => doc.text(String(s??""), x, y);
|
||
const tR = (s,x,y) => doc.text(String(s??""), x, y, {align:"right"});
|
||
const sp = (s,w) => doc.splitTextToSize(String(s??""), w);
|
||
|
||
const { dl, td, sName, sAddr, sCntry, sPh, sEm, iDate, pCode, iNo, iCur,
|
||
ctName, ctAddr, ctCntry, ctPh, ctEm, ctVat, ctReg,
|
||
rows, sub, taxes, paid, toPay } = gatherData();
|
||
|
||
// ── HEADER ────────────────────────────────────────────────────────────────
|
||
let y = MT;
|
||
let sy = y; // sender column bottom tracker
|
||
let ry = y; // right column bottom tracker
|
||
|
||
// Sender name
|
||
if (sName) {
|
||
fb(13); tc(30,45,69);
|
||
tL(sName, ML, sy); sy += 6;
|
||
}
|
||
// Sender address
|
||
fn(8.5); tc(75,85,99);
|
||
[...sAddr, sCntry].filter(Boolean).forEach(line => { tL(line, ML, sy); sy += 4.5; });
|
||
// Sender contact
|
||
if (sPh || sEm) {
|
||
const parts = [];
|
||
if (sPh) parts.push(`${td("sender-phone")}: ${sPh}`);
|
||
if (sEm) parts.push(`${td("sender-email")}: ${sEm}`);
|
||
fn(8); tc(107,114,128);
|
||
tL(parts.join(" "), ML, sy); sy += 5;
|
||
}
|
||
|
||
// INVOICE title (right)
|
||
fb(26); tc(30,45,69);
|
||
tR(td("invoice"), XR, ry); ry += 11;
|
||
|
||
// Meta table (right-aligned)
|
||
const metaRows = [
|
||
iNo ? [td("invoice-no"), iNo] : null,
|
||
iDate ? [td("invoice-date"), fmtDate(iDate)]: null,
|
||
pCode ? [td("project-code"), pCode] : null,
|
||
iCur ? [td("invoice-currency"), iCur] : null,
|
||
].filter(Boolean);
|
||
|
||
metaRows.forEach(([lbl, val]) => {
|
||
fn(8.5); tc(107,114,128); tR(lbl + ":", XR - 45, ry);
|
||
fb(8.5); tc(17,24,39); tR(val, XR, ry);
|
||
ry += 5;
|
||
});
|
||
|
||
y = Math.max(sy, ry) + 5;
|
||
|
||
// Navy rule
|
||
dc(30,45,69); doc.setLineWidth(0.6);
|
||
doc.line(ML, y, XR, y); y += 6;
|
||
|
||
// ── BILL-TO ──────────────────────────────────────────────────────────────
|
||
if (ctName) {
|
||
const bLines = [...ctAddr, ctCntry].filter(Boolean);
|
||
const ctParts = [];
|
||
if (ctPh) ctParts.push(`${td("charge-to-phone")}: ${ctPh}`);
|
||
if (ctEm) ctParts.push(`${td("charge-to-email")}: ${ctEm}`);
|
||
if (ctVat) ctParts.push(`${td("vat-id")}: ${ctVat}`);
|
||
if (ctReg) ctParts.push(`${td("registration-no")}: ${ctReg}`);
|
||
const ctContact = ctParts.join(" ");
|
||
|
||
const boxH = 4.5 + 5 + 5.5 + bLines.length * 4.5 + (ctContact ? 4.5 : 0) + 4;
|
||
fc(248,249,250); dc(255,255,255); doc.setLineWidth(0);
|
||
doc.rect(ML, y, CW, boxH, "F");
|
||
fc(30,45,69);
|
||
doc.rect(ML, y, 1.5, boxH, "F");
|
||
|
||
let by = y + 4.5;
|
||
fb(7); tc(107,114,128); tL(td("charge-to").toUpperCase(), ML+4, by); by += 5;
|
||
fb(10.5); tc(30,45,69); tL(ctName, ML+4, by); by += 5.5;
|
||
fn(8.5); tc(17,24,39);
|
||
bLines.forEach(line => { tL(line, ML+4, by); by += 4.5; });
|
||
if (ctContact) { fn(8); tc(107,114,128); tL(ctContact, ML+4, by); }
|
||
|
||
y += boxH + 6;
|
||
}
|
||
|
||
// ── LINE ITEMS TABLE ──────────────────────────────────────────────────────
|
||
// Column widths: QTY=18, UOM=18, DESC=flex, PRICE=28, TOTAL=34
|
||
const CQ=18, CU=18, CP=28, CT=34;
|
||
const CD = CW - CQ - CU - CP - CT; // ~82mm
|
||
const xQ=ML, xU=xQ+CQ, xD=xU+CU, xP=xD+CD, xT=xP+CP;
|
||
|
||
const TH = 7; // table header height
|
||
|
||
if (y + TH > PH - 40) { doc.addPage(); y = MT; }
|
||
|
||
fc(30,45,69); dc(255,255,255); doc.setLineWidth(0);
|
||
doc.rect(ML, y, CW, TH, "F");
|
||
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);
|
||
tR(td("price"), xP+CP-2, y+4.8);
|
||
tR(td("line-total"), XR-2, y+4.8);
|
||
y += TH;
|
||
|
||
const ROW_H = 7.5;
|
||
|
||
rows.forEach((row, idx) => {
|
||
// Calculate row height (description and fx note may wrap)
|
||
const dLines = sp(row.desc, CD - 4);
|
||
const descH = Math.max(0, (dLines.length - 1) * 3.8);
|
||
let fxH = 0;
|
||
if (row.fxNote) {
|
||
const fxStr = `${td("converted-from")} ${row.fxNote.cur}: 1 ${row.fxNote.cur} = ${(+row.fxNote.rate).toFixed(5)} ${iCur}. `
|
||
+ `${td("per-item")}: ${row.fxNote.cur} ${fmt(row.fxNote.per)}, `
|
||
+ `${td("line-total").toLowerCase()}: ${row.fxNote.cur} ${fmt(row.fxNote.foreignTot)}`;
|
||
const fxLines = sp(fxStr, CD + CP - 4);
|
||
fxH = fxLines.length * 3.5 + 1;
|
||
}
|
||
const rh = ROW_H + descH + fxH;
|
||
|
||
if (y + rh > PH - 30) { doc.addPage(); y = MT; }
|
||
|
||
// Alternating row background
|
||
if (idx % 2 === 1) {
|
||
fc(249,250,251); dc(255,255,255); doc.setLineWidth(0);
|
||
doc.rect(ML, y, CW, rh, "F");
|
||
}
|
||
|
||
// Bottom border
|
||
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(17,24,39);
|
||
tL(qStr, xQ+2, yt);
|
||
tL(row.uomLbl, xU+2, yt);
|
||
|
||
// Description (possibly multi-line)
|
||
dLines.forEach((dline, li) => tL(dline, xD+2, yt + li*3.8));
|
||
|
||
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("converted-from")} ${row.fxNote.cur}: `
|
||
+ `1 ${row.fxNote.cur} = ${(+row.fxNote.rate).toFixed(5)} ${iCur}. `
|
||
+ `${td("per-item")}: ${row.fxNote.cur} ${fmt(row.fxNote.per)}, `
|
||
+ `${td("line-total").toLowerCase()}: ${row.fxNote.cur} ${fmt(row.fxNote.foreignTot)}`;
|
||
fn(7); tc(107,114,128);
|
||
// Split if too long for the description column
|
||
const fxLines = sp(fxStr, CD + CP - 4);
|
||
fxLines.forEach((fl, fi) => tL(fl, xD+2, y + ROW_H + descH + 3.2 + fi * 3.5));
|
||
}
|
||
|
||
y += rh;
|
||
});
|
||
|
||
y += 4;
|
||
|
||
// ── TOTALS ────────────────────────────────────────────────────────────────
|
||
const TW = 78; // totals block width
|
||
const TX = XR - TW; // totals block left edge
|
||
const TRH = 6.5; // row height
|
||
const TLBX = XR - 40; // label right-align x
|
||
|
||
const totRows = [
|
||
[td("subtotal"), fmt(sub)],
|
||
...taxes.map(tx => [tx.lineLabel, fmt(tx.amt)]),
|
||
...(paid > 0 ? [[td("paid"), "−" + fmt(paid)]] : []),
|
||
];
|
||
|
||
totRows.forEach(([lbl, val]) => {
|
||
if (y + TRH > PH - 20) { doc.addPage(); y = MT; }
|
||
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;
|
||
});
|
||
|
||
// To Pay (navy bar)
|
||
if (y + 9 > PH - 10) { doc.addPage(); y = MT; }
|
||
fc(30,45,69); dc(255,255,255); doc.setLineWidth(0);
|
||
doc.rect(TX, y, TW, 9, "F");
|
||
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);
|
||
|
||
// ── Save ──────────────────────────────────────────────────────────────────
|
||
doc.save(iNo ? `invoice-${iNo}.pdf` : "invoice.pdf");
|
||
}
|
||
|
||
// ── Start ─────────────────────────────────────────────────────────────────────
|
||
loadCfg();
|
||
</script>
|
||
</body>
|
||
</html>
|