Files
BlackMarkandClaude Opus 5.5 45424e6fd6 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>
2026-11-02 16:52:27 +01:00

168 lines
9.7 KiB
HTML
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
{% 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 %}