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:
commit
45424e6fd6
47 files changed
+6701
No files matched your search
@@ -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 %}
|
||||
Reference in new issue
Block a user