Owner settings: services, hours/buffer, auto-confirm, notify channel (#21)

Adds a session-authenticated /owner/settings blueprint for services CRUD
(create/edit/deactivate), per-resource opening hours + min-notice/max-advance/
buffer, and client-level auto_confirm/notify_channel — all scoped to the
logged-in owner's own client_id. Extends booking_db.py with the missing
tenant-scoped update_service/update_resource/update_client writes, mirroring
the existing update_booking allowlist pattern.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-04 09:00:38 +02:00
parent 3a20cb5d2b
commit 16500c4392
6 changed files with 663 additions and 4 deletions
@@ -0,0 +1,160 @@
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="robots" content="noindex" />
<title>Einstellungen — {{ client.business_name if client else '' }}</title>
<style>
:root {
--brand: #0f8a7e;
--bg: #fff; --ink: #16302f; --muted: #5d716f; --line: #e3eae9;
--danger: #b3261e; --danger-bg: #fdecea;
}
* { box-sizing: border-box; }
body { margin: 0; font-family: system-ui, -apple-system, Segoe UI, Roboto, sans-serif;
background: var(--bg); color: var(--ink); padding: 20px; max-width: 760px; }
h1 { font-size: 1.2rem; margin: 0 0 6px; }
h2 { font-size: .95rem; margin: 0 0 8px; color: var(--muted); }
a { color: var(--brand); }
.muted { color: var(--muted); font-size: .85rem; }
section { border: 1px solid var(--line); border-radius: 10px; padding: 16px; margin-bottom: 16px; }
table { width: 100%; border-collapse: collapse; font-size: .88rem; margin-bottom: 10px; }
td, th { text-align: left; padding: 6px 4px; border-bottom: 1px solid var(--line); }
form.inline { display: inline; }
form.row { display: flex; flex-wrap: wrap; align-items: end; gap: 8px; margin-bottom: 6px; }
input[type=text], input[type=number], input[type=time], select {
padding: 7px 9px; border: 1px solid var(--line); border-radius: 7px;
font: inherit; font-size: .85rem; }
input[type=text] { width: 140px; }
input[type=number] { width: 90px; }
.btn { background: var(--brand); color: #fff; border: 0; border-radius: 7px;
padding: 6px 12px; font: inherit; font-size: .82rem; font-weight: 600; cursor: pointer; }
.btn-small { padding: 4px 9px; font-size: .78rem; }
.error { background: var(--danger-bg); color: var(--danger); border-radius: 9px;
padding: 10px 14px; font-size: .88rem; margin-bottom: 14px; }
.field { display: inline-block; }
label { display: block; font-size: .75rem; color: var(--muted); margin-bottom: 3px; }
.weekday-row { display: flex; align-items: center; gap: 8px; margin-bottom: 6px; font-size: .85rem; }
.weekday-row .day-name { width: 90px; }
.weekday-row label { display: inline; margin: 0 0 0 4px; font-size: .8rem; }
</style>
</head>
<body>
<h1>{{ client.business_name if client else 'Einstellungen' }}</h1>
{% if error == "invalid_service" %}
<div class="error">Bitte Name und eine gültige Dauer angeben.</div>
{% elif error == "invalid_resource" %}
<div class="error">Vorlaufzeit, Vorausbuchung und Puffer müssen 0 oder größer sein.</div>
{% elif error == "invalid_hours" %}
<div class="error">Bitte gültige Öffnungszeiten angeben (Ende nach Beginn).</div>
{% elif error == "invalid_notify_channel" %}
<div class="error">Nur Telegram ist derzeit als Benachrichtigungskanal verfügbar.</div>
{% elif error == "not_found" %}
<div class="error">Nicht gefunden.</div>
{% endif %}
<section>
<h2>Leistungen</h2>
{% if services %}
<table>
<thead><tr><th>Name</th><th>Dauer (Min.)</th><th>Preis</th><th>Aktiv</th><th></th></tr></thead>
<tbody>
{% for s in services %}
<tr>
<form method="post" action="{{ url_for('owner_settings.update_service', service_id=s.service_id) }}">
<td><input type="text" name="name" value="{{ s.name }}" required /></td>
<td><input type="number" name="duration_minutes" value="{{ s.duration_minutes }}" min="1" required /></td>
<td><input type="number" name="price" value="{{ s.price if s.price is not none else '' }}" step="0.01" min="0" /></td>
<td><input type="checkbox" name="active" {{ 'checked' if s.active }} /></td>
<td><button type="submit" class="btn btn-small">Speichern</button></td>
</form>
</tr>
{% endfor %}
</tbody>
</table>
{% else %}
<p class="muted">Noch keine Leistungen angelegt.</p>
{% endif %}
<form class="row" method="post" action="{{ url_for('owner_settings.create_service') }}">
<div class="field">
<label>Neue Leistung</label>
<input type="text" name="name" placeholder="Name" required />
</div>
<div class="field">
<label>Dauer (Min.)</label>
<input type="number" name="duration_minutes" min="1" required />
</div>
<div class="field">
<label>Preis</label>
<input type="number" name="price" step="0.01" min="0" />
</div>
<button type="submit" class="btn">Hinzufügen</button>
</form>
</section>
{% for r in resources %}
<section>
<h2>Verfügbarkeit — {{ r.name }}</h2>
<form class="row" method="post" action="{{ url_for('owner_settings.update_resource', resource_id=r.resource_id) }}">
<div class="field">
<label>Vorlaufzeit (Min.)</label>
<input type="number" name="min_notice_minutes" value="{{ r.min_notice_minutes }}" min="0" required />
</div>
<div class="field">
<label>Vorausbuchung (Tage)</label>
<input type="number" name="max_advance_days" value="{{ r.max_advance_days }}" min="0" required />
</div>
<div class="field">
<label>Puffer (Min.)</label>
<input type="number" name="buffer_minutes" value="{{ r.buffer_minutes }}" min="0" required />
</div>
<button type="submit" class="btn btn-small">Speichern</button>
</form>
<form method="post" action="{{ url_for('owner_settings.update_resource_hours', resource_id=r.resource_id) }}">
{% set hours = hours_by_resource.get(r.resource_id, {}) %}
{% set day_names = {0: 'Montag', 1: 'Dienstag', 2: 'Mittwoch', 3: 'Donnerstag',
4: 'Freitag', 5: 'Samstag', 6: 'Sonntag'} %}
{% for weekday in weekdays %}
{% set day_hours = hours.get(weekday) %}
<div class="weekday-row">
<span class="day-name">{{ day_names[weekday] }}</span>
<input type="time" name="opens_at_{{ weekday }}"
value="{{ day_hours[0].strftime('%H:%M') if day_hours else '' }}" />
<span>&ndash;</span>
<input type="time" name="closes_at_{{ weekday }}"
value="{{ day_hours[1].strftime('%H:%M') if day_hours else '' }}" />
<input type="checkbox" name="closed_{{ weekday }}" id="closed_{{ r.resource_id }}_{{ weekday }}"
{{ 'checked' if not day_hours }} />
<label for="closed_{{ r.resource_id }}_{{ weekday }}">Geschlossen</label>
</div>
{% endfor %}
<button type="submit" class="btn btn-small">Öffnungszeiten speichern</button>
</form>
</section>
{% endfor %}
<section>
<h2>Buchungen &amp; Benachrichtigung</h2>
<form class="row" method="post" action="{{ url_for('owner_settings.update_client') }}">
<div class="field">
<label>Automatisch bestätigen</label>
<input type="checkbox" name="auto_confirm" {{ 'checked' if client and client.auto_confirm }} />
</div>
<div class="field">
<label>Benachrichtigungskanal</label>
<select name="notify_channel">
<option value="" {{ 'selected' if not client or not client.notify_channel }}>Kein</option>
<option value="telegram" {{ 'selected' if client and client.notify_channel == 'telegram' }}>Telegram</option>
</select>
</div>
<button type="submit" class="btn">Speichern</button>
</form>
</section>
<p class="muted"><a href="{{ url_for('owner_auth.dashboard') }}">Zurück</a></p>
</body>
</html>