mirror of
https://github.com/kbenestad/invoice.git
synced 2026-06-18 08:04:32 +00:00
Compare commits
5 commits
fcd2f047e7
...
69e4ad9624
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
69e4ad9624 | ||
|
|
3ef2f9206c | ||
|
|
b06500512a | ||
|
|
195e61794d | ||
|
|
46b17cd154 |
9 changed files with 246 additions and 47 deletions
BIN
app/apple-touch-icon.png
Normal file
BIN
app/apple-touch-icon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 2.6 KiB |
|
|
@ -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
BIN
app/favicon-16.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 455 B |
BIN
app/favicon-32.png
Normal file
BIN
app/favicon-32.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 786 B |
BIN
app/favicon-48.png
Normal file
BIN
app/favicon-48.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1 KiB |
8
app/favicon.svg
Normal file
8
app/favicon.svg
Normal 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
BIN
app/icon-512.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 11 KiB |
166
app/index.html
166
app/index.html
|
|
@ -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>© 2026 kBenestad</span>
|
<div id="about-modal" style="display:none;position:fixed;inset:0;z-index:999;
|
||||||
<span class="sep">·</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">·</span> <a href="#" onclick="openAbout();return false">${h(ab.title ?? "About")}</a>`
|
||||||
|
: "";
|
||||||
|
footer.innerHTML =
|
||||||
|
`<span>© 2026 Kristian Benestad</span>`
|
||||||
|
+ ` <span class="sep">·</span>`
|
||||||
|
+ ` <a href="https://docs.benestad.net/invoice" target="_blank" rel="noopener">docs.benestad.net</a>`
|
||||||
|
+ ` <span class="sep">·</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
35
app/site.webmanifest
Normal 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"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
Loading…
Reference in a new issue