feat: paginate the admin booking list

This commit is contained in:
fromme committed 2026-09-18 10:53:55 +02:00
1 parent ba64033948
commit 2f5d1d14e4
16 files changed
+504 -75

No files matched your search

+1
View File
@@ -10,6 +10,7 @@
"doctrine/doctrine-bundle": "^3.3", "doctrine/doctrine-bundle": "^3.3",
"doctrine/doctrine-migrations-bundle": "^4.0", "doctrine/doctrine-migrations-bundle": "^4.0",
"doctrine/orm": "^3.7", "doctrine/orm": "^3.7",
"knplabs/knp-paginator-bundle": "^6.10",
"league/oauth2-client": "^2.9", "league/oauth2-client": "^2.9",
"phpdocumentor/reflection-docblock": "^6.0", "phpdocumentor/reflection-docblock": "^6.0",
"phpstan/phpdoc-parser": "^2.3", "phpstan/phpdoc-parser": "^2.3",
Generated
+167 -1
View File
@@ -4,7 +4,7 @@
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
"This file is @generated automatically" "This file is @generated automatically"
], ],
"content-hash": "8f1bfc30d968cee3e1cb4b846d7b1e73", "content-hash": "5651480dee3b63391faefc9646fe4a95",
"packages": [ "packages": [
{ {
"name": "composer/semver", "name": "composer/semver",
@@ -1600,6 +1600,172 @@
], ],
"time": "2026-08-24T09:13:11+00:00" "time": "2026-08-24T09:13:11+00:00"
}, },
{
"name": "knplabs/knp-components",
"version": "v5.3.0",
"source": {
"type": "git",
"url": "https://github.com/KnpLabs/knp-components.git",
"reference": "1c69651056fea716f5f6c60e11c59cabf6c1bf54"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/KnpLabs/knp-components/zipball/1c69651056fea716f5f6c60e11c59cabf6c1bf54",
"reference": "1c69651056fea716f5f6c60e11c59cabf6c1bf54",
"shasum": ""
},
"require": {
"php": "^8.2",
"symfony/event-dispatcher-contracts": "^3.0"
},
"conflict": {
"doctrine/dbal": "<4.0"
},
"require-dev": {
"doctrine/dbal": "^4.0",
"doctrine/mongodb-odm": "^2.5.5",
"doctrine/orm": "^3.0",
"doctrine/phpcr-odm": "^2.0",
"ext-pdo_sqlite": "*",
"jackalope/jackalope-doctrine-dbal": "^2.0",
"phpunit/phpunit": "^11.5 || ^12.5 || ^13.2",
"propel/propel1": "^1.7",
"ruflin/elastica": "^7.0",
"solarium/solarium": "^6.0",
"symfony/http-foundation": "^6.4.4 || ^7.4",
"symfony/http-kernel": "^6.4.4 || ^7.4",
"symfony/property-access": "^6.4.4 || ^7.4",
"symfony/var-exporter": "^6.4.4 || ^7.4"
},
"suggest": {
"doctrine/common": "to allow usage pagination with Doctrine ArrayCollection",
"doctrine/mongodb-odm": "to allow usage pagination with Doctrine ODM MongoDB",
"doctrine/orm": "to allow usage pagination with Doctrine ORM",
"doctrine/phpcr-odm": "to allow usage pagination with Doctrine ODM PHPCR",
"propel/propel1": "to allow usage pagination with Propel ORM",
"ruflin/elastica": "to allow usage pagination with ElasticSearch Client",
"solarium/solarium": "to allow usage pagination with Solarium Client",
"symfony/http-foundation": "to retrieve arguments from Request",
"symfony/property-access": "to allow sorting arrays"
},
"type": "library",
"extra": {
"branch-alias": {
"dev-master": "5.x-dev"
}
},
"autoload": {
"psr-4": {
"Knp\\Component\\": "src/Knp/Component"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "KnpLabs Team",
"homepage": "https://knplabs.com"
},
{
"name": "Symfony Community",
"homepage": "https://github.com/KnpLabs/knp-components/contributors"
}
],
"description": "Knplabs component library",
"homepage": "https://github.com/KnpLabs/knp-components",
"keywords": [
"components",
"knp",
"knplabs",
"pager",
"paginator"
],
"support": {
"issues": "https://github.com/KnpLabs/knp-components/issues",
"source": "https://github.com/KnpLabs/knp-components/tree/v5.3.0"
},
"time": "2026-08-07T07:05:10+00:00"
},
{
"name": "knplabs/knp-paginator-bundle",
"version": "v6.11.0",
"source": {
"type": "git",
"url": "https://github.com/KnpLabs/KnpPaginatorBundle.git",
"reference": "a91a854720df2dbfd04cbf8c44c3dad6ce22fdca"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/KnpLabs/KnpPaginatorBundle/zipball/a91a854720df2dbfd04cbf8c44c3dad6ce22fdca",
"reference": "a91a854720df2dbfd04cbf8c44c3dad6ce22fdca",
"shasum": ""
},
"require": {
"knplabs/knp-components": "^5.0",
"php": "^8.2",
"symfony/config": "^6.4 || ^7.4 || ^8.0",
"symfony/dependency-injection": "^6.4 || ^7.4 || ^8.0",
"symfony/event-dispatcher": "^6.4 || ^7.4 || ^8.0",
"symfony/http-foundation": "^6.4 || ^7.4 || ^8.0",
"symfony/http-kernel": "^6.4 || ^7.4 || ^8.0",
"symfony/routing": "^6.4 || ^7.4 || ^8.0"
},
"require-dev": {
"phpstan/phpstan": "^2.2",
"phpstan/phpstan-strict-rules": "^2.0",
"phpunit/phpunit": "^11.5 || ^12.5 || ^13.2",
"symfony/templating": "^6.4 || ^7.4 || ^8.0",
"symfony/translation": "^6.4 || ^7.4 || ^8.0",
"twig/twig": "^3.0"
},
"suggest": {
"symfony/translation": "To use the templates",
"twig/twig": "To use the templates"
},
"type": "symfony-bundle",
"extra": {
"branch-alias": {
"dev-master": "6.x-dev"
}
},
"autoload": {
"psr-4": {
"Knp\\Bundle\\PaginatorBundle\\": "src"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "KnpLabs Team",
"homepage": "https://knplabs.com"
},
{
"name": "Symfony Community",
"homepage": "https://github.com/KnpLabs/KnpPaginatorBundle/contributors"
}
],
"description": "Paginator bundle for Symfony to automate pagination and simplify sorting and other features",
"homepage": "https://github.com/KnpLabs/KnpPaginatorBundle",
"keywords": [
"bundle",
"knp",
"knplabs",
"pager",
"pagination",
"paginator",
"symfony"
],
"support": {
"issues": "https://github.com/KnpLabs/KnpPaginatorBundle/issues",
"source": "https://github.com/KnpLabs/KnpPaginatorBundle/tree/v6.11.0"
},
"time": "2026-09-11T08:30:00+00:00"
},
{ {
"name": "league/oauth2-client", "name": "league/oauth2-client",
"version": "2.9.0", "version": "2.9.0",
+1
View File
@@ -13,4 +13,5 @@ return [
Symfony\Bundle\MonologBundle\MonologBundle::class => ['all' => true], Symfony\Bundle\MonologBundle\MonologBundle::class => ['all' => true],
Symfony\Bundle\MakerBundle\MakerBundle::class => ['dev' => true], Symfony\Bundle\MakerBundle\MakerBundle::class => ['dev' => true],
Symfony\UX\Icons\UXIconsBundle::class => ['all' => true], Symfony\UX\Icons\UXIconsBundle::class => ['all' => true],
Knp\Bundle\PaginatorBundle\KnpPaginatorBundle::class => ['all' => true],
]; ];
+14
View File
@@ -0,0 +1,14 @@
# Pagination for the admin listings. The parameter names are the bundle defaults; they are spelled
# out anyway because the query string is a URL contract -- a filtered, sorted, paged admin view is
# meant to be linkable, and the template overrides below build their links from these names.
knp_paginator:
default_options:
page_name: page
sort_field_name: sort
sort_direction_name: direction
distinct: true
# Tailwind markup matching the rest of the app, adopted from the sibling project.
template:
pagination: '_partials/_paginator_sliding.html.twig'
sortable: '_partials/_paginator_sortable_link.html.twig'
+27
View File
@@ -1490,6 +1490,29 @@ use Symfony\Component\Config\Loader\ParamConfigurator as Param;
* }, * },
* ignore_not_found?: bool|Param, // Ignore error when an icon is not found. Set to 'true' to fail silently. // Default: false * ignore_not_found?: bool|Param, // Ignore error when an icon is not found. Set to 'true' to fail silently. // Default: false
* } * }
* @psalm-type KnpPaginatorConfig = array{
* default_options?: array{
* sort_field_name?: scalar|Param|null, // Default: "sort"
* sort_direction_name?: scalar|Param|null, // Default: "direction"
* filter_field_name?: scalar|Param|null, // Default: "filterField"
* filter_value_name?: scalar|Param|null, // Default: "filterValue"
* page_name?: scalar|Param|null, // Default: "page"
* distinct?: bool|Param, // Default: true
* wrap_queries?: bool|Param, // Default: false
* page_out_of_range?: scalar|Param|null, // Default: "ignore"
* default_limit?: scalar|Param|null, // Default: 10
* },
* template?: array{
* pagination?: scalar|Param|null, // Default: "@KnpPaginator/Pagination/sliding.html.twig"
* rel_links?: scalar|Param|null, // Default: "@KnpPaginator/Pagination/rel_links.html.twig"
* filtration?: scalar|Param|null, // Default: "@KnpPaginator/Pagination/filtration.html.twig"
* sortable?: scalar|Param|null, // Default: "@KnpPaginator/Pagination/sortable_link.html.twig"
* },
* page_range?: scalar|Param|null, // Default: 5
* page_limit?: scalar|Param|null, // Default: null
* convert_exception?: bool|Param, // Default: false
* remove_first_page_param?: bool|Param, // Default: false
* }
* @psalm-type ConfigType = array{ * @psalm-type ConfigType = array{
* imports?: ImportsConfig, * imports?: ImportsConfig,
* parameters?: ParametersConfig, * parameters?: ParametersConfig,
@@ -1503,6 +1526,7 @@ use Symfony\Component\Config\Loader\ParamConfigurator as Param;
* security?: SecurityConfig, * security?: SecurityConfig,
* monolog?: MonologConfig, * monolog?: MonologConfig,
* ux_icons?: UxIconsConfig, * ux_icons?: UxIconsConfig,
* knp_paginator?: KnpPaginatorConfig,
* "when@dev"?: array{ * "when@dev"?: array{
* imports?: ImportsConfig, * imports?: ImportsConfig,
* parameters?: ParametersConfig, * parameters?: ParametersConfig,
@@ -1519,6 +1543,7 @@ use Symfony\Component\Config\Loader\ParamConfigurator as Param;
* monolog?: MonologConfig, * monolog?: MonologConfig,
* maker?: MakerConfig, * maker?: MakerConfig,
* ux_icons?: UxIconsConfig, * ux_icons?: UxIconsConfig,
* knp_paginator?: KnpPaginatorConfig,
* }, * },
* "when@prod"?: array{ * "when@prod"?: array{
* imports?: ImportsConfig, * imports?: ImportsConfig,
@@ -1533,6 +1558,7 @@ use Symfony\Component\Config\Loader\ParamConfigurator as Param;
* security?: SecurityConfig, * security?: SecurityConfig,
* monolog?: MonologConfig, * monolog?: MonologConfig,
* ux_icons?: UxIconsConfig, * ux_icons?: UxIconsConfig,
* knp_paginator?: KnpPaginatorConfig,
* }, * },
* "when@test"?: array{ * "when@test"?: array{
* imports?: ImportsConfig, * imports?: ImportsConfig,
@@ -1548,6 +1574,7 @@ use Symfony\Component\Config\Loader\ParamConfigurator as Param;
* security?: SecurityConfig, * security?: SecurityConfig,
* monolog?: MonologConfig, * monolog?: MonologConfig,
* ux_icons?: UxIconsConfig, * ux_icons?: UxIconsConfig,
* knp_paginator?: KnpPaginatorConfig,
* }, * },
* ...<string, ExtensionType|array{ // extra keys must follow the when@%env% pattern or match an extension alias * ...<string, ExtensionType|array{ // extra keys must follow the when@%env% pattern or match an extension alias
* imports?: ImportsConfig, * imports?: ImportsConfig,
@@ -9,6 +9,7 @@ use App\Form\Admin\BookingFilterType;
use App\Repository\BookingRepository; use App\Repository\BookingRepository;
use App\Repository\BookingSeriesRepository; use App\Repository\BookingSeriesRepository;
use App\Security\Role; use App\Security\Role;
use Knp\Component\Pager\PaginatorInterface;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response; use Symfony\Component\HttpFoundation\Response;
@@ -30,9 +31,16 @@ use Symfony\Component\Security\Http\Attribute\IsGranted;
#[IsGranted(Role::ADMIN)] #[IsGranted(Role::ADMIN)]
final class IndexController extends AbstractController final class IndexController extends AbstractController
{ {
/**
* The window defaults to a year, so the unpaged list grew with the company. 25 rows is about
* one screen of the table without scrolling past the filter form.
*/
private const PER_PAGE = 25;
public function __construct( public function __construct(
private readonly BookingRepository $bookings, private readonly BookingRepository $bookings,
private readonly BookingSeriesRepository $series, private readonly BookingSeriesRepository $series,
private readonly PaginatorInterface $paginator,
) { ) {
} }
@@ -45,10 +53,19 @@ final class IndexController extends AbstractController
// Unfiltered first visits simply keep the constructor's defaults. // Unfiltered first visits simply keep the constructor's defaults.
$form->handleRequest($request); $form->handleRequest($request);
// No defaultSortFieldName: one field cannot express the date/start/resource ordering the
// repository already sorts by, and naming one here would replace all three. Unsorted
// requests keep that ordering; a clicked column header takes precedence over it.
$pagination = $this->paginator->paginate(
$this->bookings->createFilteredQueryBuilder($filter),
$request->query->getInt('page', 1),
self::PER_PAGE,
);
return $this->render('admin/bookings.html.twig', [ return $this->render('admin/bookings.html.twig', [
'form' => $form, 'form' => $form,
'filter' => $filter, 'filter' => $filter,
'bookings' => $this->bookings->findForAdmin($filter), 'pagination' => $pagination,
'series' => $this->series->findActive($filter->fromDate(), $filter->user, $filter->type), 'series' => $this->series->findActive($filter->fromDate(), $filter->user, $filter->type),
]); ]);
} }
+4
View File
@@ -61,6 +61,10 @@ final class BookingFilterType extends AbstractType
'data_class' => BookingFilter::class, 'data_class' => BookingFilter::class,
'method' => 'GET', 'method' => 'GET',
'csrf_protection' => false, 'csrf_protection' => false,
// The paginator writes page/sort/direction into the same query string this form reads
// back. Without this the form counts them as extra fields, turns invalid, and the
// listing answers 422 to every sorted request.
'allow_extra_fields' => true,
]); ]);
} }
+5 -3
View File
@@ -216,9 +216,11 @@ class BookingRepository extends ServiceEntityRepository
* and eager-selects the holder as well, because the admin list names a different person on * and eager-selects the holder as well, because the admin list names a different person on
* every row and would otherwise fire a query per booking. * every row and would otherwise fire a query per booking.
* *
* @return list<Booking> * Unexecuted, because the paginator has to add the LIMIT and the ORDER BY of a clicked column
* header itself. Only to-one associations are fetch-joined, so a LIMIT over this query cannot
* truncate a half-hydrated row the way a joined collection would.
*/ */
public function findForAdmin(BookingFilter $filter): array public function createFilteredQueryBuilder(BookingFilter $filter): QueryBuilder
{ {
$qb = $this->createQueryBuilder('b') $qb = $this->createQueryBuilder('b')
->addSelect('u', 'r', 'g', 's') ->addSelect('u', 'r', 'g', 's')
@@ -241,7 +243,7 @@ class BookingRepository extends ServiceEntityRepository
$qb->andWhere('r.type = :type')->setParameter('type', $filter->type); $qb->andWhere('r.type = :type')->setParameter('type', $filter->type);
} }
return $qb->getQuery()->getResult(); return $qb;
} }
/** /**
+3
View File
@@ -35,6 +35,9 @@
"migrations/.gitignore" "migrations/.gitignore"
] ]
}, },
"knplabs/knp-paginator-bundle": {
"version": "v6.11.0"
},
"phpunit/phpunit": { "phpunit/phpunit": {
"version": "13.3", "version": "13.3",
"recipe": { "recipe": {
+1
View File
@@ -2,4 +2,5 @@
@import "components/button.css"; @import "components/button.css";
@import "components/forms.css"; @import "components/forms.css";
@import "components/badge.css"; @import "components/badge.css";
@import "components/datatable.css";
@import "components/toast.css"; @import "components/toast.css";
+4 -1
View File
@@ -6,7 +6,10 @@
} }
.button--small { .button--small {
@apply px-2 py-1 md:px-4 text-xs sm:text-sm; /* The fixed height of .button is what made these look full-size; a small button sizes to its
own padding instead, so it fits inside a table row and next to a form field. */
@apply h-auto md:h-auto leading-none md:leading-none;
@apply px-2 py-1.5 md:px-3 md:py-2 text-xs sm:text-sm normal-case;
} }
.button--primary { .button--primary {
+31
View File
@@ -0,0 +1,31 @@
.data-table-wrapper {
@apply overflow-x-auto lg:overflow-x-visible mb-8;
}
.data-table-wrapper__inner {
@apply align-middle inline-block min-w-full overflow-hidden lg:overflow-visible;
}
.data-table {
@apply min-w-full;
}
.data-table tbody {
@apply bg-white;
}
.data-table tr {
@apply border border-gray-300;
}
.data-table th {
@apply px-4 py-2 bg-gray-200 text-left leading-4 font-bold text-primary align-top;
}
.data-table td {
@apply px-4 py-2 leading-5 align-top;
}
.data-table tbody tr {
@apply hover:bg-gray-100;
}
@@ -0,0 +1,68 @@
{#
The Knp paginator's sliding window, styled like the sibling project's admin tables: the bar
butts against the bottom border of the table it belongs to, hence border-t-0.
Every control is a plain link -- the house rule hands navigation to the browser, so paging is a
normal request and the page ends up in the URL, next to the filter and sort parameters that
query|merge carries along.
#}
{% if pageCount > 0 %}
<div class="bg-gray-100 border border-gray-300 border-t-0 px-4 py-3 flex items-center justify-between sm:px-4">
<div class="flex-1 flex items-center justify-between">
<div class="text-sm leading-5 text-gray-700">
Einträge <span class="font-medium">{{ firstItemNumber }}</span>
bis <span class="font-medium">{{ lastItemNumber }}</span>
von {{ totalCount }}
</div>
<div>
<nav class="relative z-0 inline-flex">
{% if pageCount > 1 %}
{% if first is defined and current != first %}
<a href="{{ path(route, query|merge({(pageParameterName): first})) }}" class="relative inline-flex items-center px-2 py-2 border border-gray-300 bg-white text-sm leading-5 font-medium hover:bg-secondary hover:text-white" aria-label="Erste Seite">
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="w-5 h-5">
<path stroke-linecap="round" stroke-linejoin="round" d="m18.75 4.5-7.5 7.5 7.5 7.5m-6-15L5.25 12l7.5 7.5" />
</svg>
</a>
{% endif %}
{% if previous is defined %}
<a href="{{ path(route, query|merge({(pageParameterName): previous})) }}" class="-ml-px relative inline-flex items-center px-2 py-2 border border-gray-300 bg-white text-sm leading-5 font-medium hover:bg-secondary hover:text-white" aria-label="Vorherige Seite">
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="w-5 h-5">
<path stroke-linecap="round" stroke-linejoin="round" d="M15.75 19.5 8.25 12l7.5-7.5" />
</svg>
</a>
{% endif %}
{% if current > 3 %}
<span class="-ml-px relative inline-flex items-center px-4 py-2 border border-gray-300 bg-white text-sm leading-5 font-medium text-gray-700">
...
</span>
{% endif %}
{% for page in pagesInRange %}
<a href="{{ path(route, query|merge({(pageParameterName): page})) }}" class="-ml-px relative inline-flex items-center px-4 py-2 border border-gray-300 text-sm leading-5 font-medium focus:z-10 hover:bg-secondary {% if page == current %}bg-primary text-white hover:text-white{% else %}bg-white text-gray-700 hover:text-white{% endif %}"{% if page == current %} aria-current="page"{% endif %}>
{{ page }}
</a>
{% endfor %}
{% if current < pageCount - 3 %}
<span class="-ml-px relative inline-flex items-center px-4 py-2 border border-gray-300 bg-white text-sm leading-5 font-medium text-gray-700">
...
</span>
{% endif %}
{% if next is defined %}
<a href="{{ path(route, query|merge({(pageParameterName): next})) }}" class="-ml-px relative inline-flex items-center px-2 py-2 border border-gray-300 bg-white text-sm leading-5 font-medium hover:bg-secondary hover:text-white" aria-label="Nächste Seite">
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="w-5 h-5">
<path stroke-linecap="round" stroke-linejoin="round" d="m8.25 4.5 7.5 7.5-7.5 7.5" />
</svg>
</a>
{% endif %}
{% if last is defined and current != last %}
<a href="{{ path(route, query|merge({(pageParameterName): last})) }}" class="-ml-px relative inline-flex items-center px-2 py-2 border border-gray-300 bg-white text-sm leading-5 font-medium hover:bg-secondary hover:text-white" aria-label="Letzte Seite">
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="w-5 h-5">
<path stroke-linecap="round" stroke-linejoin="round" d="m5.25 4.5 7.5 7.5-7.5 7.5m6-15 7.5 7.5-7.5 7.5" />
</svg>
</a>
{% endif %}
{% endif %}
</nav>
</div>
</div>
</div>
{% endif %}
@@ -0,0 +1,15 @@
<a href="{{ options.href }}" class="flex items-center space-x-2">
<span>{{ title|raw }}</span>
{%- if sorted -%}
{%- if direction == 'desc' -%}
<svg class="w-4 h-4 shrink-0" fill="none" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" viewBox="0 0 24 24" stroke="currentColor">
<path d="M3 4h13M3 8h9m-9 4h6m4 0l4-4m0 0l4 4m-4-4v12"></path>
</svg>
{%- else -%}
<svg class="w-4 h-4 shrink-0" fill="none" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" viewBox="0 0 24 24" stroke="currentColor">
<path d="M3 4h13M3 8h9m-9 4h9m5-4v12m0 0l-4-4m4 4l4-4"></path>
</svg>
{%- endif -%}
{%- endif -%}
</a>
+97 -65
View File
@@ -6,6 +6,9 @@
destructive actions open the shared confirmation modal via htmx and then navigate back here, destructive actions open the shared confirmation modal via htmx and then navigate back here,
which is what ?return=admin says; without it a confirmed release swaps board fragments this which is what ?return=admin says; without it a confirmed release swaps board fragments this
page does not have. page does not have.
The appointment table is paginated; the series table is not, because a company has a handful of
standing bookings and cutting them across pages would only hide them.
#} #}
{% extends 'layout.html.twig' %} {% extends 'layout.html.twig' %}
@@ -25,80 +28,109 @@
{{ form_row(form.user) }} {{ form_row(form.user) }}
</div> </div>
<div class="flex justify-end gap-2 pt-2"> <div class="flex justify-end gap-2 pt-2">
<a class="button button--neutral" href="{{ path('app_admin_booking') }}">Zurücksetzen</a> <a class="button button--small button--neutral" href="{{ path('app_admin_booking') }}">Zurücksetzen</a>
<button type="submit" class="button button--primary">Filtern</button> <button type="submit" class="button button--small button--primary">Filtern</button>
</div> </div>
{{ form_end(form) }} {{ form_end(form) }}
{% if series is not empty %} {% if series is not empty %}
<h2 class="pt-8">Wiederkehrende Buchungen</h2> <h2 class="pt-8 pb-4">Wiederkehrende Buchungen</h2>
<ul class="divide-y divide-gray-200 border-y border-gray-200"> <div class="data-table-wrapper">
{% for entry in series %} <div class="data-table-wrapper__inner text-sm">
<li class="flex items-center justify-between gap-4 py-3"> <table class="data-table">
<div> <thead>
<div class="font-semibold"> <tr>
{{ entry.user.name }} <th>Mitarbeiter:in</th>
<span class="font-normal text-gray-500">· {{ entry.resource.label }}</span> <th>Ressource</th>
{%- if entry.resource.group %} <span class="font-normal text-gray-500">· {{ entry.resource.group.label }}</span>{% endif %} <th>Gruppe</th>
</div> <th>Wiederholung</th>
<div class="text-sm text-gray-500"> <th>Bis</th>
{{ entry.describe }} · bis {{ entry.endDate|format_datetime(pattern: 'd. MMMM y') }} <th></th>
</div> </tr>
</div> </thead>
<button type="button" class="button button--small button--neutral" <tbody>
hx-get="{{ path('app_series_end', { id: entry.id, return: 'admin' }) }}" {% for entry in series %}
hx-target="body" <tr>
hx-swap="beforeend"> <td class="font-semibold">{{ entry.user.name }}</td>
Wiederholung beenden <td>{{ entry.resource.label }}</td>
</button> <td>{{ entry.resource.group ? entry.resource.group.label : '–' }}</td>
</li> <td>{{ entry.describe }}</td>
{% endfor %} <td>{{ entry.endDate|format_datetime(pattern: 'd. MMMM y') }}</td>
</ul> <td class="text-right">
<button type="button" class="button button--small button--warning"
hx-get="{{ path('app_series_end', { id: entry.id, return: 'admin' }) }}"
hx-target="body"
hx-swap="beforeend">
Wiederholung beenden
</button>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
{% endif %} {% endif %}
<h2 class="pt-8">Termine</h2> <h2 class="pt-8">Termine</h2>
<p class="subline"> <p class="subline pb-4">
{{ filter.fromDate|format_datetime(pattern: 'd. MMMM y') }} bis {{ filter.fromDate|format_datetime(pattern: 'd. MMMM y') }} bis
{{ filter.toDate|format_datetime(pattern: 'd. MMMM y') }} {{ filter.toDate|format_datetime(pattern: 'd. MMMM y') }}
</p> </p>
{% if bookings is empty %} <div class="data-table-wrapper">
<p class="text-sm text-gray-500">Keine Buchungen in diesem Zeitraum.</p> <div class="data-table-wrapper__inner text-sm">
{% else %} <table class="data-table">
<ul class="divide-y divide-gray-200 border-y border-gray-200"> <thead>
{% for booking in bookings %} <tr>
<li class="flex items-center justify-between gap-4 py-3"> <th>{{ knp_pagination_sortable(pagination, 'Mitarbeiter:in', 'u.name') }}</th>
<div> <th>{{ knp_pagination_sortable(pagination, 'Datum', 'b.date') }}</th>
<div class="font-semibold"> <th>{{ knp_pagination_sortable(pagination, 'Zeit', 'b.startMinute') }}</th>
{{ booking.user.name }} <th>{{ knp_pagination_sortable(pagination, 'Typ', 'r.type') }}</th>
<span class="font-normal text-gray-500"> <th>{{ knp_pagination_sortable(pagination, 'Ressource', 'r.label') }}</th>
· {{ booking.date|format_datetime(pattern: 'EEE, d. MMM y') }} <th>Gruppe</th>
· {{ booking.timeRange }} <th>Status</th>
</span> <th></th>
</div> </tr>
<div class="text-sm text-gray-500"> </thead>
{{ booking.resource.type.label }} · {{ booking.resource.label }} <tbody>
{%- if booking.resource.group %} · {{ booking.resource.group.label }}{% endif %} {% for booking in pagination %}
{% if booking.partOfSeries %} <tr>
<span class="badge bg-gray-100 text-gray-600 ml-1">wiederkehrend</span> <td class="font-semibold">{{ booking.user.name }}</td>
{% endif %} <td class="whitespace-nowrap">{{ booking.date|format_datetime(pattern: 'EEE, d. MMM y') }}</td>
{% if booking.overridden %} <td class="whitespace-nowrap">{{ booking.timeRange }}</td>
<span class="badge bg-amber-100 text-amber-800 ml-1">abweichend</span> <td>{{ booking.resource.type.label }}</td>
{% endif %} <td>{{ booking.resource.label }}</td>
</div> <td>{{ booking.resource.group ? booking.resource.group.label : '–' }}</td>
</div> <td>
<div class="flex items-center gap-3"> {% if booking.partOfSeries %}
<a class="text-sm underline shrink-0" <span class="badge bg-gray-100 text-gray-600">wiederkehrend</span>
href="{{ path('app_board', { type: booking.resource.type.value, date: booking.date|date('Y-m-d') }) }}">Im Plan</a> {% endif %}
<button type="button" class="button button--small button--neutral" {% if booking.overridden %}
hx-get="{{ path('app_booking_release', { id: booking.id, return: 'admin' }) }}" <span class="badge bg-amber-100 text-amber-800">abweichend</span>
hx-target="body" {% endif %}
hx-swap="beforeend"> </td>
Freigeben <td class="text-right">
</button> <div class="flex items-center justify-end gap-2">
</div> <a class="button button--small button--neutral"
</li> href="{{ path('app_board', { type: booking.resource.type.value, date: booking.date|date('Y-m-d') }) }}">Im Plan</a>
{% endfor %} <button type="button" class="button button--small button--warning"
</ul> hx-get="{{ path('app_booking_release', { id: booking.id, return: 'admin' }) }}"
{% endif %} hx-target="body"
hx-swap="beforeend">
Freigeben
</button>
</div>
</td>
</tr>
{% else %}
<tr>
<td colspan="8" class="text-gray-500">Keine Buchungen in diesem Zeitraum.</td>
</tr>
{% endfor %}
</tbody>
</table>
{{ knp_pagination_render(pagination) }}
</div>
</div>
{% endblock %} {% endblock %}
@@ -125,7 +125,7 @@ class BookingAdminControllerTest extends WebTestCase
$crawler = $this->client->request('GET', '/admin/bookings?user='.$bea->getId()); $crawler = $this->client->request('GET', '/admin/bookings?user='.$bea->getId());
self::assertResponseIsSuccessful(); self::assertResponseIsSuccessful();
$text = $crawler->filter('ul')->text(); $text = $crawler->filter('tbody')->text();
self::assertStringContainsString('Bea Berger', $text); self::assertStringContainsString('Bea Berger', $text);
self::assertStringNotContainsString('Carl Conrad', $text); self::assertStringNotContainsString('Carl Conrad', $text);
} }
@@ -139,7 +139,7 @@ class BookingAdminControllerTest extends WebTestCase
$crawler = $this->client->request('GET', '/admin/bookings?type=parking'); $crawler = $this->client->request('GET', '/admin/bookings?type=parking');
self::assertResponseIsSuccessful(); self::assertResponseIsSuccessful();
$text = $crawler->filter('ul')->text(); $text = $crawler->filter('tbody')->text();
self::assertStringContainsString('Carl Conrad', $text); self::assertStringContainsString('Carl Conrad', $text);
self::assertStringNotContainsString('Bea Berger', $text); self::assertStringNotContainsString('Bea Berger', $text);
} }
@@ -236,6 +236,50 @@ class BookingAdminControllerTest extends WebTestCase
); );
} }
/**
* The list is paginated, so the window no longer decides how much gets rendered. Page two is
* the part of the assertion that matters: the first page could be right by accident if the
* limit were applied without an offset.
*/
public function testTheListIsPaginated(): void
{
$user = $this->user('[email protected]', 'Bea Berger');
$desk = $this->resource('D1');
for ($day = 0; $day < 30; ++$day) {
$this->booking($user, $desk, new \DateTimeImmutable('today +'.$day.' days'));
}
$this->client->loginUser($this->admin());
$crawler = $this->client->request('GET', '/admin/bookings');
self::assertResponseIsSuccessful();
self::assertCount(25, $crawler->filter('tbody tr'));
$crawler = $this->client->request('GET', '/admin/bookings?page=2');
self::assertResponseIsSuccessful();
self::assertCount(5, $crawler->filter('tbody tr'));
}
/**
* Sorting has to survive next to the filter: both live in the query string, and a sort link
* that dropped the filter would silently widen what is being paged through.
*/
public function testAColumnHeaderSortsWithoutLosingTheFilter(): void
{
$bea = $this->user('[email protected]', 'Bea Berger');
$this->booking($bea, $this->resource('D1'));
$this->booking($this->user('[email protected]', 'Carl Conrad'), $this->resource('D2'));
$this->client->loginUser($this->admin());
$crawler = $this->client->request('GET', '/admin/bookings?user='.$bea->getId().'&sort=u.name&direction=desc');
self::assertResponseIsSuccessful();
$text = $crawler->filter('tbody')->text();
self::assertStringContainsString('Bea Berger', $text);
self::assertStringNotContainsString('Carl Conrad', $text);
}
private function confirmationToken(string $uri): string private function confirmationToken(string $uri): string
{ {
$crawler = $this->client->request('GET', $uri, server: ['HTTP_HX-Request' => 'true']); $crawler = $this->client->request('GET', $uri, server: ['HTTP_HX-Request' => 'true']);
@@ -246,9 +290,9 @@ class BookingAdminControllerTest extends WebTestCase
return json_decode((string) $vals, true, flags: \JSON_THROW_ON_ERROR)['_token']; return json_decode((string) $vals, true, flags: \JSON_THROW_ON_ERROR)['_token'];
} }
private function booking(User $user, Resource $resource): Booking private function booking(User $user, Resource $resource, ?\DateTimeImmutable $date = null): Booking
{ {
$booking = new Booking($user, $resource, new \DateTimeImmutable('today')); $booking = new Booking($user, $resource, $date ?? new \DateTimeImmutable('today'));
$this->em->persist($booking); $this->em->persist($booking);
$this->em->flush(); $this->em->flush();