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

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