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

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