Compare commits

...

5 commits

Author SHA1 Message Date
Claude
69e4ad9624
Port all development-branch features into kBenestad redesign
Some checks failed
/ mirror (push) Has been cancelled
- Bidirectional FX rate entry: rcur dropdown (e.g. "35 THB per USD" or
  "0.028 USD per THB") with rother label updating dynamically
- FX labels from config translations; currency code appended at runtime
- ct-pick persisted to localStorage; restoreStorage rebuilds client-
  specific project code options before re-applying saved pcode value
- Footer rebuilt by buildFooter() with dynamic About link
- About modal with markdown-rendered content from config.yml (marked.js)
- about section added to config.yml with EN/DE/FR/NO content
- rcur saved/restored in inv_lines_v1; gatherData includes rcur in fxNote
- relabel() calls updateFxLabels() for each active FX line on lang switch

https://claude.ai/code/session_01MkM7p8Us3L8YAfLKGA13NS
2026-06-08 04:35:08 +00:00
Claude
3ef2f9206c
Fix line item row vertical alignment: top-align cells, nudge remove button
https://claude.ai/code/session_01MkM7p8Us3L8YAfLKGA13NS
2026-06-08 04:27:06 +00:00
Claude
b06500512a
Restructure toolbar and header: lang dropdown left, app name in brand
Move language select to the left of the toolbar. Add lowercase "invoice"
app name next to the squircle logo in the brand section.

https://claude.ai/code/session_01MkM7p8Us3L8YAfLKGA13NS
2026-06-08 04:24:39 +00:00
Claude
195e61794d
Add favicons and web manifest
Copies the design-system favicon set into app/ and wires up SVG, PNG
(16/32/48), apple-touch-icon, web manifest, and theme-color meta tag.

https://claude.ai/code/session_01MkM7p8Us3L8YAfLKGA13NS
2026-06-08 04:19:40 +00:00
Claude
46b17cd154
Remove brand text from header — icon only
https://claude.ai/code/session_01MkM7p8Us3L8YAfLKGA13NS
2026-06-08 04:05:29 +00:00
9 changed files with 246 additions and 47 deletions

BIN
app/apple-touch-icon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.6 KiB

View file

@ -21,6 +21,58 @@ languages:
name: Norsk name: Norsk
direction: ltr direction: ltr
# ── About modal ───────────────────────────────────────────────────────────────
# Optional. Remove this section entirely to hide the About link in the footer.
about:
en:
title: "About"
content: |
### Invoice Generator
A single-file browser app for generating freelance invoices as PDF.
No backend, no build step — just a browser and a config file.
**Source:** [kbenestad/invoice](https://github.com/kbenestad/invoice)
**Docs:** [docs.benestad.net/invoice](https://docs.benestad.net/invoice)
© 2026 Kristian Benestad. Licensed under the Apache 2 license.
btn-close: "Close"
de:
title: "Über"
content: |
### Rechnungsgenerator
Eine Single-File-Browser-App zur Erstellung von Freiberufler-Rechnungen als PDF.
Kein Backend, kein Build-Schritt.
**Quellcode:** [kbenestad/invoice](https://github.com/kbenestad/invoice)
**Dokumentation:** [docs.benestad.net/invoice](https://docs.benestad.net/invoice)
© 2026 Kristian Benestad. Lizenziert unter Apache 2.
btn-close: "Schließen"
fr:
title: "À propos"
content: |
### Générateur de factures
Une application de navigateur en fichier unique pour générer des factures en PDF.
Aucun backend, aucune étape de build.
**Code source :** [kbenestad/invoice](https://github.com/kbenestad/invoice)
**Documentation :** [docs.benestad.net/invoice](https://docs.benestad.net/invoice)
© 2026 Kristian Benestad. Sous licence Apache 2.
btn-close: "Fermer"
"no":
title: "Om"
content: |
### Fakturagenerator
En nettleserapp i én fil for å generere frilansfakturaer som PDF.
Ingen backend, ingen byggtrinn.
**Kildekode:** [kbenestad/invoice](https://github.com/kbenestad/invoice)
**Dokumentasjon:** [docs.benestad.net/invoice](https://docs.benestad.net/invoice)
© 2026 Kristian Benestad. Lisensiert under Apache 2.
btn-close: "Lukk"
# ── Payment info visibility ─────────────────────────────────────────────────── # ── Payment info visibility ───────────────────────────────────────────────────
# Set to true to hide the payment info panel entirely (useful if payment info # Set to true to hide the payment info panel entirely (useful if payment info
# should not appear on invoices, e.g. per company policy). # should not appear on invoices, e.g. per company policy).
@ -355,25 +407,25 @@ translations:
fr: Devise étrangère fr: Devise étrangère
"no": Utenlandsk valuta "no": Utenlandsk valuta
currency-code: currency-code:
en: Currency code en: Foreign currency code
de: Währungscode de: Fremdwährungscode
fr: Code devise fr: Code devise étrangère
"no": Valutakode "no": Utenlandsk valutakode
exchange-rate: exchange-rate:
en: "Exchange rate (X foreign = 1 local)" en: Exchange rate
de: "Wechselkurs (X Fremd = 1 Inland)" de: Wechselkurs
fr: "Taux de change (X étranger = 1 local)" fr: Taux de change
"no": "Valutakurs (X utenlandsk = 1 lokal)" "no": Valutakurs
per-item: per-item:
en: Price per item (foreign currency) en: Price per item in
de: Preis je Einheit (Fremdwährung) de: Preis je Einheit in
fr: Prix par unité (devise étrangère) fr: Prix par unité en
"no": Pris per enhet (utenlandsk valuta) "no": Pris per enhet i
total-foreign: total-foreign:
en: Line total in foreign currency en: Line total in
de: Zeilenbetrag (Fremdwährung) de: Zeilenbetrag in
fr: Total ligne en devise étrangère fr: Total ligne en
"no": Linjebeløp i utenlandsk valuta "no": Linjebeløp i
add-line: add-line:
en: "+ Add new line" en: "+ Add new line"
de: "+ Neue Zeile hinzufügen" de: "+ Neue Zeile hinzufügen"

BIN
app/favicon-16.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 455 B

BIN
app/favicon-32.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 786 B

BIN
app/favicon-48.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1 KiB

8
app/favicon.svg Normal file
View file

@ -0,0 +1,8 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 48 48" width="48" height="48" role="img" aria-label="invoice">
<rect width="48" height="48" rx="12" fill="#2f6fed"></rect>
<path d="M14 9 H29 L34 14 V39 H14 Z" fill="none" stroke="#fff" stroke-width="3" stroke-linejoin="round"></path>
<path d="M29 9 V14 H34" fill="none" stroke="#fff" stroke-width="3" stroke-linejoin="round"></path>
<path d="M18.5 20 H27" stroke="#fff" stroke-width="2.6" stroke-linecap="round"></path>
<path d="M18.5 25 H29.5" stroke="#fff" stroke-width="2.6" stroke-linecap="round" opacity=".55"></path>
<path d="M18.5 33 H29.5" stroke="#fff" stroke-width="3.2" stroke-linecap="round"></path>
</svg>

After

Width:  |  Height:  |  Size: 684 B

BIN
app/icon-512.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

View file

@ -5,6 +5,13 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta name="format-detection" content="telephone=no"> <meta name="format-detection" content="telephone=no">
<title>Invoice</title> <title>Invoice</title>
<link rel="icon" href="favicon.svg" type="image/svg+xml">
<link rel="icon" href="favicon-48.png" sizes="48x48" type="image/png">
<link rel="icon" href="favicon-32.png" sizes="32x32" type="image/png">
<link rel="icon" href="favicon-16.png" sizes="16x16" type="image/png">
<link rel="apple-touch-icon" href="apple-touch-icon.png">
<link rel="manifest" href="site.webmanifest">
<meta name="theme-color" content="#2f6fed">
<script> <script>
/* Restore persisted theme before first paint */ /* Restore persisted theme before first paint */
(function(){var t=localStorage.getItem("kb-theme");if(t)document.documentElement.setAttribute("data-theme",t);})(); (function(){var t=localStorage.getItem("kb-theme");if(t)document.documentElement.setAttribute("data-theme",t);})();
@ -150,15 +157,13 @@
border-bottom: 1px solid var(--border); border-bottom: 1px solid var(--border);
} }
.kb-brand { display: flex; align-items: center; gap: 9px; min-width: 0; } .kb-brand { display: flex; align-items: center; gap: 9px; min-width: 0; }
.kb-brand .org { font-size: 14px; font-weight: 600; letter-spacing: -0.01em; color: var(--text-soft); }
.kb-brand .logo { .kb-brand .logo {
height: 28px; width: 28px; flex: 0 0 28px; border-radius: 7px; height: 28px; width: 28px; flex: 0 0 28px; border-radius: 7px;
display: grid; place-items: center; overflow: hidden; opacity: .82; display: grid; place-items: center; overflow: hidden; opacity: .82;
} }
.kb-brand .logo svg { width: 28px; height: 28px; display: block; } .kb-brand .logo svg { width: 28px; height: 28px; display: block; }
.kb-brand .org {
font-size: 12px; font-weight: 500; color: var(--text-muted);
letter-spacing: -0.01em; opacity: .7;
}
.kb-doctitle { text-align: right; } .kb-doctitle { text-align: right; }
.kb-doctitle h1 { margin: 0; font-size: var(--fs-h1); font-weight: 700; letter-spacing: -0.01em; color: var(--text); } .kb-doctitle h1 { margin: 0; font-size: var(--fs-h1); font-weight: 700; letter-spacing: -0.01em; color: var(--text); }
@ -240,6 +245,7 @@
.kb-btn--block { width: 100%; } .kb-btn--block { width: 100%; }
/* round / buttons for rows */ /* round / buttons for rows */
.kb-row .kb-circbtn { margin-top: 6px; }
.kb-circbtn { .kb-circbtn {
width: 24px; height: 24px; border-radius: 50%; width: 24px; height: 24px; border-radius: 50%;
display: inline-grid; place-items: center; display: inline-grid; place-items: center;
@ -252,7 +258,8 @@
.kb-circbtn--rm:hover { background: var(--danger); color: #fff; } .kb-circbtn--rm:hover { background: var(--danger); color: #fff; }
/* ── Row grids (line items / tax rows) ──────────────────────────────────── */ /* ── Row grids (line items / tax rows) ──────────────────────────────────── */
.kb-rowhead, .kb-row { display: grid; align-items: center; gap: 8px; } .kb-rowhead, .kb-row { display: grid; align-items: start; gap: 8px; }
.kb-rowhead { align-items: center; }
.kb-rowhead { .kb-rowhead {
padding: 0 4px 8px; padding: 0 4px 8px;
font-size: var(--fs-label); font-weight: 700; font-size: var(--fs-label); font-weight: 700;
@ -348,16 +355,15 @@
<!-- Utility toolbar --> <!-- Utility toolbar -->
<div class="kb-toolbar" id="lang-bar"> <div class="kb-toolbar" id="lang-bar">
<div id="lang-part" style="display:none">
<select id="lang-sel" class="kb-select" style="width:auto;padding:5px 28px 5px 8px;font-size:var(--fs-small)"></select>
</div>
<div class="spacer"></div>
<div class="kb-seg" role="group" aria-label="Text size"> <div class="kb-seg" role="group" aria-label="Text size">
<button id="sz-down" onclick="bumpZoom(-1)" aria-label="Smaller text">A</button> <button id="sz-down" onclick="bumpZoom(-1)" aria-label="Smaller text">A</button>
<button id="sz-up" onclick="bumpZoom(1)" aria-label="Larger text">A+</button> <button id="sz-up" onclick="bumpZoom(1)" aria-label="Larger text">A+</button>
</div> </div>
<span id="sz-label" style="font-size:12px;color:var(--text-muted);font-family:var(--font-mono)">100%</span> <span id="sz-label" style="font-size:12px;color:var(--text-muted);font-family:var(--font-mono)">100%</span>
<div class="spacer"></div>
<div id="lang-part" class="kb-seg" role="group" aria-label="Language" style="display:none">
<label id="lbl-language" for="lang-sel" style="padding:5px 10px;font-size:var(--fs-small);font-weight:600;color:var(--text-muted)">Language</label>
<select id="lang-sel" class="kb-select" style="width:auto;border:0;box-shadow:none;background:transparent;padding:5px 24px 5px 4px"></select>
</div>
<button class="kb-iconbtn" id="btn-theme" onclick="toggleTheme()" title="Toggle light/dark" aria-label="Toggle dark mode"> <button class="kb-iconbtn" id="btn-theme" onclick="toggleTheme()" title="Toggle light/dark" aria-label="Toggle dark mode">
<svg viewBox="0 0 16 16" width="15" height="15" fill="currentColor"> <svg viewBox="0 0 16 16" width="15" height="15" fill="currentColor">
<path d="M8 1.5a6.5 6.5 0 1 0 0 13A6.5 6.5 0 0 0 8 1.5zM0 8a8 8 0 1 1 16 0A8 8 0 0 1 0 8z"/> <path d="M8 1.5a6.5 6.5 0 1 0 0 13A6.5 6.5 0 0 0 8 1.5zM0 8a8 8 0 1 1 16 0A8 8 0 0 1 0 8z"/>
@ -379,7 +385,7 @@
<path d="M18.5 33 H29.5" stroke="#fff" stroke-width="3.2" stroke-linecap="round"/> <path d="M18.5 33 H29.5" stroke="#fff" stroke-width="3.2" stroke-linecap="round"/>
</svg> </svg>
</span> </span>
<span class="org">kBenestad</span> <span class="org">invoice</span>
</div> </div>
<div class="kb-doctitle"> <div class="kb-doctitle">
<h1 id="inv-title">Invoice</h1> <h1 id="inv-title">Invoice</h1>
@ -394,11 +400,21 @@
</div><!-- /.kb-wrap --> </div><!-- /.kb-wrap -->
<footer class="kb-footer"> <!-- About modal -->
<span>&#169; 2026 kBenestad</span> <div id="about-modal" style="display:none;position:fixed;inset:0;z-index:999;
<span class="sep">&#183;</span> background:rgba(0,0,0,.45);align-items:center;justify-content:center;padding:20px">
<a href="https://github.com/kbenestad/invoice">kbenestad/invoice</a> <div style="background:var(--surface);border:1px solid var(--border);border-radius:var(--radius);
</footer> box-shadow:var(--shadow);max-width:520px;width:100%;padding:28px 28px 20px">
<h2 id="about-dialog-header" style="font-size:18px;font-weight:700;margin:0 0 16px;color:var(--text)"></h2>
<div id="about-dialog-body" style="font-size:var(--fs-base);color:var(--text-soft);line-height:1.65;
max-height:60vh;overflow-y:auto"></div>
<div style="margin-top:20px;text-align:right">
<button id="about-btn-close" class="kb-btn kb-btn--ghost" onclick="closeAbout()">Close</button>
</div>
</div>
</div>
<footer id="app-footer" class="kb-footer"></footer>
<!-- js-yaml --> <!-- js-yaml -->
<script src="https://cdnjs.cloudflare.com/ajax/libs/js-yaml/4.1.0/js-yaml.min.js" <script src="https://cdnjs.cloudflare.com/ajax/libs/js-yaml/4.1.0/js-yaml.min.js"
@ -406,6 +422,9 @@
<!-- jsPDF --> <!-- jsPDF -->
<script src="https://cdnjs.cloudflare.com/ajax/libs/jspdf/2.5.1/jspdf.umd.min.js" <script src="https://cdnjs.cloudflare.com/ajax/libs/jspdf/2.5.1/jspdf.umd.min.js"
crossorigin="anonymous"></script> crossorigin="anonymous"></script>
<!-- marked (About modal markdown) -->
<script src="https://cdn.jsdelivr.net/npm/marked/marked.min.js"
crossorigin="anonymous"></script>
<script> <script>
"use strict"; "use strict";
@ -577,6 +596,7 @@ async function loadCfg() {
function boot() { function boot() {
buildLangBar(); buildLangBar();
buildForm(); buildForm();
buildFooter();
restoreStorage(); restoreStorage();
if (!loadLines()) addLine(); if (!loadLines()) addLine();
document.getElementById("loading").style.display = "none"; document.getElementById("loading").style.display = "none";
@ -608,8 +628,7 @@ function buildLangBar() {
const langs = cfg.languages || [{ code: cfg["default-code"], name: cfg["default-name"] }]; const langs = cfg.languages || [{ code: cfg["default-code"], name: cfg["default-name"] }];
if (langs.length < 2) return; if (langs.length < 2) return;
const part = document.getElementById("lang-part"); const part = document.getElementById("lang-part");
if (part) part.style.display = "flex"; if (part) part.style.display = "";
document.getElementById("lbl-language").textContent = t("language");
const sel = document.getElementById("lang-sel"); const sel = document.getElementById("lang-sel");
sel.innerHTML = langs.map(l => sel.innerHTML = langs.map(l =>
`<option value="${h(l.code)}" ${l.code === lang ? "selected" : ""}>${h(l.name)}</option>` `<option value="${h(l.code)}" ${l.code === lang ? "selected" : ""}>${h(l.name)}</option>`
@ -762,7 +781,7 @@ function buildForm() {
<section class="kb-card"> <section class="kb-card">
<h2 class="kb-card__title"> <h2 class="kb-card__title">
<span id="sec-ct">${t("charge-to")}</span> <span id="sec-ct">${t("charge-to")}</span>
<select id="ct-pick" class="kb-select" <select id="ct-pick" class="kb-select" data-ls="ct-pick"
style="width:auto;font-size:var(--fs-small);margin-left:8px;padding:4px 28px 4px 8px"> style="width:auto;font-size:var(--fs-small);margin-left:8px;padding:4px 28px 4px 8px">
<option value="">${t("select")}</option> <option value="">${t("select")}</option>
${ctOpts} ${ctOpts}
@ -1132,24 +1151,33 @@ function toggleFx(i) {
fxDiv.innerHTML = ` fxDiv.innerHTML = `
<div class="kb-grid cols-3"> <div class="kb-grid cols-3">
<div class="kb-field"> <div class="kb-field">
<span class="kb-label">${t("currency-code")}</span> <span class="kb-label" id="fcur-lbl-${i}">${t("currency-code")}</span>
<select id="fcur-${i}" class="kb-select" <select id="fcur-${i}" class="kb-select"
onchange="updateFxLabels(${i});calcLine(${i});saveLines()"> onchange="updateFxLabels(${i});calcLine(${i});saveLines()">
${currOpts(defaultFcy)} ${currOpts(defaultFcy)}
</select> </select>
</div> </div>
<div class="kb-field"> <div class="kb-field">
<span class="kb-label" id="frate-lbl-${i}">Exchange rate (X ${h(defaultFcy)} = 1 ${h(lcy)})</span> <span class="kb-label" id="frate-lbl-${i}">${t("exchange-rate")}</span>
<input type="number" id="frate-${i}" class="kb-input num" value="" min="0" step="any" <div style="display:flex;align-items:center;gap:6px;flex-wrap:wrap">
oninput="calcFxFromPer(${i})"> <input type="number" id="frate-${i}" class="kb-input num" value="" min="0" step="any"
style="flex:1;min-width:70px" oninput="calcFxFromPer(${i})">
<select id="rcur-${i}" class="kb-select" style="width:auto"
onchange="updateFxLabels(${i});calcFxFromPer(${i});saveLines()">
<option value="${h(lcy)}">${h(lcy)}</option>
<option value="${h(defaultFcy)}">${h(defaultFcy)}</option>
</select>
<span class="kb-label" style="margin:0">per</span>
<span id="rother-${i}" style="font-size:var(--fs-small);font-weight:600;color:var(--text)">${h(defaultFcy)}</span>
</div>
</div> </div>
<div class="kb-field"> <div class="kb-field">
<span class="kb-label" id="fper-lbl-${i}">Price per item in ${h(defaultFcy)}</span> <span class="kb-label" id="fper-lbl-${i}">${t("per-item")} ${h(defaultFcy)}</span>
<input type="number" id="fper-${i}" class="kb-input num" value="" min="0" step="any" <input type="number" id="fper-${i}" class="kb-input num" value="" min="0" step="any"
oninput="calcFxFromPer(${i})"> oninput="calcFxFromPer(${i})">
</div> </div>
</div> </div>
<div class="fx-note">${t("total-foreign")}: <strong id="fltot-${i}">0.00</strong></div>`; <div class="fx-note"><span id="ftot-lbl-${i}">${t("total-foreign")} ${h(defaultFcy)}</span>: <strong id="fltot-${i}">0.00</strong></div>`;
lr?.insertAdjacentElement("afterend", fxDiv); lr?.insertAdjacentElement("afterend", fxDiv);
calcLine(i); calcLine(i);
saveLines(); saveLines();
@ -1158,8 +1186,12 @@ function toggleFx(i) {
function calcFxFromPer(i) { function calcFxFromPer(i) {
const per = pn(document.getElementById(`fper-${i}`)?.value); const per = pn(document.getElementById(`fper-${i}`)?.value);
const rate = pn(document.getElementById(`frate-${i}`)?.value) || 1; const rate = pn(document.getElementById(`frate-${i}`)?.value) || 1;
const rcur = document.getElementById(`rcur-${i}`)?.value;
const lcy = document.getElementById("icur")?.value || "";
const prEl = document.getElementById(`price-${i}`); const prEl = document.getElementById(`price-${i}`);
if (prEl) prEl.value = (per / rate).toFixed(6); if (prEl) prEl.value = rcur === lcy
? (per * rate).toFixed(6)
: (per / rate).toFixed(6);
calcLine(i); calcLine(i);
saveLines(); saveLines();
} }
@ -1241,6 +1273,17 @@ function restoreStorage() {
if (el) el.value = v; if (el) el.value = v;
}); });
// Restore charge-to: rebuild its project code options then re-apply pcode.
// fillChargeTo() calls updateProjectCodes() which resets the pcode select,
// and may call saveStorage() mid-flow, so we re-apply and re-save after.
const ctPickEl = document.getElementById("ct-pick");
if (ctPickEl?.value) {
fillChargeTo(ctPickEl.value);
const pcodeEl = document.getElementById("pcode");
if (pcodeEl && d.pcode) pcodeEl.value = d.pcode;
saveStorage();
}
if (d.pcode === "__other__") { if (d.pcode === "__other__") {
const w = document.getElementById("pcode-other-wrap"); const w = document.getElementById("pcode-other-wrap");
if (w) w.style.display = "block"; if (w) w.style.display = "block";
@ -1304,6 +1347,7 @@ function relabel() {
fe.innerHTML = `<option value="no">${t("no-option")}</option><option value="yes">${t("yes")}</option>`; fe.innerHTML = `<option value="no">${t("no-option")}</option><option value="yes">${t("yes")}</option>`;
fe.value = s; fe.value = s;
} }
if (document.getElementById(`rcur-${i}`)) updateFxLabels(+i);
}); });
const alBtn = document.getElementById("btn-al"); const alBtn = document.getElementById("btn-al");
@ -1404,10 +1448,11 @@ function gatherData(renderLang) {
let fxNote = null; let fxNote = null;
if (isFx) { if (isFx) {
const cur = document.getElementById(`fcur-${i}`)?.value || ""; const cur = document.getElementById(`fcur-${i}`)?.value || "";
const rcur = document.getElementById(`rcur-${i}`)?.value || "";
const rate = pn(document.getElementById(`frate-${i}`)?.value) || 1; const rate = pn(document.getElementById(`frate-${i}`)?.value) || 1;
const per = pn(document.getElementById(`fper-${i}`)?.value); const per = pn(document.getElementById(`fper-${i}`)?.value);
const foreignTot = per * qty; const foreignTot = per * qty;
fxNote = { cur, rate, per, foreignTot, td }; fxNote = { cur, rcur, rate, per, foreignTot, td };
} }
rows.push({ qty, uomLbl, desc, price, tot, fxNote }); rows.push({ qty, uomLbl, desc, price, tot, fxNote });
}); });
@ -1646,12 +1691,24 @@ function buildPDF() {
// ── Update FX labels when currency or invoice currency changes ──────────────── // ── Update FX labels when currency or invoice currency changes ────────────────
function updateFxLabels(i) { function updateFxLabels(i) {
const fcy = document.getElementById(`fcur-${i}`)?.value || "FCY"; const fcy = document.getElementById(`fcur-${i}`)?.value || "FCY";
const lcy = document.getElementById("icur")?.value || ""; const lcy = document.getElementById("icur")?.value || "";
const rcurEl = document.getElementById(`rcur-${i}`);
const rotherEl = document.getElementById(`rother-${i}`);
if (rcurEl) {
const prev = rcurEl.value;
rcurEl.innerHTML = `<option value="${h(lcy)}">${h(lcy)}</option><option value="${h(fcy)}">${h(fcy)}</option>`;
rcurEl.value = (prev === lcy || prev === fcy) ? prev : lcy;
}
if (rotherEl) rotherEl.textContent = rcurEl?.value === lcy ? fcy : lcy;
const cl = document.getElementById(`fcur-lbl-${i}`);
if (cl) cl.textContent = t("currency-code");
const rl = document.getElementById(`frate-lbl-${i}`); const rl = document.getElementById(`frate-lbl-${i}`);
if (rl) rl.textContent = t("exchange-rate");
const pl = document.getElementById(`fper-lbl-${i}`); const pl = document.getElementById(`fper-lbl-${i}`);
if (rl) rl.textContent = `Exchange rate (X ${fcy} = 1 ${lcy})`; if (pl) pl.textContent = t("per-item") + " " + fcy;
if (pl) pl.textContent = `Price per item in ${fcy}`; const tl = document.getElementById(`ftot-lbl-${i}`);
if (tl) tl.textContent = t("total-foreign") + " " + fcy;
} }
// ── Save / load invoice lines ────────────────────────────────────────────────── // ── Save / load invoice lines ──────────────────────────────────────────────────
@ -1670,6 +1727,7 @@ function saveLines() {
price: document.getElementById(`price-${i}`)?.value || "", price: document.getElementById(`price-${i}`)?.value || "",
fx: document.getElementById(`fx-${i}`)?.value || "no", fx: document.getElementById(`fx-${i}`)?.value || "no",
fcur: document.getElementById(`fcur-${i}`)?.value || "", fcur: document.getElementById(`fcur-${i}`)?.value || "",
rcur: document.getElementById(`rcur-${i}`)?.value || "",
frate: document.getElementById(`frate-${i}`)?.value || "", frate: document.getElementById(`frate-${i}`)?.value || "",
fper: document.getElementById(`fper-${i}`)?.value || "", fper: document.getElementById(`fper-${i}`)?.value || "",
}); });
@ -1719,6 +1777,10 @@ function loadLines() {
const el = document.getElementById(`fcur-${i}`); const el = document.getElementById(`fcur-${i}`);
if (el) { el.value = ld.fcur; updateFxLabels(i); } if (el) { el.value = ld.fcur; updateFxLabels(i); }
} }
if (ld.rcur) {
const el = document.getElementById(`rcur-${i}`);
if (el) { el.value = ld.rcur; updateFxLabels(i); }
}
if (ld.frate) { const el = document.getElementById(`frate-${i}`); if (el) el.value = ld.frate; } if (ld.frate) { const el = document.getElementById(`frate-${i}`); if (el) el.value = ld.frate; }
if (ld.fper) { const el = document.getElementById(`fper-${i}`); if (el) el.value = ld.fper; } if (ld.fper) { const el = document.getElementById(`fper-${i}`); if (el) el.value = ld.fper; }
calcFxFromPer(i); calcFxFromPer(i);
@ -1751,6 +1813,48 @@ function resetLines() {
calcTotals(); calcTotals();
} }
// ── Footer & About modal ──────────────────────────────────────────────────────
function aboutData() {
const ab = cfg?.about;
if (!ab) return null;
return ab[lang] ?? ab[cfg["default-code"]] ?? Object.values(ab)[0] ?? null;
}
function buildFooter() {
const footer = document.getElementById("app-footer");
if (!footer) return;
const ab = aboutData();
const aboutLink = ab
? ` <span class="sep">&#183;</span> <a href="#" onclick="openAbout();return false">${h(ab.title ?? "About")}</a>`
: "";
footer.innerHTML =
`<span>&#169; 2026 Kristian Benestad</span>`
+ ` <span class="sep">&#183;</span>`
+ ` <a href="https://docs.benestad.net/invoice" target="_blank" rel="noopener">docs.benestad.net</a>`
+ ` <span class="sep">&#183;</span>`
+ ` <a href="https://github.com/kbenestad/invoice" target="_blank" rel="noopener">kbenestad/invoice</a>`
+ aboutLink;
}
function openAbout() {
const ab = aboutData();
if (!ab) return;
document.getElementById("about-dialog-header").textContent = ab.title ?? "";
document.getElementById("about-dialog-body").innerHTML =
typeof marked !== "undefined" ? marked.parse(ab.content ?? "") : h(ab.content ?? "");
document.getElementById("about-btn-close").textContent = ab["btn-close"] ?? "Close";
document.getElementById("about-modal").style.display = "flex";
document.getElementById("about-btn-close").focus();
}
function closeAbout() {
document.getElementById("about-modal").style.display = "none";
}
document.addEventListener("keydown", e => {
if (e.key === "Escape") closeAbout();
});
// ── Start ───────────────────────────────────────────────────────────────────── // ── Start ─────────────────────────────────────────────────────────────────────
loadCfg(); loadCfg();
</script> </script>

35
app/site.webmanifest Normal file
View file

@ -0,0 +1,35 @@
{
"name": "Invoice",
"short_name": "invoice",
"theme_color": "#2f6fed",
"background_color": "#2f6fed",
"display": "standalone",
"icons": [
{
"src": "icon-512.png",
"sizes": "512x512",
"type": "image/png",
"purpose": "any maskable"
},
{
"src": "apple-touch-icon.png",
"sizes": "180x180",
"type": "image/png"
},
{
"src": "favicon-48.png",
"sizes": "48x48",
"type": "image/png"
},
{
"src": "favicon-32.png",
"sizes": "32x32",
"type": "image/png"
},
{
"src": "favicon-16.png",
"sizes": "16x16",
"type": "image/png"
}
]
}