mirror of
https://github.com/kbenestad/invoice.git
synced 2026-06-18 08:04:32 +00:00
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
1233 lines
48 KiB
HTML
1233 lines
48 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;
|
||
--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>🌐</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-print" id="btn-print" onclick="window.print()">🖨 Print / Save as PDF</button>
|
||
<button class="btn-close" id="btn-close" onclick="closeOverlay()">✕ 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, "&")
|
||
.replace(/</g, "<")
|
||
.replace(/>/g, ">")
|
||
.replace(/"/g, """);
|
||
}
|
||
|
||
// ── 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})">×</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"))}`
|
||
+ ` — ${td("per-item")}: ${h(cur)} ${fmt(per)}`
|
||
+ ` — ${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">−${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>
|