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
This commit is contained in:
Claude 2026-06-08 04:35:08 +00:00
parent 3ef2f9206c
commit 69e4ad9624
No known key found for this signature in database
2 changed files with 186 additions and 36 deletions

View file

@ -21,6 +21,58 @@ languages:
name: Norsk
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 ───────────────────────────────────────────────────
# Set to true to hide the payment info panel entirely (useful if payment info
# should not appear on invoices, e.g. per company policy).
@ -355,25 +407,25 @@ translations:
fr: Devise étrangère
"no": Utenlandsk valuta
currency-code:
en: Currency code
de: Währungscode
fr: Code devise
"no": Valutakode
en: Foreign currency code
de: Fremdwährungscode
fr: Code devise étrangère
"no": Utenlandsk valutakode
exchange-rate:
en: "Exchange rate (X foreign = 1 local)"
de: "Wechselkurs (X Fremd = 1 Inland)"
fr: "Taux de change (X étranger = 1 local)"
"no": "Valutakurs (X utenlandsk = 1 lokal)"
en: Exchange rate
de: Wechselkurs
fr: Taux de change
"no": Valutakurs
per-item:
en: Price per item (foreign currency)
de: Preis je Einheit (Fremdwährung)
fr: Prix par unité (devise étrangère)
"no": Pris per enhet (utenlandsk valuta)
en: Price per item in
de: Preis je Einheit in
fr: Prix par unité en
"no": Pris per enhet i
total-foreign:
en: Line total in foreign currency
de: Zeilenbetrag (Fremdwährung)
fr: Total ligne en devise étrangère
"no": Linjebeløp i utenlandsk valuta
en: Line total in
de: Zeilenbetrag in
fr: Total ligne en
"no": Linjebeløp i
add-line:
en: "+ Add new line"
de: "+ Neue Zeile hinzufügen"

View file

@ -400,11 +400,21 @@
</div><!-- /.kb-wrap -->
<footer class="kb-footer">
<span>&#169; 2026 kBenestad</span>
<span class="sep">&#183;</span>
<a href="https://github.com/kbenestad/invoice">kbenestad/invoice</a>
</footer>
<!-- About modal -->
<div id="about-modal" style="display:none;position:fixed;inset:0;z-index:999;
background:rgba(0,0,0,.45);align-items:center;justify-content:center;padding:20px">
<div style="background:var(--surface);border:1px solid var(--border);border-radius:var(--radius);
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 -->
<script src="https://cdnjs.cloudflare.com/ajax/libs/js-yaml/4.1.0/js-yaml.min.js"
@ -412,6 +422,9 @@
<!-- jsPDF -->
<script src="https://cdnjs.cloudflare.com/ajax/libs/jspdf/2.5.1/jspdf.umd.min.js"
crossorigin="anonymous"></script>
<!-- marked (About modal markdown) -->
<script src="https://cdn.jsdelivr.net/npm/marked/marked.min.js"
crossorigin="anonymous"></script>
<script>
"use strict";
@ -583,6 +596,7 @@ async function loadCfg() {
function boot() {
buildLangBar();
buildForm();
buildFooter();
restoreStorage();
if (!loadLines()) addLine();
document.getElementById("loading").style.display = "none";
@ -614,8 +628,7 @@ function buildLangBar() {
const langs = cfg.languages || [{ code: cfg["default-code"], name: cfg["default-name"] }];
if (langs.length < 2) return;
const part = document.getElementById("lang-part");
if (part) part.style.display = "flex";
document.getElementById("lbl-language").textContent = t("language");
if (part) part.style.display = "";
const sel = document.getElementById("lang-sel");
sel.innerHTML = langs.map(l =>
`<option value="${h(l.code)}" ${l.code === lang ? "selected" : ""}>${h(l.name)}</option>`
@ -768,7 +781,7 @@ function buildForm() {
<section class="kb-card">
<h2 class="kb-card__title">
<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">
<option value="">${t("select")}</option>
${ctOpts}
@ -1138,24 +1151,33 @@ function toggleFx(i) {
fxDiv.innerHTML = `
<div class="kb-grid cols-3">
<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"
onchange="updateFxLabels(${i});calcLine(${i});saveLines()">
${currOpts(defaultFcy)}
</select>
</div>
<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>
<div style="display:flex;align-items:center;gap:6px;flex-wrap:wrap">
<input type="number" id="frate-${i}" class="kb-input num" value="" min="0" step="any"
oninput="calcFxFromPer(${i})">
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 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"
oninput="calcFxFromPer(${i})">
</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);
calcLine(i);
saveLines();
@ -1164,8 +1186,12 @@ function toggleFx(i) {
function calcFxFromPer(i) {
const per = pn(document.getElementById(`fper-${i}`)?.value);
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}`);
if (prEl) prEl.value = (per / rate).toFixed(6);
if (prEl) prEl.value = rcur === lcy
? (per * rate).toFixed(6)
: (per / rate).toFixed(6);
calcLine(i);
saveLines();
}
@ -1247,6 +1273,17 @@ function restoreStorage() {
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__") {
const w = document.getElementById("pcode-other-wrap");
if (w) w.style.display = "block";
@ -1310,6 +1347,7 @@ function relabel() {
fe.innerHTML = `<option value="no">${t("no-option")}</option><option value="yes">${t("yes")}</option>`;
fe.value = s;
}
if (document.getElementById(`rcur-${i}`)) updateFxLabels(+i);
});
const alBtn = document.getElementById("btn-al");
@ -1410,10 +1448,11 @@ function gatherData(renderLang) {
let fxNote = null;
if (isFx) {
const cur = document.getElementById(`fcur-${i}`)?.value || "";
const rcur = document.getElementById(`rcur-${i}`)?.value || "";
const rate = pn(document.getElementById(`frate-${i}`)?.value) || 1;
const per = pn(document.getElementById(`fper-${i}`)?.value);
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 });
});
@ -1654,10 +1693,22 @@ function buildPDF() {
function updateFxLabels(i) {
const fcy = document.getElementById(`fcur-${i}`)?.value || "FCY";
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}`);
if (rl) rl.textContent = t("exchange-rate");
const pl = document.getElementById(`fper-lbl-${i}`);
if (rl) rl.textContent = `Exchange rate (X ${fcy} = 1 ${lcy})`;
if (pl) pl.textContent = `Price per item in ${fcy}`;
if (pl) pl.textContent = t("per-item") + " " + fcy;
const tl = document.getElementById(`ftot-lbl-${i}`);
if (tl) tl.textContent = t("total-foreign") + " " + fcy;
}
// ── Save / load invoice lines ──────────────────────────────────────────────────
@ -1676,6 +1727,7 @@ function saveLines() {
price: document.getElementById(`price-${i}`)?.value || "",
fx: document.getElementById(`fx-${i}`)?.value || "no",
fcur: document.getElementById(`fcur-${i}`)?.value || "",
rcur: document.getElementById(`rcur-${i}`)?.value || "",
frate: document.getElementById(`frate-${i}`)?.value || "",
fper: document.getElementById(`fper-${i}`)?.value || "",
});
@ -1725,6 +1777,10 @@ function loadLines() {
const el = document.getElementById(`fcur-${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.fper) { const el = document.getElementById(`fper-${i}`); if (el) el.value = ld.fper; }
calcFxFromPer(i);
@ -1757,6 +1813,48 @@ function resetLines() {
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 ─────────────────────────────────────────────────────────────────────
loadCfg();
</script>