invoice/app/index.html
Claude 8bac41fe77
Build invoicing app: app/index.html and app/config.yml
Static single-page invoice app per spec. Features:
- Multilingual UI (EN/DE/FR/NO) loaded from config.yml; form always
  prints in the default language
- Sender fields + invoice details (date, project code, invoice no.)
  persisted in localStorage; invoice number auto-increments after
  each generation
- Predefined charge-to recipients selectable from config, or manual entry
- Dynamic invoice line items: predefined products with UOM/price
  pre-fill, free-text fallback, foreign-currency sub-row with
  exchange-rate and per-item price that calculates back to local currency
- Subtotal / tax (configurable rates) / paid / to-pay calculated live
- "Generate Invoice" renders a clean A4-formatted preview overlay;
  "Print / Save as PDF" triggers browser print-to-PDF

https://claude.ai/code/session_015iyCBgoTXNNqaErR287U1u
2026-05-18 18:03:48 +00:00

1233 lines
48 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<!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;
--navy-mid: #2c4066;
--navy-light: #3b5491;
--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;
--warn: #92400e;
--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; }
/* main line row */
.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; }
/* foreign-currency sub-row */
.line-tbl .fx td {
padding: 4px 10px 10px;
border-bottom: 1px solid var(--border-light);
background: #f9fafb;
}
/* add-line row */
.line-tbl .al td { padding: 10px; }
.col-qty { width: 72px; }
.col-uom { width: 100px; }
.col-desc { }
.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); }
.tax-sel { width: auto !important; display: inline-block; }
.paid-inp { width: 120px !important; text-align: right; }
/* ── 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-print {
background: var(--success);
color: var(--white);
padding: 10px 20px;
font-size: 14px;
font-weight: 600;
border-radius: var(--radius);
}
.btn-print: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 document (screen + print) ──────────────────────────────────── */
#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;
}
/* header strip */
.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; }
/* bill-to strip */
.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; }
/* line items */
.d-lines { width: 100%; border-collapse: collapse; margin-bottom: 20px; }
.d-lines thead tr { background: var(--navy); color: var(--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; }
/* totals */
.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: var(--white); font-size: 14px; font-weight: 700; border-top: 2px solid var(--navy); }
.d-tots .fin .tl { color: rgba(255,255,255,.75); }
/* ── 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;
}
/* ── Print styles ───────────────────────────────────────────────────────── */
@media print {
body > * { display: none !important; }
#overlay { display: block !important; background: none !important; padding: 0 !important; overflow: visible !important; }
#overlay-actions { display: none !important; }
#inv-doc {
box-shadow: none !important;
width: 100% !important;
padding: 0 !important;
min-height: 0 !important;
}
}
</style>
</head>
<body>
<div class="wrap">
<!-- Language bar -->
<div id="lang-bar" style="display:none">
<span>&#127760;</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&#8230;</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-print" id="btn-print" onclick="window.print()">&#128424; Print / Save as PDF</button>
<button class="btn-close" id="btn-close" onclick="closeOverlay()">&#x2715; Close</button>
</div>
<div id="inv-doc"></div>
</div>
<!-- js-yaml CDN -->
<script src="https://cdnjs.cloudflare.com/ajax/libs/js-yaml/4.1.0/js-yaml.min.js"
integrity="sha512-CSBhVREyzHAjAчереBOiDV5Ml/8mRxEJNIdB8M9TWBF9n3/OKq2W3kOikj7HwqZO3VWh1tYDPzLpJJrNbqXNA=="
crossorigin="anonymous"></script>
<script>
"use strict";
// ── State ─────────────────────────────────────────────────────────────────────
let cfg = null; // parsed config
let lang = "en"; // active UI language
let lid = 0; // auto-increment line id
const lines = {}; // lid → {} (kept for reference; dom is source of truth)
// ── i18n helpers ──────────────────────────────────────────────────────────────
function t(key) {
const e = cfg?.translations?.[key];
if (!e) return key;
return e[lang] ?? e[cfg["default-code"]] ?? key;
}
function tTax(tr, l) {
const lk = "label-" + (l || lang);
const fb = "label-" + (cfg["default-code"] || "en");
return tr[lk] ?? tr[fb] ?? `Tax ${tr.rate}%`;
}
// ── Number helpers ────────────────────────────────────────────────────────────
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 helper ───────────────────────────────────────────────────────────────
function fmtMonth(v) {
if (!v) return "";
const [y, m] = v.split("-");
return new Date(+y, +m - 1, 1).toLocaleDateString("en-US", { year: "numeric", month: "long" });
}
// ── HTML escaping ─────────────────────────────────────────────────────────────
function h(s) {
if (s == null) return "";
return String(s)
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;");
}
// ── Country data ──────────────────────────────────────────────────────────────
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 monthDef = `${today.getFullYear()}-${String(today.getMonth() + 1).padStart(2, "0")}`;
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("");
const taxOpts = (cfg["tax-rates"] || [])
.map((tr, i) => `<option value="${tr.rate}" ${i === 0 ? "selected" : ""}>${h(tTax(tr))}</option>`).join("");
document.getElementById("form-root").innerHTML = `
<form id="the-form" novalidate>
<!-- Sender + Invoice details (2 cols) -->
<div class="two-col">
<!-- Sender -->
<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>
<!-- Invoice details -->
<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="month" value="${monthDef}"></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>
<!-- Charge-to -->
<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 class="two-col">
<div>
<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>
<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>
</div>
</div>
<!-- Line items -->
<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>
<!-- Totals -->
<div id="totals-card">
<table class="tot-tbl">
<tr>
<td class="lbl" id="lbl-sub">${t("subtotal")}</td>
<td class="val" id="v-sub">0.00</td>
</tr>
<tr>
<td class="lbl">
<select id="tax-sel" class="tax-sel">${taxOpts}</select>
</td>
<td class="val" id="v-tax">0.00</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>
</table>
</div>
<button type="submit" id="btn-generate">${t("generate-invoice")}</button>
</form>`;
// Events
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("tax-sel").addEventListener("change", calcTotals);
document.getElementById("paid-inp").addEventListener("input", calcTotals);
document.getElementById("the-form").addEventListener("submit", e => { e.preventDefault(); generateInvoice(); });
// Save on change for ls-tagged fields
document.querySelectorAll("[data-ls]").forEach(el => el.addEventListener("change", saveStorage));
}
// ── Fill charge-to from config ────────────────────────────────────────────────
function fillChargeTo(v) {
const f = (id, val) => { const el = document.getElementById(id); if (el) el.value = val ?? ""; };
if (v === "" || v === "__other__") {
["ctn","ca1","ca2","ca3","ca4","cc","cph","cem","cvat"].forEach(id => f(id, ""));
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"]);
}
// ── UOM / product 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("");
}
// ── Add / remove line ─────────────────────────────────────────────────────────
function addLine() {
const i = lid++;
lines[i] = {};
const tbody = document.getElementById("tbody");
// Remove add-line row if present so we can re-append after the new row
const alRow = document.getElementById("al-row");
if (alRow) alRow.remove();
// Main row
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})">&#x00D7;</button></td>`;
tbody.appendChild(tr);
// Re-append add-line row
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();
}
// ── Product pick ──────────────────────────────────────────────────────────────
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}`);
if (uomEl && p.uom) uomEl.value = p.uom;
const prEl = document.getElementById(`price-${i}`);
if (prEl && p.price != null) prEl.value = p.price;
}
}
calcLine(i);
}
// ── Foreign currency toggle ───────────────────────────────────────────────────
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}`);
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 tot = qty * price;
const el = document.getElementById(`ltv-${i}`);
if (el) el.textContent = fmt(tot);
if (document.getElementById(`fx-${i}`)?.value === "yes") {
const rate = pn(document.getElementById(`frate-${i}`)?.value) || 1;
const per = pn(document.getElementById(`fper-${i}`)?.value);
const ltot = document.getElementById(`fltot-${i}`);
if (ltot) ltot.textContent = fmt((per / rate) * 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);
});
const taxRate = pn(document.getElementById("tax-sel")?.value) / 100;
const taxAmt = sub * taxRate;
const paid = pn(document.getElementById("paid-inp")?.value);
const toPay = sub + taxAmt - paid;
const set = (id, v) => { const e = document.getElementById(id); if (e) e.textContent = fmt(v); };
set("v-sub", sub);
set("v-tax", taxAmt);
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;
});
// Bump invoice number after a previous generation
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 (_) { /* ignore */ }
}
function bumpNum(s) {
// Increment trailing numeric portion: "INV-001" → "INV-002"
return String(s).replace(/(\d+)$/, m => String(+m + 1).padStart(m.length, "0"));
}
// ── Relabel (language switch) ─────────────────────────────────────────────────
function relabel() {
// Static labels
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-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",
"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);
});
// Banner
document.getElementById("inv-banner").querySelector("h1").textContent = t("invoice");
// Per-line dynamic labels
Object.keys(lines).forEach(i => {
const fxLbl = document.getElementById(`lbl-fx-${i}`);
if (fxLbl) fxLbl.textContent = t("foreign-currency") + ":";
const dTxt = document.getElementById(`dtxt-${i}`);
if (dTxt) dTxt.placeholder = t("description");
// Rebuild selects
const uomEl = document.getElementById(`uom-${i}`);
if (uomEl) { const s = uomEl.value; uomEl.innerHTML = uomOpts(s); }
const dSel = document.getElementById(`dsel-${i}`);
if (dSel) { const s = dSel.value; dSel.innerHTML = prodOpts(s); }
const fxEl = document.getElementById(`fx-${i}`);
if (fxEl) {
const s = fxEl.value;
fxEl.innerHTML = `<option value="no">${t("no-option")}</option><option value="yes">${t("yes")}</option>`;
fxEl.value = s;
}
});
// Add-line button
const alBtn = document.getElementById("btn-al");
if (alBtn) alBtn.textContent = t("add-line");
// Generate button
const genBtn = document.getElementById("btn-generate");
if (genBtn) genBtn.textContent = t("generate-invoice");
// Tax select
const taxSel = document.getElementById("tax-sel");
if (taxSel) {
const cur = taxSel.value;
taxSel.innerHTML = (cfg["tax-rates"] || []).map(tr =>
`<option value="${tr.rate}" ${String(tr.rate) === cur ? "selected" : ""}>${h(tTax(tr))}</option>`
).join("");
}
// Charge-to picker
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;
}
// Overlay buttons
document.getElementById("btn-print").textContent = "🖨 " + t("print-invoice");
document.getElementById("btn-close").textContent = "✕ " + t("close");
}
// ── Generate invoice ──────────────────────────────────────────────────────────
function generateInvoice() {
saveStorage();
localStorage.setItem(LS_GEN, "true");
document.getElementById("inv-doc").innerHTML = buildDoc();
const ov = document.getElementById("overlay");
ov.classList.add("on");
ov.scrollTop = 0;
// Update overlay button labels
document.getElementById("btn-print").textContent = "🖨 " + t("print-invoice");
document.getElementById("btn-close").textContent = "✕ " + t("close");
}
function closeOverlay() {
document.getElementById("overlay").classList.remove("on");
}
// ── Build invoice document HTML ───────────────────────────────────────────────
function buildDoc() {
// Always render invoice in default language
const dl = cfg["default-code"] || "en";
const td = key => {
const e = cfg?.translations?.[key];
if (!e) return key;
return e[dl] ?? e["en"] ?? key;
};
// Gather form values
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 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");
// Lines
let linesHTML = "";
let sub = 0;
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; // skip blank lines
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 = "";
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 ltot = (per / rate) * qty;
fxNote = `<div class="d-fx-note">${h(td("foreign-currency"))}: `
+ `${h(cur)}, 1 ${h(cur)} = ${fmt(1/rate)} ${h(td("invoice"))}`
+ ` &mdash; ${td("per-item")}: ${h(cur)} ${fmt(per)}`
+ ` &mdash; ${td("total-local")}: ${fmt(ltot)}</div>`;
}
linesHTML += `<tr>
<td>${qty % 1 === 0 ? qty : fmt(qty)}</td>
<td>${h(uomLbl)}</td>
<td>${h(desc)}${fxNote}</td>
<td class="r">${fmt(price)}</td>
<td class="r b">${fmt(tot)}</td>
</tr>`;
});
const taxRate = pn(document.getElementById("tax-sel")?.value);
const taxAmt = sub * (taxRate / 100);
const paid = pn(document.getElementById("paid-inp")?.value);
const toPay = sub + taxAmt - paid;
const taxRateObj = (cfg["tax-rates"]||[]).find(r => r.rate == taxRate);
const taxLabel = taxRateObj ? tTax(taxRateObj, dl) : `${td("tax")} ${taxRate}%`;
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(fmtMonth(iDate))}</td></tr>` : ""}
${pCode ? `<tr><td class="ml">${h(td("project-code"))}</td><td class="mv">${h(pCode)}</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>` : ""}
</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 || `<tr><td colspan="5" style="text-align:center;color:#9ca3af;padding:20px">—</td></tr>`}
</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>
${taxRate > 0 ? `
<tr>
<td class="sp"></td>
<td class="tl">${h(taxLabel)}</td>
<td class="tv">${fmt(taxAmt)}</td>
</tr>` : ""}
${paid > 0 ? `
<tr>
<td class="sp"></td>
<td class="tl">${h(td("paid"))}</td>
<td class="tv">&minus;${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>`;
}
// ── Start ─────────────────────────────────────────────────────────────────────
loadCfg();
</script>
</body>
</html>