Vermietungs-Tool
A local web app for a small room-rental business in Austria: guest register, room ledger, occupancy plan, invoices, the monthly tourist-tax report and the quarterly VAT list. Application code only; it ships no data. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
commit
45424e6fd6
47 files changed
+6701
No files matched your search
@@ -0,0 +1,644 @@
|
||||
// Client-side grid engine for the spreadsheet-like tables: sorting, live search + dropdown filters.
|
||||
//
|
||||
// Built for LARGE tables (a ledger of thousands of rows) and for FIREFOX as well as Chromium. The server ships
|
||||
// every row inside an inert <template> (parsed, but the browser builds no layout boxes), and the grid
|
||||
// only ever ATTACHES the ~60 rows around the viewport — spacer rows keep the scrollbar honest, so it
|
||||
// still feels like one giant scrollable list. Sorting/filtering/searching work on an in-memory model
|
||||
// (each cell's text and sort key computed once), then just re-render the window: cost is O(visible),
|
||||
// not O(table), in every engine. Column widths are measured once (from the rows with the longest text
|
||||
// per column), frozen into generated CSS, and remembered in localStorage keyed by a db fingerprint —
|
||||
// the next visit re-applies them in <head> and never lays out the full table at all.
|
||||
//
|
||||
// Sorting: every column cycles ascending -> descending -> out; clicking more columns while one is
|
||||
// sorted adds sort levels (no modifier keys). Arrows + priority numbers live in the headers. The sort
|
||||
// survives navigating away and back (sessionStorage); the scroll offset does not — a chronological
|
||||
// grid always opens on its newest rows (data-scroll="end"). Columns can be PINNED
|
||||
// (Excel "Fenster fixieren", per column): the header's pin glyph toggles it, defaults come from the
|
||||
// table's data-pin attribute, overrides live in localStorage. Trade-off of windowed rendering: the
|
||||
// browser's own Ctrl+F / select-all only see the attached rows — the grid's search box covers the
|
||||
// full data instead.
|
||||
(function () {
|
||||
var collator = new Intl.Collator("de");
|
||||
var STORE_V = 5; // bump when the generated-CSS format changes (v5: per-column caps)
|
||||
var MAX_COL = 200; // widest a single column may freeze to (px)
|
||||
var OVERSCAN = 12; // extra rows rendered above/below the viewport
|
||||
var TEXT = window.GRID_TEXT; // UI labels, from de.json via base.html
|
||||
|
||||
// Typed sort key: a German date (dd.mm.yyyy) sorts chronologically, a German number sorts numerically,
|
||||
// anything starting with digits sorts naturally by that number ('57*' lands at 57, not alphabetically),
|
||||
// pure text sorts alphabetically. Decided per CELL, so one odd value can't degrade the whole column.
|
||||
function makeKey(s) {
|
||||
if (s === "") return { e: true };
|
||||
var d = s.match(/^(\d{2})\.(\d{2})\.(\d{4})$/);
|
||||
if (d) return { n: +(d[3] + d[2] + d[1]), s: "" };
|
||||
var t = s.replace(/[\s€]/g, "").replace(/\./g, "").replace(",", ".");
|
||||
if (/^-?\d+(\.\d+)?$/.test(t)) return { n: parseFloat(t), s: "" };
|
||||
var m = s.match(/^(\d+)\s*(.*)$/);
|
||||
if (m) return { n: parseInt(m[1], 10), s: m[2].toLowerCase() };
|
||||
return { s: s.toLowerCase() };
|
||||
}
|
||||
|
||||
// Which column does a filter/sum mean? A NAME (the header's own text) survives inserting a column;
|
||||
// a hardcoded index does not — a filter would silently move to the neighbouring column when a column
|
||||
// is added. Numbers still work for anything unnamed.
|
||||
function colIndex(grid, spec) {
|
||||
if (/^\d+$/.test(spec || "")) return parseInt(spec, 10);
|
||||
var want = (spec || "").trim().toLowerCase();
|
||||
for (var i = 0; i < grid.ths.length; i++) {
|
||||
// the header's OWN text only: the pin glyph is a child element and would never match
|
||||
var th = grid.ths[i], text = "";
|
||||
for (var n = 0; n < th.childNodes.length; n++) {
|
||||
if (th.childNodes[n].nodeType === 3) text += th.childNodes[n].nodeValue;
|
||||
}
|
||||
if (text.trim().toLowerCase() === want) return i;
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
|
||||
function cmpKeys(a, b) {
|
||||
if (a.e || b.e) return (a.e ? 0 : 1) - (b.e ? 0 : 1); // empty cells first
|
||||
var na = a.n !== undefined, nb = b.n !== undefined;
|
||||
if (na && nb && a.n !== b.n) return a.n - b.n;
|
||||
if (na !== nb) return na ? -1 : 1; // numbers before text
|
||||
return collator.compare(a.s, b.s);
|
||||
}
|
||||
|
||||
function Grid(table) {
|
||||
this.table = table;
|
||||
this.tbody = table.tBodies[0];
|
||||
this.wrap = table.closest(".tablewrap");
|
||||
this.ths = table.tHead.rows[0].cells;
|
||||
this.spec = []; // active sort: [{col, dir}], empty = the server's order
|
||||
this.q = "";
|
||||
this.filters = [];
|
||||
this.rowH = 39; // refined from the stored layout or the first rendered row
|
||||
this.a = -1;
|
||||
this.b = -1;
|
||||
|
||||
var tpl = this.tbody.querySelector("template");
|
||||
var els = tpl ? tpl.content.querySelectorAll("tr")
|
||||
: Array.prototype.slice.call(this.tbody.rows); // fallback: server-attached rows
|
||||
this.rows = [];
|
||||
for (var i = 0; i < els.length; i++) {
|
||||
if (els[i].dataset.empty) continue;
|
||||
var texts = [], sortable = null, filterable = null, cells = els[i].cells;
|
||||
for (var c = 0; c < cells.length; c++) {
|
||||
texts.push(cells[c].textContent.trim());
|
||||
// a cell may carry its own sort key (data-k) when the printed text does not sort sensibly —
|
||||
// an invoice number spans four eras of spelling, so the server hands over year.sequence
|
||||
if (cells[c].dataset.k) (sortable = sortable || {})[c] = cells[c].dataset.k;
|
||||
// ... and its own FILTER value (data-f) when the question is not answerable from the text: an
|
||||
// empty 'Bis' means "still here" only on a row that has a 'Von' at all
|
||||
if (cells[c].dataset.f) (filterable = filterable || {})[c] = cells[c].dataset.f;
|
||||
}
|
||||
this.rows.push({ el: els[i], i: this.rows.length, texts: texts, sortable: sortable,
|
||||
filterable: filterable, keys: [], search: null });
|
||||
}
|
||||
if (tpl) tpl.remove();
|
||||
this.view = this.rows.slice();
|
||||
this.top = document.createElement("tr");
|
||||
this.bot = document.createElement("tr");
|
||||
this.top.className = this.bot.className = "vspace";
|
||||
}
|
||||
|
||||
Grid.prototype.key = function (r, col) {
|
||||
return r.keys[col] || (r.keys[col] = makeKey((r.sortable && r.sortable[col]) || r.texts[col] || ""));
|
||||
};
|
||||
|
||||
// Recompute the visible/sorted row list from the current search, filters and sort spec, then render.
|
||||
Grid.prototype.update = function () {
|
||||
if (!this.rows.length) return 0;
|
||||
var g = this, out = [];
|
||||
for (var i = 0; i < this.rows.length; i++) {
|
||||
var r = this.rows[i], show = true;
|
||||
if (this.q) {
|
||||
if (r.search === null) r.search = r.texts.join(" ").toLowerCase();
|
||||
show = r.search.indexOf(this.q) !== -1;
|
||||
}
|
||||
for (var f = 0; show && f < this.filters.length; f++) {
|
||||
// a dropdown normally matches the cell text exactly; two sentinels ask about the cell being
|
||||
// EMPTY or filled at all — that is how "not departed yet" is a filter without a column
|
||||
var col = this.filters[f].col, want = this.filters[f].value;
|
||||
var txt = ((r.filterable && r.filterable[col]) || r.texts[col] || "").toLowerCase();
|
||||
show = want === "__empty__" ? txt === "" : want === "__filled__" ? txt !== "" : txt === want;
|
||||
}
|
||||
if (show) out.push(r);
|
||||
}
|
||||
var spec = this.spec;
|
||||
if (spec.length) out.sort(function (a, b) {
|
||||
for (var s = 0; s < spec.length; s++) {
|
||||
var d = cmpKeys(g.key(a, spec[s].col), g.key(b, spec[s].col));
|
||||
if (d) return d * spec[s].dir;
|
||||
}
|
||||
return a.i - b.i; // full tie -> keep server order
|
||||
});
|
||||
this.view = out;
|
||||
this.a = -1; // force a re-render
|
||||
this.render();
|
||||
return out.length;
|
||||
};
|
||||
|
||||
// Attach only the rows around the viewport; two spacer rows stand in for everything else.
|
||||
Grid.prototype.render = function () {
|
||||
if (!this.rows.length) return;
|
||||
var win = Math.ceil(this.wrap.clientHeight / this.rowH) + 2 * OVERSCAN;
|
||||
// clamp into the view: after a filter shrinks the list, the old scroll offset must not leave the
|
||||
// window pointing past the last row (the browser only clamps scrollTop after we shrink the spacers)
|
||||
var a = Math.max(0, Math.min(Math.floor(this.wrap.scrollTop / this.rowH) - OVERSCAN,
|
||||
this.view.length - win));
|
||||
var b = Math.min(this.view.length, a + win);
|
||||
if (a === this.a && b === this.b) return;
|
||||
var kids = [this.top];
|
||||
for (var i = a; i < b; i++) {
|
||||
var el = this.view[i].el;
|
||||
el.classList.toggle("zeb", (i & 1) === 1); // stripe by display position, not DOM position
|
||||
kids.push(el);
|
||||
}
|
||||
kids.push(this.bot);
|
||||
this.top.style.height = a * this.rowH + "px";
|
||||
this.bot.style.height = (this.view.length - b) * this.rowH + "px";
|
||||
this.tbody.replaceChildren.apply(this.tbody, kids);
|
||||
this.a = a;
|
||||
this.b = b;
|
||||
if (!this.measured && b > a) { // true row height once rows exist
|
||||
this.measured = true;
|
||||
var h = kids[1].getBoundingClientRect().height;
|
||||
if (h > 5 && Math.abs(h - this.rowH) > 0.5) { this.rowH = h; this.a = -1; this.render(); }
|
||||
}
|
||||
this.placeAtNewest();
|
||||
};
|
||||
|
||||
// Hold the pane at the very bottom while the list is still settling (see stickEnd).
|
||||
Grid.prototype.placeAtNewest = function () {
|
||||
if (this.stickEnd) this.wrap.scrollTop = this.wrap.scrollHeight;
|
||||
};
|
||||
|
||||
// Every column cycles independently: ascending -> descending -> out. Clicking another column while
|
||||
// one is sorted simply ADDS it as the next sort level (no modifier keys); when the last column cycles
|
||||
// out, the grid is back in the server's order.
|
||||
Grid.prototype.headerClick = function (col) {
|
||||
var at = -1;
|
||||
for (var i = 0; i < this.spec.length; i++) if (this.spec[i].col === col) at = i;
|
||||
if (at < 0) this.spec.push({ col: col, dir: 1 });
|
||||
else if (this.spec[at].dir === 1) this.spec[at].dir = -1;
|
||||
else this.spec.splice(at, 1);
|
||||
this.update();
|
||||
this.markHeaders();
|
||||
try { // keep the sort while flipping between pages
|
||||
sessionStorage.setItem("sort:" + this.table.id, JSON.stringify(this.spec));
|
||||
} catch (e) { /* storage blocked: sorting still works, it just won't survive navigation */ }
|
||||
};
|
||||
|
||||
Grid.prototype.restoreSort = function () {
|
||||
var spec;
|
||||
try { spec = JSON.parse(sessionStorage.getItem("sort:" + this.table.id)); } catch (e) { return; }
|
||||
if (!Array.isArray(spec)) return;
|
||||
var nCols = this.ths.length;
|
||||
this.spec = spec.filter(function (s) {
|
||||
return s && typeof s.col === "number" && s.col < nCols && (s.dir === 1 || s.dir === -1);
|
||||
});
|
||||
this.markHeaders();
|
||||
};
|
||||
|
||||
Grid.prototype.markHeaders = function () {
|
||||
for (var i = 0; i < this.ths.length; i++) {
|
||||
this.ths[i].removeAttribute("data-sd");
|
||||
this.ths[i].removeAttribute("data-prio");
|
||||
}
|
||||
for (var s = 0; s < this.spec.length; s++) {
|
||||
var th = this.ths[this.spec[s].col];
|
||||
if (!th) continue;
|
||||
th.setAttribute("data-sd", this.spec[s].dir === 1 ? "a" : "d");
|
||||
if (this.spec.length > 1) th.setAttribute("data-prio", s + 1);
|
||||
}
|
||||
};
|
||||
|
||||
Grid.prototype.sum = function (col) {
|
||||
var total = 0;
|
||||
for (var i = 0; i < this.view.length; i++) {
|
||||
var k = this.key(this.view[i], col);
|
||||
if (k.n !== undefined) total += k.n;
|
||||
}
|
||||
return total;
|
||||
};
|
||||
|
||||
// --- pinned columns (Excel "Fenster fixieren", but per column) -----------------------------------
|
||||
// A pinned column is position:sticky with left = the summed widths of the pinned columns BEFORE it:
|
||||
// it scrolls normally until it reaches that offset, then docks flush against the pinned block while
|
||||
// the unpinned columns slide underneath. Pure CSS — nothing runs on scroll. Column widths come from
|
||||
// freezeLayout's measurement, so toggling never re-measures.
|
||||
Grid.prototype.loadPins = function () {
|
||||
var pins = null;
|
||||
try { pins = JSON.parse(localStorage.getItem("pin:" + this.table.id)); } catch (e) { /* default */ }
|
||||
if (!Array.isArray(pins)) { // no user override -> the template's default set
|
||||
pins = (this.table.dataset.pin || "").split(/\s+/).filter(Boolean).map(Number);
|
||||
}
|
||||
var n = this.ths.length;
|
||||
this.pins = pins.filter(function (c) { return c >= 1 && c <= n; });
|
||||
};
|
||||
|
||||
Grid.prototype.togglePin = function (col) {
|
||||
var at = this.pins.indexOf(col);
|
||||
if (at < 0) this.pins.push(col); else this.pins.splice(at, 1);
|
||||
try { localStorage.setItem("pin:" + this.table.id, JSON.stringify(this.pins)); }
|
||||
catch (e) { /* storage blocked: the pin still applies, it just won't survive the session */ }
|
||||
this.applyPins();
|
||||
};
|
||||
|
||||
Grid.prototype.applyPins = function () {
|
||||
if (!this.pinStyle) {
|
||||
this.pinStyle = document.createElement("style"); // appended AFTER the layout css, so it wins ties
|
||||
document.head.appendChild(this.pinStyle);
|
||||
}
|
||||
var id = "#" + this.table.id, g = this;
|
||||
var sorted = this.pins.slice().sort(function (a, b) { return a - b; });
|
||||
var active = [], left = 0;
|
||||
// cap: at least ~240px of the pane must stay scrollable, or pinning could lock sideways scrolling
|
||||
// up entirely; anything past the cap simply stays a normal column
|
||||
var budget = Math.max(240, (this.wrap.clientWidth || 800) - 240);
|
||||
for (var i = 0; i < sorted.length && this.widths; i++) {
|
||||
var w = this.widths[sorted[i] - 1] || 0;
|
||||
if (left + w > budget) break;
|
||||
active.push({ col: sorted[i], left: left });
|
||||
left += w;
|
||||
}
|
||||
this.markPins();
|
||||
if (!active.length) { this.pinStyle.textContent = ""; return; }
|
||||
var css = id + " thead{z-index:4}"; // above the pinned cells on the vertical axis
|
||||
var sel = function (part) {
|
||||
return active.map(function (a) { return id + part.replace(/N/g, a.col); }).join(",");
|
||||
};
|
||||
for (var p = 0; p < active.length; p++) {
|
||||
// th needs z 2: every header cell already carries z-index:1 (vertical stickiness), and a later
|
||||
// unpinned sibling would otherwise paint OVER the docked one (equal z -> DOM order wins)
|
||||
css += id + " th:nth-child(" + active[p].col + ")" +
|
||||
"{position:sticky;left:" + active[p].left + "px;z-index:2}" +
|
||||
id + " td:nth-child(" + active[p].col + ")" +
|
||||
"{position:sticky;left:" + active[p].left + "px;z-index:1}";
|
||||
}
|
||||
// sticky cells float over the scrolled columns -> they need an opaque background per row state
|
||||
css += sel(" thead th:nth-child(N)") + "{background:var(--accent-soft)}" +
|
||||
sel(" tbody tr td:nth-child(N)") + "{background:var(--panel)}" +
|
||||
sel(" tbody tr.zeb td:nth-child(N)") + "{background:#fafcfb}" +
|
||||
sel(" tbody tr:hover td:nth-child(N)") + "{background:var(--accent-soft)}";
|
||||
// shadows on the pinned edges, only once the table is actually scrolled sideways. A box-shadow
|
||||
// per cell fades at every cell's corners (a scalloped edge), so each cell instead hangs a plain
|
||||
// horizontal-gradient strip outside its right edge: the strips tile into ONE seamless shadow that
|
||||
// only fades in over the header (the first cell) and out over the last row. EVERY pinned column
|
||||
// carries it - a not-yet-docked pin has content sliding under its own right edge - and once
|
||||
// columns dock, the inner strips are simply covered by the next pinned cell's opaque background.
|
||||
// The layout css hides cell overflow -> lift that on pinned columns (safe: widths carry slack,
|
||||
// nothing ever clips); th::after is the sort arrow, so the header strip rides on ::before.
|
||||
var sth = sel(".xscrolled thead th:nth-child(N)");
|
||||
var std = sel(".xscrolled tbody tr:not([data-empty]) td:nth-child(N)");
|
||||
css += sth + "," + std + "{overflow:visible}" +
|
||||
sel(".xscrolled thead th:nth-child(N)::before") + "," +
|
||||
sel(".xscrolled tbody tr:not([data-empty]) td:nth-child(N)::after") +
|
||||
"{content:'';position:absolute;top:0;bottom:0;right:-6px;width:6px;pointer-events:none;" +
|
||||
"background:linear-gradient(to right,rgba(0,0,0,.22),rgba(0,0,0,0))}" +
|
||||
sel(".xscrolled thead th:nth-child(N)::before") +
|
||||
"{-webkit-mask-image:linear-gradient(rgba(0,0,0,0),#000);" +
|
||||
"mask-image:linear-gradient(rgba(0,0,0,0),#000)}" +
|
||||
// the bottom spacer is always the tbody's last child -> nth-last-child(2) is the last row
|
||||
sel(".xscrolled tbody tr:nth-last-child(2) td:nth-child(N)::after") +
|
||||
"{-webkit-mask-image:linear-gradient(#000,rgba(0,0,0,0));" +
|
||||
"mask-image:linear-gradient(#000,rgba(0,0,0,0))}";
|
||||
this.pinStyle.textContent = css;
|
||||
};
|
||||
|
||||
Grid.prototype.markPins = function () {
|
||||
var g = this;
|
||||
Array.prototype.forEach.call(this.ths, function (th, i) {
|
||||
var btn = th.querySelector(".pinbtn");
|
||||
if (!btn) return;
|
||||
var pinned = g.pins.indexOf(i + 1) >= 0;
|
||||
th.classList.toggle("pinned", pinned);
|
||||
btn.title = pinned ? TEXT.unpin : TEXT.pin;
|
||||
});
|
||||
};
|
||||
|
||||
Grid.prototype.initPinControls = function () {
|
||||
var g = this;
|
||||
Array.prototype.forEach.call(this.ths, function (th, i) {
|
||||
if (th.classList.contains("actions")) return; // pinning the trailing edit column is nonsense
|
||||
var btn = document.createElement("span");
|
||||
btn.className = "pinbtn";
|
||||
btn.textContent = "📌"; // 📌
|
||||
btn.addEventListener("click", function (ev) {
|
||||
ev.stopPropagation(); // never trigger the header's sort click
|
||||
g.togglePin(i + 1);
|
||||
});
|
||||
th.appendChild(btn);
|
||||
});
|
||||
};
|
||||
|
||||
// Freeze the column widths into generated CSS and switch the table to one flex row per <tr> — the
|
||||
// windowed rows then always line up, whatever subset is attached. Widths are measured by attaching
|
||||
// only the header plus the rows with the longest text per column (a full-table auto layout would cost
|
||||
// seconds in Firefox). The result is remembered in localStorage, keyed by the db fingerprint, and
|
||||
// re-applied by base.html BEFORE the table parses on the next visit.
|
||||
function freezeLayout(table, grid) {
|
||||
if (!table.id || !grid.rows.length) return;
|
||||
var pre = window.__pregrids && window.__pregrids[table.id];
|
||||
if (pre) { // stored layout already active
|
||||
grid.rowH = +pre.rowH || grid.rowH;
|
||||
grid.widths = pre.widths || null;
|
||||
table.classList.add("flexgrid");
|
||||
return;
|
||||
}
|
||||
var cols = grid.ths.length, candidates = {};
|
||||
for (var c = 0; c < cols; c++) {
|
||||
var top = grid.rows.slice().sort(function (x, y) { // longest text per column
|
||||
return (y.texts[c] || "").length - (x.texts[c] || "").length;
|
||||
}).slice(0, 6);
|
||||
for (var t = 0; t < top.length; t++) candidates[top[t].i] = top[t];
|
||||
}
|
||||
var att = Object.keys(candidates).map(function (k) { return candidates[k].el; });
|
||||
grid.tbody.replaceChildren.apply(grid.tbody, att); // measure with auto table layout
|
||||
var id = "#" + table.id;
|
||||
var rowH = att[0].getBoundingClientRect().height;
|
||||
var css = id + "," + id + " thead," + id + " tbody{display:block}" +
|
||||
id + " tr{display:flex;min-width:max-content}" +
|
||||
id + " tr.vspace{display:block;min-width:0}" +
|
||||
id + " th," + id + " td{flex:none;overflow:hidden;text-overflow:ellipsis;max-width:none}" +
|
||||
id + " thead{position:sticky;top:0;z-index:1;background:var(--accent-soft)}" +
|
||||
// striping follows the SORTED position (class set by render), not DOM position
|
||||
id + " tbody tr:nth-child(even){background:transparent}" +
|
||||
id + " tbody tr.zeb{background:#fafcfb}" +
|
||||
id + " tbody tr:hover{background:var(--accent-soft)}";
|
||||
var widths = [];
|
||||
for (var i = 0; i < cols; i++) {
|
||||
// slack so the widest cell never sprouts an ellipsis (widths come from longest-TEXT sampling)
|
||||
var w = Math.ceil(grid.ths[i].getBoundingClientRect().width) + 6;
|
||||
// ... but one very long Bemerkung must not push every other column off screen: cap the column and
|
||||
// let it ellipsize, so more fits at a glance; hovering a cut-off cell shows its whole text (see
|
||||
// showTip). A header may set its own, tighter cap (data-max): one long GB note or payer name
|
||||
// otherwise decides how far right the RNr. sits on a small screen.
|
||||
w = Math.min(w, +grid.ths[i].dataset.max || MAX_COL);
|
||||
widths.push(w);
|
||||
css += id + " th:nth-child(" + (i + 1) + ")," + id + " td:nth-child(" + (i + 1) + "){width:" + w + "px}";
|
||||
}
|
||||
var style = document.createElement("style");
|
||||
style.textContent = css;
|
||||
document.head.appendChild(style);
|
||||
table.classList.remove("th-abbr"); // each column now abbreviates itself (ellipsis + showTip)
|
||||
table.classList.add("flexgrid");
|
||||
grid.rowH = rowH > 5 ? rowH : grid.rowH;
|
||||
grid.widths = widths; // pinned-column offsets are computed from these
|
||||
try {
|
||||
if (window.__gridfp) localStorage.setItem("grid:" + table.id,
|
||||
JSON.stringify({ v: STORE_V, fp: window.__gridfp, vw: window.innerWidth, css: css, rowH: grid.rowH,
|
||||
widths: widths }));
|
||||
} catch (e) { /* storage full/blocked: just skip the head start next time */ }
|
||||
}
|
||||
|
||||
// Headers carry FULL names; the tooltip always shows them. Non-grid tables (e.g. the quality page)
|
||||
// additionally get the old shrink-to-fit abbreviation — grid columns ellipsize themselves.
|
||||
function fitHeaders() {
|
||||
document.querySelectorAll(".tablewrap table").forEach(function (table) {
|
||||
if (!table.tHead || !table.tHead.rows.length) return;
|
||||
Array.prototype.forEach.call(table.tHead.rows[0].cells, function (th) {
|
||||
var full = th.textContent.trim();
|
||||
if (full && !th.title) th.title = full;
|
||||
});
|
||||
if (table.classList.contains("grid")) return;
|
||||
var wrap = table.closest(".tablewrap");
|
||||
table.classList.remove("th-abbr"); // measure at full width first
|
||||
if (table.scrollWidth > wrap.clientWidth + 1) table.classList.add("th-abbr");
|
||||
});
|
||||
}
|
||||
|
||||
// A cut-off cell's whole text, the moment the mouse is on it. The browser's own title tooltip is not
|
||||
// good enough: it comes late, is tiny at 150 % display scaling, and Firefox drops it as soon as the
|
||||
// mouse moves. So the grid shows its own box, at once, for every cell that is ACTUALLY cut off right
|
||||
// now (measured live, whatever capped it), and for no other.
|
||||
var tip = null;
|
||||
|
||||
function showTip(td) {
|
||||
if (!tip) {
|
||||
tip = document.createElement("div");
|
||||
tip.className = "celltip";
|
||||
document.body.appendChild(tip);
|
||||
}
|
||||
tip.textContent = td.textContent.trim();
|
||||
tip.style.display = "block";
|
||||
var r = td.getBoundingClientRect();
|
||||
var top = r.bottom + 4; // below the cell, above it near the bottom
|
||||
if (top + tip.offsetHeight > window.innerHeight - 4) top = r.top - tip.offsetHeight - 4;
|
||||
tip.style.left = Math.max(4, Math.min(r.left, window.innerWidth - tip.offsetWidth - 4)) + "px";
|
||||
tip.style.top = top + "px";
|
||||
}
|
||||
|
||||
function hideTip() {
|
||||
if (tip) tip.style.display = "none";
|
||||
}
|
||||
|
||||
function cutOff(td) {
|
||||
return td.scrollWidth > td.clientWidth + 1;
|
||||
}
|
||||
|
||||
var grids = {};
|
||||
window.__grids = grids; // for the Playwright checks — the model is otherwise unreachable
|
||||
|
||||
// The selected checkboxes of a grid — read from the MODEL, because windowed rendering keeps most
|
||||
// rows detached (a plain querySelectorAll would only see the attached window).
|
||||
// Printing. Two things stand in the way: the table only ever holds the visible ~30 rows in the DOM
|
||||
// (the rest are two spacers), and 20 columns are wider than a sheet. So: attach all filtered rows,
|
||||
// shrink the page until it fits ONE landscape sheet (never below MIN_ZOOM — below that it may take
|
||||
// several pages), print, put everything back.
|
||||
var PX_MM = 96 / 25.4, PAGE_W = (297 - 20) * PX_MM, PAGE_H = (210 - 20) * PX_MM, MIN_ZOOM = 0.45;
|
||||
|
||||
var printing = false, printedGrids = [];
|
||||
|
||||
// Preparing means: attach all filtered rows (normally the table holds only the visible ~30), switch
|
||||
// the page to its paper view and shrink it until it fits one landscape sheet. Hooked on beforeprint,
|
||||
// so Ctrl+P works exactly like the Drucken button.
|
||||
function preparePrint() {
|
||||
if (printing) return;
|
||||
printing = true;
|
||||
document.body.classList.add("printing");
|
||||
printedGrids = [];
|
||||
for (var id in grids) {
|
||||
var g = grids[id];
|
||||
if (!g.rows.length) continue;
|
||||
var kids = [];
|
||||
for (var i = 0; i < g.view.length; i++) {
|
||||
g.view[i].el.classList.toggle("zeb", (i & 1) === 1);
|
||||
kids.push(g.view[i].el);
|
||||
}
|
||||
g.tbody.replaceChildren.apply(g.tbody, kids);
|
||||
var info = document.querySelector(".printinfo[data-grid='" + id + "']");
|
||||
if (info) info.textContent = printLabel(id, g.view.length);
|
||||
printedGrids.push(g);
|
||||
}
|
||||
if (printedGrids.length) {
|
||||
var g0 = printedGrids[0];
|
||||
fitToSheet(g0.table.closest("main") || document.body);
|
||||
}
|
||||
}
|
||||
|
||||
function restoreAfterPrint() {
|
||||
if (!printing) return;
|
||||
printing = false;
|
||||
document.body.classList.remove("printing");
|
||||
printedGrids.forEach(function (g) {
|
||||
var main = g.table.closest("main");
|
||||
if (main) main.style.zoom = "";
|
||||
g.a = -1;
|
||||
g.render();
|
||||
});
|
||||
printedGrids = [];
|
||||
}
|
||||
|
||||
window.addEventListener("beforeprint", preparePrint);
|
||||
window.addEventListener("afterprint", restoreAfterPrint);
|
||||
|
||||
window.gridPrint = function () {
|
||||
preparePrint();
|
||||
window.print();
|
||||
setTimeout(restoreAfterPrint, 0); // in case afterprint never comes (Safari/Firefox)
|
||||
};
|
||||
|
||||
// On paper the sheet itself settles the WIDTH: in the print view the list is a real table again with
|
||||
// width:100%, the columns shrink and text wraps. What is left is the HEIGHT. So the page is briefly set
|
||||
// to the sheet's width on screen — text then wraps exactly as it will on paper — and the shrink factor
|
||||
// comes from the measured height. Never below MIN_ZOOM: rather a second sheet than a magnifying glass.
|
||||
// (zoom does not affect getBoundingClientRect in this engine, so measuring again after shrinking
|
||||
// measures nothing.)
|
||||
function fitToSheet(main) {
|
||||
var before = main.style.width;
|
||||
main.style.width = PAGE_W + "px";
|
||||
var height = main.getBoundingClientRect().height;
|
||||
main.style.width = before;
|
||||
var zoom = PAGE_H / Math.max(1, height);
|
||||
main.style.zoom = zoom < 0.999 ? Math.max(MIN_ZOOM, zoom) : "";
|
||||
return zoom;
|
||||
}
|
||||
|
||||
// What is on the sheet? Search and filters belong on it, or nobody knows any more what the list shows.
|
||||
// (The German words here are UI text hardcoded in JS — to move into de.json: docs/plan.md.)
|
||||
function printLabel(id, n) {
|
||||
var parts = [];
|
||||
var search = document.querySelector('.live-search[data-grid="' + id + '"]');
|
||||
if (search && search.value.trim()) parts.push(TEXT.search.replace("{q}", search.value.trim()));
|
||||
document.querySelectorAll('.live-filter[data-grid="' + id + '"]').forEach(function (sel) {
|
||||
if (sel.value) parts.push(sel.dataset.col + ": " + sel.value);
|
||||
});
|
||||
parts.push(n === 1 ? TEXT.rows_one : TEXT.rows.replace("{n}", n));
|
||||
parts.push(TEXT.date.replace("{date}", new Date().toLocaleDateString("de-AT")));
|
||||
return parts.join(" • ");
|
||||
}
|
||||
|
||||
window.gridChecked = function (id) {
|
||||
var g = grids[id], out = [];
|
||||
if (!g) return out;
|
||||
for (var i = 0; i < g.rows.length; i++) {
|
||||
var cb = g.rows[i].el.querySelector(".rowsel");
|
||||
if (cb && cb.checked) out.push(cb.value);
|
||||
}
|
||||
return out;
|
||||
};
|
||||
|
||||
document.addEventListener("DOMContentLoaded", function () {
|
||||
function refresh(name) {
|
||||
var g = grids[name];
|
||||
if (!g) return;
|
||||
var search = document.querySelector('.live-search[data-grid="' + name + '"]');
|
||||
// searching or filtering means looking for something specific — stop holding the bottom
|
||||
if (g.ready && (search && search.value.trim())) g.stickEnd = false;
|
||||
g.q = search ? search.value.trim().toLowerCase() : "";
|
||||
g.filters = [];
|
||||
document.querySelectorAll('.live-filter[data-grid="' + name + '"]').forEach(function (sel) {
|
||||
var col = colIndex(g, sel.dataset.col);
|
||||
if (sel.value && col >= 0) g.filters.push({ col: col, value: sel.value.toLowerCase() });
|
||||
});
|
||||
var shown = g.update();
|
||||
var count = document.querySelector('.grid-count[data-grid="' + name + '"]');
|
||||
if (count) count.textContent = shown;
|
||||
// live sum over the VISIBLE rows of a marked money column (Excel's bottom SUM row equivalent)
|
||||
document.querySelectorAll('.grid-sum[data-grid="' + name + '"]').forEach(function (el) {
|
||||
el.textContent = g.sum(colIndex(g, el.dataset.col))
|
||||
.toLocaleString("de-AT", { minimumFractionDigits: 2, maximumFractionDigits: 2 });
|
||||
});
|
||||
}
|
||||
|
||||
document.querySelectorAll("table.grid").forEach(function (table) {
|
||||
if (!table.tHead || !table.tBodies.length || !table.id) return;
|
||||
var g = grids[table.id] = new Grid(table);
|
||||
Array.prototype.forEach.call(table.tHead.rows[0].cells, function (th, i) {
|
||||
if (th.classList.contains("nosort")) return;
|
||||
th.addEventListener("click", function () { g.headerClick(i); });
|
||||
});
|
||||
g.restoreSort(); // the sort chosen on the last visit still applies
|
||||
});
|
||||
|
||||
fitHeaders();
|
||||
window.addEventListener("resize", fitHeaders);
|
||||
|
||||
// the dropdown filters chosen on the last visit still apply (per tab, like the sort) —
|
||||
// restored BEFORE the first render below so counts, sums and rows come up already filtered
|
||||
document.querySelectorAll(".live-filter[data-grid]").forEach(function (el) {
|
||||
var key = "filter:" + el.dataset.grid + ":" + el.dataset.col;
|
||||
try {
|
||||
var saved = sessionStorage.getItem(key);
|
||||
if (saved !== null) el.value = saved; // a vanished option leaves the select unfiltered
|
||||
} catch (e) { /* fine */ }
|
||||
el.addEventListener("change", function () {
|
||||
try {
|
||||
if (el.value) sessionStorage.setItem(key, el.value);
|
||||
else sessionStorage.removeItem(key);
|
||||
} catch (e) { /* fine */ }
|
||||
});
|
||||
});
|
||||
|
||||
Object.keys(grids).forEach(function (name) {
|
||||
var g = grids[name];
|
||||
freezeLayout(g.table, g);
|
||||
g.loadPins(); // stored override, else the template's data-pin set
|
||||
g.initPinControls();
|
||||
g.applyPins();
|
||||
refresh(name); // first window render (+ counts and sums)
|
||||
|
||||
// These grids ALWAYS open on their newest entries, the bottom row visible first, so nobody has to
|
||||
// scroll down every time. Placing the pane once is not
|
||||
// enough: the first rendered rows correct the row height, the spacers are resized from it, and the
|
||||
// bottom moves away underneath us. So the grid STAYS glued to the end across those re-renders until
|
||||
// the user actually touches it — wheel, drag, key, or a search/filter/sort. (No remembered scroll
|
||||
// offset: it would reopen a long list anywhere; sorting is remembered, under its own key.)
|
||||
g.stickEnd = g.table.dataset.scroll === "end";
|
||||
g.ready = true;
|
||||
g.placeAtNewest();
|
||||
["wheel", "mousedown", "touchstart", "keydown"].forEach(function (ev) {
|
||||
g.wrap.addEventListener(ev, function () { g.stickEnd = false; }, { passive: true });
|
||||
});
|
||||
g.table.classList.toggle("xscrolled", g.wrap.scrollLeft > 0);
|
||||
var pending = null;
|
||||
g.wrap.addEventListener("mouseover", function (e) {
|
||||
var td = e.target.closest ? e.target.closest("td") : null;
|
||||
if (td && g.tbody.contains(td) && cutOff(td)) showTip(td);
|
||||
else hideTip();
|
||||
});
|
||||
g.wrap.addEventListener("mouseleave", hideTip);
|
||||
g.wrap.addEventListener("scroll", function () {
|
||||
hideTip(); // the row under it is about to move or be detached
|
||||
if (pending) return;
|
||||
pending = requestAnimationFrame(function () {
|
||||
pending = null;
|
||||
g.render();
|
||||
g.table.classList.toggle("xscrolled", g.wrap.scrollLeft > 0);
|
||||
});
|
||||
}, { passive: true });
|
||||
});
|
||||
|
||||
var timer = null;
|
||||
document.querySelectorAll(".live-search[data-grid]").forEach(function (el) {
|
||||
el.addEventListener("input", function () { // debounced: not on every keystroke
|
||||
clearTimeout(timer);
|
||||
timer = setTimeout(function () { refresh(el.dataset.grid); }, 120);
|
||||
});
|
||||
});
|
||||
document.querySelectorAll(".live-filter[data-grid]").forEach(function (el) {
|
||||
el.addEventListener("change", function () { refresh(el.dataset.grid); });
|
||||
});
|
||||
});
|
||||
})();
|
||||
Reference in new issue
Block a user