feat: add booking time ranges and repeating series
This commit is contained in:
1 parent
59feca2f8e
commit
d433d271d0
29 files changed
+2135
-180
No files matched your search
@@ -1,13 +1,15 @@
|
||||
{# 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') }) }}"
|
||||
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>
|
||||
@@ -17,7 +19,7 @@
|
||||
<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-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>
|
||||
@@ -47,7 +49,7 @@
|
||||
<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-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. #}
|
||||
@@ -66,6 +68,6 @@
|
||||
<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>Zahl = belegte Plätze{{ selected_time.allDay ? ' an dem Tag' : ' zwischen ' ~ selected_time.start ~ ' und ' ~ selected_time.end }}</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -3,8 +3,14 @@
|
||||
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.
|
||||
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 },
|
||||
@@ -16,47 +22,65 @@
|
||||
'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 %}
|
||||
|
||||
{% 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 %}
|
||||
{% 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 booking is null) %}
|
||||
{% 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-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"
|
||||
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') }) }}"
|
||||
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 booking %} — {{ mine ? 'von dir gebucht' : booking.user.name }}{% else %} — frei{% endif -%}
|
||||
{{- 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 + (booking ? height / 2 - 4 : height / 2 + 4) }}"
|
||||
<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 booking %}
|
||||
{% if caption %}
|
||||
<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>
|
||||
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-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>
|
||||
@@ -65,6 +89,8 @@
|
||||
</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 %}
|
||||
@@ -79,14 +105,14 @@
|
||||
{% 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) }}
|
||||
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(null), selected_date, is_past) }}
|
||||
bookings_by_resource[resource.id]|default([]), selected_date, is_past, time_params) }}
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
</svg>
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
{#
|
||||
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>
|
||||
@@ -4,7 +4,7 @@
|
||||
|
||||
{% block content %}
|
||||
<h1>Buchen</h1>
|
||||
<p class="subline">Tag im Kalender wählen, dann einen freien Platz im Raumplan anklicken.</p>
|
||||
<p class="subline">Tag und Zeitraum 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">
|
||||
|
||||
Reference in new issue
Block a user