Files
mivanchenko 456ca3872f
Test backoffice (smb-crm) / test (push) Successful in 1m46s
Add locations (Filialen) as a grouping layer above resources
Enables multiple barbers/staff bookable at the same location and time
-- previously "resource" conflated "location" and "the thing that
can't double-book itself" into one row, so a Filiale could only ever
have exactly one bookable slot at once.

- New `locations` table; `resources.location_id` with a generic,
  idempotent backfill migration (any resource without a location gets
  one auto-created matching its name -- not a one-off for any single
  client, protects any future resource stuck in the old flat shape too)
- `resources`/`resource_hours`/services keep everything they already
  had (hours, min-notice, max-advance, buffer, the no-overlap
  constraint) scoped to resource_id, not location_id -- two barbers at
  one location must stay independently bookable at the same time
- booking_db.py: new locations CRUD mirroring the existing
  resources/services pattern; create_resource now requires a
  location_id, guarded the same way every other tenant check here is
  (get_location existence check, no real FK -- matches this schema's
  existing no-FK convention throughout)
- app.py: new POST /api/locations provisioning route; POST
  /api/resources now requires location_id
- owner_settings.py + settings.html: new self-service "add a Filiale"
  / "add a barber" UI -- there was previously no way to create a
  resource at all outside the CRM/n8n provisioning API
- public_booking.py + book.html: new Filiale picker (reuses the
  existing wireOptionGroup button-group pattern), filtering the
  Mitarbeiter picker to the selected location -- a single-location
  client sees no extra click, same as before Filialen existed
- owner_booking.py + agenda.html: the Filiale show/hide toggle and
  hide-cancelled toggle (shipped earlier this session) now key off
  location_id instead of resource_id, so hiding a Filiale hides every
  barber's bookings at it; manual-booking dropdown grouped by Filiale
- n8n/onboarding.json: default provisioning now creates a "Hauptfiliale"
  location before its resource (inert until re-imported into the live
  n8n instance)

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-12 03:02:56 +02:00

229 lines
10 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>Kalender — {{ 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); }
.nav { display: flex; align-items: center; justify-content: space-between; margin: 14px 0 20px; }
.nav a { text-decoration: none; font-size: .88rem; }
.day { border: 1px solid var(--line); border-radius: 10px; padding: 14px 16px; margin-bottom: 12px; }
.day.empty { color: var(--muted); }
.day.today { border-color: var(--brand); background: #f6faf9; }
.day.today h2 { color: var(--brand); }
table { width: 100%; border-collapse: collapse; font-size: .88rem; }
td, th { text-align: left; padding: 6px 4px; border-bottom: 1px solid var(--line); }
.status-cancelled { color: var(--muted); text-decoration: line-through; }
.status-pending { color: #9a6b00; }
form.inline { display: inline; }
input[type=text], input[type=datetime-local], select {
padding: 7px 9px; border: 1px solid var(--line); border-radius: 7px;
font: inherit; font-size: .85rem; }
.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-danger { background: var(--danger); }
.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; }
.new-booking { border: 1px solid var(--line); border-radius: 10px; padding: 16px; margin-top: 24px; }
.new-booking .field { display: inline-block; margin: 0 8px 8px 0; }
label { display: block; font-size: .75rem; color: var(--muted); margin-bottom: 3px; }
.filiale-filter { display: flex; gap: 16px; align-items: center; flex-wrap: wrap;
margin: 10px 0 18px; padding: 10px 14px; background: #f6faf9; border: 1px solid var(--line);
border-radius: 9px; }
.filiale-filter .flabel { font-size: .78rem; color: var(--muted); margin: 0; }
.filiale-filter label { display: flex; align-items: center; gap: 6px; margin: 0;
font-size: .85rem; color: var(--ink); cursor: pointer; }
.filiale-filter input[type=checkbox] { width: 15px; height: 15px; accent-color: var(--brand);
cursor: pointer; }
tr.res-hidden { display: none; }
.day-no-match { color: var(--muted); font-size: .85rem; margin: 0; display: none; }
</style>
</head>
<body>
<h1>{{ client.business_name if client else 'Kalender' }}</h1>
<div class="filiale-filter" id="filialeFilter">
{% if locations|length > 1 %}
<p class="flabel">Filialen:</p>
{% for loc in locations %}
<label><input type="checkbox" class="loc-toggle" value="{{ loc.location_id }}" checked> {{ loc.name }}</label>
{% endfor %}
{% endif %}
<label><input type="checkbox" id="showCancelled"> Stornierte anzeigen</label>
</div>
{% if error == "missing_fields" %}
<div class="error">Bitte alle Felder ausfüllen.</div>
{% elif error == "not_found" %}
<div class="error">Ressource, Leistung oder Buchung nicht gefunden.</div>
{% elif error == "slot_taken" %}
<div class="error">Dieser Zeitraum überschneidet sich mit einem bestehenden Termin.</div>
{% elif error == "unavailable" %}
<div class="error">Dieser Termin liegt außerhalb der verfügbaren Zeiten.</div>
{% elif error == "already_cancelled" %}
<div class="error">Diese Buchung wurde bereits storniert.</div>
{% elif error == "bad_time" %}
<div class="error">Bitte eine gültige Uhrzeit angeben.</div>
{% endif %}
<div class="nav">
<a href="{{ url_for('owner_booking.agenda', week=prev_week) }}">&larr; Vorherige Woche</a>
<strong>Woche ab {{ week_start.strftime('%d.%m.%Y') }}</strong>
<a href="{{ url_for('owner_booking.agenda', week=next_week) }}">Nächste Woche &rarr;</a>
</div>
{% if week_start.isoformat() != this_week %}
<p><a href="{{ url_for('owner_booking.agenda', week=this_week) }}">&uarr; Zu heute springen</a></p>
{% endif %}
{% for day, bookings in days %}
<div class="day {{ 'today' if day == today else '' }} {{ 'empty' if not bookings }}">
<h2>{{ day.strftime('%A, %d.%m.%Y') }}{{ ' — Heute' if day == today else '' }}</h2>
{% if not bookings %}
<p class="muted" style="margin:0;">Keine Buchungen.</p>
{% else %}
<table>
<thead><tr><th>Zeit</th><th>Kunde</th><th>Leistung</th><th>Ressource</th><th>Status</th><th></th></tr></thead>
<tbody>
{% for b in bookings %}
<tr class="status-{{ b.status }}" data-resource="{{ b.resource_id }}" data-location="{{ b.location_id }}">
<td>{{ b.start_local.strftime('%H:%M') }}</td>
<td>{{ b.customer_name }}</td>
<td>{{ b.service }}</td>
<td>{{ b.resource_name }}</td>
<td>{{ b.status }}</td>
<td>
{% if b.status != "cancelled" %}
<form class="inline" method="post"
action="{{ url_for('owner_booking.reschedule_manual_booking', booking_id=b.booking_id) }}">
<input type="hidden" name="week" value="{{ week_start.isoformat() }}" />
<input type="datetime-local" name="start_time" required />
<button type="submit" class="btn btn-small">Verschieben</button>
</form>
<form class="inline" method="post"
action="{{ url_for('owner_booking.cancel_manual_booking', booking_id=b.booking_id) }}">
<input type="hidden" name="week" value="{{ week_start.isoformat() }}" />
<button type="submit" class="btn btn-danger btn-small">Stornieren</button>
</form>
{% endif %}
</td>
</tr>
{% endfor %}
</tbody>
</table>
<p class="day-no-match">Keine Buchungen für die aktuelle Filter-Auswahl.</p>
{% endif %}
</div>
{% endfor %}
<div class="new-booking">
<h2>Neue Buchung (Laufkundschaft / Telefon)</h2>
<form method="post" action="{{ url_for('owner_booking.create_manual_booking') }}">
<input type="hidden" name="week" value="{{ week_start.isoformat() }}" />
<div class="field">
<label>Ressource</label>
<select name="resource_id" required>
{% for loc in locations %}
<optgroup label="{{ loc.name }}">
{% for r in resources_by_location.get(loc.location_id, []) %}
<option value="{{ r.resource_id }}">{{ r.name }}</option>
{% endfor %}
</optgroup>
{% endfor %}
</select>
</div>
<div class="field">
<label>Leistung</label>
<select name="service_id" required>
{% for s in services %}
<option value="{{ s.service_id }}">{{ s.name }}</option>
{% endfor %}
</select>
</div>
<div class="field">
<label>Datum &amp; Uhrzeit</label>
<input type="datetime-local" name="start_time" required />
</div>
<div class="field">
<label>Name</label>
<input type="text" name="customer_name" required />
</div>
<div class="field">
<label>Kontakt</label>
<input type="text" name="customer_contact" required />
</div>
<div class="field" style="vertical-align:bottom;">
<button type="submit" class="btn">Buchen</button>
</div>
</form>
</div>
<p class="muted" style="margin-top:20px;"><a href="{{ url_for('owner_auth.dashboard') }}">Zurück</a></p>
<script>
// Per-Filiale show/hide + hide-cancelled-by-default, remembered across
// week navigation via localStorage since each week is a full page load.
(function () {
var RES_KEY = 'ownerAgendaFilialeFilter';
var CANCELLED_KEY = 'ownerAgendaShowCancelled';
var checkboxes = document.querySelectorAll('.loc-toggle');
var showCancelledCb = document.getElementById('showCancelled');
if (!checkboxes.length && !showCancelledCb) return;
try {
var saved = JSON.parse(localStorage.getItem(RES_KEY) || 'null');
if (saved && saved.length) {
checkboxes.forEach(function (cb) { cb.checked = saved.indexOf(cb.value) !== -1; });
}
} catch (e) {}
try {
if (showCancelledCb) {
showCancelledCb.checked = localStorage.getItem(CANCELLED_KEY) === '1';
}
} catch (e) {}
function applyFilter() {
var active = [];
checkboxes.forEach(function (cb) { if (cb.checked) active.push(cb.value); });
var showCancelled = !!(showCancelledCb && showCancelledCb.checked);
document.querySelectorAll('table tbody tr[data-resource]').forEach(function (tr) {
var locOk = !checkboxes.length || active.indexOf(tr.getAttribute('data-location')) !== -1;
var statusOk = showCancelled || !tr.classList.contains('status-cancelled');
tr.classList.toggle('res-hidden', !(locOk && statusOk));
});
document.querySelectorAll('.day').forEach(function (dayEl) {
var table = dayEl.querySelector('table');
var noMatch = dayEl.querySelector('.day-no-match');
if (!table) return;
var anyVisible = Array.prototype.some.call(
table.querySelectorAll('tbody tr'),
function (tr) { return !tr.classList.contains('res-hidden'); });
table.style.display = anyVisible ? '' : 'none';
if (noMatch) noMatch.style.display = anyVisible ? 'none' : 'block';
});
try { localStorage.setItem(RES_KEY, JSON.stringify(active)); } catch (e) {}
try { localStorage.setItem(CANCELLED_KEY, showCancelled ? '1' : '0'); } catch (e) {}
}
checkboxes.forEach(function (cb) { cb.addEventListener('change', applyFilter); });
if (showCancelledCb) showCancelledCb.addEventListener('change', applyFilter);
applyFilter();
})();
</script>
</body>
</html>