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
Whitespace-only changes.
+116
@@ -0,0 +1,116 @@
|
||||
"""The audit log (migration 002): every row a data table gains, loses or changes, with who wrote it — and
|
||||
undoing chosen entries.
|
||||
|
||||
python -m app.audit [--table T] [--row ID] [--actor A] [--tx TX] [--limit N] newest entries first
|
||||
python -m app.audit revert (ENTRY... | --tx TX) [--apply] a dry run without --apply
|
||||
"""
|
||||
import argparse
|
||||
import json
|
||||
|
||||
from app import db
|
||||
|
||||
KEYS = {"company_key": ("key",), "dismissed_finding": ("kind", "key")} # every other table: id
|
||||
|
||||
|
||||
class Conflict(Exception):
|
||||
"""The row no longer looks as the entry left it: someone changed it since."""
|
||||
|
||||
|
||||
def entries(conn, table=None, row_id=None, actor=None, tx=None, ids=None, limit=50):
|
||||
"""Log entries, newest first."""
|
||||
where, params = [], []
|
||||
for sql, value in (("table_name = ?", table), ("row_id = ?", row_id), ("actor = ?", actor), ("tx = ?", tx)):
|
||||
if value is not None:
|
||||
where.append(sql)
|
||||
params.append(value)
|
||||
if ids:
|
||||
where.append("id = ANY(?)")
|
||||
params.append(list(ids))
|
||||
sql = "SELECT * FROM audit_log" + (" WHERE " + " AND ".join(where) if where else "")
|
||||
return [dict(r) for r in db.rows(conn, sql + " ORDER BY id DESC LIMIT ?", params + [limit])]
|
||||
|
||||
|
||||
def _where(table, row):
|
||||
cols = KEYS.get(table, ("id",))
|
||||
return " AND ".join(f'"{c}" = ?' for c in cols), [row[c] for c in cols]
|
||||
|
||||
|
||||
def revert(conn, chosen):
|
||||
"""Undo the chosen entries newest first, in the caller's transaction (commit or roll back after). Each
|
||||
step first checks that the row still looks exactly as the entry left it — a later change by anyone else
|
||||
raises Conflict, so the caller rolls everything back. Returns one line per step."""
|
||||
conn.execute("SET CONSTRAINTS ALL DEFERRED") # parents and children come back in any order
|
||||
steps = []
|
||||
for e in sorted(chosen, key=lambda e: e["id"], reverse=True):
|
||||
table, op = e["table_name"], e["op"]
|
||||
if table not in db.DATA_TABLES or op == "TRUNCATE":
|
||||
raise Conflict(f"#{e['id']}: {op} on {table} cannot be undone row by row — restore a snapshot")
|
||||
where, key = _where(table, e["new"] or e["old"])
|
||||
now = db.row(conn, f"SELECT to_jsonb(x) FROM {table} x WHERE {where}", key)
|
||||
if (now[0] if now else None) != e["new"]:
|
||||
raise Conflict(f"#{e['id']} {table} {key}: changed since — now {now[0] if now else 'gone'}")
|
||||
if op == "INSERT":
|
||||
conn.execute(f"DELETE FROM {table} WHERE {where}", key)
|
||||
elif op == "DELETE":
|
||||
conn.execute(f"INSERT INTO {table} SELECT * FROM jsonb_populate_record(NULL::{table}, "
|
||||
f"(SELECT old FROM audit_log WHERE id = ?))", (e["id"],))
|
||||
else:
|
||||
cols = ", ".join(f'"{c}"' for c in e["old"] if e["old"][c] != e["new"].get(c))
|
||||
conn.execute(f"UPDATE {table} SET ({cols}) = (SELECT {cols} FROM jsonb_populate_record("
|
||||
f"NULL::{table}, (SELECT old FROM audit_log WHERE id = ?))) WHERE {where}",
|
||||
[e["id"]] + key)
|
||||
steps.append(f"#{e['id']} {op} {table} {key} undone")
|
||||
return steps
|
||||
|
||||
|
||||
def describe(e):
|
||||
if e["op"] == "UPDATE":
|
||||
what = "; ".join(f"{c}: {e['old'][c]!r} -> {e['new'].get(c)!r}"
|
||||
for c in e["old"] if e["old"][c] != e["new"].get(c))
|
||||
else:
|
||||
what = json.dumps(e["new"] or e["old"], ensure_ascii=False)
|
||||
return (f"#{e['id']} {e['at']:%Y-%m-%d %H:%M:%S} tx {e['tx']} {e['actor']} ({e['db_user']}) "
|
||||
f"{e['op']} {e['table_name']} {e['row_id'] or ''} {what[:300]}")
|
||||
|
||||
|
||||
def main():
|
||||
p = argparse.ArgumentParser(prog="python -m app.audit")
|
||||
p.add_argument("command", nargs="?", choices=["list", "revert"], default="list")
|
||||
p.add_argument("ids", nargs="*", type=int, help="entries to revert")
|
||||
p.add_argument("--table")
|
||||
p.add_argument("--row", type=int)
|
||||
p.add_argument("--actor")
|
||||
p.add_argument("--tx", type=int)
|
||||
p.add_argument("--limit", type=int, default=50)
|
||||
p.add_argument("--apply", action="store_true", help="write the revert (default: dry run)")
|
||||
a = p.parse_args()
|
||||
if a.command == "list":
|
||||
for e in entries(db.connect(), a.table, a.row, a.actor, a.tx, limit=a.limit):
|
||||
print(describe(e))
|
||||
return
|
||||
if not (a.ids or a.tx):
|
||||
p.error("revert needs entry ids or --tx")
|
||||
if a.apply:
|
||||
with db.connect(owner=True) as owner:
|
||||
print("Snapshot saved:", db.snapshot(owner, "before revert"))
|
||||
label = f"revert {'tx ' + str(a.tx) if a.tx else ' '.join(f'#{i}' for i in a.ids)}"
|
||||
conn = db.connect(actor=label)
|
||||
chosen = entries(conn, tx=a.tx, ids=a.ids, limit=100000)
|
||||
if not chosen:
|
||||
raise SystemExit("no such entries")
|
||||
try:
|
||||
for step in revert(conn, chosen):
|
||||
print(step)
|
||||
except Conflict as c:
|
||||
conn.rollback()
|
||||
raise SystemExit(f"NOTHING written — {c}")
|
||||
if a.apply:
|
||||
conn.commit()
|
||||
print(f"reverted {len(chosen)} entries (logged as '{label}')")
|
||||
else:
|
||||
conn.rollback()
|
||||
print("dry run — nothing written; add --apply")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
+167
@@ -0,0 +1,167 @@
|
||||
"""Money rules for invoice lines and totals, and the stay rules behind them — the single source for every
|
||||
page and report.
|
||||
|
||||
A line's net is `quantity * gross_per_unit / (1 + vat_rate)` at full precision, unless an explicit
|
||||
`line_net_override` pins it. The standard accommodation rate is 10%. A storno line (kind='storno') is *nicht
|
||||
steuerbar* (vat_rate 0): a cancellation fee is damages, not a supply, so it carries no VAT and is kept OUT of
|
||||
the taxable Nettobetrag — it is a separate subtotal on the invoice. Totals sum the full-precision line values
|
||||
and round once.
|
||||
"""
|
||||
from decimal import Decimal, ROUND_HALF_UP
|
||||
|
||||
VAT_STANDARD = Decimal("0.10") # Beherbergung reduced rate
|
||||
|
||||
|
||||
def _d(x):
|
||||
return Decimal(str(x))
|
||||
|
||||
|
||||
def _round2(value):
|
||||
return float(Decimal(value).quantize(Decimal("0.01"), rounding=ROUND_HALF_UP))
|
||||
|
||||
|
||||
def _rate(vat_rate):
|
||||
return VAT_STANDARD if vat_rate is None else _d(vat_rate)
|
||||
|
||||
|
||||
def _net_exact(quantity, gross_per_unit, line_net_override=None, vat_rate=None):
|
||||
if line_net_override is not None:
|
||||
return _d(line_net_override)
|
||||
return _d(quantity) * _d(gross_per_unit) / (Decimal(1) + _rate(vat_rate))
|
||||
|
||||
|
||||
# --- per-line display values ---
|
||||
def net_per_unit(gross_per_unit, vat_rate=None):
|
||||
return _round2(_d(gross_per_unit) / (Decimal(1) + _rate(vat_rate)))
|
||||
|
||||
|
||||
def line_net(quantity, gross_per_unit, line_net_override=None, vat_rate=None):
|
||||
return _round2(_net_exact(quantity, gross_per_unit, line_net_override, vat_rate))
|
||||
|
||||
|
||||
def line_vat(quantity, gross_per_unit, line_net_override=None, vat_rate=None):
|
||||
return _round2(_net_exact(quantity, gross_per_unit, line_net_override, vat_rate) * _rate(vat_rate))
|
||||
|
||||
|
||||
def line_gross(quantity, gross_per_unit, line_net_override=None, vat_rate=None):
|
||||
return _round2(_net_exact(quantity, gross_per_unit, line_net_override, vat_rate) * (Decimal(1) + _rate(vat_rate)))
|
||||
|
||||
|
||||
# --- invoice totals over charge dicts (keys: quantity, gross_per_unit, line_net_override?, vat_rate?, kind?) ---
|
||||
def _is_storno(c):
|
||||
return c.get("kind") == "storno"
|
||||
|
||||
|
||||
def _net_exact_c(c):
|
||||
return _net_exact(c["quantity"], c["gross_per_unit"], c.get("line_net_override"), c.get("vat_rate"))
|
||||
|
||||
|
||||
def _storno_amount(c):
|
||||
# nicht steuerbar: the whole amount, no VAT split (rate forced to 0)
|
||||
return _net_exact(c["quantity"], c["gross_per_unit"], c.get("line_net_override"), 0)
|
||||
|
||||
|
||||
def invoice_net(charges):
|
||||
"""Steuerbarer Nettobetrag — taxable lines only (storno is nicht steuerbar and excluded from the base)."""
|
||||
return _round2(sum((_net_exact_c(c) for c in charges if not _is_storno(c)), Decimal("0")))
|
||||
|
||||
|
||||
def invoice_vat(charges):
|
||||
return _round2(sum((_net_exact_c(c) * _rate(c.get("vat_rate")) for c in charges if not _is_storno(c)), Decimal("0")))
|
||||
|
||||
|
||||
def invoice_vat_breakdown(charges):
|
||||
"""Per-rate (net, vat) for the taxable lines, so a mixed-rate invoice (e.g. a Betriebskosten settlement with
|
||||
0/10/20 %) can print one 'USt X %' row per rate. A plain 10 % invoice yields a single row, unchanged.
|
||||
0 % lines stay in the Nettobetrag but get no row — 'USt 0 % 0,00' must never be printed (§11 Abs 12)."""
|
||||
buckets = {}
|
||||
for c in charges:
|
||||
if _is_storno(c):
|
||||
continue
|
||||
rate = _rate(c.get("vat_rate"))
|
||||
b = buckets.setdefault(rate, [Decimal("0"), Decimal("0")])
|
||||
net = _net_exact_c(c)
|
||||
b[0] += net
|
||||
b[1] += net * rate
|
||||
out = []
|
||||
for rate, (net, vat) in sorted(buckets.items()):
|
||||
if not rate:
|
||||
continue
|
||||
p = float(rate) * 100
|
||||
pct = str(int(round(p))) if abs(p - round(p)) < 0.005 else f"{p:.2f}".replace(".", ",")
|
||||
out.append({"rate": float(rate), "pct": pct, "net": _round2(net), "vat": _round2(vat)})
|
||||
return out
|
||||
|
||||
|
||||
def invoice_storno(charges):
|
||||
"""Sum of the nicht-steuerbare Storno lines (kept out of the VAT base, shown as its own subtotal)."""
|
||||
return _round2(sum((_storno_amount(c) for c in charges if _is_storno(c)), Decimal("0")))
|
||||
|
||||
|
||||
def invoice_gross(charges, tourism_tax=0):
|
||||
# Rounds the exact gross sum ONCE: the Rechnungsbetrag is the summed gross column, even where net + USt,
|
||||
# each rounded on its own, differ from it by a cent.
|
||||
taxable = sum((_net_exact_c(c) * (Decimal(1) + _rate(c.get("vat_rate"))) for c in charges if not _is_storno(c)),
|
||||
Decimal("0"))
|
||||
storno = sum((_storno_amount(c) for c in charges if _is_storno(c)), Decimal("0"))
|
||||
return _round2(taxable + storno + _d(tourism_tax))
|
||||
|
||||
|
||||
def days_between(arrival, departure):
|
||||
"""Whole days between two ISO dates (`bis - von`): the nights of a stay."""
|
||||
from datetime import date
|
||||
if not arrival or not departure:
|
||||
return None
|
||||
a = date.fromisoformat(arrival)
|
||||
d = date.fromisoformat(departure)
|
||||
return (d - a).days
|
||||
|
||||
|
||||
def weeks_for_period(period_from, period_to):
|
||||
"""The week rule: a stay of >= 4 nights is billed in WHOLE weeks, rounded up (5 nights -> 1 week,
|
||||
10 nights -> 2). Shorter stays are not a week (billed per day/night) -> None."""
|
||||
import math
|
||||
nights = days_between(period_from, period_to)
|
||||
if nights is None or nights < 4:
|
||||
return None
|
||||
return math.ceil(nights / 7)
|
||||
|
||||
|
||||
# Aufenthaltsabgabe (Ortstaxe) per person per night, by the date it applies from; newest first
|
||||
TOURISM_RATES = (("2026-05-01", 4.0), ("", 3.0))
|
||||
TOURISM_EXEMPT_OVER_NIGHTS = 10 # professional stays longer than this are exempt (T-AAG §4)
|
||||
|
||||
|
||||
def tourism_rate(day):
|
||||
"""The Aufenthaltsabgabe per person and night in force on an ISO date."""
|
||||
return next(r for start, r in TOURISM_RATES if day >= start)
|
||||
|
||||
|
||||
def tourism_liable(arrival, departure):
|
||||
"""Does this stay owe the Aufenthaltsabgabe? (the guest sheet's 'Abg' column). >10 consecutive nights
|
||||
on a professional stay are exempt (T-AAG §4) — the same rule the TVB filing counts by. None while the
|
||||
departure is still open: nobody can know yet."""
|
||||
nights = days_between(arrival, departure)
|
||||
if nights is None:
|
||||
return None
|
||||
return 1 if nights <= TOURISM_EXEMPT_OVER_NIGHTS else 0
|
||||
|
||||
|
||||
def tourism_amount(period_from, period_to, total_nights=None):
|
||||
"""Ortstaxe for one invoice line: 0 when the guest's WHOLE continuous stay exceeds 10 nights (T-AAG §4
|
||||
professional-stay exemption), otherwise this line's nights x the rate at the arrival date. Pass
|
||||
`total_nights` = the continuous stay length so a slice billed in a later month stays exempt even though
|
||||
the slice itself is short; it defaults to this line's own nights when the full length isn't known."""
|
||||
line_nights = days_between(period_from, period_to)
|
||||
if not line_nights or line_nights <= 0:
|
||||
return 0
|
||||
stay_nights = total_nights if total_nights is not None else line_nights
|
||||
if stay_nights > TOURISM_EXEMPT_OVER_NIGHTS:
|
||||
return 0
|
||||
return round(line_nights * tourism_rate(period_from), 2)
|
||||
|
||||
|
||||
def tourism_for_period(period_from, period_to):
|
||||
"""Ortstaxe for a self-contained stay: the exemption is judged on this same period (see tourism_amount
|
||||
for the continuation case where the stay started in an earlier, separately billed month)."""
|
||||
return tourism_amount(period_from, period_to, days_between(period_from, period_to))
|
||||
@@ -0,0 +1,23 @@
|
||||
"""Settings that must not live in git — database URLs. Read from the environment first, else from the
|
||||
git-ignored `.env` at the repo root (KEY=VALUE lines, # comments)."""
|
||||
import os
|
||||
from functools import lru_cache
|
||||
from pathlib import Path
|
||||
|
||||
ENV_FILE = Path(__file__).resolve().parents[1] / ".env"
|
||||
|
||||
|
||||
@lru_cache(maxsize=1)
|
||||
def _values():
|
||||
values = {}
|
||||
if ENV_FILE.exists():
|
||||
for line in ENV_FILE.read_text(encoding="utf-8").splitlines():
|
||||
line = line.strip()
|
||||
if line and not line.startswith("#") and "=" in line:
|
||||
key, value = line.split("=", 1)
|
||||
values[key.strip()] = value.strip()
|
||||
return values
|
||||
|
||||
|
||||
def get(key, default=None):
|
||||
return os.environ.get(key) or _values().get(key, default)
|
||||
@@ -0,0 +1,277 @@
|
||||
"""The only module that opens the database (PostgreSQL) and applies schema migrations.
|
||||
|
||||
SQL in this code base uses `?` placeholders (DB-API qmark style); `_translate` turns each statement into
|
||||
psycopg's format once. Rows behave like sqlite3.Row (by name, by index, dict(row)), an INSERT reports
|
||||
`lastrowid`, and ints are sent untyped so Postgres reads them as the column needs. Two roles: the app's role
|
||||
reads and writes data only; the owner role applies migrations — `python -m app.db migrate` — and takes
|
||||
snapshots before risky writes. With a single role, DATABASE_URL serves both.
|
||||
"""
|
||||
import re
|
||||
import sys
|
||||
from datetime import date, datetime
|
||||
from decimal import Decimal
|
||||
from functools import lru_cache
|
||||
from pathlib import Path
|
||||
|
||||
import psycopg
|
||||
from psycopg.adapt import Dumper, Loader
|
||||
from psycopg.conninfo import conninfo_to_dict
|
||||
from psycopg.sql import Identifier
|
||||
|
||||
from app import config
|
||||
|
||||
SYSTEM_DIR = Path(__file__).resolve().parent.parent
|
||||
MIGRATIONS_DIR = SYSTEM_DIR / "migrations"
|
||||
URL = config.get("DATABASE_URL") # the app's role: data only
|
||||
OWNER_URL = config.get("DATABASE_OWNER_URL") or URL # migrations, snapshots
|
||||
SCHEMA = "public" # the tests give every test a schema of its own
|
||||
TIMEZONE = "Europe/Vienna"
|
||||
APP_ROLE = conninfo_to_dict(URL)["user"] if URL else None
|
||||
DATA_TABLES = ("unit", "room", "company", "client", "company_key", "dismissed_finding", "guest", "invoice",
|
||||
"stay", "charge", "payment")
|
||||
TABLES_WITH_ID = {"unit", "room", "company", "client", "guest", "invoice", "stay", "charge", "payment"}
|
||||
IntegrityError = psycopg.IntegrityError
|
||||
INSERT = re.compile(r"\s*INSERT\s+INTO\s+(\w+)", re.I)
|
||||
|
||||
|
||||
class Row:
|
||||
"""A result row like sqlite3.Row: row["name"] (any case), row[0], dict(row), iteration over the values."""
|
||||
__slots__ = ("_index", "_values")
|
||||
|
||||
def __init__(self, index, values):
|
||||
self._index, self._values = index, values
|
||||
|
||||
def keys(self):
|
||||
return [k for k in self._index if not k.startswith("\0")]
|
||||
|
||||
def __getitem__(self, key):
|
||||
if isinstance(key, (int, slice)):
|
||||
return self._values[key]
|
||||
try:
|
||||
return self._values[self._index[key]]
|
||||
except KeyError:
|
||||
return self._values[self._index["\0" + key.lower()]]
|
||||
|
||||
def __iter__(self):
|
||||
return iter(self._values)
|
||||
|
||||
def __len__(self):
|
||||
return len(self._values)
|
||||
|
||||
def __eq__(self, other):
|
||||
return isinstance(other, Row) and self.keys() == other.keys() and self._values == other._values
|
||||
|
||||
def __hash__(self):
|
||||
return hash(tuple(self._values))
|
||||
|
||||
def __repr__(self):
|
||||
return f"Row({dict(self)!r})"
|
||||
|
||||
|
||||
def _row_factory(cursor):
|
||||
names = [c.name for c in cursor.description or ()]
|
||||
index = {n: i for i, n in enumerate(names)}
|
||||
index.update({"\0" + n.lower(): i for i, n in enumerate(names)}) # case-insensitive, like sqlite3.Row
|
||||
return lambda values: Row(index, values)
|
||||
|
||||
|
||||
class _Untyped(Dumper):
|
||||
"""Python ints and bools go to the server as untyped literals (bools as 1/0, the integer flag columns):
|
||||
Postgres then reads `'22'` as the text room number or the integer id, whichever the context needs."""
|
||||
oid = 0
|
||||
|
||||
def dump(self, obj):
|
||||
return str(int(obj)).encode()
|
||||
|
||||
|
||||
class _IsoText(Dumper):
|
||||
"""Dates go as untyped ISO text: the date columns are text, and Postgres has no `text >= date`
|
||||
operator."""
|
||||
oid = 0
|
||||
|
||||
def dump(self, obj):
|
||||
return (obj.isoformat(" ") if isinstance(obj, datetime) else obj.isoformat()).encode()
|
||||
|
||||
|
||||
class _Numeric(Loader):
|
||||
"""numeric -> int when integral, else float (charge.persons: 2, or 1.5)."""
|
||||
|
||||
def load(self, data):
|
||||
d = Decimal(bytes(data).decode())
|
||||
return int(d) if d == d.to_integral_value() else float(d)
|
||||
|
||||
|
||||
@lru_cache(maxsize=None)
|
||||
def _translate(sql):
|
||||
"""`?` -> `%s` and `%` -> `%%`. psycopg reads `%` everywhere — inside string literals and comments too —
|
||||
so every `%` is doubled; a `?` inside a literal or comment stays a question mark."""
|
||||
out, i, n = [], 0, len(sql)
|
||||
while i < n:
|
||||
ch = sql[i]
|
||||
if ch in "'\"":
|
||||
end = sql.find(ch, i + 1)
|
||||
end = n - 1 if end < 0 else end
|
||||
out.append(sql[i:end + 1].replace("%", "%%"))
|
||||
i = end + 1
|
||||
continue
|
||||
if sql.startswith("--", i):
|
||||
end = sql.find("\n", i)
|
||||
end = n if end < 0 else end
|
||||
out.append(sql[i:end].replace("%", "%%"))
|
||||
i = end
|
||||
continue
|
||||
out.append("%s" if ch == "?" else "%%" if ch == "%" else ch)
|
||||
i += 1
|
||||
return "".join(out)
|
||||
|
||||
|
||||
class Cursor:
|
||||
"""A cursor as sqlite3 offers one: fetchone/fetchall/iteration, lastrowid after an INSERT, rowcount."""
|
||||
|
||||
def __init__(self, cur, returning):
|
||||
self._cur, self.lastrowid, self.rowcount = cur, None, cur.rowcount
|
||||
self._done = cur.description is None
|
||||
if returning:
|
||||
ids = cur.fetchall()
|
||||
self.lastrowid = ids[-1][0] if ids else None
|
||||
self._done = True
|
||||
|
||||
def fetchone(self):
|
||||
return None if self._done else self._cur.fetchone()
|
||||
|
||||
def fetchall(self):
|
||||
return [] if self._done else self._cur.fetchall()
|
||||
|
||||
def __iter__(self):
|
||||
return iter(self.fetchall())
|
||||
|
||||
|
||||
class Connection:
|
||||
"""A psycopg connection with the sqlite3 interface this code is written against. `with conn:` commits
|
||||
or rolls back and keeps the connection open, as sqlite3 does; `close()` closes it."""
|
||||
|
||||
def __init__(self, raw):
|
||||
self.raw = raw
|
||||
|
||||
def execute(self, sql, params=()):
|
||||
m = INSERT.match(sql)
|
||||
returning = bool(m and m.group(1).lower() in TABLES_WITH_ID and not re.search(r"\bRETURNING\b", sql, re.I))
|
||||
if returning:
|
||||
sql = sql.rstrip().rstrip(";") + " RETURNING id"
|
||||
return Cursor(self.raw.execute(_translate(sql), tuple(params)), returning)
|
||||
|
||||
def executescript(self, sql):
|
||||
self.raw.execute(sql) # no parameters: Postgres takes several statements at once
|
||||
|
||||
def commit(self):
|
||||
self.raw.commit()
|
||||
|
||||
def rollback(self):
|
||||
self.raw.rollback()
|
||||
|
||||
def close(self):
|
||||
self.raw.close()
|
||||
|
||||
def __enter__(self):
|
||||
return self
|
||||
|
||||
def __exit__(self, kind, exc, tb):
|
||||
self.raw.rollback() if kind else self.raw.commit()
|
||||
|
||||
|
||||
def connect(owner=False, actor="cli"):
|
||||
"""A connection as the app's role — or as the owner, for migrations, snapshots and maintenance. `actor`
|
||||
names whoever writes through it in the audit log (002): `web`, `cli`, ..."""
|
||||
url = OWNER_URL if owner else URL
|
||||
if not url:
|
||||
raise RuntimeError("no database URL: set DATABASE_URL (environment or .env)")
|
||||
actor = actor.replace("\\", "\\\\").replace(" ", "\\ ") # libpq options split at unescaped spaces
|
||||
raw = psycopg.connect(url, options=f"-c search_path={SCHEMA} -c TimeZone={TIMEZONE} -c app.actor={actor}",
|
||||
application_name="vermietungs-tool", row_factory=_row_factory)
|
||||
raw.adapters.register_dumper(int, _Untyped)
|
||||
raw.adapters.register_dumper(bool, _Untyped)
|
||||
raw.adapters.register_dumper(date, _IsoText)
|
||||
raw.adapters.register_dumper(datetime, _IsoText)
|
||||
raw.adapters.register_loader("numeric", _Numeric)
|
||||
return Connection(raw)
|
||||
|
||||
|
||||
def _migrations():
|
||||
return {int(p.name[:3]): p for p in sorted(MIGRATIONS_DIR.glob("[0-9][0-9][0-9]_*.sql"))}
|
||||
|
||||
|
||||
def pending_migrations(conn):
|
||||
exists = conn.execute("SELECT to_regclass('schema_version') IS NOT NULL").fetchone()[0]
|
||||
applied = {r[0] for r in conn.execute("SELECT version FROM schema_version")} if exists else set()
|
||||
return sorted(v for v in _migrations() if v not in applied)
|
||||
|
||||
|
||||
def migrate(conn):
|
||||
"""Apply every migration not yet recorded in schema_version, each in its own transaction (DDL is
|
||||
transactional in Postgres), then grant the app's role its data rights. Needs the owner role."""
|
||||
conn.execute("CREATE TABLE IF NOT EXISTS schema_version (version integer PRIMARY KEY, applied_at text "
|
||||
"COLLATE \"C\" DEFAULT to_char(now(), 'YYYY-MM-DD HH24:MI:SS'))")
|
||||
conn.commit()
|
||||
for version in pending_migrations(conn):
|
||||
conn.executescript(_migrations()[version].read_text(encoding="utf-8"))
|
||||
conn.execute("INSERT INTO schema_version (version) VALUES (?)", (version,))
|
||||
conn.commit()
|
||||
_grant(conn)
|
||||
|
||||
|
||||
def _grant(conn):
|
||||
"""The app's role may read and write the data tables — nothing else: no DDL, no TRUNCATE, and it cannot
|
||||
rewrite the schema history or the audit log (its trigger writes as the owner)."""
|
||||
me = conn.execute("SELECT current_user").fetchone()[0]
|
||||
if not APP_ROLE or APP_ROLE == me:
|
||||
return
|
||||
schema, role = Identifier(SCHEMA).as_string(conn.raw), Identifier(APP_ROLE).as_string(conn.raw)
|
||||
conn.executescript(f"GRANT USAGE ON SCHEMA {schema} TO {role};"
|
||||
f"GRANT SELECT, INSERT, UPDATE, DELETE ON ALL TABLES IN SCHEMA {schema} TO {role};"
|
||||
f"GRANT USAGE, SELECT, UPDATE ON ALL SEQUENCES IN SCHEMA {schema} TO {role};"
|
||||
f"REVOKE INSERT, UPDATE, DELETE ON {schema}.schema_version, {schema}.audit_log FROM {role};"
|
||||
f"REVOKE ALL ON SEQUENCE {schema}.audit_log_id_seq FROM {role};")
|
||||
conn.commit()
|
||||
|
||||
|
||||
def snapshot(conn, label):
|
||||
"""Copy every data table into a new schema `snap_<YYYYMMDD_HHMMSS>_<label>` — the backup before a risky
|
||||
write outside the app. Owner role. Returns the schema name."""
|
||||
name = f"snap_{datetime.now():%Y%m%d_%H%M%S}_{re.sub(r'[^a-z0-9]+', '_', label.lower()).strip('_')}"[:63]
|
||||
conn.execute(f'CREATE SCHEMA "{name}"')
|
||||
for table in DATA_TABLES:
|
||||
conn.execute(f'CREATE TABLE "{name}"."{table}" AS TABLE "{SCHEMA}"."{table}"')
|
||||
conn.commit()
|
||||
return name
|
||||
|
||||
|
||||
def rows(conn, sql, params=()):
|
||||
return conn.execute(sql, params).fetchall()
|
||||
|
||||
|
||||
def row(conn, sql, params=()):
|
||||
return conn.execute(sql, params).fetchone()
|
||||
|
||||
|
||||
def execute(conn, sql, params=()):
|
||||
cur = conn.execute(sql, params)
|
||||
conn.commit()
|
||||
return cur.lastrowid
|
||||
|
||||
|
||||
def main():
|
||||
"""python -m app.db migrate | status | snapshot <label>"""
|
||||
command = sys.argv[1] if len(sys.argv) > 1 else "status"
|
||||
with connect(owner=True) as conn:
|
||||
if command == "migrate":
|
||||
migrate(conn)
|
||||
elif command == "snapshot":
|
||||
print(snapshot(conn, sys.argv[2] if len(sys.argv) > 2 else "manual"))
|
||||
return
|
||||
print("pending migrations:", pending_migrations(conn) or "none")
|
||||
print("snapshots:", [r[0] for r in conn.execute(
|
||||
"SELECT nspname FROM pg_namespace WHERE nspname LIKE 'snap\\_%' ORDER BY 1")])
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
+11
@@ -0,0 +1,11 @@
|
||||
"""Tiny localization lookup. All UI text comes from locales/de.json via t()."""
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
_FILE = Path(__file__).resolve().parent.parent / "locales" / "de.json"
|
||||
_strings = json.loads(_FILE.read_text(encoding="utf-8"))
|
||||
|
||||
|
||||
def t(key, **kwargs):
|
||||
text = _strings.get(key, key)
|
||||
return text.format(**kwargs) if kwargs else text
|
||||
@@ -0,0 +1,45 @@
|
||||
"""German number/currency formatting for display (1.234,56 €)."""
|
||||
from app import i18n
|
||||
|
||||
|
||||
def de_number(value, decimals=2):
|
||||
if value is None or value == "":
|
||||
return ""
|
||||
formatted = f"{float(value):,.{decimals}f}" # 1,234.56
|
||||
return formatted.replace(",", "\x00").replace(".", ",").replace("\x00", ".")
|
||||
|
||||
|
||||
def euro(value):
|
||||
if value is None or value == "":
|
||||
return ""
|
||||
return f"{de_number(value)} €"
|
||||
|
||||
|
||||
WEEKDAYS = i18n.t("weekdays_short")
|
||||
|
||||
|
||||
def de_date_wd(iso):
|
||||
"""'Mo 13.09.2026' — the weekday before the date, so the lists show at once which day an arrival or
|
||||
departure falls on (weekly prices run from weekday to weekday)."""
|
||||
import datetime
|
||||
text = de_date(iso)
|
||||
if not text:
|
||||
return text
|
||||
try:
|
||||
day = datetime.date.fromisoformat(str(iso)[:10])
|
||||
except ValueError:
|
||||
return text # not a real date (a month name or the like) — leave it as it is
|
||||
# a non-breaking space: on paper the table would otherwise break between weekday and date
|
||||
# ("So" on one line, "13.09.2026" on the next)
|
||||
return f"{WEEKDAYS[day.weekday()]} {text}"
|
||||
|
||||
|
||||
def de_date(iso):
|
||||
"""ISO yyyy-mm-dd -> German dd.mm.yyyy for display."""
|
||||
if not iso:
|
||||
return ""
|
||||
try:
|
||||
y, m, d = str(iso).split("-")
|
||||
return f"{d}.{m}.{y}"
|
||||
except ValueError:
|
||||
return str(iso)
|
||||
@@ -0,0 +1,53 @@
|
||||
"""Pure normalization rules: company-name spellings and invoice-number formats."""
|
||||
import re
|
||||
|
||||
|
||||
def company_key(name):
|
||||
"""Grouping key for company-name spellings: case/punctuation/whitespace-insensitive, the German
|
||||
legal-form spellings unified (GmbH == Ges.m.b.H. == GesmbH, 'und' == '&'). Different legal forms
|
||||
(GmbH vs AG) stay different — near-misses beyond this are only SUGGESTED for manual merge."""
|
||||
n = (name or "").strip().lower()
|
||||
n = n.replace(" und ", " & ").replace(" u. ", " & ")
|
||||
n = re.sub(r"ges\.?\s*m\.?\s*b\.?\s*h\.?", "gmbh", n)
|
||||
n = re.sub(r"[^a-z0-9äöüß&]+", "", n)
|
||||
return n.replace("gbmh", "gmbh") # 'GbmH' is a transposition typo, not a different legal form
|
||||
|
||||
|
||||
def invoice_seq(number, year):
|
||||
"""Canonical integer sequence of an invoice number for the given 4-digit year, whatever the format:
|
||||
'202262' -> 62, '2022-62' -> 62, '22-62' -> 62, '62-2015' -> 62 (SEQ-YEAR), '2026-01' -> 1, '62' -> 62.
|
||||
Combined / concatenated numbers ('41/46', '5556') return None so they never pass for a single invoice."""
|
||||
toks = [t for t in re.split(r"\D+", str(number or "")) if t]
|
||||
if not toks:
|
||||
return None
|
||||
if len(toks) == 1: # bare '25' / glued 'YYYYNN' / 'YYNN'
|
||||
s = toks[0]
|
||||
if s == year:
|
||||
return None # a bare 4-digit year is not a sequence
|
||||
if year and s.startswith(year) and len(s) > len(year):
|
||||
s = s[len(year):] # 'YYYYNN'
|
||||
elif year and s.startswith(year[2:]) and len(s) > 2:
|
||||
s = s[2:] # 'YYNN'
|
||||
# else keep s verbatim: a bare sequence ('25','62') even when it equals the 2-digit year (invoice #25 in 2025)
|
||||
else: # PREFIX-SEQ / SEQ-YEAR: drop ONE year token
|
||||
non_year = list(toks)
|
||||
for yt in (year, year[2:]):
|
||||
if yt in non_year:
|
||||
non_year.remove(yt)
|
||||
break
|
||||
if len(non_year) != 1:
|
||||
return None # combined ('41','46') -> no single sequence
|
||||
s = non_year[0]
|
||||
s = s.lstrip("0") or s
|
||||
return int(s) if s.isdigit() and len(s) <= 3 else None
|
||||
|
||||
|
||||
def invoice_year(number):
|
||||
"""The 4-digit year an invoice number encodes: '2025-46'->'2025', '25-46'/'26-01'->20YY, else None. A
|
||||
New-Year invoice dated in January can still carry the old year's number; this is that year."""
|
||||
s = str(number or "")
|
||||
m = re.search(r"(?:19|20)\d\d", s)
|
||||
if m:
|
||||
return m.group(0)
|
||||
m = re.match(r"\s*(\d{2})\D", s) # 'YY-NN' 2-digit prefix
|
||||
return "20" + m.group(1) if m else None
|
||||
+25
@@ -0,0 +1,25 @@
|
||||
"""Render invoice HTML to PDF with a headless Chromium-family browser through Playwright.
|
||||
|
||||
PDF_BROWSER picks the browser: an installed `msedge` (default) or `chrome`, or `chromium` — Playwright's own
|
||||
build, after `playwright install chromium`."""
|
||||
from playwright.sync_api import sync_playwright
|
||||
|
||||
from app import config
|
||||
|
||||
BROWSER = config.get("PDF_BROWSER", "msedge")
|
||||
|
||||
|
||||
def render(html):
|
||||
"""The A4 PDF of a full-bleed HTML page, as bytes."""
|
||||
with sync_playwright() as p:
|
||||
browser = p.chromium.launch(channel=BROWSER)
|
||||
try:
|
||||
page = browser.new_page()
|
||||
page.set_content(html, wait_until="load")
|
||||
# Full-bleed: the template's @page/.sheet own every margin, so the sheet maps 1:1 to the A4 page
|
||||
# and the address can be placed by physical millimetres (window-envelope position). Any margin
|
||||
# here would re-inset (and scale) the 210mm sheet and throw those millimetres off.
|
||||
return page.pdf(format="A4", print_background=True, prefer_css_page_size=True,
|
||||
margin={"top": "0", "bottom": "0", "left": "0", "right": "0"})
|
||||
finally:
|
||||
browser.close()
|
||||
+128
@@ -0,0 +1,128 @@
|
||||
"""Belegungsplan: a rooms × days occupancy timeline built from the guest register (the house's
|
||||
who-is-present record; the ledger is billing and stays out of this).
|
||||
|
||||
Date semantics are half-open [arrival, departure): the departure day itself is free — it is the
|
||||
'Von' of the follow-up row when a weekly renter is carried forward, so the bars tile without
|
||||
overlapping. An open departure means "still here" only while the arrival is recent; an open stay
|
||||
older than a year is a forgotten departure (same 365-day rule as the TVB Meldung) and is left to
|
||||
the Datenprüfung instead of blocking a room forever.
|
||||
"""
|
||||
import datetime
|
||||
|
||||
from app import db, i18n
|
||||
|
||||
FAR = "9999-12-31"
|
||||
STALE_AFTER_DAYS = 365 # keep in sync with tvb.STALE_AFTER_DAYS
|
||||
MONTHS = i18n.t("months")
|
||||
MONTHS_SHORT = i18n.t("months_short")
|
||||
WEEKDAYS = i18n.t("weekdays_short")
|
||||
|
||||
|
||||
def default_start(today=None):
|
||||
"""Monday of last week — a little context behind, most of the window ahead."""
|
||||
today = today or datetime.date.today()
|
||||
return today - datetime.timedelta(days=today.weekday() + 7)
|
||||
|
||||
|
||||
def _pack_lanes(stays):
|
||||
"""Greedy interval packing: each stay goes into the first lane free at its arrival, so
|
||||
concurrent occupants of a room stack while back-to-back ones share a line."""
|
||||
lane_ends, out = [], []
|
||||
for st in stays:
|
||||
dep = st["departure"] or FAR
|
||||
for i, end in enumerate(lane_ends):
|
||||
if end <= st["arrival"]:
|
||||
lane_ends[i] = dep
|
||||
out.append((st, i))
|
||||
break
|
||||
else:
|
||||
lane_ends.append(dep)
|
||||
out.append((st, len(lane_ends) - 1))
|
||||
return out, max(len(lane_ends), 1)
|
||||
|
||||
|
||||
def build(conn, start, days, today=None):
|
||||
today = today or datetime.date.today()
|
||||
end = start + datetime.timedelta(days=days)
|
||||
s_iso, e_iso, t_iso = start.isoformat(), end.isoformat(), today.isoformat()
|
||||
fresh = (today - datetime.timedelta(days=STALE_AFTER_DAYS)).isoformat()
|
||||
|
||||
stays = db.rows(conn, """
|
||||
SELECT s.id, s.room_id, s.arrival, s.departure, s.sheet_number,
|
||||
COALESCE(TRIM(g.last_name || ' ' || COALESCE(g.first_name,'')), s.label) AS name,
|
||||
cl.name AS employer
|
||||
FROM stay s LEFT JOIN guest g ON g.id=s.guest_id
|
||||
LEFT JOIN client cl ON cl.id=s.employer_client_id
|
||||
WHERE s.source_sheet='guestsheet' AND s.arrival IS NOT NULL
|
||||
AND s.arrival < ? AND COALESCE(s.departure, ?) > ?
|
||||
AND (s.departure IS NOT NULL OR s.arrival >= ?)
|
||||
ORDER BY s.arrival NULLS FIRST, COALESCE(s.departure, ?), s.id""",
|
||||
(e_iso, FAR, s_iso, fresh, FAR))
|
||||
|
||||
per_room, unplaced = {}, []
|
||||
for st in stays:
|
||||
if st["room_id"] is None:
|
||||
unplaced.append(dict(st))
|
||||
else:
|
||||
per_room.setdefault(st["room_id"], []).append(st)
|
||||
|
||||
# today's occupants per room, independent of the visible window
|
||||
counts = {r[0]: r[1] for r in db.rows(conn, """
|
||||
SELECT room_id, COUNT(*) FROM stay
|
||||
WHERE source_sheet='guestsheet' AND room_id IS NOT NULL AND arrival IS NOT NULL
|
||||
AND arrival <= ? AND COALESCE(departure, ?) > ?
|
||||
AND (departure IS NOT NULL OR arrival >= ?)
|
||||
GROUP BY room_id""", (t_iso, FAR, t_iso, fresh))}
|
||||
|
||||
rooms = []
|
||||
for r in db.rows(conn, """SELECT r.id, r.number, r.category, r.capacity, r.long_term,
|
||||
u.code AS unit FROM room r
|
||||
LEFT JOIN unit u ON u.id=r.unit_id ORDER BY r.number"""):
|
||||
packed, lanes = _pack_lanes(per_room.get(r["id"], []))
|
||||
bars = []
|
||||
for st, lane in packed:
|
||||
dep = st["departure"] or FAR
|
||||
a, b = max(st["arrival"], s_iso), min(dep, e_iso)
|
||||
c1 = (datetime.date.fromisoformat(a) - start).days + 1
|
||||
c2 = max((datetime.date.fromisoformat(b) - start).days + 1, c1 + 1)
|
||||
state = ("past" if st["departure"] and st["departure"] <= t_iso
|
||||
else "future" if st["arrival"] > t_iso else "current")
|
||||
bars.append({
|
||||
"stay_id": st["id"], "name": st["name"] or "?", "employer": st["employer"],
|
||||
"sheet_number": st["sheet_number"], "arrival": st["arrival"],
|
||||
"departure": st["departure"], "col": c1, "col_end": c2, "lane": lane + 1,
|
||||
"state": state, "clip_start": st["arrival"] < s_iso, "clip_end": dep > e_iso,
|
||||
"open_end": st["departure"] is None,
|
||||
})
|
||||
rooms.append({**dict(r), "bars": bars, "lanes": lanes,
|
||||
"today_count": counts.get(r["id"], 0), "floor": str(r["number"])[:1]})
|
||||
|
||||
day_list, months = [], []
|
||||
for i in range(days):
|
||||
d = start + datetime.timedelta(days=i)
|
||||
day_list.append({"iso": d.isoformat(), "dom": d.day, "wd": WEEKDAYS[d.weekday()],
|
||||
"weekend": d.weekday() >= 5, "today": d == today,
|
||||
"monday": d.weekday() == 0, "month_start": d.day == 1,
|
||||
"label": f"{d.day:02d}.{d.month:02d}."})
|
||||
m = f"{MONTHS[d.month - 1]} {d.year}"
|
||||
if months and months[-1]["label"] == m:
|
||||
months[-1]["span"] += 1
|
||||
else:
|
||||
months.append({"label": m, "short": MONTHS_SHORT[d.month - 1], "span": 1})
|
||||
for m in months:
|
||||
# pick a label that fits the span so it can't run into the next month's divider —
|
||||
# 810px is the grid width at 200% display scaling, the narrowest supported case
|
||||
est = m["span"] * 810 / days
|
||||
m["text"] = m["label"] if est >= 95 else (m["short"] if est >= 40 else "")
|
||||
|
||||
live = [r for r in rooms if not r["long_term"]]
|
||||
return {
|
||||
"rooms": rooms, "days": day_list, "months": months, "n_days": days,
|
||||
"start": start, "end": end, "today": today,
|
||||
"last": (end - datetime.timedelta(days=1)).isoformat(),
|
||||
"prev_start": (start - datetime.timedelta(days=7)).isoformat(),
|
||||
"next_start": (start + datetime.timedelta(days=7)).isoformat(),
|
||||
"guests_today": sum(r["today_count"] for r in rooms),
|
||||
"free_beds": sum(max(0, (r["capacity"] or 0) - r["today_count"]) for r in live),
|
||||
"unplaced": unplaced,
|
||||
}
|
||||
+309
@@ -0,0 +1,309 @@
|
||||
"""Data-quality audit: surface what is missing or odd in the data, and suggest likely duplicates.
|
||||
|
||||
Two kinds of findings, kept strictly apart:
|
||||
* DATA GAPS — the data itself is incomplete or odd (missing dates, departure before arrival, money-less
|
||||
rows, duplicate or year-mismatched invoice numbers, recipients without an address). These are
|
||||
surfaced per row, never invented away; reviewed ones can be dismissed as correct-as-is.
|
||||
* SUGGESTIONS — near-duplicate companies/guests (typo variants) offered for one-click manual merge;
|
||||
only exact-normalized spellings are merged automatically.
|
||||
"""
|
||||
import re
|
||||
from difflib import SequenceMatcher
|
||||
|
||||
from app import db, normalize
|
||||
|
||||
# a company-name slice (legal form or personnel-industry activity word) has no business in a postal
|
||||
# address line — its presence means part of the NAME was typed into the ADDRESS. Deliberately narrow
|
||||
# (no 'OG'/'AG'/'KG' — those are Obergeschoss/real streets).
|
||||
_ADDR_NAME_RE = re.compile(
|
||||
r"(g\.?m\.?b\.?h|gbmh|ges\.?m\.?b\.?h|gesmbh|e\.\s*u\.|personalmanagement|personalservice|"
|
||||
r"personalbereitstellung|personaldienst|zeitarbeit|handelsges)", re.I)
|
||||
|
||||
|
||||
def _register_ledger_mismatches(conn):
|
||||
"""A guest's Gästeblatt An-/Abreise vs the ledger periods billed under exactly that name: where the
|
||||
same name appears in both, the covered spans must agree — a mismatch means one of the two is wrong
|
||||
(Gästeblatt 22.–23., Zimmerbelegung 22.–25.). Only exact name matches are compared, no fuzzy guessing;
|
||||
room changes are bridged via the shared Gästeblatt number."""
|
||||
ledger = db.rows(conn, """
|
||||
SELECT s.label, c.period_from, c.period_to, rm.number AS room
|
||||
FROM charge c JOIN stay s ON c.stay_id=s.id LEFT JOIN room rm ON s.room_id=rm.id
|
||||
WHERE c.source_sheet='ledger' AND c.period_from IS NOT NULL AND c.period_to IS NOT NULL
|
||||
ORDER BY c.id""")
|
||||
by_name = {} # 'muster max' -> segments (labels combine persons)
|
||||
for r in ledger:
|
||||
for token in (t.strip() for t in (r["label"] or "").split(",")):
|
||||
if token:
|
||||
by_name.setdefault(" ".join(token.lower().split()), []).append(r)
|
||||
|
||||
stays = db.rows(conn, """
|
||||
SELECT s.guest_id, s.sheet_number, s.arrival, s.departure, rm.number AS room,
|
||||
TRIM(g.last_name || ' ' || COALESCE(g.first_name, '')) AS name
|
||||
FROM stay s JOIN guest g ON g.id=s.guest_id LEFT JOIN room rm ON s.room_id=rm.id
|
||||
WHERE s.source_sheet='guestsheet' AND s.arrival IS NOT NULL
|
||||
ORDER BY s.id""")
|
||||
groups = {} # one Gästeblatt = one registration span (room changes)
|
||||
for s in stays:
|
||||
key = (s["guest_id"], s["sheet_number"]) if s["sheet_number"] else ("stay", id(s))
|
||||
groups.setdefault(key, []).append(s)
|
||||
|
||||
out = []
|
||||
for rows_ in groups.values():
|
||||
first = min(rows_, key=lambda r: r["arrival"])
|
||||
arrival = first["arrival"]
|
||||
deps = [r["departure"] for r in rows_]
|
||||
if any(d is None for d in deps): # still in the house — no final span to compare yet
|
||||
continue
|
||||
departure = max(deps)
|
||||
segs = sorted((x for x in by_name.get(" ".join(first["name"].lower().split()), ())
|
||||
if x["period_from"] <= departure and x["period_to"] >= arrival),
|
||||
key=lambda x: x["period_from"])
|
||||
if not segs: # name never billed in this window — nothing to compare
|
||||
continue
|
||||
led_from, led_to = min(x["period_from"] for x in segs), max(x["period_to"] for x in segs)
|
||||
if (led_from, led_to) != (arrival, departure):
|
||||
out.append({"sheet_number": first["sheet_number"], "room": first["room"], "name": first["name"],
|
||||
"arrival": arrival, "departure": departure,
|
||||
"ledger_from": led_from, "ledger_to": led_to})
|
||||
out.sort(key=lambda r: r["arrival"], reverse=True)
|
||||
return out
|
||||
|
||||
|
||||
def dismissed(conn, kind):
|
||||
"""Keys of findings reviewed and locked in as correct-as-is (natural keys, not row ids)."""
|
||||
return {r[0] for r in db.rows(conn, "SELECT key FROM dismissed_finding WHERE kind=?", (kind,))}
|
||||
|
||||
|
||||
def dismiss(conn, kind, key, note=None):
|
||||
db.execute(conn, "INSERT INTO dismissed_finding (kind, key, note) VALUES (?,?,?) ON CONFLICT DO NOTHING",
|
||||
(kind, key, note))
|
||||
|
||||
|
||||
def _keyed(rows_, kind, keyfn, done):
|
||||
"""Attach the dismiss key to each finding and hide the already-reviewed ones."""
|
||||
out = []
|
||||
for r in rows_:
|
||||
r["_kind"], r["_key"] = kind, keyfn(r)
|
||||
if r["_key"] not in done:
|
||||
out.append(r)
|
||||
return out
|
||||
|
||||
|
||||
def data_gaps(conn):
|
||||
"""Oddities in the data, surfaced per row (the tool never invents data)."""
|
||||
def rows(sql, params=()):
|
||||
return [dict(r) for r in db.rows(conn, sql, params)]
|
||||
gaps = {}
|
||||
gaps["register_ledger_mismatch"] = _keyed(
|
||||
_register_ledger_mismatches(conn), "register_ledger",
|
||||
lambda r: f"{r['sheet_number']}|{r['name']}|{r['arrival']}",
|
||||
dismissed(conn, "register_ledger"))
|
||||
gaps["register_no_departure"] = rows("""
|
||||
SELECT s.id AS stay_id, g.last_name || ' ' || COALESCE(g.first_name,'') AS name, s.arrival
|
||||
FROM stay s LEFT JOIN guest g ON g.id=s.guest_id
|
||||
WHERE s.source_sheet='guestsheet' AND s.departure IS NULL ORDER BY s.arrival DESC NULLS LAST, s.id""")
|
||||
# an open guest-register stay older than a year is a forgotten departure; the TVB Sammelmeldung
|
||||
# page skips such rows silently, so they must stay visible here until the date is filled in
|
||||
gaps["tvb_stale_open"] = rows("""
|
||||
SELECT s.id AS stay_id, rm.number AS room, COALESCE(TRIM(g.last_name || ' ' ||
|
||||
COALESCE(g.first_name,'')), s.label) AS name, s.arrival, s.sheet_number
|
||||
FROM stay s LEFT JOIN guest g ON g.id=s.guest_id LEFT JOIN room rm ON s.room_id=rm.id
|
||||
WHERE s.source_sheet='guestsheet' AND s.departure IS NULL AND s.arrival < to_char(CURRENT_DATE - interval '1 year', 'YYYY-MM-DD')
|
||||
ORDER BY s.arrival NULLS FIRST, s.id""")
|
||||
# ledger rows keep their period on the charge only — the backwards-period check looks at stays for the
|
||||
# register and at charges for the ledger. Dismissable: Storno/Korrektur rows legitimately carry
|
||||
# reversed dates.
|
||||
gaps["depart_before_arrival"] = _keyed(rows("""
|
||||
SELECT s.id AS stay_id, s.source_sheet, COALESCE(g.last_name || ' ' || COALESCE(g.first_name,''),
|
||||
s.label) AS name, s.arrival, s.departure
|
||||
FROM stay s LEFT JOIN guest g ON g.id=s.guest_id
|
||||
WHERE s.arrival IS NOT NULL AND s.departure IS NOT NULL AND s.departure < s.arrival
|
||||
ORDER BY s.id""") + rows("""
|
||||
SELECT c.id AS stay_id, 'ledger' AS source_sheet, s.label AS name,
|
||||
c.period_from AS arrival, c.period_to AS departure
|
||||
FROM charge c JOIN stay s ON s.id=c.stay_id
|
||||
WHERE c.source_sheet='ledger' AND c.period_from IS NOT NULL AND c.period_to IS NOT NULL
|
||||
AND c.period_to < c.period_from
|
||||
ORDER BY c.id"""),
|
||||
"backwards_period", lambda r: f"{r['name']}|{r['arrival']}|{r['departure']}",
|
||||
dismissed(conn, "backwards_period"))
|
||||
# the ledger's GB column must point at a Gästeblatt that actually exists. Multi-person rows write
|
||||
# consecutive sheets in shorthand — '816, 827', '277f' (and following), '300-1', '304 305', and
|
||||
# concatenated pairs ('23435' = 234+235, '1992' = 199+200) — a ref only counts as unknown if NO reading
|
||||
# resolves to an existing sheet
|
||||
gb_known = {str(r[0]).strip() for r in db.rows(conn,
|
||||
"SELECT DISTINCT sheet_number FROM stay WHERE source_sheet='guestsheet' AND sheet_number IS NOT NULL")}
|
||||
|
||||
def gb_resolves(token):
|
||||
t = token.strip().rstrip("fF") # '277f'/'285ff' -> the base sheet starts the run
|
||||
if not t or t in gb_known:
|
||||
return True
|
||||
if "-" in t: # '300-1' -> the range starts at 300
|
||||
return t.split("-")[0].strip() in gb_known
|
||||
if t.isdigit() and len(t) >= 4: # concatenated consecutives: a known 2-3 digit prefix
|
||||
return any(t[:n] in gb_known and len(t) - n <= n for n in (3, 2))
|
||||
return False
|
||||
|
||||
gaps["gb_ref_unknown"] = _keyed([
|
||||
{**r, "missing": ", ".join(t for t in re.split(r"[,.\s]+", str(r["gb_ref"])) if t and not gb_resolves(t))}
|
||||
for r in rows("""SELECT c.id AS charge_id, rm.number AS room, s.label AS name, c.period_from, c.gb_ref
|
||||
FROM charge c JOIN stay s ON c.stay_id=s.id LEFT JOIN room rm ON s.room_id=rm.id
|
||||
WHERE c.source_sheet='ledger' AND c.gb_ref IS NOT NULL ORDER BY c.id""")
|
||||
if any(t and not gb_resolves(t) for t in re.split(r"[,.\s]+", str(r["gb_ref"])))],
|
||||
"gb_ref_unknown", lambda r: f"{r['room']}|{r['name']}|{r['period_from']}|{r['gb_ref']}",
|
||||
dismissed(conn, "gb_ref_unknown"))
|
||||
# a CLOSED register stay whose guest never shows up in any ledger billing: either the stay was
|
||||
# never charged (lost revenue) or the two lists spell the name differently — both worth seeing
|
||||
ledger_tokens = set()
|
||||
for r in db.rows(conn, "SELECT DISTINCT lower(label) l FROM stay "
|
||||
"WHERE source_sheet='ledger' AND label IS NOT NULL"):
|
||||
for tok in r["l"].split(","):
|
||||
tok = " ".join(tok.split())
|
||||
if len(tok) > 4:
|
||||
ledger_tokens.add(tok)
|
||||
|
||||
def billed(name):
|
||||
n = " ".join(name.lower().split())
|
||||
if any(n in t or t in n for t in ledger_tokens):
|
||||
return True
|
||||
last = n.split()[0]
|
||||
return len(last) > 3 and any(last in t for t in ledger_tokens)
|
||||
|
||||
gaps["register_never_billed"] = _keyed(
|
||||
[r for r in rows("""SELECT s.id AS stay_id, s.sheet_number, rm.number AS room,
|
||||
TRIM(g.last_name || ' ' || COALESCE(g.first_name,'')) AS name,
|
||||
s.arrival, s.departure
|
||||
FROM stay s JOIN guest g ON g.id=s.guest_id
|
||||
LEFT JOIN room rm ON rm.id=s.room_id
|
||||
WHERE s.source_sheet='guestsheet' AND s.departure IS NOT NULL
|
||||
ORDER BY s.arrival DESC NULLS LAST, s.id""") if not billed(r["name"])],
|
||||
"never_billed", lambda r: f"{r['sheet_number']}|{r['name']}|{r['arrival']}",
|
||||
dismissed(conn, "never_billed"))
|
||||
# Betrag matches NEITHER Anzahl x Satz NOR Anzahl x Satz x Personen: a possible typo in one of
|
||||
# the three numbers (legitimate discounts exist — hence reviewable, not an error)
|
||||
gaps["betrag_off_formula"] = _keyed(
|
||||
[dict(r, calc=round(r["quantity"] * r["gross_per_unit"] * (r["persons"] or 1), 2))
|
||||
for r in rows("""SELECT c.id AS charge_id, rm.number AS room, s.label AS name, c.period_from,
|
||||
c.quantity, c.gross_per_unit, c.persons,
|
||||
ROUND((c.line_net_override*1.1)::numeric, 2)::float8 AS betrag
|
||||
FROM charge c JOIN stay s ON s.id=c.stay_id LEFT JOIN room rm ON rm.id=s.room_id
|
||||
WHERE c.source_sheet='ledger' AND c.quantity IS NOT NULL
|
||||
AND c.gross_per_unit IS NOT NULL AND c.line_net_override IS NOT NULL
|
||||
ORDER BY c.period_from DESC NULLS LAST, c.id""")
|
||||
if abs(r["quantity"] * r["gross_per_unit"] - r["betrag"]) > 1.0
|
||||
and abs(r["quantity"] * r["gross_per_unit"] * (r["persons"] or 1) - r["betrag"]) > 1.0],
|
||||
"betrag_formula", lambda r: f"{r['room']}|{r['name']}|{r['period_from']}|{r['betrag']}",
|
||||
dismissed(conn, "betrag_formula"))
|
||||
gaps["charges_without_money"] = rows("""
|
||||
SELECT c.id AS charge_id, rm.number AS room, s.label AS name, c.period_from, c.period_to
|
||||
FROM charge c JOIN stay s ON c.stay_id=s.id LEFT JOIN room rm ON s.room_id=rm.id
|
||||
WHERE c.source_sheet='ledger' AND c.gross_per_unit IS NULL AND c.line_net_override IS NULL
|
||||
ORDER BY c.id""")
|
||||
# a company that is billed or worked for but has no postal address anywhere: an invoice to it would
|
||||
# print half a recipient block (short 'Zlg. durch' spellings create such companies). Payment channels
|
||||
# are not recipients and never count.
|
||||
gaps["client_without_address"] = _keyed(rows("""
|
||||
SELECT * FROM (
|
||||
SELECT co.id AS company_id, co.name, COALESCE(paying.n, 0) AS as_payer,
|
||||
(SELECT COUNT(*) FROM stay s JOIN client c2 ON c2.id=s.employer_client_id
|
||||
WHERE c2.company_id=co.id) AS as_employer
|
||||
FROM company co
|
||||
-- payers counted once, not per company: one scan of charge instead of one per company
|
||||
LEFT JOIN (SELECT lower(TRIM(payer)) AS payer, COUNT(*) AS n FROM charge GROUP BY 1) paying
|
||||
ON paying.payer = lower(co.name)
|
||||
WHERE NOT EXISTS (SELECT 1 FROM client cl WHERE cl.company_id=co.id
|
||||
AND COALESCE(cl.address,'') <> '')
|
||||
AND lower(co.name) NOT IN ('bar','bank')) counted
|
||||
WHERE as_payer > 0 OR as_employer > 0
|
||||
ORDER BY as_payer + as_employer DESC, company_id"""),
|
||||
"client_without_address", lambda r: r["name"], dismissed(conn, "client_without_address"))
|
||||
# a recipient whose ADDRESS line carries a slice of the company NAME — it would print wrong on every
|
||||
# invoice to that client. Reviewable, not invented.
|
||||
gaps["address_has_name"] = _keyed(
|
||||
[dict(r) for r in rows("""SELECT cl.id AS client_id, co.name AS company, cl.name, cl.address,
|
||||
cl.postcode, cl.city
|
||||
FROM client cl LEFT JOIN company co ON cl.company_id=co.id
|
||||
WHERE cl.address IS NOT NULL AND TRIM(cl.address) <> ''
|
||||
ORDER BY cl.id""")
|
||||
if _ADDR_NAME_RE.search(r["address"])],
|
||||
"address_has_name", lambda r: f"{r['client_id']}|{r['address']}",
|
||||
dismissed(conn, "address_has_name"))
|
||||
# a negative or absurdly large quantity (a 'bis' before the 'von', a typo) poisons every sum in the grid
|
||||
gaps["implausible_quantities"] = _keyed(rows("""
|
||||
SELECT c.id AS charge_id, rm.number AS room, s.label AS name, c.period_from, c.period_to,
|
||||
c.quantity, ROUND(COALESCE(c.line_net_override * 1.1,
|
||||
c.quantity * c.gross_per_unit)::numeric, 2)::float8 AS amount
|
||||
FROM charge c JOIN stay s ON c.stay_id=s.id LEFT JOIN room rm ON s.room_id=rm.id
|
||||
WHERE c.source_sheet='ledger' AND (c.quantity < 0 OR c.quantity > 500
|
||||
OR COALESCE(c.line_net_override * 1.1, c.quantity * c.gross_per_unit) < 0)
|
||||
ORDER BY c.id"""),
|
||||
"implausible_qty", lambda r: f"{r['room']}|{r['name']}|{r['period_from']}|{r['amount']}",
|
||||
dismissed(conn, "implausible_qty"))
|
||||
# an RNr typed into the ledger that no invoice answers to: a typo, or an invoice not entered yet
|
||||
gaps["rnr_unlinked"] = rows("""
|
||||
SELECT c.id AS charge_id, rm.number AS room, s.label AS name, c.period_from, c.rnr
|
||||
FROM charge c JOIN stay s ON c.stay_id=s.id LEFT JOIN room rm ON s.room_id=rm.id
|
||||
WHERE c.source_sheet='ledger' AND c.invoice_id IS NULL
|
||||
AND c.rnr IS NOT NULL AND c.rnr <> '-----'
|
||||
ORDER BY c.id""")
|
||||
gaps["open_items"] = rows("""
|
||||
SELECT c.id AS charge_id, rm.number AS room, s.label AS name, c.period_from,
|
||||
ROUND((c.line_net_override * 1.1)::numeric, 2)::float8 AS amount
|
||||
FROM charge c JOIN stay s ON c.stay_id=s.id LEFT JOIN room rm ON s.room_id=rm.id
|
||||
WHERE c.paid_status='open' ORDER BY c.period_from NULLS FIRST, c.id""")
|
||||
gaps["duplicate_invoice_numbers"] = _keyed(rows("""
|
||||
SELECT u.code AS unit, i.number, COUNT(*) AS n, string_agg(i.id::text, ',' ORDER BY i.id) AS ids
|
||||
FROM invoice i JOIN unit u ON u.id=i.unit_id
|
||||
GROUP BY u.code, i.number HAVING COUNT(*) > 1 ORDER BY i.number NULLS FIRST, u.code"""),
|
||||
"duplicate_number", lambda r: f"{r['unit']}|{r['number']}", dismissed(conn, "duplicate_number"))
|
||||
# numbers can carry the year 4-digit ('7-2016'), or 2-digit-coded ('1803' = 2018 seq 03,
|
||||
# '25-46') — only a number that matches NEITHER reading of its invoice date is a mismatch
|
||||
gaps["number_year_mismatch"] = _keyed(rows("""
|
||||
SELECT i.id, u.code AS unit, i.number, i.date FROM invoice i JOIN unit u ON u.id=i.unit_id
|
||||
WHERE i.date IS NOT NULL AND i.number ~ '[0-9]{4}'
|
||||
AND NOT i.number LIKE '%' || substr(i.date, 1, 4) || '%'
|
||||
AND NOT i.number ~ ('^' || substr(i.date, 3, 2) || '[0-9][0-9]$')
|
||||
AND NOT i.number ~ ('^' || substr(i.date, 3, 2) || '[- ]')
|
||||
ORDER BY i.date, i.id"""),
|
||||
"number_year", lambda r: f"{r['unit']}|{r['number']}|{r['date']}", dismissed(conn, "number_year"))
|
||||
return gaps
|
||||
|
||||
|
||||
def _fuzzy_pairs(items, threshold=0.87, prefix=4):
|
||||
"""(id, key, label) triples -> near-duplicate pairs by key similarity. The shared-prefix guard keeps
|
||||
the company comparison cheap (hundreds of names); tiny same-birthday guest groups compare unguarded."""
|
||||
out = []
|
||||
items = sorted(items, key=lambda x: x[1])
|
||||
for i in range(len(items)):
|
||||
for j in range(i + 1, len(items)):
|
||||
a, b = items[i], items[j]
|
||||
if prefix and a[1][:prefix] != b[1][:prefix]:
|
||||
continue
|
||||
if SequenceMatcher(None, a[1], b[1]).ratio() > threshold:
|
||||
out.append({"a_id": a[0], "a": a[2], "b_id": b[0], "b": b[2]})
|
||||
return out
|
||||
|
||||
|
||||
def suggestions(conn):
|
||||
comps = [(r["id"], normalize.company_key(r["name"]), r["name"])
|
||||
for r in db.rows(conn, "SELECT id, name FROM company ORDER BY id")]
|
||||
companies = _fuzzy_pairs([c for c in comps if c[1]])
|
||||
|
||||
guests = []
|
||||
by_birth = {}
|
||||
for r in db.rows(conn, "SELECT id, last_name, first_name, birth_date FROM guest "
|
||||
"WHERE birth_date IS NOT NULL ORDER BY id"):
|
||||
label = f"{r['last_name']} {r['first_name'] or ''}".strip()
|
||||
by_birth.setdefault(r["birth_date"], []).append((r["id"], label.lower(), f"{label} ({r['birth_date']})"))
|
||||
for birth, people in by_birth.items():
|
||||
if len(people) > 1:
|
||||
guests += _fuzzy_pairs(people, threshold=0.75, prefix=0)
|
||||
# pairs reviewed as "genuinely different" stay dismissed (keyed by labels, not ids)
|
||||
pair_key = lambda p: "|".join(sorted([p["a"].lower(), p["b"].lower()])) # noqa: E731
|
||||
companies = _keyed(companies, "company_pair", pair_key, dismissed(conn, "company_pair"))
|
||||
guests = _keyed(guests, "guest_pair", pair_key, dismissed(conn, "guest_pair"))
|
||||
return {"companies": companies, "guests": guests}
|
||||
|
||||
|
||||
def overview(conn):
|
||||
return {"gaps": data_gaps(conn), "suggestions": suggestions(conn)}
|
||||
+1499
File diff suppressed because it is too large.
Load diff
@@ -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}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
<!doctype html>
|
||||
<html lang="de">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>{{ t('app_title') }}</title>
|
||||
{# a house emoji as favicon: kills the browser's /favicon.ico 404 on every page load #}
|
||||
<link rel="icon" href="data:image/svg+xml,<svg xmlns=%22http://www.w3.org/2000/svg%22 viewBox=%220 0 100 100%22><text y=%22.9em%22 font-size=%2290%22>🏠</text></svg>">
|
||||
<link rel="stylesheet" href="{{ url_for('static', filename='style.css') }}">
|
||||
<script data-fp="{{ db_fp }}">
|
||||
// Re-apply the grid layouts measured on an earlier visit BEFORE the tables parse, so the browser
|
||||
// never has to lay out thousands of rows at once (grids.js stores them, keyed by a db fingerprint —
|
||||
// any data change re-measures). See freezeLayout() in grids.js.
|
||||
(function () {
|
||||
try {
|
||||
var fp = document.currentScript.dataset.fp;
|
||||
window.__gridfp = fp;
|
||||
window.__pregrids = {};
|
||||
for (var i = localStorage.length - 1; i >= 0; i--) {
|
||||
var k = localStorage.key(i);
|
||||
if (k.indexOf("grid:") !== 0) continue;
|
||||
var v = JSON.parse(localStorage.getItem(k));
|
||||
if (v.v !== 5) { localStorage.removeItem(k); continue; } // older stored format: re-measure
|
||||
if (v.fp === fp && v.vw === window.innerWidth) {
|
||||
var s = document.createElement("style");
|
||||
s.textContent = v.css;
|
||||
document.head.appendChild(s);
|
||||
window.__pregrids[k.slice(5)] = { rowH: v.rowH || 1, widths: v.widths || null };
|
||||
} else if (v.fp !== fp) {
|
||||
localStorage.removeItem(k);
|
||||
}
|
||||
}
|
||||
} catch (e) { /* no stored layout -> the table measures itself after load */ }
|
||||
})();
|
||||
</script>
|
||||
<script>window.GRID_TEXT = {{ {'search': t('print_search'), 'rows': t('print_rows'), 'rows_one': t('print_rows_one'),
|
||||
'date': t('print_date'), 'pin': t('pin_tip'), 'unpin': t('unpin_tip')}|tojson }};</script>
|
||||
<script src="{{ url_for('static', filename='grids.js') }}" defer></script>
|
||||
</head>
|
||||
<body>
|
||||
<header class="topbar">
|
||||
<div class="brand">{{ t('app_title') }}</div>
|
||||
<nav>
|
||||
<a href="{{ url_for('home') }}" class="{{ 'active' if request.endpoint == 'home' }}">{{ t('nav_home') }}</a>
|
||||
<a href="{{ url_for('register') }}" class="{{ 'active' if request.endpoint in ('register','stay_form') }}">{{ t('nav_register') }}</a>
|
||||
<a href="{{ url_for('ledger') }}" class="{{ 'active' if request.endpoint == 'ledger' }}">{{ t('nav_ledger') }}</a>
|
||||
<a href="{{ url_for('plan_page') }}" class="{{ 'active' if request.endpoint == 'plan_page' }}">{{ t('nav_plan') }}</a>
|
||||
<a href="{{ url_for('invoices') }}" class="{{ 'active' if request.endpoint and request.endpoint.startswith('invoice') }}">{{ t('nav_invoices') }}</a>
|
||||
<a href="{{ url_for('clients') }}" class="{{ 'active' if request.endpoint and request.endpoint.startswith('client') }}">{{ t('nav_clients') }}</a>
|
||||
<a href="{{ url_for('tvb_page') }}" class="{{ 'active' if request.endpoint == 'tvb_page' }}">{{ t('nav_tvb') }}</a>
|
||||
<a href="{{ url_for('vat_page') }}" class="{{ 'active' if request.endpoint == 'vat_page' }}">{{ t('nav_vat') }}</a>
|
||||
<a href="{{ url_for('quality_page') }}" class="{{ 'active' if request.endpoint and request.endpoint.startswith('quality') }}">{{ t('nav_quality') }}</a>
|
||||
</nav>
|
||||
</header>
|
||||
<main class="{% block main_class %}{% endblock %}">
|
||||
{% with msgs = get_flashed_messages() %}
|
||||
{% if msgs %}<div class="flash">{% for m in msgs %}<div>{{ m }}</div>{% endfor %}</div>{% endif %}
|
||||
{% endwith %}
|
||||
{% block content %}{% endblock %}
|
||||
</main>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,126 @@
|
||||
{% extends "base.html" %}
|
||||
{% block content %}
|
||||
<h1>{{ t('form_edit_client') }} – {{ comp.name }}</h1>
|
||||
|
||||
<form class="card" method="post" style="max-width:900px">
|
||||
<div class="grid2">
|
||||
<div class="field"><label>{{ t('col_name') }} *</label>
|
||||
<input type="text" name="name" value="{{ comp.name }}" required></div>
|
||||
<div class="field"><label>{{ t('col_kind') }}</label>
|
||||
<select name="kind">
|
||||
<option value="company" {{ 'selected' if comp.kind != 'private' }}>{{ t('kind_company') }}</option>
|
||||
<option value="private" {{ 'selected' if comp.kind == 'private' }}>{{ t('kind_private') }}</option>
|
||||
</select></div>
|
||||
</div>
|
||||
<div class="grid2">
|
||||
<div class="field"><label>{{ t('col_uid') }}</label>
|
||||
<input type="text" name="uid" value="{{ comp.uid or '' }}"></div>
|
||||
<div class="field"><label>{{ t('col_email') }}</label>
|
||||
<input type="text" name="email" value="{{ comp.email or '' }}"></div>
|
||||
</div>
|
||||
<div class="grid2">
|
||||
<div class="field"><label>{{ t('col_phone') }}</label>
|
||||
<input type="text" name="phone" value="{{ comp.phone or '' }}"></div>
|
||||
<div class="field"><label>{{ t('col_notes') }}</label>
|
||||
<input type="text" name="notes" value="{{ comp.notes or '' }}"></div>
|
||||
</div>
|
||||
|
||||
{% if comp.aliases %}
|
||||
<div class="field"><label>{{ t('lbl_known_spellings') }}</label>
|
||||
<div style="color:#6b756e; font-size:13px">{{ comp.aliases|join(' · ') }}</div></div>
|
||||
{% endif %}
|
||||
|
||||
<div class="formactions">
|
||||
<button class="btn" type="submit">{{ t('btn_save') }}</button>
|
||||
<a class="btn secondary" href="{{ url_for('clients') }}">{{ t('btn_cancel') }}</a>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<div class="card" style="max-width:900px; margin:16px auto 0">
|
||||
<h3 style="margin:0 0 8px">{{ t('lbl_addresses') }}</h3>
|
||||
<div class="tablewrap" style="max-height:none">
|
||||
<table>
|
||||
<thead><tr><th>{{ t('lbl_address_current') }}</th><th>{{ t('col_name') }}</th><th>{{ t('col_address') }}</th><th>{{ t('col_postcode') }}</th>
|
||||
<th>{{ t('col_city') }}</th><th></th></tr></thead>
|
||||
<tbody>
|
||||
{% for v in comp.versions %}
|
||||
<tr>
|
||||
<td style="text-align:center">{% if v.id == comp.current_client_id %}<span style="color:var(--accent); font-weight:700" title="{{ t('lbl_current_address_short') }}">✓</span>{% endif %}</td>
|
||||
<td>{{ v.name }}</td><td>{{ v.address or '' }}</td><td>{{ v.postcode or '' }}</td><td>{{ v.city or '' }}</td>
|
||||
<td style="white-space:nowrap; text-align:right">
|
||||
<button type="button" class="btn secondary small" onclick="document.getElementById('addr-{{ v.id }}').showModal()">{{ t('btn_edit') }}</button>
|
||||
<form method="post" style="display:inline" onsubmit="return confirm('{{ t('confirm_delete_address') }}')">
|
||||
<input type="hidden" name="client_id" value="{{ v.id }}">
|
||||
<button class="btn danger small" name="action" value="delete_address"
|
||||
title="{{ v.stays }} {{ t('home_stays') }} · {{ v.invoices }} {{ t('nav_invoices') }}"
|
||||
{{ 'disabled' if (v.stays + v.invoices) or comp.versions|length < 2 }}>{{ t('btn_delete') }}</button>
|
||||
</form>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% for v in comp.versions %}
|
||||
<dialog id="addr-{{ v.id }}" style="border:1px solid var(--line); border-radius:12px; padding:22px; max-width:520px; width:92%">
|
||||
<form method="post">
|
||||
<input type="hidden" name="client_id" value="{{ v.id }}">
|
||||
<h3 style="margin:0 0 14px">{{ t('lbl_edit_address') }}</h3>
|
||||
<div class="field"><label>{{ t('col_name') }}</label><input type="text" name="name" value="{{ v.name }}"></div>
|
||||
<div class="field"><label>{{ t('col_address') }}</label><input type="text" name="address" value="{{ v.address or '' }}"></div>
|
||||
<div class="grid2">
|
||||
<div class="field"><label>{{ t('col_postcode') }}</label><input type="text" name="postcode" value="{{ v.postcode or '' }}"></div>
|
||||
<div class="field"><label>{{ t('col_city') }}</label><input type="text" name="city" value="{{ v.city or '' }}"></div>
|
||||
</div>
|
||||
<div class="formactions" style="margin-top:16px; flex-wrap:wrap">
|
||||
<button class="btn" type="submit" name="action" value="edit_address">{{ t('btn_save') }}</button>
|
||||
{% if v.id != comp.current_client_id %}
|
||||
<button class="btn secondary" type="submit" name="action" value="make_current">{{ t('btn_make_current') }}</button>
|
||||
{% endif %}
|
||||
<button class="btn secondary" type="button" onclick="this.closest('dialog').close()">{{ t('btn_cancel') }}</button>
|
||||
</div>
|
||||
</form>
|
||||
</dialog>
|
||||
{% endfor %}
|
||||
|
||||
<form class="card" method="post" style="max-width:900px; margin-top:16px">
|
||||
<input type="hidden" name="action" value="add_address">
|
||||
<h3 style="margin:0 0 8px">{{ t('lbl_new_address') }}</h3>
|
||||
<div class="field"><label>{{ t('col_address') }}</label><input type="text" name="address"></div>
|
||||
<div class="grid2">
|
||||
<div class="field"><label>{{ t('col_postcode') }}</label><input type="text" name="postcode"></div>
|
||||
<div class="field"><label>{{ t('col_city') }}</label><input type="text" name="city"></div>
|
||||
</div>
|
||||
<div class="field"><label><input type="checkbox" name="make_current" checked> {{ t('lbl_use_from_now') }}</label></div>
|
||||
<div class="formactions"><button class="btn secondary" type="submit">{{ t('btn_add_address') }}</button></div>
|
||||
</form>
|
||||
|
||||
<form class="card" method="post" style="max-width:900px; margin-top:16px"
|
||||
onsubmit="return confirm('{{ t('confirm_merge') }}')">
|
||||
<input type="hidden" name="action" value="merge">
|
||||
<h3 style="margin:0 0 8px">{{ t('lbl_merge') }}</h3>
|
||||
<p style="color:#6b756e; font-size:13px; margin:0 0 10px">{{ t('merge_hint') }}</p>
|
||||
<div class="field"><label>{{ t('lbl_merge_into') }}</label>
|
||||
<select name="merge_into">
|
||||
<option value="">—</option>
|
||||
{% for o in others %}<option value="{{ o.id }}">{{ o.name }}</option>{% endfor %}
|
||||
</select></div>
|
||||
<div class="formactions"><button class="btn danger" type="submit">{{ t('btn_merge') }}</button></div>
|
||||
</form>
|
||||
|
||||
{% set refs = (comp.versions|sum(attribute='stays')) + (comp.versions|sum(attribute='invoices')) %}
|
||||
<form class="card" method="post" style="max-width:900px; margin-top:16px"
|
||||
action="{{ url_for('client_delete', company_id=comp.id) }}"
|
||||
onsubmit="return confirm('{{ t('confirm_delete_company') }}')">
|
||||
<h3 style="margin:0 0 8px">{{ t('lbl_delete_company') }}</h3>
|
||||
<p style="color:#6b756e; font-size:13px; margin:0 0 10px">{{ t('delete_company_hint') }}</p>
|
||||
<div class="formactions">
|
||||
<button class="btn danger" type="submit" {{ 'disabled' if refs }}>{{ t('btn_delete_company') }}</button>
|
||||
{% if refs %}<span style="color:#6b756e; font-size:13px">
|
||||
{{ comp.versions|sum(attribute='stays') }} {{ t('home_stays') }} ·
|
||||
{{ comp.versions|sum(attribute='invoices') }} {{ t('nav_invoices') }}</span>{% endif %}
|
||||
</div>
|
||||
</form>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,39 @@
|
||||
{% extends "base.html" %}
|
||||
{% block main_class %}grid-page{% endblock %}
|
||||
{% block content %}
|
||||
<h1>{{ t('clients_title') }}</h1>
|
||||
<div class="toolbar">
|
||||
<input class="search live-search" data-grid="cli" type="text" placeholder="{{ t('search_placeholder') }}">
|
||||
<span class="count"><span class="grid-count" data-grid="cli">{{ rows|length }}</span> {{ t('showing_count', count='')|trim }}</span>
|
||||
<span class="spacer"></span>
|
||||
</div>
|
||||
<div class="tablewrap">
|
||||
<table id="cli" class="grid">
|
||||
<thead><tr>
|
||||
<th>{{ t('col_name') }}</th><th>{{ t('col_kind') }}</th><th>{{ t('col_address_current') }}</th>
|
||||
<th class="num">{{ t('col_versions') }}</th><th class="num">{{ t('home_stays') }}</th>
|
||||
<th class="num">{{ t('nav_invoices') }}</th><th>{{ t('col_uid') }}</th><th class="nosort actions"></th>
|
||||
</tr></thead>
|
||||
<tbody>
|
||||
{# rows ship inside an inert <template>: grids.js renders only the visible window (see freezeLayout) #}
|
||||
<template>
|
||||
{% for r in rows %}
|
||||
<tr>
|
||||
<td>{{ r.name }}</td>
|
||||
<td>{{ t('kind_private') if r.kind == 'private' else t('kind_company') }}</td>
|
||||
<td>{{ r.cur_address or '' }}{% if r.cur_postcode or r.cur_city %}, {{ r.cur_postcode or '' }} {{ r.cur_city or '' }}{% endif %}</td>
|
||||
<td class="num">{{ r.versions }}</td>
|
||||
<td class="num">{{ r.stays }}</td>
|
||||
<td class="num">{{ r.invoices }}</td>
|
||||
<td>{{ r.uid or '' }}</td>
|
||||
<td class="actions"><a class="btn secondary small" href="{{ url_for('client_edit', company_id=r.id) }}">{{ t('btn_edit') }}</a></td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</template>
|
||||
{% if not rows %}
|
||||
<tr data-empty="1"><td class="empty" colspan="8">{{ t('no_results') }}</td></tr>
|
||||
{% endif %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,5 @@
|
||||
{% extends "base.html" %}
|
||||
{% block content %}
|
||||
<h1>{{ t('err_generic') if not message else message }}</h1>
|
||||
<p><a class="btn" href="{{ url_for('home') }}">{{ t('nav_home') }}</a></p>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,17 @@
|
||||
{% extends "base.html" %}
|
||||
{% block content %}
|
||||
<h1>{{ t('home_welcome') }}</h1>
|
||||
<p class="lead">{{ t('home_intro') }}</p>
|
||||
<div class="cards">
|
||||
<div class="card"><div class="n">{{ stats.guests|num(0) }}</div><div class="l">{{ t('home_guests') }}</div></div>
|
||||
<div class="card"><div class="n">{{ stats.stays|num(0) }}</div><div class="l">{{ t('home_stays') }}</div></div>
|
||||
<div class="card"><div class="n">{{ stats.invoices|num(0) }}</div><div class="l">{{ t('home_invoices') }}</div></div>
|
||||
<div class="card"><div class="n">{{ stats.open|num(0) }}</div><div class="l">{{ t('home_open') }}</div></div>
|
||||
</div>
|
||||
<div class="quicklinks">
|
||||
<a class="btn" href="{{ url_for('plan_page') }}">{{ t('nav_plan') }}</a>
|
||||
<a class="btn" href="{{ url_for('register') }}">{{ t('nav_register') }}</a>
|
||||
<a class="btn" href="{{ url_for('ledger') }}">{{ t('nav_ledger') }}</a>
|
||||
<a class="btn" href="{{ url_for('invoices') }}">{{ t('nav_invoices') }}</a>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,123 @@
|
||||
{% extends "base.html" %}
|
||||
{% block content %}
|
||||
<style>
|
||||
/* keep all 9 line columns visible without horizontal scrolling */
|
||||
#lines input[type=date] { width: 132px; }
|
||||
#lines td, #lines th { padding: 6px 4px; }
|
||||
</style>
|
||||
<h1>{{ t('form_edit_invoice') if editing else t('form_new_invoice') }}</h1>
|
||||
<datalist id="dl_client">{% for v in (suggest|default({})).client|default([]) %}<option value="{{ v }}">{% endfor %}</datalist>
|
||||
<form class="card" method="post" style="max-width:1240px">
|
||||
<div class="grid2">
|
||||
<div class="field"><label>{{ t('lbl_unit') }} *</label>
|
||||
<select name="unit_id" onchange="setNumber(true)">
|
||||
{% for u in units %}
|
||||
<option value="{{ u.id }}" {{ 'selected' if form.get('unit_id')|string == u.id|string }}>{{ u.code }} – {{ u.name }}</option>
|
||||
{% endfor %}
|
||||
</select></div>
|
||||
<div class="field"><label>{{ t('lbl_client') }} *</label>
|
||||
<input type="text" name="client" required value="{{ form.get('client','') }}" list="dl_client"></div>
|
||||
</div>
|
||||
{# a recipient without an address: the companies the name may mean, or the deliberate exception #}
|
||||
{% if client_choices is not none or form.get('confirm_client') %}
|
||||
<div class="confirm-order client-choices">
|
||||
<div>{{ t('lbl_client_choices' if client_choices else 'lbl_client_unknown', name=form.get('client','')) }}</div>
|
||||
{% for c in client_choices or [] %}
|
||||
<button type="button" class="btn secondary small" onclick="pickClient(this)" data-name="{{ c.name }}">
|
||||
{{ c.name }} – {{ c.address }}, {{ c.postcode }} {{ c.city }}</button>
|
||||
{% endfor %}
|
||||
<label><input type="checkbox" name="confirm_client" value="1" {{ 'checked' if form.get('confirm_client') }}>
|
||||
{{ t('lbl_confirm_client') }}</label>
|
||||
</div>
|
||||
{% endif %}
|
||||
<div class="grid2">
|
||||
<div class="field"><label>{{ t('lbl_invoice_no') }}</label>
|
||||
<input type="text" name="number" value="{{ form.get('number','') }}"></div>
|
||||
<div class="field"><label>{{ t('lbl_invoice_date') }}</label>
|
||||
<input type="date" name="date" value="{{ form.get('date') or today|default('') }}"></div>
|
||||
</div>
|
||||
{# a date against the number order: the warning stands at the top, here only the deliberate exception #}
|
||||
{# kept while ticked, so answering the other warning does not bring this one back #}
|
||||
{% if order_warning or form.get('confirm_order') %}
|
||||
<label class="confirm-order"><input type="checkbox" name="confirm_order" value="1" {{ 'checked' if form.get('confirm_order') }}> {{ t('lbl_confirm_order') }}</label>
|
||||
{% endif %}
|
||||
<div class="grid2">
|
||||
<div class="field"><label>{{ t('lbl_paid_date') }}</label>
|
||||
<input type="date" name="paid_date" value="{{ form.get('paid_date') or '' }}"></div>
|
||||
<div class="field"><label>{{ t('lbl_payment_method') }}</label>
|
||||
<input type="text" name="payment_method" value="{{ form.get('payment_method') or '' }}"></div>
|
||||
</div>
|
||||
<div class="field"><label>{{ t('lbl_invoice_note') }}</label>
|
||||
<textarea name="note" rows="2" style="width:100%">{{ form.get('note') if form.get('note') is not none else t('invoice_note_default') }}</textarea></div>
|
||||
{% if form.get('link_charges') %}<input type="hidden" name="link_charges" value="{{ form.get('link_charges') }}">{% endif %}
|
||||
|
||||
<h3 style="margin:18px 0 8px">{{ t('lbl_select_charges') }}</h3>
|
||||
<div class="tablewrap" style="max-height:none">
|
||||
<table id="lines">
|
||||
<thead><tr>
|
||||
<th style="min-width:200px">{{ t('col_name') }} *</th><th>{{ t('col_from') }}</th>
|
||||
<th>{{ t('col_to') }}</th><th class="num">{{ t('col_qty') }}</th><th>{{ t('col_unit_type') }}</th>
|
||||
<th>{{ t('col_art') }}</th><th class="num">{{ t('col_gross_per_unit') }} *</th>
|
||||
<th class="num">{{ t('col_tourism') }}</th><th></th>
|
||||
</tr></thead>
|
||||
<tbody>
|
||||
{% for ln in (lines or [none]) %}
|
||||
<tr class="linerow">
|
||||
{# 'or ""': '{{ x if ln }}' renders the STRING 'None' for an empty field and it would be
|
||||
saved back as literal text (the number/date inputs just drop it, the text input keeps it) #}
|
||||
<td><input type="text" name="worker" style="width:100%" value="{{ ln.worker or '' if ln else '' }}"></td>
|
||||
<td><input type="date" name="period_from" value="{{ ln.period_from or '' if ln else '' }}"></td>
|
||||
<td><input type="date" name="period_to" value="{{ ln.period_to or '' if ln else '' }}"></td>
|
||||
<td><input type="number" name="quantity" step="any" min="0" style="width:70px" class="num" value="{{ ln.quantity if ln and ln.quantity is not none else '' }}"></td>
|
||||
<td><select name="unit_type">
|
||||
<option value="week" {{ 'selected' if ln and ln.unit_type=='week' }}>{{ t('unit_types').week }}</option>
|
||||
<option value="day" {{ 'selected' if ln and ln.unit_type=='day' }}>{{ t('unit_types').day }}</option>
|
||||
<option value="night" {{ 'selected' if ln and ln.unit_type=='night' }}>{{ t('unit_types').night }}</option>
|
||||
<option value="month" {{ 'selected' if ln and ln.unit_type=='month' }}>{{ t('unit_types').month }}</option>
|
||||
<option value="total" {{ 'selected' if ln and ln.unit_type=='total' }}>{{ t('unit_types').total }}</option></select></td>
|
||||
<td><select name="kind">
|
||||
<option value="stay" {{ 'selected' if not ln or ln.kind!='storno' }}>{{ t('line_kinds').stay }}</option>
|
||||
<option value="storno" {{ 'selected' if ln and ln.kind=='storno' }}>{{ t('line_kinds').storno }}</option></select></td>
|
||||
<td><input type="number" name="gross_per_unit" step="0.01" min="0" style="width:90px" class="num" value="{{ ln.gross_per_unit if ln and ln.gross_per_unit is not none else '' }}"></td>
|
||||
<td><input type="number" name="tourism" step="0.01" min="0" style="width:80px" class="num" value="{{ ln.tourism_tax if ln and ln.tourism_tax is not none else '' }}"></td>
|
||||
<td><button type="button" class="btn secondary small" onclick="removeLine(this)" title="{{ t('btn_remove_line') }}">✕</button></td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<p><button class="btn secondary small" type="button" onclick="addLine()">{{ t('btn_add_line') }}</button>
|
||||
<small style="color:#6b756e">{{ t('hint_invoice_tourism') }}</small></p>
|
||||
|
||||
<div class="formactions">
|
||||
<button class="btn" type="submit">{{ t('btn_save') }}</button>
|
||||
<a class="btn secondary" href="{{ url_for('invoices') }}">{{ t('btn_cancel') }}</a>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<script>
|
||||
const SUGG = {{ suggestions|tojson }};
|
||||
function setNumber(force) {
|
||||
const nr = document.querySelector('[name=number]');
|
||||
const v = SUGG[document.querySelector('[name=unit_id]').value];
|
||||
if (v && (force || !nr.value)) nr.value = v; // don't wipe an existing number on edit (no suggestions there)
|
||||
}
|
||||
function addLine() {
|
||||
const row = document.querySelector('#lines tbody .linerow');
|
||||
const copy = row.cloneNode(true);
|
||||
copy.querySelectorAll('input').forEach(i => i.value = '');
|
||||
copy.querySelectorAll('select').forEach(s => s.selectedIndex = 0);
|
||||
document.querySelector('#lines tbody').appendChild(copy);
|
||||
}
|
||||
function pickClient(btn) {
|
||||
btn.form.client.value = btn.dataset.name;
|
||||
btn.parentNode.querySelectorAll('button').forEach(b => b.classList.toggle('picked', b === btn));
|
||||
}
|
||||
function removeLine(btn) {
|
||||
const rows = document.querySelectorAll('#lines tbody .linerow');
|
||||
if (rows.length > 1) btn.closest('tr').remove();
|
||||
else btn.closest('tr').querySelectorAll('input').forEach(i => i.value = '');
|
||||
}
|
||||
setNumber(false);
|
||||
</script>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,37 @@
|
||||
{% extends "base.html" %}
|
||||
{% block content %}
|
||||
<h1>{{ t('form_payment', number=inv.number) }}</h1>
|
||||
<form class="card" method="post">
|
||||
{% if hint %}
|
||||
{# The linked Zimmerbelegung rows may already say how it was paid. Offer it, never apply it by
|
||||
itself: an issued invoice only changes by a deliberate click. #}
|
||||
<div class="field">
|
||||
<label>{{ t('lbl_payment_from_ledger') }}</label>
|
||||
<div>
|
||||
{% if hint.dates %}{{ t('col_paid_date') }}: {{ hint.dates|map('date')|join(', ') }}{% endif %}
|
||||
{% if hint.payers %} · {{ t('col_payer') }}: {{ hint.payers|join(', ') }}{% endif %}
|
||||
<button class="btn secondary" type="button" id="takeover"
|
||||
data-date="{{ hint.paid_date or '' }}" data-method="{{ hint.payment_method or '' }}"
|
||||
>{{ t('btn_take_over') }}</button>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
<div class="grid2">
|
||||
<div class="field"><label>{{ t('lbl_paid_date') }}</label>
|
||||
<input type="date" name="paid_date" value="{{ inv.paid_date or '' }}" autofocus></div>
|
||||
<div class="field"><label>{{ t('lbl_payment_method') }}</label>
|
||||
<input type="text" name="payment_method" value="{{ inv.payment_method or '' }}"></div>
|
||||
</div>
|
||||
<div class="formactions">
|
||||
<button class="btn" type="submit">{{ t('btn_save') }}</button>
|
||||
<a class="btn secondary" href="{{ url_for('invoice_view', invoice_id=inv.id) }}">{{ t('btn_cancel') }}</a>
|
||||
</div>
|
||||
</form>
|
||||
<script>
|
||||
const t = document.getElementById("takeover");
|
||||
if (t) t.addEventListener("click", function () {
|
||||
if (t.dataset.date) document.querySelector("[name=paid_date]").value = t.dataset.date;
|
||||
if (t.dataset.method) document.querySelector("[name=payment_method]").value = t.dataset.method;
|
||||
});
|
||||
</script>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,174 @@
|
||||
<!doctype html>
|
||||
<html lang="de">
|
||||
{% set accent = '#26588c' if inv.unit.theme == 'blue' else '#2f6d5b' %}
|
||||
{% set units_de = {'week':'Wochen','day':'Tage','night':'Nächte','month':'Monate','total':''} %}
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title>Rechnung {{ inv.number }}</title>
|
||||
<style>
|
||||
/* Full-bleed A4: the sheet maps 1:1 to the page so the recipient block hits the DIN window and the totals
|
||||
block anchors at a constant Y. Up to 10 line rows fit one page. */
|
||||
@page { size: A4; margin: 0; }
|
||||
* { box-sizing: border-box; }
|
||||
html, body { margin: 0; }
|
||||
body { font-family: Arial, "Segoe UI", sans-serif; color:#1b1b1b; font-size:13px; background:#e9e9e9; }
|
||||
|
||||
.toolbar { display:flex; gap:10px; width:210mm; max-width:100%; margin:14px auto 10px; }
|
||||
.toolbar a, .toolbar button { font-size:14px; padding:8px 16px; border-radius:8px; border:1px solid #ccc;
|
||||
background:#f3f3f3; color:#222; cursor:pointer; text-decoration:none; }
|
||||
.toolbar .primary { background:{{ accent }}; color:#fff; border-color:{{ accent }}; }
|
||||
.flash { width:210mm; max-width:100%; margin:0 auto 10px; box-sizing:border-box; padding:8px 14px; font-size:14px;
|
||||
background:#fff6e6; border:1px solid #e6c98a; color:#8a5a12; border-radius:8px; }
|
||||
|
||||
.sheet { position:relative; width:210mm; min-height:296mm; margin:0 auto; background:#fff;
|
||||
display:flex; flex-direction:column; box-shadow:0 1px 8px rgba(0,0,0,.18); }
|
||||
|
||||
/* Recipient — DIN 5008 form B address field (20mm left, 45mm top) → shows in a window envelope. */
|
||||
.recipient { position:absolute; left:20mm; top:45mm; width:85mm; height:45mm; }
|
||||
.recipient .return { font-size:8px; color:#555; border-bottom:1px solid #999;
|
||||
padding-bottom:2px; margin-bottom:9px; white-space:nowrap; }
|
||||
.recipient .to { line-height:1.4; }
|
||||
.recipient .to b { font-size:15px; }
|
||||
.recipient .uid { font-size:10px; color:#333; margin-top:3px; }
|
||||
|
||||
/* .main grows to push the bottom block (totals+notice+footer) to a fixed position → totals at a constant Y. */
|
||||
.main { flex:1 0 auto; display:flex; flex-direction:column; padding:9mm 14mm 0; }
|
||||
/* 82mm clears both the address window (bottom 90mm) and the branding stack for every unit. */
|
||||
.masthead { position:relative; min-height:82mm; }
|
||||
.masthead .brand .name { color:{{ accent }}; font-size:25px; font-weight:700; letter-spacing:1px; line-height:1.1; }
|
||||
.masthead .brand .sub { color:{{ accent }}; font-style:italic; margin:2px 0 0; }
|
||||
/* Branding column, top-right, spanning the masthead. Each image gets an explicit size from
|
||||
masthead_art(): all images of a unit share ONE width, solved so the stack fills the box height
|
||||
(capped at the box width) — logo and house always render equally wide, with no dead letterbox
|
||||
space. Box is 64 x 80 mm, gap 4mm — keep in sync with ART_BOX_* in web.py. */
|
||||
.masthead .art { position:absolute; top:0; right:0; bottom:2mm; width:64mm;
|
||||
display:flex; flex-direction:column; align-items:flex-end; justify-content:center; gap:4mm; }
|
||||
.masthead .art img { border-radius:2px; }
|
||||
|
||||
.title { font-weight:700; font-size:16px; margin:0 0 3px; }
|
||||
.date { font-size:12px; margin-bottom:8px; }
|
||||
|
||||
table.lines { width:100%; border-collapse:collapse; font-size:10.5px; }
|
||||
table.lines th, table.lines td { border:1px solid #9a9a9a; } /* full gridlines */
|
||||
table.lines th { background:{{ accent }}; color:#fff; font-weight:600; text-align:left; padding:4px 6px; font-size:9.5px; }
|
||||
table.lines td { padding:2px 6px; vertical-align:bottom; line-height:1.25; }
|
||||
table.lines td.nm { vertical-align:top; } table.lines td.nm .wn { font-weight:700; }
|
||||
table.lines td.n, table.lines th.n { text-align:right; font-variant-numeric:tabular-nums; white-space:nowrap; }
|
||||
|
||||
/* Anchored bottom block. */
|
||||
.bottom { flex-shrink:0; padding:0 14mm; }
|
||||
table.totals { margin-left:auto; border-collapse:collapse; font-size:13px; margin-top:5mm; }
|
||||
table.totals td { padding:3px 10px; }
|
||||
table.totals td.l { text-align:right; font-weight:700; color:#333; white-space:nowrap; }
|
||||
table.totals td.v { text-align:right; font-variant-numeric:tabular-nums; min-width:120px; }
|
||||
table.totals tr.grand td { font-size:15px; font-weight:700; border-top:2px solid {{ accent }}; color:{{ accent }}; padding-top:5px; }
|
||||
.note { text-align:center; white-space:pre-line; min-height:12mm; margin:6mm 0 0; font-size:12px; }
|
||||
.thanks { text-align:center; font-weight:600; margin:4mm 0 0; }
|
||||
|
||||
footer { flex-shrink:0; display:flex; justify-content:center; gap:40px; margin:5mm 14mm 10mm;
|
||||
border-top:1px solid #ddd; padding-top:8px; font-size:11px; color:#444; }
|
||||
footer .col b { color:{{ accent }}; display:block; margin-bottom:3px; }
|
||||
|
||||
@media print { body { background:#fff; } .toolbar, .flash { display:none; } .sheet { box-shadow:none; margin:0; } }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="toolbar">
|
||||
<button class="primary" onclick="window.print()">{{ t('btn_print') }}</button>
|
||||
<a href="{{ url_for('invoice_pdf', invoice_id=inv.id) }}">{{ t('btn_pdf') }}</a>
|
||||
<a href="{{ url_for('invoice_edit', invoice_id=inv.id) }}">{{ t('btn_edit') }}</a>
|
||||
<a href="{{ url_for('invoice_payment', invoice_id=inv.id) }}">{{ t('btn_payment') }}{% if inv.paid_date %} ✓{% endif %}</a>
|
||||
<a href="{{ url_for('invoices') }}">{{ t('btn_back') }}</a>
|
||||
{% if can_regen %}
|
||||
<form method="post" action="{{ url_for('invoice_regenerate', invoice_id=inv.id) }}" style="display:inline"
|
||||
onsubmit="return confirm('{{ t('confirm_regenerate') }}')">
|
||||
<button type="submit" class="primary">{{ t('btn_regenerate') }}</button>
|
||||
</form>
|
||||
{% endif %}
|
||||
<form method="post" action="{{ url_for('invoice_delete', invoice_id=inv.id) }}" style="display:inline;margin-left:auto"
|
||||
onsubmit="return confirm('{{ t('confirm_delete') }}')">
|
||||
<button type="submit" style="background:#fff;color:#b23a3a;border:1px solid #e3b7b7;padding:8px 16px;border-radius:8px;cursor:pointer">{{ t('btn_delete') }}</button>
|
||||
</form>
|
||||
</div>
|
||||
{% with msgs = get_flashed_messages() %}
|
||||
{% if msgs %}<div class="flash">{% for m in msgs %}<div>{{ m }}</div>{% endfor %}</div>{% endif %}
|
||||
{% endwith %}
|
||||
|
||||
<div class="sheet">
|
||||
<div class="recipient">
|
||||
<div class="return">{{ inv.unit.name }}, {{ (inv.unit.address or '')|replace('\n', ', ') }}</div>
|
||||
<div class="to">
|
||||
{% if inv.client and inv.client.name %}
|
||||
{% if inv.client.kind == 'private' %}Herrn{% else %}Firma{% endif %}<br>
|
||||
<b>{{ inv.client.name }}</b><br>
|
||||
{{ inv.client.address or '' }}{% if inv.client.postcode or inv.client.city %}<br>{{ inv.client.postcode or '' }} {{ inv.client.city or '' }}{% endif %}
|
||||
{% if inv.gross and inv.gross > 10000 and inv.client.uid %}<div class="uid">UID: {{ inv.client.uid }}</div>{% endif %}
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="main">
|
||||
<div class="masthead">
|
||||
<div class="brand">
|
||||
<div class="name">{{ inv.unit.name }}</div>
|
||||
<div class="sub">{{ inv.unit.subtitle }}</div>
|
||||
</div>
|
||||
<div class="art">
|
||||
{% for a in masthead_art(inv.unit) %}
|
||||
<img class="{{ a.cls }}" src="{{ asset_uri(a.src) }}" alt="" style="width:{{ a.w }}mm; height:{{ a.h }}mm">
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="title">RECHNUNG für Unterkunft {{ inv.number }}</div>
|
||||
<div class="date">{{ inv.unit.place ~ ', am' if inv.unit.place else 'Datum:' }} {{ inv.date|date }}</div>
|
||||
|
||||
<table class="lines">
|
||||
<thead><tr>
|
||||
<th>Name / Zeitraum</th><th class="n">Anz</th><th>Einheit</th><th class="n">Brutto/Einh.</th>
|
||||
<th class="n">Netto/Einh.</th><th class="n">Summe Netto</th><th class="n">USt</th>
|
||||
<th class="n">Summe Brutto</th><th class="n">Ortstaxe</th>
|
||||
</tr></thead>
|
||||
<tbody>
|
||||
{% for ln in inv.lines %}
|
||||
<tr>
|
||||
{% if ln.kind == 'storno' %}
|
||||
<td class="nm"><div class="wn">{{ ln.worker }}</div>Stornogebühr – nicht steuerbarer Schadenersatz{% if ln.period_from %} · {{ ln.period_from|date }}{% endif %}</td>
|
||||
<td class="n">{{ ln.quantity|num(0) if (ln.quantity or 0) % 1 == 0 else ln.quantity|num(1) }}</td><td>{{ units_de.get(ln.unit_type, ln.unit_type) }}</td>
|
||||
<td class="n">{{ ln.gross_per_unit|euro }}</td><td class="n">—</td>
|
||||
<td class="n">—</td><td class="n">—</td>
|
||||
<td class="n">{{ ln.line_gross|euro }}</td><td class="n"></td>
|
||||
{% else %}
|
||||
<td class="nm"><div class="wn">{{ ln.worker }}</div>{{ ln.period_from|date }}{% if ln.period_to %} – {{ ln.period_to|date }}{% endif %}</td>
|
||||
<td class="n">{{ ln.quantity|num(0) if (ln.quantity or 0) % 1 == 0 else ln.quantity|num(1) }}</td><td>{{ units_de.get(ln.unit_type, ln.unit_type) }}</td>
|
||||
<td class="n">{{ ln.gross_per_unit|euro }}</td><td class="n">{{ ln.net_per_unit|num }}</td>
|
||||
<td class="n">{{ ln.line_net|num }}</td><td class="n">{{ ln.line_vat|num }}</td>
|
||||
<td class="n">{{ ln.line_gross|euro }}</td>
|
||||
<td class="n">{% if ln.tourism_tax %}{{ ln.tourism_tax|euro }}{% endif %}</td>
|
||||
{% endif %}
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div class="bottom">
|
||||
<table class="totals">
|
||||
<tr><td class="l">{% if inv.storno %}Steuerbarer Nettobetrag{% else %}Nettobetrag{% endif %}</td><td class="v">{{ inv.net|euro }}</td></tr>
|
||||
{% for b in inv.vat_breakdown %}<tr><td class="l">USt {{ b.pct }} %</td><td class="v">{{ b.vat|euro }}</td></tr>{% endfor %}
|
||||
{% if inv.storno %}<tr><td class="l">Stornogebühr (nicht steuerbar)</td><td class="v">{{ inv.storno|euro }}</td></tr>{% endif %}
|
||||
{% if inv.tourism_tax %}<tr><td class="l">Tourismusabgabe</td><td class="v">{{ inv.tourism_tax|euro }}</td></tr>{% endif %}
|
||||
<tr class="grand"><td class="l">Rechnungsbetrag</td><td class="v">{{ inv.gross|euro }}</td></tr>
|
||||
</table>
|
||||
<div class="note">{{ inv.notes or t('invoice_note_default') }}</div>
|
||||
<div class="thanks">Vielen Dank!</div>
|
||||
</div>
|
||||
|
||||
<footer>
|
||||
<div class="col"><b>Anschrift:</b>{{ inv.unit.name }}<br>{{ inv.unit.subtitle }}<br>{{ (inv.unit.address or '')|e|replace('\n', '<br>'|safe) }}</div>
|
||||
<div class="col"><b>Bankverbindung:</b>{{ inv.unit.bank }}<br>IBAN: {{ inv.unit.iban }}<br><br>UID: {{ inv.unit.uid }}</div>
|
||||
{% if inv.unit.contact %}<div class="col"><b>Kontakt:</b>{{ inv.unit.contact|e|replace('\n', '<br>'|safe) }}</div>{% endif %}
|
||||
</footer>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,54 @@
|
||||
{% extends "base.html" %}
|
||||
{% block main_class %}grid-page{% endblock %}
|
||||
{% block content %}
|
||||
{% set unit_opts = rows|map(attribute='unit')|select|unique|sort %}
|
||||
<h1>{{ t('invoices_title') }}</h1>
|
||||
<div class="toolbar">
|
||||
<input class="search live-search" data-grid="inv" type="text" placeholder="{{ t('search_placeholder') }}">
|
||||
<select class="live-filter" data-grid="inv" data-col="{{ t('col_unit') }}"><option value="">{{ t('filter_all', col=t('col_unit')) }}</option>
|
||||
{% for u in unit_opts %}<option>{{ u }}</option>{% endfor %}</select>
|
||||
<select class="live-filter" data-grid="inv" data-col="{{ t('col_payment') }}"><option value="">{{ t('filter_all', col=t('col_payment')) }}</option>
|
||||
<option>{{ t('payment_paid') }}</option><option>{{ t('payment_open') }}</option>
|
||||
<option value="{{ t('payment_unknown') }}">{{ t('payment_unknown_label') }}</option></select>
|
||||
<span class="count"><span class="grid-count" data-grid="inv">{{ rows|length }}</span> {{ t('showing_count', count='')|trim }}</span>
|
||||
<span class="count">{{ t('sum_label') }}: <span class="grid-sum" data-grid="inv" data-col="{{ t('col_invoice_total') }}"></span> €</span>
|
||||
<span class="spacer"></span>
|
||||
<button class="btn secondary" type="button" onclick="gridPrint('inv')">{{ t('btn_print') }}</button>
|
||||
<a class="btn" href="{{ url_for('invoice_new') }}">+ {{ t('btn_new_invoice') }}</a>
|
||||
</div>
|
||||
<p class="printinfo" data-grid="inv"></p>
|
||||
<div class="tablewrap">
|
||||
<table id="inv" class="grid">
|
||||
<thead><tr>
|
||||
<th>{{ t('col_invoice') }}</th><th>{{ t('col_date') }}</th><th>{{ t('col_unit') }}</th>
|
||||
<th>{{ t('col_client') }}</th><th class="num">{{ t('col_invoice_total') }}</th>
|
||||
<th>{{ t('col_payment') }}</th><th>{{ t('col_paid_date') }}</th>
|
||||
<th class="nosort actions"></th>
|
||||
</tr></thead>
|
||||
<tbody>
|
||||
{# rows ship inside an inert <template>: grids.js renders only the visible window (see freezeLayout) #}
|
||||
<template>
|
||||
{% for r in rows %}
|
||||
<tr>
|
||||
<td data-k="{{ '%04d.%04d'|format(r.number_key[0], r.number_key[1]) }}">{{ r.number }}</td><td>{{ r.date|date }}</td>
|
||||
<td>{% if r.unit %}<span class="pill unit">{{ r.unit }}</span>{% endif %}</td>
|
||||
<td>{{ r.client or '' }}</td>
|
||||
<td class="num">{{ r.gross|euro if r.gross else '' }}</td>
|
||||
<td>{% if r.payment_state == 'paid' %}<span class="pill paid">{{ t('payment_paid') }}</span>
|
||||
{% elif r.payment_state == 'open' %}<span class="pill open">{{ t('payment_open') }}</span>
|
||||
{% else %}{{ t('payment_unknown') }}{% endif %}</td>
|
||||
<td>{{ r.paid_on|date }}</td>
|
||||
<td class="actions">
|
||||
<a class="btn secondary small" href="{{ url_for('invoice_view', invoice_id=r.id) }}">{{ t('btn_open') }}</a>
|
||||
<a class="btn secondary small" href="{{ url_for('invoice_edit', invoice_id=r.id) }}">{{ t('btn_edit') }}</a>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</template>
|
||||
{% if not rows %}
|
||||
<tr data-empty="1"><td class="empty" colspan="8">{{ t('no_results') }}</td></tr>
|
||||
{% endif %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,96 @@
|
||||
{% extends "base.html" %}
|
||||
{% block main_class %}grid-page{% endblock %}
|
||||
{% block content %}
|
||||
<h1>{{ t('ledger_title') }}</h1>
|
||||
<div class="toolbar">
|
||||
<input class="search live-search" data-grid="led" type="text" placeholder="{{ t('search_placeholder') }}">
|
||||
{# Jahr first: the list spans many years, and picking one is the most common filter #}
|
||||
<select class="live-filter" data-grid="led" data-col="{{ t('col_from') }}">
|
||||
<option value="">{{ t('filter_all', col=t('filter_year')) }}</option>
|
||||
{% for y in years %}<option>{{ y }}</option>{% endfor %}</select>
|
||||
<select class="live-filter" data-grid="led" data-col="{{ t('hd_room') }}"><option value="">{{ t('filter_all', col=t('col_room')) }}</option>
|
||||
{% for r in rooms %}<option>{{ r.number }}</option>{% endfor %}</select>
|
||||
{# between Zimmer and Bezahlt: who is still here? A row without 'Bis' is still running. #}
|
||||
<select class="live-filter" data-grid="led" data-col="{{ t('col_to') }}">
|
||||
<option value="">{{ t('filter_all', col=t('filter_persons')) }}</option>
|
||||
<option>{{ t('filter_still_here') }}</option></select>
|
||||
<select class="live-filter" data-grid="led" data-col="{{ t('col_payment') }}"><option value="">{{ t('filter_all', col=t('col_payment')) }}</option>
|
||||
<option>{{ t('payment_paid') }}</option><option>{{ t('payment_open') }}</option><option>{{ t('payment_cash') }}</option>
|
||||
<option value="{{ t('payment_unknown') }}">{{ t('payment_unknown_label') }}</option></select>
|
||||
<span class="count"><span class="grid-count" data-grid="led">{{ rows|length }}</span> {{ t('showing_count', count='')|trim }}</span>
|
||||
<span class="count">{{ t('sum_label') }}: <span class="grid-sum" data-grid="led" data-col="{{ t('col_amount') }}"></span> €</span>
|
||||
<span class="spacer"></span>
|
||||
<button class="btn secondary" type="button" onclick="invoiceFromSelection()">{{ t('btn_invoice_from_selection') }}</button>
|
||||
<button class="btn secondary" type="button" onclick="gridPrint('led')">{{ t('btn_print') }}</button>
|
||||
<a class="btn" href="{{ url_for('ledger_new') }}">+ {{ t('btn_add_ledger') }}</a>
|
||||
</div>
|
||||
<p class="printinfo" data-grid="led"></p>
|
||||
|
||||
<div class="tablewrap">
|
||||
{# data-pin: Zimmer + Name stay on screen while scrolling to the payment columns.
|
||||
data-max: tighter maximum widths, so RNr. and Re vom can be read on a small screen (1280 px at 150 %)
|
||||
without scrolling — anything longer is cut off and shows in full on hover #}
|
||||
<table id="led" class="grid" data-scroll="end" data-pin="1 2 6">
|
||||
<thead><tr>
|
||||
<th class="nosort selcol"></th><th title="{{ t('col_room') }}">{{ t('hd_room') }}</th><th>{{ t('col_from') }}</th><th>{{ t('col_to') }}</th>
|
||||
<th title="{{ t('col_gb') }}" data-max="70">{{ t('hd_gb') }}</th><th data-max="135">{{ t('col_occupant') }}</th><th class="num">{{ t('col_days') }}</th>
|
||||
<th class="num">{{ t('col_rate') }}</th><th class="num" title="{{ t('col_qty') }}">{{ t('hd_qty') }}</th>
|
||||
<th class="num" title="{{ t('col_persons') }}">{{ t('hd_persons') }}</th><th class="num">{{ t('col_amount') }}</th>
|
||||
<th class="num">{{ t('col_tourism_tax') }}</th>
|
||||
<th title="{{ t('col_payer') }}" data-max="90">{{ t('hd_payer') }}</th><th data-max="40">{{ t('col_art') }}</th><th title="{{ t('col_invoice') }}" data-max="72">{{ t('hd_invoice') }}</th>
|
||||
<th title="{{ t('col_invoice_date') }}">{{ t('hd_invoice_date') }}</th><th data-max="105">{{ t('col_payment') }}</th>
|
||||
<th title="{{ t('col_paid_date') }}">{{ t('hd_paid_date') }}</th><th>{{ t('col_notes') }}</th><th class="nosort actions"></th>
|
||||
</tr></thead>
|
||||
<tbody>
|
||||
{# rows ship inside an inert <template>: grids.js renders only the visible window (see freezeLayout) #}
|
||||
<template>
|
||||
{% for r in rows %}
|
||||
<tr>
|
||||
<td class="selcol"><input type="checkbox" class="rowsel" value="{{ r.charge_id }}"></td>
|
||||
<td>{{ r.room or '' }}</td>
|
||||
{%- set year = (r.first_date or '')[:4] %}
|
||||
{#- the weekday before the date, to be seen at a glance. data-k keeps the sort:
|
||||
makeKey only recognises a bare DD.MM.YYYY; with "Mo " in front it would sort alphabetically -#}
|
||||
<td{% if year %} data-f="{{ year }}"{% endif %}{% if r.period_from %} data-k="{{ r.period_from|replace('-', '') }}"{% endif %}>{{ r.period_from|datewd }}</td>
|
||||
{#- 'noch da' = a running occupancy: Von set, no Bis, and either a Gästeblatt number or not billed
|
||||
yet. 'Bereitstellung'/'Storno' rows without Bis are settled items, not people in the house. -#}
|
||||
<td{% if r.period_from and not r.period_to and (r.gb_ref or not r.line_net_override) %} data-f="{{ t('filter_still_here') }}"{% endif %}{% if r.period_to %} data-k="{{ r.period_to|replace('-', '') }}"{% endif %}>{{ r.period_to|datewd }}</td><td>{{ r.gb_ref or '' }}</td><td>{{ r.occupant or '' }}</td>
|
||||
<td class="num">{{ r.days if r.days is not none else '' }}</td>
|
||||
<td class="num">{{ r.gross_per_unit|num if r.gross_per_unit else '' }}</td>
|
||||
<td class="num">{{ (r.quantity|num(0) if r.quantity % 1 == 0 else r.quantity|num(1)) if r.quantity is not none else '' }}</td>
|
||||
<td class="num">{{ r.persons|num(0) if r.persons }}</td>
|
||||
<td class="num">{{ r.amount|euro }}</td>
|
||||
<td class="num">{{ r.tourism_tax|euro if r.tourism_tax else '' }}</td>
|
||||
<td>{{ r.payer or '' }}</td><td>{{ r.art or '' }}</td>
|
||||
<td>{% if r.invoice_id %}<a href="{{ url_for('invoice_view', invoice_id=r.invoice_id) }}">{{ r.invoice_no }}</a>
|
||||
{% else %}{{ r.invoice_no or '' }}{% endif %}</td>
|
||||
<td>{{ r.invoice_date|date }}</td>
|
||||
{#- paid or open: repo._payment — the Umsatzsteuer list reads the same rule -#}
|
||||
{%- set payment = r.payment %}
|
||||
{#- data-f: the filter asks for the STATE, even when text stands beside it (the part payment)
|
||||
— without it "OFFEN" would miss exactly these rows -#}
|
||||
<td data-f="{{ t('payment_' + payment) }}">
|
||||
{%- if payment == 'unknown' %}{{ t('payment_unknown') }}
|
||||
{%- else %}<span class="pill {{ payment }}">{{ t('payment_' + payment) }}</span>{% endif %}
|
||||
{%- if r.open_amount %} {{ t('payment_part', paid=r.paid_amount|euro, open=r.open_amount|euro) }}{% endif %}</td>
|
||||
{#- without a date of the row's own, this shows when the INVOICE was paid -#}
|
||||
<td>{{ r.paid_on|date }}</td>
|
||||
<td>{{ r.notes or '' }}</td>
|
||||
<td class="actions"><a class="btn secondary small" href="{{ url_for('ledger_edit', charge_id=r.charge_id) }}">{{ t('btn_edit') }}</a></td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</template>
|
||||
{% if not rows %}
|
||||
<tr data-empty="1"><td class="empty" colspan="20">{{ t('no_results') }}</td></tr>
|
||||
{% endif %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<script>
|
||||
function invoiceFromSelection() {
|
||||
var ids = window.gridChecked('led'); // from the grid model — most rows are not in the DOM
|
||||
if (!ids.length) { alert('Bitte zuerst Zeilen auswählen.'); return; }
|
||||
window.location = '{{ url_for("invoice_new") }}?charges=' + ids.join(',');
|
||||
}
|
||||
</script>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,167 @@
|
||||
{% extends "base.html" %}
|
||||
{% block content %}
|
||||
<h1>{% if is_new %}{{ t('form_new_ledger') }}{% else %}{{ t('form_edit_ledger') }}{% if ch.room %} – {{ t('form_room', room=ch.room) }}{% endif %}{% endif %}</h1>
|
||||
<form class="card" method="post"{% if is_new %} action="{{ url_for('ledger_new') }}"{% endif %}>
|
||||
{% if is_new %}
|
||||
<div class="field"><label>{{ t('col_room') }}</label>
|
||||
<select name="room_id"><option value="">—</option>
|
||||
{% for r in rooms %}<option value="{{ r.id }}">{{ r.number }}</option>{% endfor %}</select></div>
|
||||
{% endif %}
|
||||
<div class="grid2">
|
||||
<div class="field"><label>{{ t('col_occupant') }}</label>
|
||||
<input type="text" name="occupant" value="{{ ch.occupant or '' }}" autofocus></div>
|
||||
<div class="field"><label>{{ t('col_gb_number') }}</label>
|
||||
<input type="text" name="gb_ref" value="{{ ch.gb_ref or '' }}"></div>
|
||||
</div>
|
||||
<div class="grid2">
|
||||
<div class="field"><label>{{ t('col_from') }}</label>
|
||||
<input type="date" name="period_from" value="{{ ch.period_from or '' }}"></div>
|
||||
<div class="field"><label>{{ t('col_to') }}</label>
|
||||
<input type="date" name="period_to" value="{{ ch.period_to or '' }}"></div>
|
||||
</div>
|
||||
<div class="grid2">
|
||||
<div class="field"><label>{{ t('col_persons') }}</label>
|
||||
<input type="number" name="persons" step="1" min="0" value="{{ ch.persons if ch.persons is not none else '' }}"></div>
|
||||
<div class="field"><label>{{ t('col_qty') }}</label>
|
||||
<input type="number" name="quantity" step="any" min="0" value="{{ ch.quantity if ch.quantity is not none else '' }}"></div>
|
||||
</div>
|
||||
<div class="grid2">
|
||||
<div class="field"><label>{{ t('col_unit_type') }}</label>
|
||||
<select name="unit_type">
|
||||
{% for v, lbl in t('unit_types').items() %}
|
||||
<option value="{{ v }}" {{ 'selected' if ch.unit_type==v }}>{{ lbl }}</option>{% endfor %}</select></div>
|
||||
<div class="field"><label>{{ t('col_rate_per_unit') }}</label>
|
||||
<input type="number" name="gross_per_unit" step="0.01" min="0" value="{{ ch.gross_per_unit if ch.gross_per_unit is not none else '' }}"></div>
|
||||
</div>
|
||||
<div class="grid2">
|
||||
<div class="field"><label>{{ t('col_amount_eur') }}</label>
|
||||
<input type="number" name="betrag" step="0.01" min="0" value="{{ ch.betrag if ch.betrag is not none else '' }}"></div>
|
||||
<div class="field"><label>{{ t('col_tourism_tax') }} (€)</label>
|
||||
<input type="number" name="tourism_tax" step="0.01" min="0" data-rate="{{ tourism_rate }}"
|
||||
value="{{ ch.tourism_tax if ch.tourism_tax is not none else '' }}"></div>
|
||||
</div>
|
||||
<div class="grid2">
|
||||
<div class="field"><label>{{ t('col_art') }}</label>
|
||||
<input type="text" name="art" value="{{ ch.art or '' }}"></div>
|
||||
<div class="field"></div>
|
||||
</div>
|
||||
<div class="grid2">
|
||||
<div class="field"><label>{{ t('col_payer') }}</label>
|
||||
<input type="text" name="payer" value="{{ ch.payer or '' }}"></div>
|
||||
<div class="field"><label>{{ t('col_invoice') }}</label>
|
||||
<input type="text" name="rnr" value="{{ ch.rnr or '' }}"></div>
|
||||
</div>
|
||||
<div class="field"><label>{{ t('col_notes') }}</label>
|
||||
<input type="text" name="notes" value="{{ ch.notes or '' }}"></div>
|
||||
<div class="grid2">
|
||||
<div class="field"><label>{{ t('col_payment') }}</label>
|
||||
<select name="paid_status">
|
||||
<option value="" {{ 'selected' if not ch.paid_status }}>—</option>
|
||||
<option value="paid" {{ 'selected' if ch.paid_status=='paid' }}>{{ t('payment_paid') }}</option>
|
||||
<option value="open" {{ 'selected' if ch.paid_status=='open' }}>{{ t('payment_open') }}</option>
|
||||
<option value="cash" {{ 'selected' if ch.paid_status=='cash' }}>{{ t('payment_cash') }}</option></select></div>
|
||||
<div class="field"><label>{{ t('lbl_paid_date') }}</label>
|
||||
<input type="date" name="paid_date" value="{{ ch.paid_date or '' }}"></div>
|
||||
</div>
|
||||
{# Payments in parts, each with date and amount (paid without the Ortstaxe, or in two instalments).
|
||||
For the usual ONE full payment "bezahlt am" above is enough. #}
|
||||
<div class="field"><label>{{ t('lbl_payments') }}</label>
|
||||
<div id="payLines">
|
||||
{% for d, a in (ch.payments or []) %}
|
||||
<div class="pay-line"><input type="date" name="pay_date" value="{{ d }}" required>
|
||||
<input type="number" name="pay_amount" step="0.01" min="0" value="{{ '%.2f'|format(a) }}"></div>
|
||||
{% endfor %}
|
||||
<div class="pay-line"><input type="date" name="pay_date">
|
||||
<input type="number" name="pay_amount" step="0.01" min="0" placeholder="€"></div>
|
||||
</div>
|
||||
<button type="button" class="btn secondary small" id="addPayment">{{ t('btn_add_payment') }}</button>
|
||||
<small>{{ t('hint_payments') }}</small></div>
|
||||
<div class="formactions">
|
||||
<button class="btn" type="submit">{{ t('btn_save') }}</button>
|
||||
{% if not is_new %}
|
||||
{# month end: bill up to the invoice date and let the occupancy run on from that day #}
|
||||
<button class="btn secondary" type="submit" name="action" value="continue"
|
||||
title="{{ t('hint_continue') }}">{{ t('btn_continue') }}</button>
|
||||
{% endif %}
|
||||
<a class="btn secondary" href="{{ url_for('ledger') }}">{{ t('btn_cancel') }}</a>
|
||||
</div>
|
||||
</form>
|
||||
{% if ch.id %}
|
||||
<form class="formactions" method="post" action="{{ url_for('ledger_delete', charge_id=ch.id) }}"
|
||||
onsubmit="return confirm('{{ t('confirm_delete') }}')">
|
||||
<button class="btn danger" type="submit">{{ t('btn_delete') }}</button>
|
||||
</form>
|
||||
{% endif %}
|
||||
<script>
|
||||
// Editing Satz, Anzahl or Personen recomputes the Betrag the way the ledger counts it:
|
||||
// Anzahl x Satz x PERSONEN (2 Pers. x 1 Woche x 161 = 322, not 161). Betrag stays a normal input,
|
||||
// so a negotiated amount typed by hand sticks until one of the three changes again.
|
||||
const rate = document.querySelector('[name=gross_per_unit]'),
|
||||
qty = document.querySelector('[name=quantity]'),
|
||||
betrag = document.querySelector('[name=betrag]'),
|
||||
taxe = document.querySelector('[name=tourism_tax]'),
|
||||
persons = document.querySelector('[name=persons]'),
|
||||
from = document.querySelector('[name=period_from]'),
|
||||
to = document.querySelector('[name=period_to]'),
|
||||
unit = document.querySelector('[name=unit_type]');
|
||||
function recalc() {
|
||||
const r = parseFloat(rate.value), q = parseFloat(qty.value), p = parseFloat(persons.value) || 1;
|
||||
if (!isNaN(r) && !isNaN(q)) betrag.value = (q * r * p).toFixed(2);
|
||||
}
|
||||
// Changing Von/Bis fills the Anzahl: for Wochen a started week counts in full from 4 nights on, like the
|
||||
// invoice (compute.weeks_for_period: 12 nights are 2 weeks), a shorter stay the fraction priced by
|
||||
// hand; the plain day count for Tag/Nacht. Anzahl stays an ordinary input, so a value typed by hand
|
||||
// stands until a date moves again. Monat/gesamt derive nothing.
|
||||
const PER_UNIT = { week: 7, day: 1, night: 1 };
|
||||
function recalcQty() {
|
||||
const a = Date.parse(from.value), b = Date.parse(to.value), per = PER_UNIT[unit.value];
|
||||
if (isNaN(a) || isNaN(b) || !per) return;
|
||||
const nights = (b - a) / 86400000; // both dates parse as UTC -> exact day count
|
||||
qty.value = unit.value === 'week' && nights >= 4 ? Math.ceil(nights / 7) : +(nights / per).toFixed(6);
|
||||
recalc();
|
||||
recalcTaxe(nights);
|
||||
}
|
||||
// Ortstaxe = Nächte x Satz x Personen, but only for a stay of at most 10 nights: longer professional
|
||||
// stays are exempt (T-AAG §4). A hand-typed amount is left alone, like the Betrag.
|
||||
function recalcTaxe(nights) {
|
||||
const rate = parseFloat(taxe.dataset.rate), p = parseFloat(persons.value) || 1;
|
||||
if (isNaN(rate) || nights <= 0) return;
|
||||
taxe.value = nights > 10 ? '' : (nights * rate * p).toFixed(2);
|
||||
}
|
||||
// entering a payment date means paid — set the dropdown with it instead of asking for it twice
|
||||
const paidStatus = document.querySelector('[name=paid_status]'),
|
||||
paidDate = document.querySelector('[name=paid_date]'),
|
||||
payLines = document.getElementById('payLines');
|
||||
paidDate.addEventListener('change', function () {
|
||||
if (paidDate.value && !paidStatus.value) paidStatus.value = 'paid';
|
||||
});
|
||||
// Payments in parts decide by themselves, like the server (repo._settle): once they cover Betrag +
|
||||
// Ortstaxe the row is paid — on the day of the last one —, otherwise it stays open. Typing an amount
|
||||
// requires a date with it: a payment without a date belongs to no quarter.
|
||||
function checkPayments() {
|
||||
let sum = 0, last = '';
|
||||
payLines.querySelectorAll('.pay-line').forEach(function (z) {
|
||||
const d = z.querySelector('[name=pay_date]'), a = parseFloat(z.querySelector('[name=pay_amount]').value);
|
||||
d.required = !isNaN(a);
|
||||
if (!isNaN(a) && d.value) { sum += a; if (d.value > last) last = d.value; }
|
||||
});
|
||||
if (!last) return;
|
||||
paidStatus.value = sum + 0.005 >= total() ? 'paid' : 'open';
|
||||
paidDate.value = last;
|
||||
}
|
||||
payLines.addEventListener('input', checkPayments);
|
||||
document.getElementById('addPayment').addEventListener('click', function () {
|
||||
const line = payLines.lastElementChild.cloneNode(true);
|
||||
line.querySelectorAll('input').forEach(function (i) { i.value = ''; i.required = false; });
|
||||
payLines.appendChild(line);
|
||||
line.querySelector('input').focus();
|
||||
});
|
||||
function total() {
|
||||
return (parseFloat(betrag.value) || 0) + (parseFloat(taxe.value) || 0);
|
||||
}
|
||||
rate.addEventListener('input', recalc);
|
||||
qty.addEventListener('input', recalc);
|
||||
persons.addEventListener('input', recalc);
|
||||
[from, to, unit, persons].forEach(el => el.addEventListener('change', recalcQty));
|
||||
</script>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,87 @@
|
||||
{% extends "base.html" %}
|
||||
{% block main_class %}grid-page{% endblock %}
|
||||
{% block content %}
|
||||
<h1>{{ t('plan_title') }}</h1>
|
||||
<div class="toolbar">
|
||||
<a class="btn secondary small" href="{{ url_for('plan_page', start=p.prev_start, weeks=weeks) }}"
|
||||
title="{{ t('plan_prev_week') }}">◀</a>
|
||||
<a class="btn secondary small" href="{{ url_for('plan_page', weeks=weeks) }}">{{ t('plan_today') }}</a>
|
||||
<a class="btn secondary small" href="{{ url_for('plan_page', start=p.next_start, weeks=weeks) }}"
|
||||
title="{{ t('plan_next_week') }}">▶</a>
|
||||
<span class="count">{{ p.start.isoformat()|date }} – {{ p.last|date }}</span>
|
||||
<span class="seg">
|
||||
<a class="{{ 'on' if weeks == 4 }}" href="{{ url_for('plan_page', start=p.start.isoformat(), weeks=4) }}">{{ t('plan_weeks_4') }}</a>
|
||||
<a class="{{ 'on' if weeks == 8 }}" href="{{ url_for('plan_page', start=p.start.isoformat(), weeks=8) }}">{{ t('plan_weeks_8') }}</a>
|
||||
</span>
|
||||
<span class="spacer"></span>
|
||||
<span class="count">{{ t('plan_guests_one') if p.guests_today == 1 else t('plan_guests', guests=p.guests_today) }} · {{ t('plan_free_one') if p.free_beds == 1 else t('plan_free', free=p.free_beds) }}</span>
|
||||
</div>
|
||||
|
||||
<div class="tablewrap planwrap">
|
||||
<div class="plan" style="--days:{{ p.n_days }}">
|
||||
<div class="plan-head">
|
||||
<div class="plan-hrow">
|
||||
<div class="plan-label"></div>
|
||||
<div class="plan-cells">
|
||||
{% for m in p.months %}<div class="pm" style="grid-column:span {{ m.span }}">{{ m.text }}</div>{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
<div class="plan-hrow">
|
||||
<div class="plan-label"></div>
|
||||
<div class="plan-cells plan-daysrow">
|
||||
{% for d in p.days %}
|
||||
<div class="pd{{ ' we' if d.weekend }}{{ ' today' if d.today }}{{ ' mon' if d.monday }}{{ ' m1' if d.month_start }}">
|
||||
{% if p.n_days <= 35 %}<b>{{ d.dom }}</b><span>{{ d.wd }}</span>
|
||||
{% elif d.monday %}<b>{{ d.label }}</b>{% endif %}
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% for r in p.rooms %}
|
||||
<div class="plan-row{{ ' newfloor' if loop.index0 and r.floor != p.rooms[loop.index0 - 1].floor }}">
|
||||
<div class="plan-label">
|
||||
<div class="rl-top"><b>{{ t('plan_room', number=r.number) }}</b>
|
||||
{% if r.long_term %}
|
||||
<span class="pill unit" title="{{ t('plan_longterm') }}">{{ t('plan_longterm_short') }}</span>
|
||||
{% else %}
|
||||
<span class="pill {{ 'open' if r.today_count > r.capacity else ('have' if r.today_count >= r.capacity else 'paid') }}"
|
||||
title="{{ t('plan_pill_tip') }}">{{ r.today_count }}/{{ r.capacity }}</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
<div class="rl-sub">{{ r.category }} · {{ r.unit }}</div>
|
||||
</div>
|
||||
<div class="plan-cells plan-lanes{{ ' longterm' if r.long_term }}" style="--lanes:{{ r.lanes }}">
|
||||
{# day lines live in their own full-height overlay: as grid items they could neither
|
||||
overlap the bars (auto-placement) nor reach below the last lane when the room label
|
||||
makes the row taller than the lane stack #}
|
||||
<div class="daylines">
|
||||
{% for d in p.days %}<i class="{{ 'we' if d.weekend }}{{ ' today' if d.today }}{{ ' mon' if d.monday }}{{ ' m1' if d.month_start }}"></i>{% endfor %}
|
||||
</div>
|
||||
{% if r.long_term and not r.bars %}<div class="lt-note">{{ t('plan_longterm') }}</div>{% endif %}
|
||||
{% for b in r.bars %}
|
||||
<a class="bar {{ b.state }}{{ ' clipstart' if b.clip_start }}{{ ' clipend' if b.clip_end }}"
|
||||
style="grid-column:{{ b.col }}/{{ b.col_end }};grid-row:{{ b.lane }}"
|
||||
href="{{ url_for('stay_form', stay_id=b.stay_id) }}"
|
||||
title="{{ b.name }} · {{ b.arrival|date }} – {{ b.departure|date if b.departure else t('plan_open') }}{% if b.sheet_number %} · {{ t('plan_sheet', number=b.sheet_number) }}{% endif %}{% if b.employer %} · {{ b.employer }}{% endif %}">{{ b.name }}</a>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="plan-foot">
|
||||
<span><span class="key" style="background:var(--accent)"></span>{{ t('plan_legend_current') }}</span>
|
||||
<span><span class="key" style="background:#b9c9c0"></span>{{ t('plan_legend_past') }}</span>
|
||||
<span><span class="key" style="background:var(--accent-soft);box-shadow:inset 0 0 0 1px var(--accent)"></span>{{ t('plan_legend_future') }}</span>
|
||||
<span><span class="key" style="background:#fff3d6;box-shadow:inset 0 0 0 1px var(--warn)"></span>{{ t('plan_legend_today') }}</span>
|
||||
{% if p.unplaced %}
|
||||
<span class="plan-unplaced">{{ t('plan_no_room') }}
|
||||
{% for u in p.unplaced %}
|
||||
<a href="{{ url_for('stay_form', stay_id=u.id) }}">{{ u.name }} ({{ u.arrival|date }} – {{ u.departure|date if u.departure else t('plan_open') }})</a>{{ ', ' if not loop.last }}
|
||||
{% endfor %}
|
||||
</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,85 @@
|
||||
{% extends "base.html" %}
|
||||
{% block content %}
|
||||
<h1>{{ t('quality_title') }}</h1>
|
||||
<p class="lead">{{ t('quality_intro') }}</p>
|
||||
|
||||
<h2 style="margin:26px 0 6px">{{ t('quality_gaps') }}</h2>
|
||||
{% macro gaptable(title, rows, cols) %}
|
||||
{# rows carrying _kind/_key (set in quality.py) get a per-row "Passt so" button: the finding is
|
||||
reviewed-as-correct and stays hidden — keyed by natural key, not by row id #}
|
||||
{% set dismissable = rows and rows[0]._kind is defined %}
|
||||
<details style="margin:8px 0" {% if rows|length and rows|length <= 12 %}open{% endif %}>
|
||||
<summary style="cursor:pointer; font-weight:600">{{ title }}
|
||||
<span class="pill {{ 'open' if rows|length else 'paid' }}">{{ rows|length }}</span></summary>
|
||||
{% if rows %}
|
||||
<div class="tablewrap" style="max-height:280px; margin-top:6px">
|
||||
<table>
|
||||
<thead><tr>{% for c in cols %}<th>{{ c }}</th>{% endfor %}{% if dismissable %}<th></th>{% endif %}</tr></thead>
|
||||
<tbody>
|
||||
{% for r in rows[:80] %}
|
||||
<tr>{% for c in cols %}<td>{{ r[c] if r[c] is not none else '' }}</td>{% endfor %}
|
||||
{% if dismissable %}
|
||||
<td><form method="post" action="{{ url_for('quality_dismiss') }}" style="margin:0">
|
||||
<input type="hidden" name="kind" value="{{ r._kind }}"><input type="hidden" name="key" value="{{ r._key }}">
|
||||
<button class="btn secondary small" type="submit">{{ t('btn_dismiss') }}</button></form></td>
|
||||
{% endif %}</tr>
|
||||
{% endfor %}
|
||||
{% if rows|length > 80 %}<tr><td colspan="{{ cols|length + (1 if dismissable else 0) }}">{{ t('quality_more', n=rows|length - 80) }}</td></tr>{% endif %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{% endif %}
|
||||
</details>
|
||||
{% endmacro %}
|
||||
{{ gaptable(t('gap_depart_before_arrival'), q.gaps.depart_before_arrival,
|
||||
['stay_id','source_sheet','name','arrival','departure']) }}
|
||||
{{ gaptable(t('gap_charges_without_money'), q.gaps.charges_without_money,
|
||||
['charge_id','room','name','period_from','period_to']) }}
|
||||
{{ gaptable(t('gap_client_without_address'), q.gaps.client_without_address,
|
||||
['name','as_payer','as_employer']) }}
|
||||
{{ gaptable(t('gap_address_has_name'), q.gaps.address_has_name,
|
||||
['client_id','company','name','address','postcode','city']) }}
|
||||
{{ gaptable(t('gap_implausible_qty'), q.gaps.implausible_quantities,
|
||||
['charge_id','room','name','period_from','period_to','quantity','amount']) }}
|
||||
{{ gaptable(t('gap_rnr_unlinked'), q.gaps.rnr_unlinked, ['charge_id','room','name','period_from','rnr']) }}
|
||||
{{ gaptable(t('gap_duplicate_numbers'), q.gaps.duplicate_invoice_numbers, ['unit','number','n','ids']) }}
|
||||
{{ gaptable(t('gap_number_year'), q.gaps.number_year_mismatch, ['unit','number','date']) }}
|
||||
{{ gaptable(t('gap_open_items'), q.gaps.open_items, ['room','name','period_from','amount']) }}
|
||||
{{ gaptable(t('gap_register_ledger'), q.gaps.register_ledger_mismatch,
|
||||
['room','name','sheet_number','arrival','departure','ledger_from','ledger_to']) }}
|
||||
{{ gaptable(t('gap_gb_ref_unknown'), q.gaps.gb_ref_unknown,
|
||||
['charge_id','room','name','period_from','gb_ref','missing']) }}
|
||||
{{ gaptable(t('gap_never_billed'), q.gaps.register_never_billed,
|
||||
['sheet_number','room','name','arrival','departure']) }}
|
||||
{{ gaptable(t('gap_betrag_formula'), q.gaps.betrag_off_formula,
|
||||
['room','name','period_from','quantity','gross_per_unit','persons','betrag','calc']) }}
|
||||
{{ gaptable(t('gap_tvb_stale_open'), q.gaps.tvb_stale_open, ['room','name','sheet_number','arrival']) }}
|
||||
{{ gaptable(t('gap_no_departure'), q.gaps.register_no_departure, ['name','arrival']) }}
|
||||
|
||||
<h2 style="margin:26px 0 6px">{{ t('quality_suggestions') }}</h2>
|
||||
<p style="color:#6b756e; font-size:13px">{{ t('quality_suggestions_hint') }}</p>
|
||||
{% for s in q.suggestions.companies %}
|
||||
<form method="post" action="{{ url_for('quality_merge_companies') }}" style="margin:4px 0; display:flex; gap:8px; align-items:center"
|
||||
onsubmit="return confirm('{{ t('confirm_merge') }}')">
|
||||
<input type="hidden" name="keep" value="{{ s.a_id }}"><input type="hidden" name="drop" value="{{ s.b_id }}">
|
||||
<span><b>{{ s.a }}</b> ~ <b>{{ s.b }}</b></span>
|
||||
<button class="btn secondary small" type="submit">{{ t('btn_merge') }} → {{ s.a }}</button>
|
||||
<a class="btn secondary small" href="{{ url_for('client_edit', company_id=s.a_id) }}">{{ t('btn_open') }}</a>
|
||||
<button class="btn secondary small" type="submit" form="dp-c{{ loop.index }}">{{ t('btn_dismiss') }}</button>
|
||||
</form>
|
||||
<form id="dp-c{{ loop.index }}" method="post" action="{{ url_for('quality_dismiss') }}" style="display:none">
|
||||
<input type="hidden" name="kind" value="{{ s._kind }}"><input type="hidden" name="key" value="{{ s._key }}"></form>
|
||||
{% else %}<p>{{ t('quality_no_company_suggestions') }}</p>{% endfor %}
|
||||
|
||||
{% for s in q.suggestions.guests %}
|
||||
<form method="post" action="{{ url_for('quality_merge_guests') }}" style="margin:4px 0; display:flex; gap:8px; align-items:center"
|
||||
onsubmit="return confirm('{{ t('confirm_merge') }}')">
|
||||
<input type="hidden" name="keep" value="{{ s.a_id }}"><input type="hidden" name="drop" value="{{ s.b_id }}">
|
||||
<span>{{ t('quality_guest') }}: <b>{{ s.a }}</b> ~ <b>{{ s.b }}</b></span>
|
||||
<button class="btn secondary small" type="submit">{{ t('btn_merge') }} → {{ s.a }}</button>
|
||||
<button class="btn secondary small" type="submit" form="dp-g{{ loop.index }}">{{ t('btn_dismiss') }}</button>
|
||||
</form>
|
||||
<form id="dp-g{{ loop.index }}" method="post" action="{{ url_for('quality_dismiss') }}" style="display:none">
|
||||
<input type="hidden" name="kind" value="{{ s._kind }}"><input type="hidden" name="key" value="{{ s._key }}"></form>
|
||||
{% endfor %}
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,77 @@
|
||||
{% extends "base.html" %}
|
||||
{% block main_class %}grid-page{% endblock %}
|
||||
{% block content %}
|
||||
{% set unit_opts = rows|map(attribute='unit')|select|unique|sort %}
|
||||
{% set nat_opts = rows|map(attribute='nationality')|select|unique|sort %}
|
||||
<h1>{{ t('register_title') }}</h1>
|
||||
<div class="toolbar">
|
||||
<input class="search live-search" data-grid="reg" type="text" placeholder="{{ t('search_placeholder') }}">
|
||||
<select class="live-filter" data-grid="reg" data-col="{{ t('col_unit') }}"><option value="">{{ t('filter_all', col=t('col_unit')) }}</option>
|
||||
{% for u in unit_opts %}<option>{{ u }}</option>{% endfor %}</select>
|
||||
<select class="live-filter" data-grid="reg" data-col="{{ t('col_departure') }}"><option value="">{{ t('filter_all', col=t('filter_stay')) }}</option>
|
||||
<option value="__empty__">{{ t('filter_still_here') }}</option>
|
||||
<option value="__filled__">{{ t('filter_departed') }}</option></select>
|
||||
<select class="live-filter" data-grid="reg" data-col="{{ t('col_nationality') }}"><option value="">{{ t('filter_all', col=t('col_nationality')) }}</option>
|
||||
{% for nat in nat_opts %}<option>{{ nat }}</option>{% endfor %}</select>
|
||||
<span class="count"><span class="grid-count" data-grid="reg">{{ rows|length }}</span> {{ t('showing_count', count='')|trim }}</span>
|
||||
<span class="spacer"></span>
|
||||
<button class="btn secondary" type="button" onclick="invoiceFromSelection()">{{ t('btn_invoice_from_selection') }}</button>
|
||||
<button class="btn secondary" type="button" onclick="gridPrint('reg')">{{ t('btn_print') }}</button>
|
||||
<a class="btn" href="{{ url_for('stay_form') }}">+ {{ t('btn_add_stay') }}</a>
|
||||
</div>
|
||||
<p class="printinfo" data-grid="reg"></p>
|
||||
|
||||
<div class="tablewrap">
|
||||
{# data-pin: checkbox, Einheit, G-Blatt, Name and Vorname stay on screen while scrolling #}
|
||||
<table id="reg" class="grid" data-scroll="end" data-pin="1 2 3 4 5">
|
||||
<thead><tr>
|
||||
<th class="nosort selcol"></th><th>{{ t('col_unit') }}</th><th>{{ t('col_sheetno') }}</th>
|
||||
<th>{{ t('col_lastname') }}</th><th>{{ t('col_firstname') }}</th><th>{{ t('col_birth') }}</th>
|
||||
<th>{{ t('col_residence') }}</th><th>{{ t('col_room') }}</th>
|
||||
<th>{{ t('col_arrival') }}</th><th>{{ t('col_departure') }}</th><th class="num">{{ t('col_days') }}</th>
|
||||
<th>{{ t('col_tourism_liable') }}</th><th>{{ t('col_nationality') }}</th><th>{{ t('col_document') }}</th>
|
||||
<th>{{ t('col_docnr') }}</th><th>{{ t('col_profession') }}</th><th>{{ t('col_address') }}</th>
|
||||
<th>{{ t('col_postcode') }}</th><th>{{ t('col_city') }}</th><th>{{ t('col_country') }}</th>
|
||||
<th>{{ t('col_phone') }}</th><th>{{ t('col_employer') }}</th><th>{{ t('col_employer_address') }}</th>
|
||||
<th>{{ t('col_notes_long') }}</th>
|
||||
<th class="nosort actions"></th>
|
||||
</tr></thead>
|
||||
<tbody>
|
||||
{# rows ship inside an inert <template>: grids.js renders only the visible window (see freezeLayout) #}
|
||||
<template>
|
||||
{% for r in rows %}
|
||||
<tr>
|
||||
<td class="selcol"><input type="checkbox" class="rowsel" value="{{ r.stay_id }}"></td>
|
||||
<td>{% if r.unit %}<span class="pill unit">{{ r.unit }}</span>{% endif %}</td>
|
||||
<td>{{ r.sheet_number or '' }}</td><td>{{ r.last_name or '' }}</td><td>{{ r.first_name or '' }}</td>
|
||||
<td>{{ r.birth_date|date }}</td>
|
||||
<td>{{ 'HWS' if r.hauptwohnsitz == 1 else ('NWS' if r.hauptwohnsitz == 0 else '') }}</td>
|
||||
<td>{{ r.room or '' }}</td>
|
||||
{#- the weekday before the date; data-k = the ISO date, or the column would sort by "Mo/Di/Mi" -#}
|
||||
<td{% if r.arrival %} data-k="{{ r.arrival|replace('-', '') }}"{% endif %}>{{ r.arrival|datewd }}</td><td{% if r.departure %} data-k="{{ r.departure|replace('-', '') }}"{% endif %}>{{ r.departure|datewd }}</td>
|
||||
<td class="num">{{ r.days if r.days is not none else '' }}</td>
|
||||
<td>{{ 'Ja' if r.tourism_liable == 1 else ('-' if r.tourism_liable == 0 else '') }}</td>
|
||||
<td>{{ r.nationality or '' }}</td><td>{{ r.doc_type or '' }}</td>
|
||||
<td>{{ r.doc_number or '' }}</td><td>{{ r.profession or '' }}</td><td>{{ r.home_street or '' }}</td>
|
||||
<td>{{ r.home_postcode or '' }}</td><td>{{ r.home_city or '' }}</td><td>{{ r.home_country or '' }}</td>
|
||||
<td>{{ r.phone or '' }}</td><td>{{ r.employer or '' }}</td>
|
||||
<td>{{ r.employer_address or '' }}</td>
|
||||
<td>{{ r.notes or '' }}</td>
|
||||
<td class="actions"><a class="btn secondary small" href="{{ url_for('stay_form', stay_id=r.stay_id) }}">{{ t('btn_edit') }}</a></td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</template>
|
||||
{% if not rows %}
|
||||
<tr data-empty="1"><td class="empty" colspan="25">{{ t('no_results') }}</td></tr>
|
||||
{% endif %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<script>
|
||||
function invoiceFromSelection() {
|
||||
var ids = window.gridChecked('reg'); // from the grid model — most rows are not in the DOM
|
||||
if (!ids.length) { alert('Bitte zuerst Zeilen auswählen.'); return; }
|
||||
window.location = '{{ url_for("invoice_new") }}?stays=' + ids.join(',');
|
||||
}
|
||||
</script>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,114 @@
|
||||
{% extends "base.html" %}
|
||||
{% block content %}
|
||||
{% set g = (stay.guest if stay else {}) or {} %}
|
||||
{% set sg = suggest|default({}) %}
|
||||
<h1>{{ t('form_room_change') if room_change else t('form_edit_stay') if stay else t('form_new_stay') }}</h1>
|
||||
|
||||
<datalist id="dl_nationality">{% for v in sg.nationality|default([]) %}<option value="{{ v }}">{% endfor %}</datalist>
|
||||
<datalist id="dl_doc_type">{% for v in sg.doc_type|default([]) %}<option value="{{ v }}">{% endfor %}</datalist>
|
||||
<datalist id="dl_profession">{% for v in sg.profession|default([]) %}<option value="{{ v }}">{% endfor %}</datalist>
|
||||
<datalist id="dl_city">{% for v in sg.city|default([]) %}<option value="{{ v }}">{% endfor %}</datalist>
|
||||
<datalist id="dl_country">{% for v in sg.country|default([]) %}<option value="{{ v }}">{% endfor %}</datalist>
|
||||
<datalist id="dl_client">{% for v in sg.client|default([]) %}<option value="{{ v }}">{% endfor %}</datalist>
|
||||
|
||||
<form class="card" method="post">
|
||||
<div class="grid2">
|
||||
<div class="field"><label>{{ t('col_name') }} *</label>
|
||||
<input type="text" name="last_name" value="{{ g.last_name or '' }}" autofocus required></div>
|
||||
<div class="field"><label>{{ t('col_firstname') }}</label>
|
||||
<input type="text" name="first_name" value="{{ g.first_name or '' }}"></div>
|
||||
</div>
|
||||
<div class="grid2">
|
||||
<div class="field"><label>{{ t('col_birth') }}</label>
|
||||
<input type="date" name="birth_date" value="{{ g.birth_date or '' }}"></div>
|
||||
<div class="field"><label>{{ t('col_nationality') }}</label>
|
||||
<input type="text" name="nationality" value="{{ g.nationality or '' }}" list="dl_nationality"></div>
|
||||
</div>
|
||||
<div class="grid2">
|
||||
<div class="field"><label>{{ t('col_document') }}</label>
|
||||
<input type="text" name="doc_type" value="{{ g.doc_type or '' }}" list="dl_doc_type"></div>
|
||||
<div class="field"><label>{{ t('col_docnr') }}</label>
|
||||
<input type="text" name="doc_number" value="{{ g.doc_number or '' }}"></div>
|
||||
</div>
|
||||
<div class="field"><label>{{ t('col_profession') }}</label>
|
||||
<input type="text" name="profession" value="{{ g.profession or '' }}" list="dl_profession"></div>
|
||||
|
||||
<div class="grid2">
|
||||
<div class="field"><label>{{ t('lbl_room') }}</label>
|
||||
<select name="room_id">
|
||||
<option value="">—</option>
|
||||
{% for r in rooms %}
|
||||
<option value="{{ r.id }}" {{ 'selected' if stay and stay.room_id == r.id }}>{{ r.number }} ({{ r.category }})</option>
|
||||
{% endfor %}
|
||||
</select></div>
|
||||
<div class="field"><label>{{ t('lbl_unit') }}</label>
|
||||
<select name="unit_id">
|
||||
<option value="">—</option>
|
||||
{% for u in units %}
|
||||
<option value="{{ u.id }}" {{ 'selected' if stay and stay.unit_id == u.id }}>{{ u.code }}</option>
|
||||
{% endfor %}
|
||||
</select></div>
|
||||
</div>
|
||||
<div class="field"><label>{{ t('col_employer') }}</label>
|
||||
<input type="text" name="employer" value="{{ stay.employer_name or '' if stay else '' }}" list="dl_client"></div>
|
||||
|
||||
<div class="grid2">
|
||||
<div class="field"><label>{{ t('col_arrival') }}</label>
|
||||
<input type="date" name="arrival" value="{{ stay.arrival or '' if stay else '' }}"></div>
|
||||
<div class="field"><label>{{ t('col_departure') }}</label>
|
||||
<input type="date" name="departure" value="{{ stay.departure or '' if stay else '' }}"></div>
|
||||
</div>
|
||||
<div class="field"><label>{{ t('col_sheetno') }}</label>
|
||||
<input type="text" name="sheet_number" value="{{ stay.sheet_number or '' if stay else '' }}" style="max-width:160px"></div>
|
||||
|
||||
<div class="grid2">
|
||||
<div class="field"><label>{{ t('col_address_private') }}</label>
|
||||
<input type="text" name="home_street" value="{{ g.home_street or '' }}"></div>
|
||||
<div class="field"><label>{{ t('col_country') }}</label>
|
||||
<input type="text" name="home_country" value="{{ g.home_country or '' }}" list="dl_country"></div>
|
||||
</div>
|
||||
<div class="grid2">
|
||||
<div class="field"><label>{{ t('col_postcode') }}</label>
|
||||
<input type="text" name="home_postcode" value="{{ g.home_postcode or '' }}"></div>
|
||||
<div class="field"><label>{{ t('col_city') }}</label>
|
||||
<input type="text" name="home_city" value="{{ g.home_city or '' }}" list="dl_city"></div>
|
||||
</div>
|
||||
<div class="field"><label>{{ t('col_phone') }}</label>
|
||||
<input type="text" name="phone" value="{{ g.phone or '' }}"></div>
|
||||
<div class="grid2">
|
||||
<div class="field"><label>{{ t('col_residence') }}</label>
|
||||
<select name="hauptwohnsitz">
|
||||
<option value="" {{ 'selected' if not stay or stay.hauptwohnsitz is none }}>—</option>
|
||||
<option value="1" {{ 'selected' if stay and stay.hauptwohnsitz == 1 }}>{{ t('hws_full') }}</option>
|
||||
<option value="0" {{ 'selected' if stay and stay.hauptwohnsitz == 0 }}>{{ t('hws_none') }}</option>
|
||||
</select></div>
|
||||
<div class="field"><label>{{ t('col_tourism_liable') }}</label>
|
||||
<select name="tourism_liable">
|
||||
<option value="" {{ 'selected' if not stay or stay.tourism_liable is none }}>—</option>
|
||||
<option value="1" {{ 'selected' if stay and stay.tourism_liable == 1 }}>{{ t('yes') }}</option>
|
||||
<option value="0" {{ 'selected' if stay and stay.tourism_liable == 0 }}>{{ t('no') }}</option>
|
||||
</select></div>
|
||||
</div>
|
||||
<div class="field"><label>{{ t('col_notes') }}</label>
|
||||
{# 'or ""' matters: '{{ x if stay }}' renders the STRING 'None' for an empty field, which then
|
||||
gets saved back as literal text on the next submit #}
|
||||
<textarea name="notes" rows="2">{{ stay.notes or '' if stay else '' }}</textarea></div>
|
||||
|
||||
<div class="formactions">
|
||||
<button class="btn" type="submit">{{ t('btn_save') }}</button>
|
||||
{% if stay and stay.id %}
|
||||
{# a room change: same person and number, a new sheet in another room from the departure day #}
|
||||
<button class="btn secondary" type="submit" name="action" value="room_change"
|
||||
title="{{ t('hint_room_change') }}">{{ t('btn_room_change') }}</button>
|
||||
{% endif %}
|
||||
<a class="btn secondary" href="{{ url_for('register') }}">{{ t('btn_cancel') }}</a>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
{% if stay and stay.id %}
|
||||
<form class="formactions" method="post" action="{{ url_for('stay_delete', stay_id=stay.id) }}"
|
||||
onsubmit="return confirm('{{ t('confirm_delete') }}')">
|
||||
<button class="btn danger" type="submit">{{ t('btn_delete') }}</button>
|
||||
</form>
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,89 @@
|
||||
{% extends "base.html" %}
|
||||
{% block content %}
|
||||
{# Landscape when printed — the wide detail table reads better that way. As THIS document's default
|
||||
page, not a named page: with mixed page sizes a printer may still print a later unit's part portrait.
|
||||
This way there is only one size. #}
|
||||
<style>@media print { @page { size: A4 landscape; margin: 10mm; } }</style>
|
||||
<h1>{{ t('tvb_title') }} – {{ r.month_name }}</h1>
|
||||
<p class="lead noprint">{{ t('tvb_intro') }}</p>
|
||||
|
||||
<form method="get" style="margin:10px 0 18px" class="noprint">
|
||||
<label style="font-weight:600; margin-right:8px">{{ t('tvb_month') }}</label>
|
||||
<input type="month" name="month" value="{{ month_value }}" onchange="this.form.submit()">
|
||||
<button type="button" class="btn" style="margin-left:14px" onclick="window.print()">{{ t('btn_print') }}</button>
|
||||
</form>
|
||||
|
||||
{% for u in r.units %}
|
||||
<section class="tvb-unit" style="margin:0 0 34px">
|
||||
<h2 style="margin:18px 0 4px">{{ u.name }}
|
||||
<small style="font-weight:400; color:#6b756e">— {{ t('tvb_number') }} {{ u.tvb_number or '?' }}</small></h2>
|
||||
|
||||
{% if u.warnings %}
|
||||
<div class="flash" style="margin:8px 0">
|
||||
<b>{{ t('tvb_warnings') }}:</b>
|
||||
{% for w in u.warnings %}<div>{{ w }}</div>{% endfor %}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if not u.rows %}
|
||||
<p>{{ t('tvb_empty') }}</p>
|
||||
{% else %}
|
||||
<h3 style="margin:12px 0 6px">{{ t('tvb_detail') }}</h3>
|
||||
<div class="tablewrap" style="max-height:420px">
|
||||
<table>
|
||||
<thead><tr>
|
||||
<th>{{ t('col_room') }}</th><th>{{ t('col_name') }}</th><th>{{ t('tvb_col_land') }}</th>
|
||||
<th>{{ t('col_sheetno') }}</th><th class="num">{{ t('tvb_col_gb_new') }}</th>
|
||||
<th>{{ t('tvb_col_stay') }}</th><th class="num">{{ t('tvb_col_nights_month') }}</th>
|
||||
<th></th><th>{{ t('tvb_col_note') }}</th>
|
||||
</tr></thead>
|
||||
<tbody>
|
||||
{% for row in u.rows %}
|
||||
<tr>
|
||||
<td>{{ row.room or '' }}</td><td>{{ row.name }}</td><td>{{ row.land }}</td>
|
||||
<td>{{ row.sheet or '' }}</td><td class="num">{{ row.person }}</td>
|
||||
<td>{{ row.arrival|date }}–{{ row.departure|date }}{% if row.total_nights %} ({{ row.total_nights }}){% endif %}</td>
|
||||
<td class="num">{{ row.nights }}</td>
|
||||
<td><span class="pill {{ 'paid' if row.exempt else 'open' }}">
|
||||
{{ t('tvb_status_frei') if row.exempt else t('tvb_status_pflichtig') }}</span></td>
|
||||
<td class="note"><div>{{ row.note or '' }}</div></td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{# heading and tables stay on ONE sheet: otherwise "Für Feratel" can slide alone to the bottom of a
|
||||
page and the figures onto the next #}
|
||||
<div class="tvb-feratel">
|
||||
<h3 style="margin:16px 0 6px">{{ t('tvb_feratel') }}</h3>
|
||||
<div class="tvb-summary" style="display:flex; gap:26px; flex-wrap:wrap; align-items:flex-start">
|
||||
{% for part, label in (('pflichtig', t('tvb_pflichtig')), ('frei', t('tvb_frei'))) %}
|
||||
<div class="tablewrap" style="flex:0 1 340px">
|
||||
<table>
|
||||
<thead><tr><th>{{ label }}</th><th class="num">{{ t('tvb_col_pn') }}</th></tr></thead>
|
||||
<tbody>
|
||||
{% for v in u[part] %}
|
||||
<tr><td>{{ v.land }}</td><td class="num">{{ v.persons }}/{{ v.nights }}</td></tr>
|
||||
{% endfor %}
|
||||
<tr style="font-weight:700; border-top:2px solid #9a9a9a">
|
||||
<td>{{ t('sum_label') }}</td>
|
||||
<td class="num">{{ u[part + '_sum'].persons }}/{{ u[part + '_sum'].nights }}</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
<p style="margin:10px 0 0">
|
||||
<b>{{ t('tvb_total') }}:</b>
|
||||
{{ t('tvb_pflichtig') }} {{ u.pflichtig_sum.persons }}/{{ u.pflichtig_sum.nights }} ·
|
||||
{{ t('tvb_frei') }} {{ u.frei_sum.persons }}/{{ u.frei_sum.nights }}
|
||||
· <b>{{ t('tvb_betrag') }}:</b> {{ u.betrag|euro }}
|
||||
({{ u.pflichtig_sum.nights }} × {{ r.rate|euro }})
|
||||
· {{ t('tvb_new_sheets') }}: <b>{{ u.new_sheets }}</b>
|
||||
</p>
|
||||
</div>
|
||||
{% endif %}
|
||||
</section>
|
||||
{% endfor %}
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,65 @@
|
||||
{% extends "base.html" %}
|
||||
{% block content %}
|
||||
<h1>{{ t('vat_title') }} – {{ t('vat_quarter_n', n=quarter) }} {{ year }}</h1>
|
||||
<p class="lead">{{ unit_name }} · {{ r.first|date }} – {{ r.last|date }}</p>
|
||||
<p class="noprint">{{ t('vat_intro') }}</p>
|
||||
|
||||
<form method="get" class="toolbar">
|
||||
<select name="unit" onchange="this.form.submit()">
|
||||
{% for u in units %}<option value="{{ u.code }}"{% if u.code == unit %} selected{% endif %}>{{ u.name }}</option>{% endfor %}
|
||||
</select>
|
||||
<select name="q" onchange="this.form.submit()">
|
||||
{% for n in range(1, 5) %}<option value="{{ n }}"{% if n == quarter %} selected{% endif %}>{{ t('vat_quarter_n', n=n) }}</option>{% endfor %}
|
||||
</select>
|
||||
<select name="year" onchange="this.form.submit()">
|
||||
{% for y in years %}<option{% if y == year %} selected{% endif %}>{{ y }}</option>{% endfor %}
|
||||
</select>
|
||||
<button type="button" class="btn secondary" onclick="window.print()">{{ t('btn_print') }}</button>
|
||||
</form>
|
||||
|
||||
{% for s in r.sections %}
|
||||
<section class="vat-room">
|
||||
<h2>{{ t('col_room') }} {{ s.room }}</h2>
|
||||
<div class="tablewrap">
|
||||
<table>
|
||||
<thead><tr>
|
||||
<th>{{ t('col_paid_date') }}</th><th>{{ t('col_occupant') }}</th><th>{{ t('col_period') }}</th>
|
||||
<th>{{ t('hd_invoice') }}</th><th>{{ t('col_payer') }}</th><th class="num">{{ t('col_amount') }}</th>
|
||||
</tr></thead>
|
||||
<tbody>
|
||||
{% for x in s.rows %}
|
||||
<tr>
|
||||
<td>{{ x.received_on|date }}</td>
|
||||
<td>{{ x.occupant or '' }}
|
||||
{%- if x.storno %} <span class="pill cash">{{ t('vat_storno') }}</span>{% endif %}
|
||||
{%- if x.received < x.amount %} <span class="pill open">{{ t('vat_part') }}</span>{% endif %}</td>
|
||||
{#- a monthly rent may carry only a 'bis' #}
|
||||
<td>{% if x.period_from and x.period_to %}{{ x.period_from|date }}–{{ x.period_to|date }}
|
||||
{%- elif x.period_to %}{{ t('period_until', date=x.period_to|date) }}
|
||||
{%- elif x.period_from %}{{ t('period_since', date=x.period_from|date) }}{% endif %}</td>
|
||||
<td>{{ x.invoice_no or '' }}</td><td>{{ x.payer or '' }}</td>
|
||||
<td class="num">{{ x.received|euro }}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
<tfoot><tr><td colspan="5">{{ t('vat_room_sum', room=s.room) }}</td><td class="num">{{ s.sum|euro }}</td></tr></tfoot>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
{% else %}
|
||||
<p class="empty">{{ t('vat_empty') }}</p>
|
||||
{% endfor %}
|
||||
|
||||
{% if r.sections %}
|
||||
<section class="vat-total">
|
||||
<h2>{{ t('vat_total_title') }}</h2>
|
||||
<table>
|
||||
<tr><td>{{ t('vat_total') }}</td><td class="num">{{ r.total|euro }}</td></tr>
|
||||
{% if r.storno %}<tr><td>{{ t('vat_storno_sum') }}</td><td class="num">− {{ r.storno|euro }}</td></tr>{% endif %}
|
||||
<tr class="key"><td>{{ t('vat_gross') }}</td><td class="num">{{ r.gross|euro }}</td></tr>
|
||||
<tr class="key"><td>{{ t('vat_tax') }}</td><td class="num">{{ r.vat|euro }}</td></tr>
|
||||
<tr class="key"><td>{{ t('vat_net') }}</td><td class="num">{{ r.net|euro }}</td></tr>
|
||||
</table>
|
||||
</section>
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
+218
@@ -0,0 +1,218 @@
|
||||
"""Monthly TVB Sammelmeldung (Aufenthaltsabgabe) figures, computed from the guest register.
|
||||
|
||||
The rules of the report:
|
||||
- a night belongs to the month its evening falls in (the night onto the 1st counts to the previous month);
|
||||
- "Personen" = NEW Gästeblätter that month — a guest already present counts 0 persons, only nights;
|
||||
a room change keeps the sheet number (no new person), a vacation gap issues a new sheet (counts again);
|
||||
- the Land is the HAUPTWOHNSITZ: the register's HWS flag set means the main residence is the house itself
|
||||
(→ Ö-Tirol); Austria splits by Bundesland (postcode), Germany reports per postcode region;
|
||||
- stays of more than 10 nights in total are exempt ("Freie"), shorter ones owe the levy ("Pflichtige");
|
||||
a still-open stay at the month border is assumed to run long (frei) but flagged for a human decision.
|
||||
"""
|
||||
import datetime
|
||||
import re
|
||||
|
||||
from app import compute, db, i18n
|
||||
|
||||
EXEMPT_OVER_NIGHTS = compute.TOURISM_EXEMPT_OVER_NIGHTS # > 10 nights total → exempt from the levy
|
||||
STALE_AFTER_DAYS = 365 # an open stay this much older than the month is a missing departure, not a guest
|
||||
|
||||
AT_STATES = ((1000, 1999, "Wien"), (2000, 3999, "Niederösterreich"), (4000, 4999, "Oberösterreich"),
|
||||
(5000, 5999, "Salzburg"), (6000, 6699, "Tirol"), (6700, 6999, "Vorarlberg"),
|
||||
(7000, 7999, "Burgenland"), (8000, 8999, "Steiermark"), (9000, 9999, "Kärnten"))
|
||||
|
||||
# Feratel's German "Stat. Land" dropdown offers 7 regions (the Statistik-Austria grouping), spelled
|
||||
# exactly as below. The Bundesland is resolved from the PLZ first, then folded into its region.
|
||||
DE_REGIONS = {"Bayern": "Bayern", "Baden-Württemberg": "Baden Württemberg",
|
||||
"Nordrhein-Westfalen": "Nordrhein-Westfalen", "Berlin": "Berlin",
|
||||
"Niedersachsen": "Norddeutschland", "Hamburg": "Norddeutschland",
|
||||
"Bremen": "Norddeutschland", "Schleswig-Holstein": "Norddeutschland",
|
||||
"Hessen": "Mitteldeutschland", "Rheinland-Pfalz": "Mitteldeutschland",
|
||||
"Saarland": "Mitteldeutschland",
|
||||
"Brandenburg": "Ostdeutschland", "Mecklenburg-Vorpommern": "Ostdeutschland",
|
||||
"Sachsen": "Ostdeutschland", "Sachsen-Anhalt": "Ostdeutschland",
|
||||
"Thüringen": "Ostdeutschland"}
|
||||
|
||||
# German Leitregion -> predominant Bundesland. A few border regions genuinely cross states
|
||||
# (e.g. Aschaffenburg 637xx is Bayern inside the Hessen block), so every German row carries its PLZ
|
||||
# as a note to check — at a few German guests a year that is the honest cut.
|
||||
DE_STATES = (
|
||||
(1000, 2999, "Sachsen"), (3000, 3999, "Brandenburg"), (4000, 5999, "Sachsen"),
|
||||
(6000, 6999, "Sachsen-Anhalt"), (7000, 7999, "Thüringen"), (8000, 9999, "Sachsen"),
|
||||
(10000, 14330, "Berlin"), (14400, 16999, "Brandenburg"), (17000, 19999, "Mecklenburg-Vorpommern"),
|
||||
(20000, 22999, "Hamburg"), (23000, 25999, "Schleswig-Holstein"),
|
||||
(26000, 27499, "Niedersachsen"), (27500, 27599, "Bremen"), (27600, 27999, "Niedersachsen"),
|
||||
(28000, 28999, "Bremen"), (29000, 31999, "Niedersachsen"), (32000, 33999, "Nordrhein-Westfalen"),
|
||||
(34000, 36999, "Hessen"), (37000, 38999, "Niedersachsen"), (39000, 39999, "Sachsen-Anhalt"),
|
||||
(40000, 48999, "Nordrhein-Westfalen"), (49000, 49999, "Niedersachsen"),
|
||||
(50000, 53999, "Nordrhein-Westfalen"), (54000, 56999, "Rheinland-Pfalz"),
|
||||
(57000, 59999, "Nordrhein-Westfalen"), (60000, 65999, "Hessen"), (66000, 66999, "Saarland"),
|
||||
(67000, 67999, "Rheinland-Pfalz"), (68000, 79999, "Baden-Württemberg"),
|
||||
(80000, 87999, "Bayern"), (88000, 89999, "Baden-Württemberg"),
|
||||
(90000, 97999, "Bayern"), (98000, 99999, "Thüringen"),
|
||||
)
|
||||
|
||||
|
||||
def _date(s):
|
||||
return datetime.date.fromisoformat(s[:10]) if s else None
|
||||
|
||||
|
||||
def _at_state(postcode):
|
||||
digits = "".join(ch for ch in str(postcode or "") if ch.isdigit())[:4]
|
||||
if len(digits) == 4:
|
||||
n = int(digits)
|
||||
for lo, hi, name in AT_STATES:
|
||||
if lo <= n <= hi:
|
||||
return name
|
||||
return None
|
||||
|
||||
|
||||
def _de_state(postcode):
|
||||
digits = "".join(ch for ch in str(postcode or "") if ch.isdigit())
|
||||
if len(digits) == 4: # a lost leading zero (02763 -> 2763)
|
||||
digits = "0" + digits
|
||||
if len(digits) == 5:
|
||||
n = int(digits)
|
||||
for lo, hi, name in DE_STATES:
|
||||
if lo <= n <= hi:
|
||||
return name
|
||||
return None
|
||||
|
||||
|
||||
def _land(row):
|
||||
"""The reporting Land for a guest, in the report's shorthand ('Ö-Wien'). Returns (land, warning?)."""
|
||||
if row["hauptwohnsitz"] == 1: # main residence IS the house → Tirol
|
||||
return "Ö-Tirol", None
|
||||
country = (row["home_country"] or "").strip()
|
||||
if not country:
|
||||
nat = (row["nationality"] or "").strip()
|
||||
if nat:
|
||||
return nat, i18n.t("tvb_warn_nationality_used")
|
||||
return "Unbekannt", i18n.t("tvb_warn_no_country")
|
||||
if country == "Österreich":
|
||||
state = _at_state(row["home_postcode"])
|
||||
if state:
|
||||
return f"Ö-{state}", None
|
||||
return "Ö-?", i18n.t("tvb_warn_at_postcode")
|
||||
if country == "Deutschland":
|
||||
state = _de_state(row["home_postcode"])
|
||||
if state: # grouped exactly like Feratel's dropdown
|
||||
return f"{DE_REGIONS[state]} (DE)", \
|
||||
i18n.t("tvb_warn_de_border", postcode=row["home_postcode"], state=state)
|
||||
return "Deutschland (?)", i18n.t("tvb_warn_de_postcode")
|
||||
return country, None
|
||||
|
||||
|
||||
def _sheet_order(sheet):
|
||||
"""Gästeblatt numbers in numeric order ('98' before '613'), a row without one last — the order the
|
||||
paper sheets are filed in."""
|
||||
m = re.match(r"\d+", sheet or "")
|
||||
return (0, int(m.group())) if m else (1, 0)
|
||||
|
||||
|
||||
def _nights_in(arrival, departure, month_start, month_end):
|
||||
"""Nights whose evening falls inside the month; the departure day itself is no night."""
|
||||
a = max(arrival, month_start)
|
||||
b = min(departure - datetime.timedelta(days=1), month_end) if departure else month_end
|
||||
return max(0, (b - a).days + 1)
|
||||
|
||||
|
||||
def monthly_report(conn, year, month):
|
||||
"""The Sammelmeldung for one month: per current unit the room-by-room detail rows (to check against
|
||||
the paper sheets) and the per-Land Pflichtige/Freie summary exactly as it is typed into Feratel."""
|
||||
month_start = datetime.date(year, month, 1)
|
||||
rate = compute.tourism_rate(month_start.isoformat())
|
||||
month_end = (month_start + datetime.timedelta(days=45)).replace(day=1) - datetime.timedelta(days=1)
|
||||
stale_before = month_start - datetime.timedelta(days=STALE_AFTER_DAYS)
|
||||
|
||||
stays = db.rows(conn, """
|
||||
SELECT s.id, s.arrival, s.departure, s.sheet_number, s.hauptwohnsitz, s.guest_id,
|
||||
COALESCE(s.unit_id, rm.unit_id) AS unit_id, rm.number AS room,
|
||||
TRIM(g.last_name || ' ' || COALESCE(g.first_name, '')) AS name,
|
||||
g.home_country, g.home_postcode, g.nationality
|
||||
FROM stay s JOIN guest g ON s.guest_id = g.id LEFT JOIN room rm ON s.room_id = rm.id
|
||||
WHERE s.source_sheet = 'guestsheet' AND s.arrival IS NOT NULL
|
||||
AND s.arrival <= ? AND (s.departure IS NULL OR s.departure > ?)
|
||||
ORDER BY rm.number NULLS FIRST, s.arrival NULLS FIRST, s.id""", (month_end.isoformat(), month_start.isoformat()))
|
||||
|
||||
# current units; predecessors report under their successor
|
||||
units = {u["id"]: dict(u) for u in db.rows(conn, "SELECT * FROM unit ORDER BY id")}
|
||||
def current_unit(uid):
|
||||
seen = set()
|
||||
while uid in units and units[uid]["succeeded_by_id"] and uid not in seen:
|
||||
seen.add(uid)
|
||||
uid = units[uid]["succeeded_by_id"]
|
||||
return uid
|
||||
|
||||
# group rows by Gästeblatt (room changes share the number); a sheetless row is its own group
|
||||
groups = {}
|
||||
for s in stays:
|
||||
key = (s["guest_id"], s["sheet_number"]) if s["sheet_number"] else ("stay", s["id"])
|
||||
groups.setdefault(key, []).append(s)
|
||||
|
||||
report = {u["code"]: {"code": u["code"], "name": u["name"], "tvb_number": u["tvb_number"],
|
||||
"rows": [], "warnings": [], "summary": {}}
|
||||
for u in units.values() if u.get("is_current")}
|
||||
|
||||
for rows in groups.values():
|
||||
first = min(rows, key=lambda r: r["arrival"])
|
||||
arrival = _date(first["arrival"])
|
||||
is_open = any(r["departure"] is None for r in rows)
|
||||
unit = units.get(current_unit(rows[0]["unit_id"]))
|
||||
target = report.get(unit["code"]) if unit else None
|
||||
if target is None: # no unit resolvable — surface, never guess a Betrieb
|
||||
for u in report.values():
|
||||
u["warnings"].append(i18n.t("tvb_warn_no_unit", name=first["name"], arrival=f"{arrival:%d.%m.%Y}"))
|
||||
continue
|
||||
if is_open and arrival < stale_before: # forgotten departure years ago, not a guest —
|
||||
continue # surfaced on the Datenprüfung page, not here
|
||||
|
||||
land, land_warn = _land(first)
|
||||
month_nights = sum(_nights_in(_date(r["arrival"]), _date(r["departure"]), month_start, month_end)
|
||||
for r in rows)
|
||||
if month_nights == 0:
|
||||
continue
|
||||
total_nights = sum((( _date(r["departure"]) or month_end + datetime.timedelta(days=1))
|
||||
- _date(r["arrival"])).days for r in rows)
|
||||
person = 1 if arrival >= month_start else 0
|
||||
exempt = total_nights > EXEMPT_OVER_NIGHTS
|
||||
note = None
|
||||
if not exempt and is_open: # foreseeably long stays are frei
|
||||
exempt, note = True, i18n.t("tvb_note_still_here", nights=total_nights)
|
||||
if land_warn:
|
||||
note = (note + "; " if note else "") + land_warn
|
||||
|
||||
for r in sorted(rows, key=lambda r: r["arrival"]):
|
||||
n = _nights_in(_date(r["arrival"]), _date(r["departure"]), month_start, month_end)
|
||||
if n == 0:
|
||||
continue
|
||||
target["rows"].append({
|
||||
"room": r["room"], "name": r["name"], "land": land,
|
||||
"person": person if r is first else 0, "nights": n,
|
||||
# the whole stay next to the month's share: a stay across a month boundary looks like a
|
||||
# mismatch against the Gästeblatt otherwise (24.08.-02.09. = 9 Nächte, 8 of them in
|
||||
# August, 1 in September)
|
||||
"arrival": r["arrival"], "departure": r["departure"], "total_nights": total_nights,
|
||||
"sheet": r["sheet_number"], "exempt": exempt, "note": note})
|
||||
note = None # print group notes once
|
||||
key = (land, exempt)
|
||||
agg = target["summary"].setdefault(key, {"land": land, "exempt": exempt, "persons": 0, "nights": 0})
|
||||
agg["persons"] += person
|
||||
agg["nights"] += month_nights
|
||||
|
||||
month_names = i18n.t("months")
|
||||
out = []
|
||||
for code in sorted(report):
|
||||
u = report[code]
|
||||
u["rows"].sort(key=lambda r: (str(r["room"] or "~"), _sheet_order(r["sheet"]), r["arrival"], r["name"]))
|
||||
u["pflichtig"] = sorted((v for v in u["summary"].values() if not v["exempt"]), key=lambda v: v["land"])
|
||||
u["frei"] = sorted((v for v in u["summary"].values() if v["exempt"]), key=lambda v: v["land"])
|
||||
for part in ("pflichtig", "frei"):
|
||||
u[part + "_sum"] = {"persons": sum(v["persons"] for v in u[part]),
|
||||
"nights": sum(v["nights"] for v in u[part])}
|
||||
u["betrag"] = round(u["pflichtig_sum"]["nights"] * rate, 2)
|
||||
u["new_sheets"] = u["pflichtig_sum"]["persons"] + u["frei_sum"]["persons"]
|
||||
del u["summary"]
|
||||
out.append(u)
|
||||
return {"year": year, "month": month, "month_start": month_start, "rate": rate,
|
||||
"month_name": f"{month_names[month - 1]} {year}", "units": out}
|
||||
+75
@@ -0,0 +1,75 @@
|
||||
"""Quarterly Umsatzsteuer-Voranmeldung list, computed from the Zimmerbelegung.
|
||||
|
||||
- A receipt belongs to the quarter it was PAID in (Ist-Besteuerung), not the one it was lived or invoiced
|
||||
in. A row paid in several goes counts each payment on its own day; otherwise paid = the same rule the
|
||||
Zimmerbelegung shows (`repo._payment`): the row's own date, else the date its invoice was paid.
|
||||
- Only the Betrag counts. The Ortstaxe is a durchlaufender Posten and never appears; payments cover the
|
||||
Betrag first (175,00 paid of 175,00 Betrag + 16,00 Ortstaxe: the Betrag is in, the Ortstaxe open).
|
||||
- One Betrieb at a time — each unit files separately. A Betrieb includes its predecessors.
|
||||
- Everything is 10 % USt: Beherbergung, and the Garçonnières' residential rent alike. A Storno is nicht
|
||||
steuerbar (`repo.is_storno`): listed, because the money came in, but kept out of the base.
|
||||
"""
|
||||
import datetime
|
||||
|
||||
from app import db, repo
|
||||
|
||||
VAT_RATE = 0.10
|
||||
|
||||
|
||||
def previous_quarter(today):
|
||||
"""The UVA is filed for the quarter that has just ended."""
|
||||
q = (today.month - 1) // 3 # 0 = we are in the 1st quarter → last year's 4th
|
||||
return (today.year, q) if q else (today.year - 1, 4)
|
||||
|
||||
|
||||
def _bounds(year, quarter):
|
||||
first = datetime.date(year, 3 * quarter - 2, 1)
|
||||
after = datetime.date(year + 1, 1, 1) if quarter == 4 else datetime.date(year, 3 * quarter + 1, 1)
|
||||
return first.isoformat(), (after - datetime.timedelta(days=1)).isoformat()
|
||||
|
||||
|
||||
def receipts(row):
|
||||
"""When this row's money came in: [(date, part of the Betrag, part of the Ortstaxe)].
|
||||
|
||||
Recorded payments count one by one, each on its own day — an invoice paid half in May and half in
|
||||
October belongs to two quarters. They cover the Betrag first and the Ortstaxe last. Without payments, a
|
||||
paid row came in whole on its 'bezahlt am'."""
|
||||
if row["payments"]:
|
||||
out, left, taxe = [], row["amount"] or 0, row["tourism_tax"] or 0
|
||||
for on, amount in row["payments"]:
|
||||
part = round(min(amount, left), 2)
|
||||
tax = round(min(amount - part, taxe), 2)
|
||||
left, taxe = left - part, taxe - tax
|
||||
if part > 0 or tax > 0:
|
||||
out.append((on, part, tax))
|
||||
return out
|
||||
if row["payment"] in ("paid", "cash") and row["paid_on"]:
|
||||
return [(row["paid_on"], row["amount"] or 0, row["tourism_tax"] or 0)]
|
||||
return []
|
||||
|
||||
|
||||
def units(conn):
|
||||
return db.rows(conn, "SELECT code, name FROM unit WHERE is_current=1 ORDER BY id")
|
||||
|
||||
|
||||
def quarterly_report(conn, unit_code, year, quarter):
|
||||
first, last = _bounds(year, quarter)
|
||||
codes = {r["code"] for r in db.rows(conn, "SELECT code FROM unit WHERE code=? OR succeeded_by_id="
|
||||
"(SELECT id FROM unit WHERE code=?)", (unit_code, unit_code))}
|
||||
rooms = {}
|
||||
for r in repo.ledger_rows(conn):
|
||||
if r["unit"] not in codes:
|
||||
continue
|
||||
for on, amount, _ in receipts(r):
|
||||
if amount and first <= on <= last:
|
||||
rooms.setdefault(r["room"] or "", []).append(
|
||||
dict(r, received_on=on, received=amount, storno=repo.is_storno(r)))
|
||||
sections = [{"room": room, "rows": sorted(rows, key=lambda x: (x["received_on"], x["charge_id"])),
|
||||
"sum": round(sum(x["received"] for x in rows), 2)}
|
||||
for room, rows in sorted(rooms.items())]
|
||||
total = round(sum(s["sum"] for s in sections), 2)
|
||||
storno = round(sum(x["received"] for s in sections for x in s["rows"] if x["storno"]), 2)
|
||||
gross = round(total - storno, 2)
|
||||
net = round(gross / (1 + VAT_RATE), 2)
|
||||
return {"first": first, "last": last, "sections": sections, "total": total, "storno": storno,
|
||||
"gross": gross, "net": net, "vat": round(gross - net, 2)}
|
||||
+737
@@ -0,0 +1,737 @@
|
||||
"""Flask app: the web UI. Refuses to start while a migration is pending. All data access goes through
|
||||
repo.py, so scripts get the same operations as the pages.
|
||||
"""
|
||||
import base64
|
||||
import io
|
||||
import mimetypes
|
||||
import secrets
|
||||
import struct
|
||||
from functools import lru_cache
|
||||
from pathlib import Path
|
||||
|
||||
from flask import (Flask, render_template, request, redirect, url_for, g, flash, abort, send_file)
|
||||
|
||||
from app import compute, db, i18n, money, pdf, plan, repo, quality, tvb, vat
|
||||
|
||||
APP_DIR = Path(__file__).resolve().parent
|
||||
INVOICE_ASSETS = APP_DIR / "static" / "invoice"
|
||||
|
||||
|
||||
def get_db():
|
||||
if "conn" not in g:
|
||||
g.conn = db.connect(actor="web")
|
||||
return g.conn
|
||||
|
||||
|
||||
_page_cache = {}
|
||||
|
||||
|
||||
def assets_fingerprint():
|
||||
"""Newest mtime of the grid's own code — static files AND templates. It belongs in the fingerprint that
|
||||
keys the FROZEN column layouts in the browser's localStorage: without it a change is invisible after a
|
||||
reload, because the browser re-applies the widths it measured against the old code. The templates count
|
||||
too: a template change can widen a column without touching a single static file."""
|
||||
files = [APP_DIR / "static" / f for f in ("grids.js", "style.css")]
|
||||
files += sorted((APP_DIR / "templates").glob("*.html"))
|
||||
return str(max(int(f.stat().st_mtime) for f in files))
|
||||
|
||||
|
||||
def _ledger_year(row):
|
||||
"""Which year does a Zimmerbelegung row belong to? That of the first date it actually has —
|
||||
`repo.ledger_rows` says which."""
|
||||
return (row["first_date"] or "")[:4] or None
|
||||
|
||||
|
||||
def page_fingerprint():
|
||||
"""What a rendered list page depends on: the data AND the grid code. Keys both the server-side page
|
||||
cache and the frozen column layouts in the browser — a cached page carrying the old fingerprint would
|
||||
keep the stale layout alive just as effectively as the layout itself."""
|
||||
return db_fingerprint() + "-" + assets_fingerprint()
|
||||
|
||||
|
||||
def db_fingerprint():
|
||||
"""The schema and its write counter (`data_version`, counted up by every statement that writes a data
|
||||
table — this app or any script; migration 001). Keys the server-side page cache and the
|
||||
browser's stored grid layouts. The schema belongs in it: the tests run each in a schema of their own."""
|
||||
return f"{db.SCHEMA}.{db.row(get_db(), 'SELECT version FROM data_version')[0]}"
|
||||
|
||||
|
||||
def cached_page(view):
|
||||
"""Serve the big read-only list pages from memory while the database is unchanged.
|
||||
Pages carrying flash messages are rendered fresh and never stored."""
|
||||
from functools import wraps
|
||||
|
||||
@wraps(view)
|
||||
def wrapped(**kwargs):
|
||||
from flask import session
|
||||
if "_flashes" in session:
|
||||
return view(**kwargs)
|
||||
key = (request.full_path, page_fingerprint())
|
||||
if _page_cache.get(view.__name__, (None,))[0] != key:
|
||||
_page_cache[view.__name__] = (key, view(**kwargs))
|
||||
return _page_cache[view.__name__][1]
|
||||
return wrapped
|
||||
|
||||
|
||||
def asset_uri(filename):
|
||||
"""Inline an invoice image as a data URI so it renders in both browser-print and the headless PDF."""
|
||||
if not filename:
|
||||
return ""
|
||||
path = INVOICE_ASSETS / filename
|
||||
if not path.exists():
|
||||
return ""
|
||||
mime = mimetypes.guess_type(str(path))[0] or "image/png"
|
||||
return f"data:{mime};base64," + base64.b64encode(path.read_bytes()).decode()
|
||||
|
||||
|
||||
ART_BOX_W, ART_BOX_H, ART_GAP = 64, 80, 4 # masthead art box (mm) — keep in sync with .masthead .art CSS
|
||||
|
||||
|
||||
@lru_cache(maxsize=None)
|
||||
def _image_size(path_str):
|
||||
"""Pixel (w, h) of a PNG or JPEG read from the file header — avoids an imaging dependency."""
|
||||
data = Path(path_str).read_bytes()
|
||||
if data[:8] == b"\x89PNG\r\n\x1a\n":
|
||||
w, h = struct.unpack(">II", data[16:24])
|
||||
return w, h
|
||||
if data[:2] == b"\xff\xd8":
|
||||
i = 2
|
||||
while i + 9 < len(data):
|
||||
if data[i] != 0xFF:
|
||||
i += 1
|
||||
elif data[i + 1] == 0x01 or 0xD0 <= data[i + 1] <= 0xD9: # marker without a length field
|
||||
i += 2
|
||||
elif 0xC0 <= data[i + 1] <= 0xCF and data[i + 1] not in (0xC4, 0xC8, 0xCC): # SOFn
|
||||
h, w = struct.unpack(">HH", data[i + 5:i + 9])
|
||||
return w, h
|
||||
else:
|
||||
i += 2 + struct.unpack(">H", data[i + 2:i + 4])[0]
|
||||
raise ValueError(f"cannot read image size: {path_str}")
|
||||
|
||||
|
||||
def masthead_art(unit):
|
||||
"""Explicit print sizes for the branding images. All images of a unit share ONE width, solved so the
|
||||
stack exactly fills the art box height (capped at the box width) — a wide logo can never render bigger
|
||||
than the house photo, and a lone image grows to fill the whole box."""
|
||||
imgs = [{"src": name, "cls": cls} for name, cls in ((unit.get("logo"), "logo"), (unit.get("photo"), "photo"))
|
||||
if name and (INVOICE_ASSETS / name).exists()]
|
||||
for a in imgs:
|
||||
w_px, h_px = _image_size(str(INVOICE_ASSETS / a["src"]))
|
||||
a["ratio"] = h_px / w_px
|
||||
if not imgs:
|
||||
return []
|
||||
usable_h = ART_BOX_H - ART_GAP * (len(imgs) - 1)
|
||||
width = min(ART_BOX_W, usable_h / sum(a["ratio"] for a in imgs))
|
||||
for a in imgs:
|
||||
a["w"] = round(width, 2)
|
||||
a["h"] = round(width * a.pop("ratio"), 2)
|
||||
return imgs
|
||||
|
||||
|
||||
def _tourism_rate(on_date=None):
|
||||
"""The Aufenthaltsabgabe per person and night in force on that date — handed to the ledger form so the
|
||||
browser can propose the amount without a second copy of the rate table."""
|
||||
import datetime
|
||||
return compute.tourism_rate(on_date or datetime.date.today().isoformat())
|
||||
|
||||
|
||||
def create_app():
|
||||
app = Flask(__name__)
|
||||
app.secret_key = secrets.token_hex(32) # the session only carries flash messages across a redirect
|
||||
|
||||
# Static assets: version the URL by file mtime and cache hard. Without this the browser's heuristic
|
||||
# cache kept serving OLD grids.js/style.css after updates (no revalidation for hours/days).
|
||||
app.config["SEND_FILE_MAX_AGE_DEFAULT"] = 60 * 60 * 24 * 365
|
||||
|
||||
@app.url_defaults
|
||||
def bust_static_cache(endpoint, values):
|
||||
if endpoint == "static" and "filename" in values:
|
||||
f = APP_DIR / "static" / values["filename"]
|
||||
if f.exists():
|
||||
values["v"] = int(f.stat().st_mtime)
|
||||
|
||||
conn = db.connect()
|
||||
pending = db.pending_migrations(conn)
|
||||
conn.close()
|
||||
if pending: # the app's role may not change the schema — the owner does, before the app starts
|
||||
raise RuntimeError(f"database schema is behind the code (migrations {pending}) — "
|
||||
f"run first: python -m app.db migrate")
|
||||
|
||||
# the grid pages render thousands of rows — don't ship template indentation as page weight
|
||||
app.jinja_env.trim_blocks = True
|
||||
app.jinja_env.lstrip_blocks = True
|
||||
app.context_processor(lambda: {"db_fp": page_fingerprint()})
|
||||
app.jinja_env.globals["t"] = i18n.t
|
||||
app.jinja_env.globals["asset_uri"] = asset_uri
|
||||
app.jinja_env.globals["masthead_art"] = masthead_art
|
||||
app.jinja_env.filters["euro"] = money.euro
|
||||
app.jinja_env.filters["num"] = money.de_number
|
||||
app.jinja_env.filters["date"] = money.de_date
|
||||
app.jinja_env.filters["datewd"] = money.de_date_wd # with the weekday, for the two lists
|
||||
|
||||
@app.after_request
|
||||
def never_cache_pages(resp):
|
||||
# dynamic pages must always re-render — without this, the browser's back button (bfcache /
|
||||
# heuristic caching) shows a STALE copy after an edit. Versioned static assets keep their year.
|
||||
if request.endpoint != "static":
|
||||
resp.headers["Cache-Control"] = "no-store"
|
||||
return resp
|
||||
|
||||
@app.teardown_appcontext
|
||||
def close_db(exc):
|
||||
conn = g.pop("conn", None)
|
||||
if conn is not None:
|
||||
conn.close()
|
||||
|
||||
@app.route("/")
|
||||
def home():
|
||||
return render_template("home.html", stats=repo.stats(get_db()))
|
||||
|
||||
# --- guest register ---
|
||||
@app.route("/register")
|
||||
@cached_page
|
||||
def register():
|
||||
search = request.args.get("q", "").strip()
|
||||
return render_template("register.html", rows=repo.register_rows(get_db(), search), q=search)
|
||||
|
||||
@app.route("/register/new", methods=["GET", "POST"])
|
||||
@app.route("/register/<int:stay_id>/edit", methods=["GET", "POST"])
|
||||
def stay_form(stay_id=None):
|
||||
conn = get_db()
|
||||
stay = repo.get_stay(conn, stay_id) if stay_id else None
|
||||
# a room change: the new sheet continues the old one — same person, same number
|
||||
from_id = None if stay_id else request.args.get("from", type=int)
|
||||
moved_from = repo.get_stay(conn, from_id) if from_id else None
|
||||
if request.method == "POST":
|
||||
f = request.form
|
||||
if not f.get("last_name", "").strip():
|
||||
flash(i18n.t("err_required", field=i18n.t("col_name")))
|
||||
elif f.get("arrival") and f.get("departure") and f["departure"] < f["arrival"]:
|
||||
flash(i18n.t("err_departure_before_arrival")) # reject at the door, not on Datenprüfung
|
||||
else:
|
||||
last = f["last_name"].strip()
|
||||
first = f.get("first_name", "").strip() or None
|
||||
gfields = dict(birth_date=f.get("birth_date") or None, nationality=f.get("nationality") or None,
|
||||
doc_type=f.get("doc_type") or None, doc_number=f.get("doc_number") or None,
|
||||
profession=f.get("profession") or None, home_street=f.get("home_street") or None,
|
||||
home_postcode=f.get("home_postcode") or None, home_city=f.get("home_city") or None,
|
||||
home_country=f.get("home_country") or None, phone=f.get("phone") or None)
|
||||
person = stay or moved_from # edit, or the same person in another room
|
||||
if person and person.get("guest_id"): # update this exact person
|
||||
repo.update_guest(conn, person["guest_id"], last_name=last, first_name=first, **gfields)
|
||||
guest_id = person["guest_id"]
|
||||
else: # new: reuse only on exact person match
|
||||
guest_id = repo.find_or_create_guest(conn, last, first, **gfields)
|
||||
room_id = int(f["room_id"]) if f.get("room_id") else None
|
||||
saved = repo.save_stay(conn, stay_id, guest_id, room_id,
|
||||
int(f["unit_id"]) if f.get("unit_id") else None,
|
||||
repo.find_or_create_client(conn, f.get("employer")),
|
||||
f.get("arrival") or None, f.get("departure") or None,
|
||||
f.get("sheet_number") or None,
|
||||
int(f["hauptwohnsitz"]) if f.get("hauptwohnsitz") in ("0", "1") else None,
|
||||
int(f["tourism_liable"]) if f.get("tourism_liable") in ("0", "1") else None,
|
||||
f.get("notes") or None)
|
||||
if f.get("action") == "room_change":
|
||||
if not f.get("departure"): # the departure IS the day of the change
|
||||
flash(i18n.t("err_room_change_needs_date"))
|
||||
return redirect(url_for("stay_form", stay_id=saved))
|
||||
return redirect(url_for("stay_form", **{"from": saved}))
|
||||
if moved_from and repo.carry_ledger_to_room(conn, f.get("sheet_number") or None,
|
||||
moved_from["room_id"], room_id, f.get("arrival"),
|
||||
{"last_name": last, "first_name": first}):
|
||||
flash(i18n.t("msg_room_changed", date=money.de_date(f["arrival"]),
|
||||
room=_room_number(conn, room_id)))
|
||||
return redirect(url_for("register"))
|
||||
if moved_from: # the old sheet, as the new one starts
|
||||
stay = dict(moved_from, id=None, room_id=None, arrival=moved_from["departure"], departure=None,
|
||||
notes=i18n.t("note_room_change", room=_room_number(conn, moved_from["room_id"])))
|
||||
return render_template("stay_form.html", stay=stay, room_change=bool(moved_from), rooms=repo.rooms(conn),
|
||||
units=repo.units(conn), suggest=repo.field_suggestions(conn))
|
||||
|
||||
@app.route("/register/<int:stay_id>/delete", methods=["POST"])
|
||||
def stay_delete(stay_id):
|
||||
try:
|
||||
repo.delete_stay(get_db(), stay_id)
|
||||
except ValueError:
|
||||
flash(i18n.t("err_delete_blocked"))
|
||||
return redirect(url_for("register"))
|
||||
|
||||
# --- room ledger ---
|
||||
@app.route("/ledger")
|
||||
@cached_page
|
||||
def ledger():
|
||||
conn = get_db()
|
||||
room = request.args.get("room", "").strip()
|
||||
search = request.args.get("q", "").strip()
|
||||
rows = repo.ledger_rows(conn, room or None, search)
|
||||
years = sorted({y for y in (_ledger_year(r) for r in rows) if y}, reverse=True)
|
||||
return render_template("ledger.html", rows=rows, years=years,
|
||||
rooms=repo.rooms(conn), room=room, q=search)
|
||||
|
||||
@app.route("/ledger/new", methods=["GET", "POST"])
|
||||
def ledger_new():
|
||||
conn = get_db()
|
||||
if request.method == "POST":
|
||||
f = request.form
|
||||
room = f.get("room_id", "").strip()
|
||||
repo.create_ledger_row(conn, int(room) if room.isdigit() else None,
|
||||
f.get("occupant", "").strip() or None,
|
||||
f.get("period_from") or None, f.get("period_to") or None,
|
||||
parse_number(f.get("persons")), parse_number(f.get("quantity")),
|
||||
f.get("unit_type") or None, parse_number(f.get("gross_per_unit")),
|
||||
parse_number(f.get("betrag")), f.get("paid_status") or None,
|
||||
f.get("paid_date") or None, f.get("art") or None,
|
||||
f.get("payer", "").strip() or None, f.get("rnr", "").strip() or None,
|
||||
f.get("gb_ref", "").strip() or None, f.get("notes", "").strip() or None,
|
||||
tourism_tax=parse_number(f.get("tourism_tax")),
|
||||
payments=_payments(f) or [])
|
||||
return redirect(url_for("ledger"))
|
||||
return render_template("ledger_form.html", ch={}, rooms=repo.rooms(conn), is_new=True,
|
||||
tourism_rate=_tourism_rate(None))
|
||||
|
||||
@app.route("/ledger/<int:charge_id>/edit", methods=["GET", "POST"])
|
||||
def ledger_edit(charge_id):
|
||||
conn = get_db()
|
||||
ch = repo.get_charge(conn, charge_id)
|
||||
if not ch:
|
||||
abort(404)
|
||||
if request.method == "POST":
|
||||
f = request.form
|
||||
repo.update_charge(conn, charge_id, f.get("occupant", "").strip() or None,
|
||||
f.get("period_from") or None, f.get("period_to") or None,
|
||||
parse_number(f.get("persons")), parse_number(f.get("quantity")),
|
||||
f.get("unit_type") or None, parse_number(f.get("gross_per_unit")),
|
||||
parse_number(f.get("betrag")), f.get("paid_status") or None,
|
||||
f.get("paid_date") or None, f.get("art") or None,
|
||||
f.get("payer", "").strip() or None, f.get("rnr", "").strip() or None,
|
||||
f.get("gb_ref", "").strip() or None, f.get("notes", "").strip() or None,
|
||||
tourism_tax=parse_number(f.get("tourism_tax")),
|
||||
payments=_payments(f))
|
||||
if f.get("action") == "continue":
|
||||
# month-end billing: bill up to this date, then carry the occupancy on from it
|
||||
if not f.get("period_to"):
|
||||
flash(i18n.t("err_continue_needs_date"))
|
||||
return redirect(url_for("ledger_edit", charge_id=charge_id))
|
||||
repo.continue_ledger_row(conn, charge_id)
|
||||
flash(i18n.t("msg_continued", date=money.de_date(f["period_to"])))
|
||||
return redirect(url_for("ledger"))
|
||||
return render_template("ledger_form.html", ch=ch, tourism_rate=_tourism_rate(ch.get("period_from")))
|
||||
|
||||
@app.route("/ledger/<int:charge_id>/delete", methods=["POST"])
|
||||
def ledger_delete(charge_id):
|
||||
repo.delete_charge(get_db(), charge_id)
|
||||
return redirect(url_for("ledger"))
|
||||
|
||||
# --- Belegungsplan (occupancy timeline) ---
|
||||
@app.route("/plan")
|
||||
def plan_page():
|
||||
# not @cached_page: the view depends on today's date (today marker, occupancy pills),
|
||||
# and the cache key only sees URL + db fingerprint — it would survive midnight.
|
||||
import datetime
|
||||
weeks = 8 if request.args.get("weeks") == "8" else 4
|
||||
try:
|
||||
start = datetime.date.fromisoformat(request.args.get("start", ""))
|
||||
except ValueError:
|
||||
start = plan.default_start()
|
||||
return render_template("plan.html", p=plan.build(get_db(), start, weeks * 7), weeks=weeks)
|
||||
|
||||
# --- invoices ---
|
||||
@app.route("/invoices")
|
||||
@cached_page
|
||||
def invoices():
|
||||
q = request.args.get("q", "").strip()
|
||||
return render_template("invoices.html", rows=repo.invoices_list(get_db(), q), q=q)
|
||||
|
||||
def _rows_follow(conn, invoice_id):
|
||||
"""The Zimmerbelegung takes over what the saved invoice bills — and the user is told it did."""
|
||||
fixed, new = repo.sync_rows_to_invoice(conn, invoice_id)
|
||||
if fixed or new:
|
||||
flash(i18n.t("msg_rows_follow_invoice", fixed=len(fixed), new=len(new)))
|
||||
|
||||
@app.route("/invoices/new", methods=["GET", "POST"])
|
||||
def invoice_new():
|
||||
conn = get_db()
|
||||
units = repo.units(conn)
|
||||
f = request.form
|
||||
lines = []
|
||||
order_warning = False
|
||||
client_choices = None
|
||||
if request.method == "POST":
|
||||
lines, incomplete = _parse_lines(f)
|
||||
unit_id = int(f["unit_id"]) if f.get("unit_id") else None
|
||||
number = f.get("number", "").strip()
|
||||
if incomplete:
|
||||
flash(i18n.t("err_number", field=", ".join(incomplete)))
|
||||
elif not unit_id or not f.get("client", "").strip() or not lines:
|
||||
flash(i18n.t("err_required", field="Einheit, Kunde, mind. 1 Zeile"))
|
||||
elif number and repo.invoice_number_taken(conn, unit_id, number):
|
||||
flash(i18n.t("err_number_taken", number=number))
|
||||
elif _order_warning(conn, f, unit_id, number):
|
||||
order_warning = True
|
||||
elif (client_choices := _recipient_warning(conn, f)) is None:
|
||||
links = [int(x) for x in f.get("link_charges", "").split(",") if x.strip().isdecimal()]
|
||||
inv_id = repo.create_invoice(
|
||||
conn, unit_id, repo.find_or_create_client(conn, f["client"]),
|
||||
number, f.get("date") or None, lines, note=f.get("note"),
|
||||
paid_date=f.get("paid_date") or None, payment_method=f.get("payment_method") or None,
|
||||
link_charge_ids=links)
|
||||
_rows_follow(conn, inv_id)
|
||||
return redirect(url_for("invoice_view", invoice_id=inv_id))
|
||||
lines = _raw_lines(f) # re-render exactly what was typed, incl. the rejected rows
|
||||
elif request.args.get("stays") or request.args.get("charges"):
|
||||
# generate a pre-filled invoice from selected Gästeblätter rows or Zimmerbelegung rows
|
||||
if request.args.get("charges"):
|
||||
ids = [int(x) for x in request.args["charges"].split(",") if x.strip().isdecimal()]
|
||||
pre = repo.lines_from_charges(conn, ids)
|
||||
else:
|
||||
ids = [int(x) for x in request.args["stays"].split(",") if x.strip().isdecimal()]
|
||||
pre = repo.lines_from_stays(conn, ids)
|
||||
lines = pre["lines"]
|
||||
f = {"unit_id": pre["unit_id"], "client": pre["client"] or "",
|
||||
# remember the ledger rows so saving writes the invoice number back into them
|
||||
"link_charges": ",".join(map(str, ids)) if request.args.get("charges") else ""}
|
||||
if pre["client"]: # a payer without an address asks at once, not only on saving
|
||||
client_choices = repo.recipient_choices(conn, pre["client"])
|
||||
import datetime
|
||||
suggestions = {u["id"]: repo.next_invoice_number(conn, u["id"]) for u in units}
|
||||
return render_template("invoice_form.html", units=units, suggestions=suggestions, form=f, lines=lines,
|
||||
suggest=repo.field_suggestions(conn), order_warning=order_warning,
|
||||
client_choices=client_choices, today=datetime.date.today().isoformat())
|
||||
|
||||
@app.route("/invoices/<int:invoice_id>/edit", methods=["GET", "POST"])
|
||||
def invoice_edit(invoice_id):
|
||||
conn = get_db()
|
||||
inv = repo.get_invoice(conn, invoice_id)
|
||||
if not inv:
|
||||
abort(404)
|
||||
units = repo.units(conn)
|
||||
order_warning = False
|
||||
client_choices = None
|
||||
if request.method == "POST":
|
||||
f = request.form
|
||||
lines, incomplete = _parse_lines(f)
|
||||
unit_id = int(f["unit_id"]) if f.get("unit_id") else None
|
||||
number = f.get("number", "").strip()
|
||||
if incomplete:
|
||||
flash(i18n.t("err_number", field=", ".join(incomplete)))
|
||||
elif not unit_id or not f.get("client", "").strip() or not lines:
|
||||
flash(i18n.t("err_required", field="Einheit, Kunde, mind. 1 Zeile"))
|
||||
elif number and repo.invoice_number_taken(conn, unit_id, number, exclude_id=invoice_id):
|
||||
flash(i18n.t("err_number_taken", number=number))
|
||||
elif _order_warning(conn, f, unit_id, number, invoice_id):
|
||||
order_warning = True
|
||||
elif (client_choices := _recipient_warning(conn, f)) is None:
|
||||
repo.update_invoice(conn, invoice_id, unit_id,
|
||||
repo.find_or_create_client(conn, f["client"]), number,
|
||||
f.get("date") or None, lines, note=f.get("note"),
|
||||
paid_date=f.get("paid_date") or None, payment_method=f.get("payment_method") or None)
|
||||
_rows_follow(conn, invoice_id)
|
||||
return redirect(url_for("invoice_view", invoice_id=invoice_id))
|
||||
return render_template("invoice_form.html", units=units, suggestions={}, form=f,
|
||||
lines=_raw_lines(f), editing=inv, suggest=repo.field_suggestions(conn),
|
||||
order_warning=order_warning, client_choices=client_choices)
|
||||
form = {"unit_id": inv["unit_id"], "client": (inv["client"] or {}).get("name", ""),
|
||||
"number": inv["number"], "date": inv["date"], "note": inv.get("notes"),
|
||||
"paid_date": inv.get("paid_date"), "payment_method": inv.get("payment_method")}
|
||||
return render_template("invoice_form.html", units=units, suggestions={}, form=form, lines=inv["lines"],
|
||||
editing=inv, suggest=repo.field_suggestions(conn),
|
||||
client_choices=repo.recipient_choices(conn, form["client"]))
|
||||
|
||||
@app.route("/invoices/<int:invoice_id>/delete", methods=["POST"])
|
||||
def invoice_delete(invoice_id):
|
||||
repo.delete_invoice(get_db(), invoice_id)
|
||||
return redirect(url_for("invoices"))
|
||||
|
||||
@app.route("/invoices/<int:invoice_id>")
|
||||
def invoice_view(invoice_id):
|
||||
conn = get_db()
|
||||
inv = repo.get_invoice(conn, invoice_id)
|
||||
if not inv:
|
||||
abort(404)
|
||||
# offer "Neu generieren" only when a linked ledger row changed since the invoice was (re)generated
|
||||
can_regen = bool(inv.get("lines_stale")) and bool(repo.linked_ledger_charges(conn, invoice_id))
|
||||
return render_template("invoice_print.html", inv=inv, can_regen=can_regen)
|
||||
|
||||
@app.route("/invoices/<int:invoice_id>/regenerate", methods=["POST"])
|
||||
def invoice_regenerate(invoice_id):
|
||||
if not repo.regenerate_invoice(get_db(), invoice_id):
|
||||
abort(404)
|
||||
return redirect(url_for("invoice_view", invoice_id=invoice_id))
|
||||
|
||||
@app.route("/invoices/<int:invoice_id>/payment", methods=["GET", "POST"])
|
||||
def invoice_payment(invoice_id):
|
||||
"""Record the settlement of an already issued invoice — the ONLY writing path onto one that is not
|
||||
a full edit. Deliberate and manual by design: an issued invoice never changes as a side effect."""
|
||||
conn = get_db()
|
||||
inv = repo.get_invoice(conn, invoice_id)
|
||||
if not inv:
|
||||
abort(404)
|
||||
if request.method == "POST":
|
||||
repo.set_invoice_payment(conn, invoice_id, request.form.get("paid_date"),
|
||||
request.form.get("payment_method"))
|
||||
return redirect(url_for("invoice_view", invoice_id=invoice_id))
|
||||
return render_template("invoice_payment.html", inv=inv,
|
||||
hint=repo.invoice_payment_hint(conn, invoice_id))
|
||||
|
||||
@app.route("/invoices/<int:invoice_id>/pdf")
|
||||
def invoice_pdf(invoice_id):
|
||||
inv = repo.get_invoice(get_db(), invoice_id)
|
||||
if not inv:
|
||||
abort(404)
|
||||
html = render_template("invoice_print.html", inv=inv, standalone=True)
|
||||
return send_file(io.BytesIO(pdf.render(html)), mimetype="application/pdf", as_attachment=True,
|
||||
download_name=repo.invoice_filename(inv))
|
||||
|
||||
# --- companies (canonical clients + their address versions) ---
|
||||
@app.route("/clients")
|
||||
@cached_page
|
||||
def clients():
|
||||
q = request.args.get("q", "").strip()
|
||||
return render_template("clients.html", rows=repo.companies_list(get_db(), q or None), q=q)
|
||||
|
||||
@app.route("/clients/<int:company_id>", methods=["GET", "POST"])
|
||||
def client_edit(company_id):
|
||||
conn = get_db()
|
||||
comp = repo.get_company(conn, company_id)
|
||||
if not comp:
|
||||
abort(404)
|
||||
if request.method == "POST":
|
||||
f = request.form
|
||||
if f.get("action") == "add_address":
|
||||
repo.add_company_address(conn, company_id, f.get("address", "").strip(),
|
||||
f.get("postcode", "").strip(), f.get("city", "").strip(),
|
||||
make_current=bool(f.get("make_current")))
|
||||
elif f.get("action") == "edit_address":
|
||||
repo.update_client_address(conn, int(f["client_id"]), f.get("name", ""),
|
||||
f.get("address", ""), f.get("postcode", ""), f.get("city", ""))
|
||||
elif f.get("action") == "make_current":
|
||||
repo.set_current_address(conn, company_id, int(f["client_id"]))
|
||||
elif f.get("action") == "delete_address":
|
||||
try:
|
||||
repo.delete_client_address(conn, int(f["client_id"]))
|
||||
except ValueError as e:
|
||||
flash(i18n.t("err_address_last" if str(e) == "last_version" else "err_address_has_refs"))
|
||||
elif f.get("action") == "merge":
|
||||
target = f.get("merge_into", "").strip()
|
||||
if target.isdigit() and int(target) != company_id:
|
||||
repo.merge_companies(conn, int(target), company_id)
|
||||
return redirect(url_for("client_edit", company_id=int(target)))
|
||||
elif not f.get("name", "").strip():
|
||||
flash(i18n.t("err_required", field=i18n.t("col_name")))
|
||||
else:
|
||||
repo.save_company(conn, company_id, f["name"].strip(), f.get("kind") or "company",
|
||||
f.get("uid", "").strip() or None, f.get("email", "").strip() or None,
|
||||
f.get("phone", "").strip() or None, f.get("notes", "").strip() or None,
|
||||
int(f["current_client_id"]) if f.get("current_client_id", "").isdigit() else None)
|
||||
return redirect(url_for("clients"))
|
||||
return redirect(url_for("client_edit", company_id=company_id))
|
||||
others = [c for c in repo.companies_list(conn) if c["id"] != company_id]
|
||||
return render_template("client_form.html", comp=comp, others=others)
|
||||
|
||||
@app.route("/clients/<int:company_id>/delete", methods=["POST"])
|
||||
def client_delete(company_id):
|
||||
try:
|
||||
repo.delete_company(get_db(), company_id)
|
||||
except ValueError:
|
||||
flash(i18n.t("err_company_has_refs"))
|
||||
return redirect(url_for("client_edit", company_id=company_id))
|
||||
return redirect(url_for("clients"))
|
||||
|
||||
# --- data-quality audit ---
|
||||
@app.route("/quality")
|
||||
@cached_page
|
||||
def quality_page():
|
||||
return render_template("quality.html", q=quality.overview(get_db()))
|
||||
|
||||
# --- monthly TVB Sammelmeldung (Aufenthaltsabgabe) ---
|
||||
@app.route("/tvb")
|
||||
def tvb_page():
|
||||
import datetime
|
||||
m = request.args.get("month", "")
|
||||
try:
|
||||
year, month = int(m[:4]), int(m[5:7])
|
||||
datetime.date(year, month, 1)
|
||||
except ValueError: # default: the month that is due now
|
||||
prev = datetime.date.today().replace(day=1) - datetime.timedelta(days=1)
|
||||
year, month = prev.year, prev.month
|
||||
return render_template("tvb.html", r=tvb.monthly_report(get_db(), year, month),
|
||||
month_value=f"{year:04d}-{month:02d}")
|
||||
|
||||
# --- quarterly Umsatzsteuer-Voranmeldung ---
|
||||
@app.route("/vat")
|
||||
def vat_page():
|
||||
import datetime
|
||||
conn = get_db()
|
||||
today = datetime.date.today()
|
||||
year, quarter = vat.previous_quarter(today) # default: the quarter that is due now
|
||||
try:
|
||||
year = int(request.args.get("year", year))
|
||||
quarter = min(max(int(request.args.get("q", quarter)), 1), 4)
|
||||
except ValueError:
|
||||
pass
|
||||
units = vat.units(conn)
|
||||
names = {u["code"]: u["name"] for u in units}
|
||||
unit = request.args.get("unit") if request.args.get("unit") in names else next(iter(names), None)
|
||||
return render_template("vat.html", r=vat.quarterly_report(conn, unit, year, quarter), units=units,
|
||||
unit=unit, unit_name=names.get(unit, unit), year=year, quarter=quarter,
|
||||
years=range(today.year, today.year - 8, -1))
|
||||
|
||||
@app.route("/quality/companies/merge", methods=["POST"])
|
||||
def quality_merge_companies():
|
||||
f = request.form
|
||||
if f.get("keep", "").isdigit() and f.get("drop", "").isdigit():
|
||||
repo.merge_companies(get_db(), int(f["keep"]), int(f["drop"]))
|
||||
return redirect(url_for("quality_page"))
|
||||
|
||||
@app.route("/quality/guests/merge", methods=["POST"])
|
||||
def quality_merge_guests():
|
||||
f = request.form
|
||||
if f.get("keep", "").isdigit() and f.get("drop", "").isdigit():
|
||||
repo.merge_guests(get_db(), int(f["keep"]), int(f["drop"]))
|
||||
return redirect(url_for("quality_page"))
|
||||
|
||||
@app.route("/quality/dismiss", methods=["POST"])
|
||||
def quality_dismiss():
|
||||
f = request.form
|
||||
if f.get("kind") and f.get("key"):
|
||||
quality.dismiss(get_db(), f["kind"], f["key"], f.get("note", "").strip() or None)
|
||||
return redirect(url_for("quality_page"))
|
||||
|
||||
@app.errorhandler(404)
|
||||
def not_found(e):
|
||||
return render_template("error.html", message=i18n.t("err_not_found")), 404
|
||||
|
||||
@app.errorhandler(500)
|
||||
def server_error(e):
|
||||
return render_template("error.html", message=i18n.t("err_generic")), 500
|
||||
|
||||
return app
|
||||
|
||||
|
||||
def _payments(f):
|
||||
"""The ledger form's payment lines as [(date, amount)] — None when the form carries no payment lines at
|
||||
all, which leaves the row's payments untouched. A line without an amount is the spare empty one; the
|
||||
form makes the date required as soon as an amount is typed."""
|
||||
if "pay_amount" not in f:
|
||||
return None
|
||||
lines = zip(f.getlist("pay_date"), (parse_number(a) for a in f.getlist("pay_amount")))
|
||||
return [(d, a) for d, a in lines if d and a]
|
||||
|
||||
|
||||
def _room_number(conn, room_id):
|
||||
return next((r["number"] for r in repo.rooms(conn) if r["id"] == room_id), "")
|
||||
|
||||
|
||||
def parse_number(value):
|
||||
"""Accept German decimals ('161,50', '1.234,56') and plain numbers; None if not parseable."""
|
||||
if value is None:
|
||||
return None
|
||||
s = str(value).strip().replace(" ", "")
|
||||
if not s:
|
||||
return None
|
||||
if "," in s: # German: comma = decimal, dot = thousands separator
|
||||
s = s.replace(".", "").replace(",", ".")
|
||||
try:
|
||||
return float(s)
|
||||
except ValueError:
|
||||
return None
|
||||
|
||||
|
||||
def _raw_lines(form):
|
||||
"""The submitted line rows verbatim, for re-rendering a form that failed validation — a rejected row
|
||||
must come back filled in, not vanish."""
|
||||
keys = ("worker", "period_from", "period_to", "quantity", "unit_type", "gross_per_unit", "tourism", "kind")
|
||||
lists = {k: form.getlist(k) for k in keys}
|
||||
n = max((len(v) for v in lists.values()), default=0)
|
||||
rows = []
|
||||
for i in range(n):
|
||||
row = {k: (lists[k][i] if i < len(lists[k]) else "") for k in keys}
|
||||
row["tourism_tax"] = row.pop("tourism")
|
||||
rows.append(row)
|
||||
return rows
|
||||
|
||||
|
||||
def _order_warning(conn, f, unit_id, number, exclude_id=None):
|
||||
"""Stop the save and say which invoice is in the way when the date breaks the number order
|
||||
(`repo.invoice_out_of_order`) — unless the user has ticked that the date is right on purpose."""
|
||||
clash = repo.invoice_out_of_order(conn, unit_id, number, f.get("date") or None, exclude_id)
|
||||
if not clash or f.get("confirm_order"):
|
||||
return False
|
||||
flash(i18n.t("warn_order_smaller" if clash["smaller"] else "warn_order_larger", number=number,
|
||||
date=money.de_date(f.get("date")), other=clash["number"], other_date=money.de_date(clash["date"])))
|
||||
return True
|
||||
|
||||
|
||||
def _recipient_warning(conn, f):
|
||||
"""None when the recipient prints an address; otherwise stop the save and return the companies the
|
||||
name may mean (`repo.recipient_choices`) — unless the user has ticked that it is right without one."""
|
||||
if f.get("confirm_client"):
|
||||
return None
|
||||
name = f.get("client", "").strip()
|
||||
choices = repo.recipient_choices(conn, name)
|
||||
if choices is not None:
|
||||
flash(i18n.t("warn_client_no_address", name=name))
|
||||
return choices
|
||||
|
||||
|
||||
def _parse_lines(form):
|
||||
"""Return (valid_lines, incomplete). A row with a name but no valid rate/quantity (or vice versa) is
|
||||
reported as incomplete rather than silently dropped, so an invoice can't be undercharged unnoticed."""
|
||||
workers = form.getlist("worker")
|
||||
gross, qtys = form.getlist("gross_per_unit"), form.getlist("quantity")
|
||||
pfrom, pto, utype = form.getlist("period_from"), form.getlist("period_to"), form.getlist("unit_type")
|
||||
tourism, kinds = form.getlist("tourism"), form.getlist("kind")
|
||||
|
||||
def at(lst, i):
|
||||
return lst[i] if i < len(lst) else None
|
||||
|
||||
lines, incomplete = [], []
|
||||
for i, worker in enumerate(workers):
|
||||
worker = worker.strip()
|
||||
rate = parse_number(at(gross, i))
|
||||
qty = parse_number(at(qtys, i))
|
||||
if not worker and rate is None:
|
||||
continue # empty row
|
||||
if not worker or rate is None or not qty or qty <= 0: # a blank/zero Anz is a mistake, not a free line
|
||||
incomplete.append(worker or "Zeile {}".format(i + 1))
|
||||
continue
|
||||
lines.append({
|
||||
"worker": worker,
|
||||
"period_from": at(pfrom, i) or None,
|
||||
"period_to": at(pto, i) or None,
|
||||
"quantity": qty,
|
||||
"unit_type": at(utype, i) or "week",
|
||||
"gross_per_unit": rate,
|
||||
"tourism_tax": parse_number(at(tourism, i)) or 0,
|
||||
"kind": at(kinds, i) or "stay",
|
||||
})
|
||||
return lines, incomplete
|
||||
|
||||
|
||||
HOST, PORT = "127.0.0.1", 5000
|
||||
|
||||
|
||||
def server_answers(host=HOST, port=PORT, timeout=0.4):
|
||||
"""Is a server already listening? Windows lets a SECOND server bind the same port instead of refusing
|
||||
it (SO_REUSEADDR), so every extra start left another instance running and Windows decided which one
|
||||
answered — after an update often the OLD one, still serving the previous code."""
|
||||
import socket
|
||||
with socket.socket() as s:
|
||||
s.settimeout(timeout)
|
||||
return s.connect_ex((host, port)) == 0
|
||||
|
||||
|
||||
def main():
|
||||
import threading
|
||||
import webbrowser
|
||||
url = f"http://{HOST}:{PORT}"
|
||||
if server_answers(): # already running: just open the window, never start twice
|
||||
webbrowser.open(url)
|
||||
return
|
||||
app = create_app()
|
||||
threading.Timer(1.0, lambda: webbrowser.open(url)).start()
|
||||
app.run(host=HOST, port=PORT, debug=False)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Reference in new issue
Block a user