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); });
|
||||
});
|
||||
});
|
||||
})();
|
||||
@@ -0,0 +1,288 @@
|
||||
:root{
|
||||
--ink:#1f2a24; --muted:#6b756e; --line:#d9e0da; --bg:#f4f6f3; --panel:#ffffff;
|
||||
--accent:#2f6d5b; --accent-dark:#245645; --accent-soft:#e5efea;
|
||||
--warn:#b4531a; --open:#b23a3a; --paid:#2e7d5b; --cash:#7a6a2e;
|
||||
}
|
||||
*{box-sizing:border-box}
|
||||
html,body{height:100%}
|
||||
body{margin:0;background:var(--bg);color:var(--ink);
|
||||
font-family:"Segoe UI",system-ui,Arial,sans-serif;font-size:15px;line-height:1.45;
|
||||
display:flex;flex-direction:column;height:100vh;overflow:hidden}
|
||||
a{color:var(--accent);text-decoration:none}
|
||||
a:hover{text-decoration:underline}
|
||||
|
||||
.topbar{display:flex;flex-shrink:0;align-items:center;gap:28px;background:var(--accent);color:#fff;
|
||||
padding:0 26px;height:56px;box-shadow:0 2px 6px rgba(0,0,0,.12)}
|
||||
.topbar .brand{font-size:20px;font-weight:700;letter-spacing:.2px}
|
||||
.topbar nav{display:flex;gap:6px;height:100%}
|
||||
.topbar nav a{color:#e8f2ee;padding:0 18px;display:flex;align-items:center;height:100%;
|
||||
font-size:17px;border-bottom:4px solid transparent}
|
||||
.topbar nav a:hover{background:var(--accent-dark);text-decoration:none}
|
||||
.topbar nav a.active{color:#fff;border-bottom-color:#fff;font-weight:600}
|
||||
|
||||
main{flex:1;min-height:0;overflow:auto;width:100%;max-width:1200px;margin:0 auto;padding:20px 26px 40px}
|
||||
/* grid pages fill the viewport so only the table scrolls (no second page scrollbar) */
|
||||
main.grid-page{max-width:none;overflow:hidden;display:flex;flex-direction:column;padding:12px 20px}
|
||||
.grid-page h1{margin:2px 0 10px;font-size:22px}
|
||||
|
||||
.flash{background:#fff6e6;border:1px solid #e6c98a;color:#8a5a12;border-radius:8px;
|
||||
padding:12px 16px;margin-bottom:18px;font-size:16px}
|
||||
|
||||
h1{font-size:28px;margin:4px 0 20px}
|
||||
.toolbar{display:flex;align-items:center;gap:12px;margin-bottom:16px;flex-wrap:wrap}
|
||||
.toolbar .spacer{flex:1}
|
||||
.count{color:var(--muted);font-size:15px}
|
||||
|
||||
.btn{display:inline-flex;align-items:center;gap:7px;background:var(--accent);color:#fff;
|
||||
border:none;border-radius:8px;padding:10px 18px;font-size:16px;cursor:pointer;text-decoration:none}
|
||||
.btn:hover{background:var(--accent-dark);text-decoration:none}
|
||||
.btn.secondary{background:#eef2ef;color:var(--ink);border:1px solid var(--line)}
|
||||
.btn.secondary:hover{background:#e3e9e4}
|
||||
.btn.danger{background:#fff;color:var(--open);border:1px solid #e3b7b7}
|
||||
.btn.danger:hover{background:#fbeeee}
|
||||
.btn.small{padding:6px 12px;font-size:14px}
|
||||
.btn:disabled{opacity:.4;cursor:not-allowed}
|
||||
|
||||
input[type=text],input[type=date],input[type=number],select{
|
||||
font-size:16px;padding:9px 11px;border:1px solid var(--line);border-radius:8px;background:#fff;color:var(--ink)}
|
||||
input:focus,select:focus{outline:2px solid var(--accent-soft);border-color:var(--accent)}
|
||||
.search{min-width:280px}
|
||||
|
||||
/* overflow-anchor off: the grids swap windowed rows in and out while scrolling — the browser's scroll
|
||||
anchoring would fight that and make the list jump */
|
||||
.tablewrap{background:var(--panel);border:1px solid var(--line);border-radius:10px;overflow:auto;
|
||||
overflow-anchor:none}
|
||||
.grid-page .tablewrap{flex:1;min-height:0}
|
||||
table{border-collapse:collapse;width:100%;font-size:13px}
|
||||
thead th{position:sticky;top:0;z-index:1;background:var(--accent-soft);color:var(--accent-dark);text-align:left;
|
||||
padding:6px 9px;font-weight:600;white-space:nowrap;border-bottom:1px solid var(--line);
|
||||
cursor:pointer;user-select:none}
|
||||
/* headers carry full names; th-abbr (set by grids.js when the table overflows its container)
|
||||
abbreviates them with an ellipsis — the tooltip keeps the full name */
|
||||
table.th-abbr thead th{max-width:78px;overflow:hidden;text-overflow:ellipsis}
|
||||
/* pin toggle (Spalte fixieren): revealed on header hover at the LEFT edge — the sort arrows own the
|
||||
right edge. Grid headers reserve room for it so the glyph never covers the title. */
|
||||
table.grid thead th{padding-left:20px}
|
||||
thead th .pinbtn{position:absolute;left:2px;top:50%;transform:translateY(-50%);
|
||||
font-size:10px;line-height:1;padding:2px;cursor:pointer;opacity:0}
|
||||
thead th:hover .pinbtn{opacity:.45}
|
||||
thead th .pinbtn:hover,thead th.pinned .pinbtn{opacity:1}
|
||||
thead th:hover{background:#d7e6df}
|
||||
/* sort indicator, pinned to the header's right edge so an abbreviated title can't swallow it;
|
||||
with several sort levels (shift-click) the priority number follows the arrow */
|
||||
thead th[data-sd]{padding-right:24px}
|
||||
thead th[data-sd]::after{position:absolute;right:6px;font-size:10px}
|
||||
thead th[data-sd="a"]::after{content:"\25B2" attr(data-prio)}
|
||||
thead th[data-sd="d"]::after{content:"\25BC" attr(data-prio)}
|
||||
tbody td{padding:2px 9px;border-bottom:1px solid #eef1ee;white-space:nowrap}
|
||||
/* the ledger has 20 columns — give its cells less air so more of them fit on screen */
|
||||
table.grid tbody td{padding-left:7px;padding-right:7px}
|
||||
table.grid thead th{padding-left:14px;padding-right:7px}
|
||||
table.grid td.actions .btn.small{padding:4px 9px}
|
||||
/* a right-aligned number needs no room on its LEFT, and the pin glyph can't collide with it either —
|
||||
so Satz/Anz/Pers/Betrag and the checkbox column give that space back */
|
||||
table.grid thead th.num,table.grid tbody td.num{padding-left:4px}
|
||||
table.grid thead th:first-child,table.grid tbody td:first-child{padding-left:5px;padding-right:5px}
|
||||
tbody tr:nth-child(even){background:#fafcfb}
|
||||
tbody tr:hover{background:var(--accent-soft)}
|
||||
td.num,th.num{text-align:right;font-variant-numeric:tabular-nums}
|
||||
td.actions{text-align:right}
|
||||
.empty{padding:40px;text-align:center;color:var(--muted)}
|
||||
|
||||
.pill{display:inline-block;padding:2px 10px;border-radius:999px;font-size:13px;font-weight:600}
|
||||
.pill.paid{background:#e4f0ea;color:var(--paid)}
|
||||
.pill.open{background:#f7e3e3;color:var(--open)}
|
||||
.pill.cash{background:#f2eed9;color:var(--cash)}
|
||||
.pill.unit{background:#e9edf5;color:#3a4a6b}
|
||||
.pill.have{background:#eef1ef;color:#5a6b62}
|
||||
/* the whole text of a cut-off list cell, on hover (grids.js showTip) */
|
||||
.celltip{position:fixed;z-index:50;display:none;max-width:480px;padding:6px 11px;background:var(--panel);
|
||||
color:var(--ink);border:1px solid var(--line);border-radius:8px;box-shadow:0 4px 14px rgba(0,0,0,.15);
|
||||
font-size:15px;line-height:1.35;white-space:normal;pointer-events:none}
|
||||
|
||||
/* Umsatzsteuer-Voranmeldung: one table with its sum per room, the summary at the end */
|
||||
.vat-room h2,.vat-total h2{font-size:19px;margin:22px 0 6px}
|
||||
.vat-room tfoot td{padding:4px 9px;font-weight:700;border-top:2px solid var(--line)}
|
||||
.vat-total table{width:auto;min-width:420px;font-size:16px;background:var(--panel);
|
||||
border:1px solid var(--line);border-radius:10px}
|
||||
.vat-total td{padding:5px 14px;border-bottom:1px solid #eef1ee}
|
||||
.vat-total td.num{padding-left:40px}
|
||||
.vat-total tr.key td{font-weight:700}
|
||||
|
||||
/* home */
|
||||
.cards{display:grid;grid-template-columns:repeat(auto-fit,minmax(200px,1fr));gap:16px;margin:20px 0 30px}
|
||||
.card{background:var(--panel);border:1px solid var(--line);border-radius:12px;padding:20px 22px}
|
||||
.card .n{font-size:34px;font-weight:700;color:var(--accent-dark)}
|
||||
.card .l{color:var(--muted);margin-top:4px}
|
||||
.lead{font-size:18px;color:var(--muted);max-width:60ch}
|
||||
.quicklinks{display:flex;gap:14px;flex-wrap:wrap;margin-top:10px}
|
||||
|
||||
/* forms */
|
||||
form.card{max-width:720px;margin:0 auto}
|
||||
.field{display:flex;flex-direction:column;gap:6px;margin-bottom:16px}
|
||||
.field label{font-weight:600;font-size:15px}
|
||||
.grid2{display:grid;grid-template-columns:1fr 1fr;gap:16px}
|
||||
/* an invoice dated against the number order: the deliberate exception (invoice_form) */
|
||||
.confirm-order{display:flex;align-items:center;gap:8px;margin:2px 0 14px;padding:8px 12px;font-size:15px;
|
||||
background:#fff6e6;border:1px solid #e6c98a;border-radius:8px;color:#8a5a12}
|
||||
.client-choices{flex-direction:column;align-items:flex-start}
|
||||
.client-choices .btn.picked{background:var(--accent);color:#fff}
|
||||
/* payments in parts: one line of date + amount each (ledger_form) */
|
||||
.pay-line{display:grid;grid-template-columns:1fr 1fr;gap:16px;margin-bottom:8px}
|
||||
#addPayment{align-self:flex-start;margin-bottom:6px}
|
||||
.formactions{display:flex;gap:12px;margin-top:24px}
|
||||
|
||||
/* Belegungsplan: rooms × days timeline. The day columns are fractional (1fr), so the plan always
|
||||
fills the viewport width exactly — no horizontal scrolling at any display scaling (up to 200%).
|
||||
Only the room rows scroll vertically; the date header sticks. */
|
||||
.planwrap{overflow-y:auto;overflow-x:hidden}
|
||||
.plan{--labelw:112px;--laneh:26px;min-width:600px}
|
||||
.plan-hrow,.plan-row{display:flex}
|
||||
.plan-row{border-bottom:1px solid var(--line)}
|
||||
.plan-row.newfloor{border-top:2px solid var(--line)}
|
||||
.plan-label{flex:0 0 var(--labelw);padding:4px 10px;border-right:1px solid var(--line);min-width:0}
|
||||
.plan-cells{flex:1;display:grid;grid-template-columns:repeat(var(--days),1fr);min-width:0}
|
||||
.plan-head{position:sticky;top:0;z-index:3;background:var(--panel);box-shadow:0 2px 4px rgba(0,0,0,.07)}
|
||||
/* the month row closes with a full-width line */
|
||||
.plan-head .plan-hrow:first-child{border-bottom:1px solid var(--line)}
|
||||
.plan-head .plan-hrow + .plan-hrow .plan-label{border-bottom:1px solid var(--line)}
|
||||
.pm{font-size:12px;font-weight:600;color:var(--accent-dark);padding:3px 5px 1px 8px;
|
||||
border-left:1px solid #b9c4bb;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
|
||||
/* the label column already draws the divider — no doubled line on the first day column */
|
||||
.pm:first-child,.pd:first-child{border-left:none}
|
||||
.pd{border-left:1px solid #eef1ee;border-bottom:1px solid var(--line);text-align:center;
|
||||
padding:1px 0 3px;overflow:visible;min-width:0}
|
||||
.pd b{display:block;font-size:12px;line-height:1.25;white-space:nowrap}
|
||||
.pd span{display:block;font-size:10px;color:var(--muted);line-height:1.2}
|
||||
.pd.we{background:#f2f4f0}
|
||||
.pd.mon{border-left-color:var(--line)}
|
||||
/* weekend and today columns are bounded on BOTH sides ('x + y' colors the following cell's left
|
||||
border = this column's end line), at normal 1px line thickness. Precedence weekend < month <
|
||||
today is enforced by DOUBLED classes (today is often also .we — a Sunday — and the '+' selectors
|
||||
carry an extra element of specificity that a single class cannot beat). */
|
||||
.pd.we,.pd.we + .pd{border-left-color:#c9d2ca}
|
||||
.pd.m1.m1{border-left-color:#b9c4bb} /* month boundary: continuous from the month row down */
|
||||
.pd.today{background:#fff3d6}
|
||||
.pd.today.today,.pd.today.today + .pd{border-left-color:var(--warn)}
|
||||
.pd.today b{color:var(--warn)}
|
||||
.plan-lanes{grid-template-rows:repeat(var(--lanes),var(--laneh));position:relative;
|
||||
align-content:center}
|
||||
/* room rows carry only neutral day grid lines — the weekend/today accents live in the header
|
||||
alone. The lines are a full-height absolute overlay so they span the whole row even when the
|
||||
room label makes the row taller than the lane stack. */
|
||||
.daylines{position:absolute;inset:0;display:grid;grid-template-columns:repeat(var(--days),1fr);
|
||||
pointer-events:none}
|
||||
.daylines i{border-left:1px solid #f0f3ef}
|
||||
.daylines i.mon{border-left-color:var(--line)}
|
||||
.daylines i.we,.daylines i.we + i{border-left-color:#c9d2ca}
|
||||
.daylines i.m1.m1{border-left-color:#b9c4bb}
|
||||
.daylines i.today.today,.daylines i.today.today + i{border-left-color:var(--warn)}
|
||||
.daylines i:first-child{border-left:none}
|
||||
/* weekend/today column tints, same hues as the header */
|
||||
.daylines i.we{background:#f2f4f0}
|
||||
.daylines i.today{background:#fff3d6}
|
||||
.plan-lanes.longterm{background:repeating-linear-gradient(45deg,#f3f5f2 0 8px,#eaeeea 8px 16px)}
|
||||
/* ALL grid lines (month boundaries included) sit BEHIND cell content — the opaque
|
||||
Dauervermietung filling covers its whole row, so these rows draw no lines or tints */
|
||||
.plan-lanes.longterm .daylines{display:none}
|
||||
.lt-note{grid-column:1/-1;grid-row:1/-1;align-self:center;padding:0 10px;color:var(--muted);
|
||||
font-size:12px;z-index:1;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
|
||||
.rl-top{display:flex;align-items:center;gap:6px;white-space:nowrap}
|
||||
.rl-top b{font-size:14px}
|
||||
.rl-top .pill{font-size:11px;padding:1px 7px}
|
||||
.rl-sub{font-size:11px;color:var(--muted);white-space:nowrap}
|
||||
.bar{z-index:1;margin:2px 1px;border-radius:5px;font-size:12.5px;line-height:calc(var(--laneh) - 5px);
|
||||
padding:0 7px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;min-width:0;color:#fff}
|
||||
.bar:hover{text-decoration:none;filter:brightness(1.08)}
|
||||
.bar.current{background:var(--accent)}
|
||||
.bar.past{background:#b9c9c0;color:#2c3a32}
|
||||
.bar.future{background:var(--accent-soft);color:var(--accent-dark);box-shadow:inset 0 0 0 1px var(--accent)}
|
||||
/* a stay running past the window edge (or an open departure) gets a pointed end */
|
||||
.bar.clipend{clip-path:polygon(0 0,calc(100% - 8px) 0,100% 50%,calc(100% - 8px) 100%,0 100%);padding-right:13px}
|
||||
.bar.clipstart{clip-path:polygon(8px 0,100% 0,100% 100%,8px 100%,0 50%);padding-left:13px}
|
||||
.bar.clipstart.clipend{clip-path:polygon(8px 0,calc(100% - 8px) 0,100% 50%,calc(100% - 8px) 100%,8px 100%,0 50%)}
|
||||
.plan-foot{flex-shrink:0;display:flex;gap:16px;align-items:center;flex-wrap:wrap;
|
||||
padding:8px 2px 0;color:var(--muted);font-size:13px}
|
||||
.plan-foot .key{display:inline-block;width:14px;height:14px;border-radius:4px;
|
||||
vertical-align:-2px;margin-right:5px}
|
||||
.plan-unplaced a{margin-left:2px}
|
||||
.seg{display:inline-flex;border:1px solid var(--line);border-radius:8px;overflow:hidden;background:#fff}
|
||||
.seg a{padding:7px 13px;font-size:14px;color:var(--ink)}
|
||||
.seg a:hover{background:#eef2ef;text-decoration:none}
|
||||
.seg a.on{background:var(--accent);color:#fff}
|
||||
|
||||
/* at 150–200% display scaling (≈960–1280 CSS px on a 24" monitor) the chrome steps down so no page
|
||||
ever grows a horizontal scrollbar */
|
||||
@media (max-width:1280px){
|
||||
.topbar{gap:16px;padding:0 14px}
|
||||
.topbar .brand{font-size:17px}
|
||||
.topbar nav a{padding:0 11px;font-size:15px}
|
||||
}
|
||||
@media (max-width:1020px){
|
||||
.topbar .brand{display:none}
|
||||
main{padding-left:14px;padding-right:14px}
|
||||
main.grid-page{padding:10px 12px}
|
||||
}
|
||||
|
||||
/* printing an app page (e.g. the TVB Meldung for the folder): the app shell scrolls inside a fixed
|
||||
viewport, which would clip everything to one page — undo that and drop chrome/warnings.
|
||||
MUST stay at the END of the file: several rules here tie on specificity with base rules above
|
||||
(tbody td, body) and win only by source order. */
|
||||
.printinfo{display:none}
|
||||
|
||||
/* Print view. Two roads lead here: Ctrl+P (media print) and the Drucken button, which measures the page
|
||||
BEFORE printing to shrink it onto one sheet — for that it must already look on screen as it will on
|
||||
paper, or the toolbar is measured too. Hence the same rules under body.printing (measuring) and
|
||||
@media print (paper). */
|
||||
body.printing .topbar,body.printing .toolbar,body.printing .flash{display:none}
|
||||
body.printing{background:#fff;height:auto;overflow:visible;display:block}
|
||||
body.printing main{overflow:visible;height:auto;max-width:none;padding:0;background:#fff}
|
||||
body.printing .tablewrap{max-height:none;overflow:visible;box-shadow:none;border:0;background:#fff}
|
||||
body.printing .printinfo{display:block;margin:0 0 8px;font-size:12px;color:#333}
|
||||
body.printing .grid th,body.printing .grid td{position:static}
|
||||
body.printing tbody td{white-space:normal}
|
||||
/* Umsatzsteuer list: only the name may wrap — otherwise every cell breaks on paper ("2026-/49") */
|
||||
.vat-room td,body.printing .vat-room td{white-space:nowrap}
|
||||
.vat-room tbody td:nth-child(2),body.printing .vat-room tbody td:nth-child(2){white-space:normal}
|
||||
/* On screen the columns are frozen to fixed pixel widths (freezeLayout, flex rows). On paper the table
|
||||
must be a real table again: then the columns shrink to their content and long texts wrap instead of
|
||||
pushing the list past the sheet's edge. */
|
||||
body.printing .grid,body.printing .grid thead,body.printing .grid tbody,body.printing .grid tr,
|
||||
body.printing .grid th,body.printing .grid td{display:revert!important;min-width:0!important}
|
||||
body.printing .grid{width:100%!important;table-layout:auto!important;border-collapse:collapse}
|
||||
body.printing .grid thead{display:table-header-group!important;position:static!important}
|
||||
body.printing .grid th,body.printing .grid td{width:auto!important;max-width:none!important;
|
||||
overflow:visible!important;text-overflow:clip!important;white-space:normal!important;
|
||||
padding:2px 5px!important;font-size:12px!important;line-height:1.25!important}
|
||||
body.printing .grid tr{break-inside:avoid}
|
||||
body.printing .grid td.num,body.printing .grid .nowrap{white-space:nowrap!important}
|
||||
/* after the display:revert rules, or revert wins over the hiding */
|
||||
body.printing .grid .selcol,body.printing .grid .actions,body.printing .pinbtn{display:none!important}
|
||||
body.printing .grid .pill{border:0;padding:0;background:none!important;color:inherit!important;font-weight:600}
|
||||
|
||||
@media print {
|
||||
/* Landscape for the wide lists (the named page "list"); everything else portrait — the TVB report sets
|
||||
its landscape itself (tvb.html). Printed duplex in landscape, the printer must flip "on the short
|
||||
edge", or the back side stands on its head — no page can set that itself. */
|
||||
@page{size:A4 portrait;margin:10mm}
|
||||
@page list{size:A4 landscape;margin:10mm}
|
||||
main.grid-page{page:list}
|
||||
body{display:block;height:auto;overflow:visible;background:#fff}
|
||||
.topbar,.noprint,.flash,.toolbar,.celltip{display:none!important}
|
||||
main{overflow:visible;height:auto;max-width:none;padding:0;background:#fff}
|
||||
.tablewrap{max-height:none!important;overflow:visible;box-shadow:none;border:0;background:#fff}
|
||||
tbody td{white-space:normal} /* long Hinweis texts wrap instead of running off the sheet */
|
||||
td.note div{max-width:240px} /* cells ignore max-width in auto layout — cap the inner block */
|
||||
.tvb-unit + .tvb-unit{page-break-before:always}
|
||||
.tvb-summary,.tvb-summary .tablewrap{break-inside:avoid} /* Feratel tables never split over pages */
|
||||
.tvb-feratel{break-inside:avoid} /* ... and their heading and total travel WITH them */
|
||||
.vat-room,.vat-total{break-inside:avoid} /* a room with its sum on one sheet */
|
||||
/* the list on paper: checkboxes, the edit button and the pin say nothing there, pinned columns must
|
||||
not stick over the others, the header row repeats on every sheet */
|
||||
.grid .selcol,.grid .actions,.pinbtn{display:none}
|
||||
.grid th,.grid td{position:static!important}
|
||||
.grid thead{display:table-header-group}
|
||||
.grid tr{break-inside:avoid}
|
||||
.printinfo{display:block;margin:0 0 8px;font-size:12px;color:#333}
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user