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:
BlackMarkandClaude Opus 5.5 committed 2026-11-02 16:52:27 +01:00
commit 45424e6fd6
47 files changed
+6701

No files matched your search

View File
Whitespace-only changes.
+116
View File
@@ -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
View File
@@ -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))
+23
View File
@@ -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)
+277
View File
@@ -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
View File
@@ -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
+45
View File
@@ -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)
+53
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
File diff suppressed because it is too large. Load diff
+644
View File
@@ -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); });
});
});
})();
+288
View File
@@ -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}
}
+62
View File
@@ -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>
+126
View File
@@ -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 %}
+39
View File
@@ -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 %}
+5
View File
@@ -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 %}
+17
View File
@@ -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 %}
+123
View File
@@ -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>
&nbsp;<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 %}
+37
View File
@@ -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 %}
+174
View File
@@ -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&nbsp;&nbsp;&nbsp;{{ 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>
+54
View File
@@ -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 %}
+96
View File
@@ -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 %}
+167
View File
@@ -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 %}
+87
View File
@@ -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') }}">&#9664;</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') }}">&#9654;</a>
<span class="count">{{ p.start.isoformat()|date }} &ndash; {{ 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) }} &middot; {{ 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 }} &middot; {{ 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 }} &middot; {{ b.arrival|date }} &ndash; {{ b.departure|date if b.departure else t('plan_open') }}{% if b.sheet_number %} &middot; {{ t('plan_sheet', number=b.sheet_number) }}{% endif %}{% if b.employer %} &middot; {{ 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 }} &ndash; {{ u.departure|date if u.departure else t('plan_open') }})</a>{{ ', ' if not loop.last }}
{% endfor %}
</span>
{% endif %}
</div>
{% endblock %}
+85
View File
@@ -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 %}
+77
View File
@@ -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 %}
+114
View File
@@ -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 %}
+89
View File
@@ -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 }}
&nbsp;·&nbsp; <b>{{ t('tvb_betrag') }}:</b> {{ u.betrag|euro }}
({{ u.pflichtig_sum.nights }} × {{ r.rate|euro }})
&nbsp;·&nbsp; {{ t('tvb_new_sheets') }}: <b>{{ u.new_sheets }}</b>
</p>
</div>
{% endif %}
</section>
{% endfor %}
{% endblock %}
+65
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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()