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

+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 %}