24d9aca812
Test backoffice (smb-crm) / test (push) Has been cancelled
Each client now gets their own clients.ics_token (lazily generated on first /owner/settings visit), which both authenticates and scopes /api/bookings.ics -- closing the gap where any shared-token holder could view another client's bookings by swapping the client_id query param. The owner settings page now surfaces a copyable subscribe URL. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
168 lines
7.7 KiB
HTML
168 lines
7.7 KiB
HTML
<!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>–</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 & 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>
|
|
|
|
<section>
|
|
<h2>Kalender-Abo</h2>
|
|
<p class="muted">Diese Adresse in Apple/Google Kalender als Abo hinzufügen, um Ihre eigenen
|
|
Termine automatisch angezeigt zu bekommen.</p>
|
|
<input type="text" value="{{ ics_url }}" readonly onclick="this.select()" style="width:100%;" />
|
|
</section>
|
|
|
|
<p class="muted"><a href="{{ url_for('owner_auth.dashboard') }}">Zurück</a></p>
|
|
</body>
|
|
</html>
|