feat: add the booking board with floor plan and calendar

This commit is contained in:
fromme committed 2026-09-13 15:18:20 +02:00
1 parent f4741e1bee
commit 59feca2f8e
22 files changed
+1015 -17

No files matched your search

+9 -10
View File
@@ -1,13 +1,12 @@
{# Flat inline alerts for now. myep renders these as Toastify toasts, but that means adopting
toastify-js as a dependency -- deferred until the toast/OOB work (plan step 7). #}
{% set levels = { error: 'bg-red-600', warning: 'bg-red-600', success: 'bg-emerald-600', info: 'bg-primary' } %}
{# Just the alerts. The positioned container with id="flashes" lives in layout.html.twig, because
that is the element htmx swaps out of band after a booking. Flat inline alerts for now: myep
renders these as Toastify toasts, which would mean adopting toastify-js (plan step 2/7). #}
{% set levels = { error: 'bg-red-600', warning: 'bg-amber-600', success: 'bg-emerald-600', info: 'bg-primary' } %}
{% for level, messages in app.flashes(levels|keys) %}
<div class="fixed bottom-4 right-4 z-50 space-y-2">
{% for message in messages %}
<div class="{{ levels[level] }} text-gray-100 rounded shadow-lg px-4 py-3 text-sm">
{{ message|trans }}
</div>
{% endfor %}
</div>
{% for message in messages %}
<div class="{{ levels[level] }} text-white rounded shadow-lg px-4 py-3 text-sm">
{{ message|trans }}
</div>
{% endfor %}
{% endfor %}
+14
View File
@@ -21,5 +21,19 @@
</li>
{% endfor %}
</ul>
{% if app.user %}
<div class="mt-auto pt-6 border-t border-gray-200">
<div class="px-2 pb-2 text-sm">
<div class="font-semibold">{{ app.user.name }}</div>
<div class="text-gray-500 truncate">{{ app.user.email }}</div>
</div>
<a href="{{ path('app_logout') }}"
class="group flex gap-x-3 rounded-md p-2 text-sm leading-6 font-semibold text-gray-700 hover:bg-gray-50">
{{ ux_icon('lucide:log-out', { class: 'h-5 w-5 shrink-0 text-gray-400' }) }}
<span class="flex-1">Abmelden</span>
</a>
</div>
{% endif %}
</nav>
</div>
+40
View File
@@ -0,0 +1,40 @@
{#
The booking modal. Extending htmx_modal.html.twig keeps it a standalone fragment -- it is a
valid response on its own, which is what the opener's hx-swap="beforeend" onto <body> needs.
The form targets the modal itself with outerHTML, which does double duty:
* invalid submit -> Symfony returns 422, htmx swaps it (see responseHandling in app.js) and
the modal replaces itself with the errors showing;
* success -> the response is nothing but out-of-band board blocks, so the modal is replaced
with an empty fragment, i.e. it closes.
#}
{% extends 'htmx_modal.html.twig' %}
{% block title %}{{ resource.label }} buchen{% endblock %}
{% block content %}
{{ form_start(form, { attr: {
'hx-post': form.vars.action,
'hx-target': '#htmx-modal',
'hx-swap': 'outerHTML',
} }) }}
{# Root-level errors, which is where a rejected booking lands -- form_end/form_rest does
NOT render these, so leaving this out makes the 422 path silently blank. #}
{{ form_errors(form) }}
<p class="pb-4 text-sm text-gray-500">
{{ resource.type.label }} · {{ resource.label }}
</p>
{{ form_row(form.date) }}
<div class="flex justify-end gap-2 pt-6">
<button type="button" class="button button--neutral" {{ stimulus_action('modal', 'close') }}>
Abbrechen
</button>
<button type="submit" class="button button--primary">
Buchen
</button>
</div>
{{ form_end(form) }}
{% endblock %}
+32
View File
@@ -4,4 +4,36 @@
{% block content %}
<h1>Meine Buchungen</h1>
<p class="subline">Alles, was ab heute für dich reserviert ist.</p>
{% if bookings is empty %}
<p class="mt-8 text-sm text-gray-500">
Noch nichts gebucht. <a class="underline" href="{{ path('app_dashboard') }}">Zum Raumplan</a>.
</p>
{% else %}
<ul class="mt-8 divide-y divide-gray-200 border-y border-gray-200">
{% for booking in bookings %}
<li class="flex items-center justify-between gap-4 py-3">
<div>
<div class="font-semibold">
{{ booking.date|format_datetime(pattern: 'EEEE, d. MMMM y') }}
</div>
<div class="text-sm text-gray-500">
{{ booking.resource.type.label }} · {{ booking.resource.label }}
</div>
</div>
<div class="flex items-center gap-3">
<a class="text-sm underline"
href="{{ path('app_dashboard', { tag: booking.date|date('Y-m-d') }) }}">Im Plan</a>
{# Ordinary form post: this page has no board fragments to swap, so it
reloads afterwards rather than pretending to be htmx. #}
<form method="post" action="{{ path('app_booking_release', { id: booking.id }) }}">
<input type="hidden" name="_token" value="{{ csrf_token('release-' ~ booking.id) }}">
<button type="submit" class="button button--small button--neutral">Freigeben</button>
</form>
</div>
</li>
{% endfor %}
</ul>
{% endif %}
{% endblock %}
+71
View File
@@ -0,0 +1,71 @@
{# Server-rendered month grid. No calendar library: prev/next and day selection are plain hx-get
requests whose response carries nothing but out-of-band blocks, hence hx-swap="none". #}
{% set weekdays = ['Mo', 'Di', 'Mi', 'Do', 'Fr', 'Sa', 'So'] %}
<div class="rounded-md border border-gray-200 p-4">
<div class="flex items-center justify-between pb-4">
<button type="button"
class="p-1 rounded-md hover:bg-gray-100"
aria-label="Voriger Monat"
hx-get="{{ path('app_dashboard_day', { date: month_start|date_modify('-1 month')|date('Y-m-d') }) }}"
hx-swap="none">
{{ ux_icon('lucide:chevron-left', { class: 'w-5 h-5' }) }}
</button>
<div class="font-bold">
{{ month_start|format_datetime(pattern: 'LLLL y') }}
</div>
<button type="button"
class="p-1 rounded-md hover:bg-gray-100"
aria-label="Nächster Monat"
hx-get="{{ path('app_dashboard_day', { date: month_start|date_modify('+1 month')|date('Y-m-d') }) }}"
hx-swap="none">
{{ ux_icon('lucide:chevron-right', { class: 'w-5 h-5' }) }}
</button>
</div>
<div class="grid grid-cols-7 gap-1 text-center text-xs text-gray-500 pb-1">
{% for weekday in weekdays %}<div>{{ weekday }}</div>{% endfor %}
</div>
<div class="grid grid-cols-7 gap-1">
{% for week in weeks %}
{% for day in week %}
{% set classes = 'relative h-12 rounded-md border text-sm flex flex-col items-center justify-center' %}
{% if day.is_selected %}
{% set classes = classes ~ ' border-primary bg-primary text-white font-bold' %}
{% elseif not day.in_month %}
{% set classes = classes ~ ' border-transparent text-gray-300' %}
{% elseif day.is_weekend %}
{% set classes = classes ~ ' border-transparent bg-gray-50 text-gray-400' %}
{% else %}
{% set classes = classes ~ ' border-gray-200 hover:bg-gray-50' %}
{% endif %}
{% if day.is_today and not day.is_selected %}
{% set classes = classes ~ ' ring-1 ring-primary' %}
{% endif %}
<button type="button"
class="{{ classes }}"
aria-pressed="{{ day.is_selected ? 'true' : 'false' }}"
hx-get="{{ path('app_dashboard_day', { date: day.date|date('Y-m-d') }) }}"
hx-swap="none">
<span>{{ day.date|date('j') }}</span>
{# Team view: the occupancy count is the whole point of showing the month. #}
<span class="text-[10px] {{ day.is_selected ? 'text-white/80' : 'text-gray-400' }}">
{{ day.count > 0 ? day.count : '' }}
</span>
{% if day.mine is not empty %}
<span class="absolute top-1 right-1 w-1.5 h-1.5 rounded-full {{ day.is_selected ? 'bg-white' : 'bg-primary' }}"></span>
{% endif %}
</button>
{% endfor %}
{% endfor %}
</div>
<div class="pt-4 text-xs text-gray-500 space-y-1">
<div class="flex items-center gap-2">
<span class="w-1.5 h-1.5 rounded-full bg-primary"></span> eigene Buchung
</div>
<div>Zahl = belegte Plätze an dem Tag</div>
</div>
</div>
+93
View File
@@ -0,0 +1,93 @@
{#
Hand-authored floor plan. The geometry below is the drawing; `svgElementId` on each Resource
is the key that ties a database row to a shape here. A resource whose id is missing from the
map is simply not drawn (and vice versa), which is why app:seed and this map have to agree.
Colouring needs no JavaScript at all: the SVG is re-rendered server side for the selected day,
so free/booked/yours is decided in Twig.
#}
{% set desk_plan = {
'desk-01': { x: 50, y: 70 }, 'desk-02': { x: 190, y: 70 }, 'desk-03': { x: 330, y: 70 },
'desk-04': { x: 50, y: 170 }, 'desk-05': { x: 190, y: 170 }, 'desk-06': { x: 330, y: 170 },
'desk-07': { x: 50, y: 270 }, 'desk-08': { x: 190, y: 270 }, 'desk-09': { x: 330, y: 270 },
'desk-10': { x: 50, y: 370 }, 'desk-11': { x: 190, y: 370 }, 'desk-12': { x: 330, y: 370 }
} %}
{% set parking_plan = {
'parking-01': { x: 540, y: 60 }, 'parking-02': { x: 540, y: 125 }, 'parking-03': { x: 540, y: 190 },
'parking-04': { x: 540, y: 255 }, 'parking-05': { x: 540, y: 320 }, 'parking-06': { x: 540, y: 385 }
} %}
{% macro spot(resource, geometry, width, height, booking, selected_date, is_past) %}
{% set mine = booking is not null and booking.user.id == app.user.id %}
{% if mine %}
{% set fill = 'fill-primary' %}{% set stroke = 'stroke-primary-dark' %}{% set text = 'fill-white' %}
{% elseif booking is not null %}
{% set fill = 'fill-gray-200' %}{% set stroke = 'stroke-gray-400' %}{% set text = 'fill-gray-600' %}
{% else %}
{% set fill = 'fill-emerald-50' %}{% set stroke = 'stroke-emerald-500' %}{% set text = 'fill-emerald-900' %}
{% endif %}
{% set interactive = not is_past and (mine or booking is null) %}
<g class="{{ interactive ? 'cursor-pointer' : 'cursor-default' }}"
{%- if interactive and mine %}
hx-post="{{ path('app_booking_release', { id: booking.id }) }}"
hx-vals='{{ { _token: csrf_token('release-' ~ booking.id) }|json_encode }}'
hx-confirm="{{ resource.label }} wieder freigeben?"
hx-swap="none"
{%- elseif interactive %}
{# The modal is appended to the end of <body>; closing it is Stimulus, not htmx. #}
hx-get="{{ path('app_booking_new', { id: resource.id, date: selected_date|date('Y-m-d') }) }}"
hx-target="body"
hx-swap="beforeend"
{%- endif %}>
<title>
{{- resource.label }}{% if booking %} — {{ mine ? 'von dir gebucht' : booking.user.name }}{% else %} — frei{% endif -%}
</title>
<rect x="{{ geometry.x }}" y="{{ geometry.y }}" width="{{ width }}" height="{{ height }}" rx="6"
class="{{ fill }} {{ stroke }}" stroke-width="2"></rect>
<text x="{{ geometry.x + width / 2 }}" y="{{ geometry.y + (booking ? height / 2 - 4 : height / 2 + 4) }}"
text-anchor="middle" class="{{ text }} text-xs font-semibold">{{ resource.label }}</text>
{% if booking %}
<text x="{{ geometry.x + width / 2 }}" y="{{ geometry.y + height / 2 + 12 }}"
text-anchor="middle" class="{{ text }} text-[10px]">{{ mine ? 'du' : booking.user.name }}</text>
{% endif %}
</g>
{% endmacro %}
<div class="rounded-md border border-gray-200 p-4">
<div class="flex flex-wrap items-baseline justify-between gap-2 pb-4">
<h2 class="!pb-0">{{ selected_date|format_datetime(pattern: 'EEEE, d. MMMM y') }}</h2>
<div class="flex items-center gap-4 text-xs text-gray-500">
<span class="flex items-center gap-1"><span class="w-3 h-3 rounded-sm bg-emerald-50 border-2 border-emerald-500"></span> frei</span>
<span class="flex items-center gap-1"><span class="w-3 h-3 rounded-sm bg-gray-200 border-2 border-gray-400"></span> belegt</span>
<span class="flex items-center gap-1"><span class="w-3 h-3 rounded-sm bg-primary border-2 border-primary-dark"></span> deine</span>
</div>
</div>
{% if is_past %}
<p class="pb-4 text-sm text-amber-700">Vergangener Tag — hier lässt sich nichts mehr buchen.</p>
{% endif %}
<svg viewBox="0 0 810 480" class="w-full h-auto" role="img" aria-label="Raumplan">
<rect x="20" y="30" width="470" height="420" rx="8" class="fill-white stroke-gray-300" stroke-width="2"></rect>
<text x="30" y="22" class="fill-gray-400 text-xs font-semibold">Büro</text>
<rect x="520" y="30" width="270" height="420" rx="8" class="fill-white stroke-gray-300" stroke-width="2"></rect>
<text x="530" y="22" class="fill-gray-400 text-xs font-semibold">Parkplatz</text>
{% for resource in desks %}
{% if resource.svgElementId and desk_plan[resource.svgElementId] is defined %}
{{ _self.spot(resource, desk_plan[resource.svgElementId], 120, 70,
bookings_by_resource[resource.id]|default(null), selected_date, is_past) }}
{% endif %}
{% endfor %}
{% for resource in parking %}
{% if resource.svgElementId and parking_plan[resource.svgElementId] is defined %}
{{ _self.spot(resource, parking_plan[resource.svgElementId], 230, 50,
bookings_by_resource[resource.id]|default(null), selected_date, is_past) }}
{% endif %}
{% endfor %}
</svg>
</div>
+25 -2
View File
@@ -3,6 +3,29 @@
{% block title %}Übersicht · ephub{% endblock %}
{% block content %}
<h1>Übersicht</h1>
<p class="subline">Schreibtische und Parkplätze buchen.</p>
<h1>Buchen</h1>
<p class="subline">Tag im Kalender wählen, dann einen freien Platz im Raumplan anklicken.</p>
<div class="mt-8 grid gap-8 xl:grid-cols-3">
<div class="xl:col-span-1">
{#
The two blocks below are what DashboardController::day and BookingController
swap out of band. Each one owns a stable id and emits hx-swap-oob only when
rendered through htmxOobResponse -- rendered normally they are plain markup.
#}
{% block calendar %}
<div id="board-calendar"{% if htmx_oob_swap is defined and htmx_oob_swap %} hx-swap-oob="true"{% endif %}>
{% include 'dashboard/_calendar.html.twig' %}
</div>
{% endblock %}
</div>
<div class="xl:col-span-2">
{% block floorplan %}
<div id="board-floorplan"{% if htmx_oob_swap is defined and htmx_oob_swap %} hx-swap-oob="true"{% endif %}>
{% include 'dashboard/_floorplan.html.twig' %}
</div>
{% endblock %}
</div>
</div>
{% endblock %}
+7 -1
View File
@@ -39,5 +39,11 @@
</div>
</div>
{% include '_partials/_flashes.html.twig' %}
{# Swapped out of band after a booking, hence the stable id and the conditional attribute. #}
{% block flashes %}
<div id="flashes" class="fixed bottom-4 right-4 z-50 space-y-2"
{%- if htmx_oob_swap is defined and htmx_oob_swap %} hx-swap-oob="true"{% endif %}>
{% include '_partials/_flashes.html.twig' %}
</div>
{% endblock %}
{% endblock %}