feat: replace the navigation with a dashboard and per-area boards
This commit is contained in:
1 parent
d63df2cc1b
commit
bce8f58be3
26 files changed
+810
-328
No files matched your search
@@ -1,39 +0,0 @@
|
||||
{# Plain nav instead of myep's knp-menu builder: a handful of links does not justify the
|
||||
indirection. Active state is a prefix match on the current route name, which mirrors what
|
||||
myep's menu matcher does with its 'routes' extras. #}
|
||||
{% set current_route = app.request.attributes.get('_route') %}
|
||||
{% set items = [
|
||||
{ route: 'app_dashboard', label: 'Übersicht', icon: 'lucide:layout-grid' },
|
||||
{ route: 'app_booking', label: 'Meine Buchungen', icon: 'lucide:calendar-days' },
|
||||
] %}
|
||||
|
||||
<div class="min-h-full flex grow flex-col gap-y-5 lg:border-r border-gray-200 bg-white md:pr-6">
|
||||
<nav class="flex flex-1 flex-col">
|
||||
<ul role="list" class="space-y-1">
|
||||
{% for item in items %}
|
||||
{% set is_active = current_route starts with item.route %}
|
||||
<li>
|
||||
<a href="{{ path(item.route) }}"
|
||||
class="group flex gap-x-3 rounded-md p-2 text-sm leading-6 font-semibold {{ is_active ? 'bg-gray-100 text-gray-900' : 'text-gray-700 hover:bg-gray-50' }}">
|
||||
{{ ux_icon(item.icon, { class: 'h-5 w-5 shrink-0 ' ~ (is_active ? 'text-gray-600' : 'text-gray-400') }) }}
|
||||
<span class="flex-1">{{ item.label }}</span>
|
||||
</a>
|
||||
</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>
|
||||
@@ -9,7 +9,7 @@
|
||||
<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-get="{{ path('app_board_day', { type: type.slug, 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>
|
||||
@@ -19,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') }|merge(time_params)) }}"
|
||||
hx-get="{{ path('app_board_day', { type: type.slug, 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>
|
||||
@@ -49,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') }|merge(time_params)) }}"
|
||||
hx-get="{{ path('app_board_day', { type: type.slug, 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. #}
|
||||
@@ -0,0 +1,32 @@
|
||||
{#
|
||||
The frame around a plan: heading, legend, span picker. The drawing itself lives in
|
||||
board/plan/_<type>.html.twig and is included by convention, so a new ResourceType needs a new
|
||||
partial there and nothing else.
|
||||
|
||||
Colouring needs no JavaScript at all: the plan 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.
|
||||
#}
|
||||
<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 'board/_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 %}
|
||||
|
||||
{% if resources is empty %}
|
||||
<p class="py-8 text-center text-sm text-gray-500">Für diesen Bereich ist noch nichts eingerichtet.</p>
|
||||
{% else %}
|
||||
{% include 'board/plan/_' ~ type.value ~ '.html.twig' %}
|
||||
{% endif %}
|
||||
</div>
|
||||
@@ -15,7 +15,7 @@
|
||||
<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-get="{{ path('app_board_day', { type: type.slug, date: day }) }}"
|
||||
hx-include="#board-bis"
|
||||
hx-trigger="change"
|
||||
hx-swap="none">
|
||||
@@ -24,7 +24,7 @@
|
||||
<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-get="{{ path('app_board_day', { type: type.slug, date: day }) }}"
|
||||
hx-include="#board-von"
|
||||
hx-trigger="change"
|
||||
hx-swap="none">
|
||||
@@ -32,7 +32,7 @@
|
||||
|
||||
{% if not selected_time.allDay %}
|
||||
<button type="button" class="button button--small button--neutral"
|
||||
hx-get="{{ path('app_dashboard_day', { date: day }) }}"
|
||||
hx-get="{{ path('app_board_day', { type: type.slug, date: day }) }}"
|
||||
hx-swap="none">
|
||||
Ganzer Tag
|
||||
</button>
|
||||
@@ -0,0 +1,34 @@
|
||||
{% extends 'layout.html.twig' %}
|
||||
|
||||
{% block title %}{{ type.plural }} buchen · ephub{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
{# The dashboard is the only navigation there is, so every board says how to get back. #}
|
||||
<a class="text-sm text-gray-500 hover:text-gray-900" href="{{ path('app_dashboard') }}">← Übersicht</a>
|
||||
|
||||
<h1 class="pt-2">{{ type.plural }} buchen</h1>
|
||||
<p class="subline">Tag und Zeitraum wählen, dann einen freien Platz 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 BoardController::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 'board/_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 'board/_floorplan.html.twig' %}
|
||||
</div>
|
||||
{% endblock %}
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,28 @@
|
||||
{#
|
||||
The office. Hand-authored geometry: `svgElementId` on each Resource is the key that ties a
|
||||
database row to a shape here, so a resource whose id is missing from the map is simply not
|
||||
drawn (and vice versa) -- app:seed and this map have to agree.
|
||||
|
||||
Found by convention from ResourceType::DESK->value, see board/_floorplan.html.twig.
|
||||
#}
|
||||
{% import 'board/plan/_spot.html.twig' as plan %}
|
||||
|
||||
{% set geometry = {
|
||||
'desk-01': { x: 40, y: 60 }, 'desk-02': { x: 200, y: 60 }, 'desk-03': { x: 360, y: 60 },
|
||||
'desk-04': { x: 40, y: 160 }, 'desk-05': { x: 200, y: 160 }, 'desk-06': { x: 360, y: 160 },
|
||||
'desk-07': { x: 40, y: 260 }, 'desk-08': { x: 200, y: 260 }, 'desk-09': { x: 360, y: 260 },
|
||||
'desk-10': { x: 40, y: 360 }, 'desk-11': { x: 200, y: 360 }, 'desk-12': { x: 360, y: 360 }
|
||||
} %}
|
||||
{% set time_params = selected_time.allDay ? {} : { von: selected_time.start, bis: selected_time.end } %}
|
||||
|
||||
<svg viewBox="0 0 520 460" class="w-full h-auto" role="img" aria-label="Raumplan Büro">
|
||||
<rect x="10" y="20" width="500" height="420" rx="8" class="fill-white stroke-gray-300" stroke-width="2"></rect>
|
||||
<text x="20" y="14" class="fill-gray-400 text-xs font-semibold">Büro</text>
|
||||
|
||||
{% for resource in resources %}
|
||||
{% if resource.svgElementId and geometry[resource.svgElementId] is defined %}
|
||||
{{ plan.spot(resource, geometry[resource.svgElementId], 140, 70,
|
||||
bookings_by_resource[resource.id]|default([]), selected_date, is_past, time_params) }}
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
</svg>
|
||||
@@ -0,0 +1,25 @@
|
||||
{#
|
||||
The car park. See board/plan/_desk.html.twig for how geometry and Resource.svgElementId
|
||||
relate; found by convention from ResourceType::PARKING->value.
|
||||
#}
|
||||
{% import 'board/plan/_spot.html.twig' as plan %}
|
||||
|
||||
{% set geometry = {
|
||||
'parking-01': { x: 40, y: 60 }, 'parking-02': { x: 40, y: 130 }, 'parking-03': { x: 40, y: 200 },
|
||||
'parking-04': { x: 280, y: 60 }, 'parking-05': { x: 280, y: 130 }, 'parking-06': { x: 280, y: 200 }
|
||||
} %}
|
||||
{% set time_params = selected_time.allDay ? {} : { von: selected_time.start, bis: selected_time.end } %}
|
||||
|
||||
<svg viewBox="0 0 520 300" class="w-full h-auto" role="img" aria-label="Parkplatz">
|
||||
<rect x="10" y="20" width="500" height="260" rx="8" class="fill-white stroke-gray-300" stroke-width="2"></rect>
|
||||
<text x="20" y="14" class="fill-gray-400 text-xs font-semibold">Parkplatz</text>
|
||||
{# The lane between the two rows. #}
|
||||
<line x1="265" y1="40" x2="265" y2="260" class="stroke-gray-200" stroke-width="2" stroke-dasharray="6 6"></line>
|
||||
|
||||
{% for resource in resources %}
|
||||
{% if resource.svgElementId and geometry[resource.svgElementId] is defined %}
|
||||
{{ plan.spot(resource, geometry[resource.svgElementId], 200, 55,
|
||||
bookings_by_resource[resource.id]|default([]), selected_date, is_past, time_params) }}
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
</svg>
|
||||
@@ -0,0 +1,62 @@
|
||||
{#
|
||||
The shared shape a plan is drawn from. Imported by each board/plan/_<type>.html.twig, so the
|
||||
per-type partials carry geometry only and free/belegt/deine looks identical everywhere.
|
||||
|
||||
Clicking always opens a modal: the create modal when free, the edit modal when it is yours
|
||||
(which is where changing the times and releasing live). Nothing else is clickable, so the SVG
|
||||
carries no confirm dialogs or inline CSRF tokens.
|
||||
#}
|
||||
{% 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) %}
|
||||
|
||||
{# 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 %}
|
||||
@@ -3,7 +3,9 @@
|
||||
{% block title %}Meine Buchungen · ephub{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<h1>Meine Buchungen</h1>
|
||||
<a class="text-sm text-gray-500 hover:text-gray-900" href="{{ path('app_dashboard') }}">← Übersicht</a>
|
||||
|
||||
<h1 class="pt-2">Meine Buchungen</h1>
|
||||
<p class="subline">Alles, was ab heute für dich reserviert ist.</p>
|
||||
|
||||
{% if series is not empty %}
|
||||
@@ -32,7 +34,7 @@
|
||||
|
||||
{% if bookings is empty %}
|
||||
<p class="text-sm text-gray-500">
|
||||
Noch nichts gebucht. <a class="underline" href="{{ path('app_dashboard') }}">Zum Raumplan</a>.
|
||||
Noch nichts gebucht. <a class="underline" href="{{ path('app_dashboard') }}">Zur Übersicht</a>.
|
||||
</p>
|
||||
{% else %}
|
||||
<ul class="divide-y divide-gray-200 border-y border-gray-200">
|
||||
@@ -55,7 +57,7 @@
|
||||
</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>
|
||||
href="{{ path('app_board', { type: booking.resource.type.slug, 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 }) }}">
|
||||
|
||||
@@ -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>
|
||||
@@ -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 %}
|
||||
+20
-30
@@ -1,42 +1,32 @@
|
||||
{% extends 'base.html.twig' %}
|
||||
|
||||
{#
|
||||
The shell. Deliberately has no navigation: the dashboard at / is how you get anywhere, so the
|
||||
only things up here are the way back to it and the way out. A booking board reached from a
|
||||
tile links back with its own breadcrumb.
|
||||
#}
|
||||
{% block body %}
|
||||
<div class="max-w-screen-2xl mx-auto px-4 lg:px-8 lg:grid lg:grid-cols-4 gap-8 py-8">
|
||||
<div class="lg:col-span-4 flex items-center justify-between pb-4 lg:pb-0">
|
||||
<a href="{{ path('app_dashboard') }}">
|
||||
<div class="border-b border-gray-200">
|
||||
<div class="max-w-screen-2xl mx-auto px-4 lg:px-8 h-16 flex items-center justify-between gap-4">
|
||||
<a href="{{ path('app_dashboard') }}" class="shrink-0">
|
||||
<img class="h-8 w-auto" src="{{ asset('images/logo_ep.svg') }}" alt="ephub">
|
||||
</a>
|
||||
<button type="button"
|
||||
class="lg:hidden"
|
||||
{{ stimulus_controller('mobilenav', [], [], { 'mobilenav': '#mobilenav' }) }}
|
||||
{{ stimulus_action('mobilenav', 'trigger', null, { 'mode': 'open' }) }}>
|
||||
{{ ux_icon('lucide:menu', { class: 'w-8 h-8 text-gray-700' }) }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="hidden lg:block sticky top-0 h-screen">
|
||||
{% include '_partials/_nav.html.twig' %}
|
||||
</div>
|
||||
|
||||
<div class="lg:col-span-3">
|
||||
{% block content %}{% endblock %}
|
||||
{% if app.user %}
|
||||
<div class="flex items-center gap-4">
|
||||
<span class="hidden sm:block text-sm text-gray-500">{{ app.user.name }}</span>
|
||||
<a href="{{ path('app_logout') }}"
|
||||
class="flex items-center gap-2 text-sm font-semibold text-gray-700 hover:text-gray-900">
|
||||
{{ ux_icon('lucide:log-out', { class: 'h-5 w-5 shrink-0 text-gray-400' }) }}
|
||||
<span class="hidden sm:inline">Abmelden</span>
|
||||
</a>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="mobilenav"
|
||||
class="fixed z-50 top-0 left-0 inset-0 pr-16 bg-black/70 -translate-x-full"
|
||||
{{ stimulus_controller('mobilenav', [], { 'closed': '-translate-x-full' }, { 'mobilenav': '#mobilenav' }) }}>
|
||||
<div class="h-screen w-full bg-white">
|
||||
<div class="flex justify-between p-8">
|
||||
<img class="h-8 w-auto" src="{{ asset('images/logo_ep.svg') }}" alt="">
|
||||
<button type="button" {{ stimulus_action('mobilenav', 'trigger', null, { 'mode': 'close' }) }}>
|
||||
{{ ux_icon('lucide:x', { class: 'w-8 h-8' }) }}
|
||||
</button>
|
||||
</div>
|
||||
<div class="w-full h-full p-8 pt-0 overflow-y-scroll">
|
||||
{% include '_partials/_nav.html.twig' %}
|
||||
</div>
|
||||
</div>
|
||||
<div class="max-w-screen-2xl mx-auto px-4 lg:px-8 py-8">
|
||||
{% block content %}{% endblock %}
|
||||
</div>
|
||||
|
||||
{# Swapped out of band after a booking, hence the stable id and the conditional attribute. #}
|
||||
|
||||
Reference in new issue
Block a user