feat: replace the navigation with a dashboard and per-area boards

This commit is contained in:
fromme committed 2026-09-13 18:08:39 +02:00
1 parent d63df2cc1b
commit bce8f58be3
26 files changed
+810 -328

No files matched your search

-73
View File
@@ -1,73 +0,0 @@
{# 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'] %}
{# Every link keeps the board's span, so changing the day never silently widens it back to all-day. #}
{% set time_params = selected_time.allDay ? {} : { von: selected_time.start, bis: selected_time.end } %}
<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') }|merge(time_params)) }}"
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') }|merge(time_params)) }}"
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') }|merge(time_params)) }}"
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{{ selected_time.allDay ? ' an dem Tag' : ' zwischen ' ~ selected_time.start ~ ' und ' ~ selected_time.end }}</div>
</div>
</div>
-119
View File
@@ -1,119 +0,0 @@
{#
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
and span, so free/belegt/deine is decided in Twig. Note "free" is always relative to the
span in the header -- a desk booked 09:00-13:00 is free for an afternoon board and belegt for
an all-day one.
Clicking a shape always opens a modal: the create modal when it is free, the edit modal when
it is yours (which is where changing the times and releasing it live). Nothing else is
clickable, so the SVG carries no confirm dialogs or inline tokens.
#}
{% 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 }
} %}
{% set time_params = selected_time.allDay ? {} : { von: selected_time.start, bis: selected_time.end } %}
{% macro spot(resource, geometry, width, height, bookings, selected_date, is_past, time_params) %}
{% set own = bookings|filter(b => b.user.id == app.user.id)|first|default(null) %}
{% set mine = own is not null %}
{% if mine %}
{% set fill = 'fill-primary' %}{% set stroke = 'stroke-primary-dark' %}{% set text = 'fill-white' %}
{% elseif bookings is not empty %}
{% 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 bookings is empty) %}
{# What the second line of the shape says. Several bookings can overlap the board's span once
part-day bookings exist, so the shape counts them and the tooltip spells them out. #}
{% if mine %}
{% set caption = own.timeRange.allDay ? 'du' : 'du ' ~ own.timeRange %}
{% elseif bookings|length > 1 %}
{% set caption = bookings|length ~ ' Buchungen' %}
{% elseif bookings is not empty %}
{% set caption = bookings|first.user.name %}
{% else %}
{% set caption = null %}
{% endif %}
<g class="{{ interactive ? 'cursor-pointer' : 'cursor-default' }}"
{%- if interactive and mine %}
hx-get="{{ path('app_booking_edit', { id: own.id }|merge(time_params)) }}"
hx-target="body"
hx-swap="beforeend"
{%- 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') }|merge(time_params)) }}"
hx-target="body"
hx-swap="beforeend"
{%- endif %}>
<title>
{{- resource.label }}
{%- if bookings is empty %} — frei
{%- else %}
{%- for booking in bookings %} — {{ booking.user.id == app.user.id ? 'du' : booking.user.name }} {{ booking.timeRange }}{% endfor %}
{%- 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 + (caption ? height / 2 - 4 : height / 2 + 4) }}"
text-anchor="middle" class="{{ text }} text-xs font-semibold">{{ resource.label }}</text>
{% if caption %}
<text x="{{ geometry.x + width / 2 }}" y="{{ geometry.y + height / 2 + 12 }}"
text-anchor="middle" class="{{ text }} text-[10px]">{{ caption }}</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-2">
<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>
{% include 'dashboard/_timerange.html.twig' %}
{% 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([]), selected_date, is_past, time_params) }}
{% 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([]), selected_date, is_past, time_params) }}
{% endif %}
{% endfor %}
</svg>
</div>
-42
View File
@@ -1,42 +0,0 @@
{#
The span the whole board is relative to. Free/belegt has no answer without it once bookings
can cover part of a day, so this drives the plan colours AND the calendar occupancy counts.
Both fields hit the same fragment endpoint as the calendar does, so narrowing the span is the
same single round trip as changing the day. hx-include pulls in the sibling field, because
a span needs both ends to mean anything.
#}
{# An all-day board leaves both fields empty: 24:00 has no representation in <input type="time">,
and an empty pair is exactly what TimeRange::parse() reads back as "the whole day". #}
{% set day = selected_date|date('Y-m-d') %}
<div class="flex flex-wrap items-end gap-3 pb-4" id="board-timerange">
<div>
<label class="block text-xs text-gray-500 pb-1" for="board-von">Von</label>
<input type="time" id="board-von" name="von" value="{{ selected_time.allDay ? '' : selected_time.start }}" step="900"
class="rounded-md border-0 py-1.5 text-sm text-gray-900 shadow-sm ring-1 ring-inset ring-gray-300 focus:ring-2 focus:ring-inset focus:ring-primary"
hx-get="{{ path('app_dashboard_day', { date: day }) }}"
hx-include="#board-bis"
hx-trigger="change"
hx-swap="none">
</div>
<div>
<label class="block text-xs text-gray-500 pb-1" for="board-bis">Bis</label>
<input type="time" id="board-bis" name="bis" value="{{ selected_time.allDay ? '' : selected_time.end }}" step="900"
class="rounded-md border-0 py-1.5 text-sm text-gray-900 shadow-sm ring-1 ring-inset ring-gray-300 focus:ring-2 focus:ring-inset focus:ring-primary"
hx-get="{{ path('app_dashboard_day', { date: day }) }}"
hx-include="#board-von"
hx-trigger="change"
hx-swap="none">
</div>
{% if not selected_time.allDay %}
<button type="button" class="button button--small button--neutral"
hx-get="{{ path('app_dashboard_day', { date: day }) }}"
hx-swap="none">
Ganzer Tag
</button>
{% else %}
<span class="text-xs text-gray-500 pb-2">ganzer Tag</span>
{% endif %}
</div>
+69 -20
View File
@@ -3,29 +3,78 @@
{% block title %}Übersicht · ephub{% endblock %}
{% block content %}
<h1>Buchen</h1>
<p class="subline">Tag und Zeitraum wählen, dann einen freien Platz im Raumplan anklicken.</p>
<h1>Übersicht</h1>
<p class="subline">{{ today|format_datetime(pattern: 'EEEE, d. MMMM y') }}</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' %}
{#
One tile per ResourceType, generated rather than written out. A new bookable kind appears
here -- with a working link and a correct count -- by adding a case to the enum.
#}
<div class="mt-8 grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
{% for tile in tiles %}
<a href="{{ path('app_board', { type: tile.type.slug }) }}"
class="group flex flex-col rounded-md border border-gray-200 p-6 hover:border-primary hover:bg-gray-50">
<div class="flex items-center gap-3 pb-2">
{{ ux_icon(tile.type.icon, { class: 'h-6 w-6 shrink-0 text-primary' }) }}
<span class="text-lg font-bold">{{ tile.type.plural }}</span>
</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' %}
<p class="text-sm text-gray-500 pb-4">{{ tile.type.description }}</p>
<div class="mt-auto flex items-baseline justify-between">
<span class="text-sm">
{% if tile.total == 0 %}
<span class="text-gray-400">nichts eingerichtet</span>
{% elseif tile.free == 0 %}
<span class="font-semibold text-red-600">heute alles belegt</span>
{% else %}
<span class="font-semibold text-emerald-700">{{ tile.free }}</span>
<span class="text-gray-500">von {{ tile.total }} heute frei</span>
{% endif %}
</span>
{% if tile.mine %}
<span class="badge bg-primary text-white">{{ tile.mine.resource.label }}</span>
{% endif %}
</div>
{% endblock %}
</div>
</a>
{% endfor %}
<a href="{{ path('app_booking') }}"
class="group flex flex-col rounded-md border border-gray-200 p-6 hover:border-primary hover:bg-gray-50">
<div class="flex items-center gap-3 pb-2">
{{ ux_icon('lucide:calendar-days', { class: 'h-6 w-6 shrink-0 text-primary' }) }}
<span class="text-lg font-bold">Meine Buchungen</span>
</div>
<p class="text-sm text-gray-500 pb-4">Alle Termine und Serien verwalten.</p>
</a>
</div>
<h2 class="pt-10">Als Nächstes</h2>
{% if upcoming is empty %}
<p class="text-sm text-gray-500">Nichts gebucht.</p>
{% else %}
<ul class="divide-y divide-gray-200 border-y border-gray-200">
{% for booking in upcoming %}
<li class="flex items-center justify-between gap-4 py-3">
<div>
<div class="font-semibold">
{{ booking.date|format_datetime(pattern: 'EEEE, d. MMMM') }}
<span class="font-normal text-gray-500">· {{ booking.timeRange }}</span>
</div>
<div class="text-sm text-gray-500">
{{ booking.resource.type.label }} · {{ booking.resource.label }}
{% if booking.partOfSeries %}
<span class="badge bg-gray-100 text-gray-600 ml-1">Serie</span>
{% endif %}
</div>
</div>
<a class="text-sm underline shrink-0"
href="{{ path('app_board', { type: booking.resource.type.slug, tag: booking.date|date('Y-m-d') }) }}">
Im Plan
</a>
</li>
{% endfor %}
</ul>
{% endif %}
{% endblock %}