feat: paginate the admin booking list
This commit is contained in:
1 parent
ba64033948
commit
2f5d1d14e4
16 files changed
+485
-56
No files matched your search
@@ -10,6 +10,7 @@
|
||||
"doctrine/doctrine-bundle": "^3.3",
|
||||
"doctrine/doctrine-migrations-bundle": "^4.0",
|
||||
"doctrine/orm": "^3.7",
|
||||
"knplabs/knp-paginator-bundle": "^6.10",
|
||||
"league/oauth2-client": "^2.9",
|
||||
"phpdocumentor/reflection-docblock": "^6.0",
|
||||
"phpstan/phpdoc-parser": "^2.3",
|
||||
|
||||
Generated
+167
-1
@@ -4,7 +4,7 @@
|
||||
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
|
||||
"This file is @generated automatically"
|
||||
],
|
||||
"content-hash": "8f1bfc30d968cee3e1cb4b846d7b1e73",
|
||||
"content-hash": "5651480dee3b63391faefc9646fe4a95",
|
||||
"packages": [
|
||||
{
|
||||
"name": "composer/semver",
|
||||
@@ -1600,6 +1600,172 @@
|
||||
],
|
||||
"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",
|
||||
"version": "2.9.0",
|
||||
|
||||
@@ -13,4 +13,5 @@ return [
|
||||
Symfony\Bundle\MonologBundle\MonologBundle::class => ['all' => true],
|
||||
Symfony\Bundle\MakerBundle\MakerBundle::class => ['dev' => true],
|
||||
Symfony\UX\Icons\UXIconsBundle::class => ['all' => true],
|
||||
Knp\Bundle\PaginatorBundle\KnpPaginatorBundle::class => ['all' => true],
|
||||
];
|
||||
@@ -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'
|
||||
@@ -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
|
||||
* }
|
||||
* @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{
|
||||
* imports?: ImportsConfig,
|
||||
* parameters?: ParametersConfig,
|
||||
@@ -1503,6 +1526,7 @@ use Symfony\Component\Config\Loader\ParamConfigurator as Param;
|
||||
* security?: SecurityConfig,
|
||||
* monolog?: MonologConfig,
|
||||
* ux_icons?: UxIconsConfig,
|
||||
* knp_paginator?: KnpPaginatorConfig,
|
||||
* "when@dev"?: array{
|
||||
* imports?: ImportsConfig,
|
||||
* parameters?: ParametersConfig,
|
||||
@@ -1519,6 +1543,7 @@ use Symfony\Component\Config\Loader\ParamConfigurator as Param;
|
||||
* monolog?: MonologConfig,
|
||||
* maker?: MakerConfig,
|
||||
* ux_icons?: UxIconsConfig,
|
||||
* knp_paginator?: KnpPaginatorConfig,
|
||||
* },
|
||||
* "when@prod"?: array{
|
||||
* imports?: ImportsConfig,
|
||||
@@ -1533,6 +1558,7 @@ use Symfony\Component\Config\Loader\ParamConfigurator as Param;
|
||||
* security?: SecurityConfig,
|
||||
* monolog?: MonologConfig,
|
||||
* ux_icons?: UxIconsConfig,
|
||||
* knp_paginator?: KnpPaginatorConfig,
|
||||
* },
|
||||
* "when@test"?: array{
|
||||
* imports?: ImportsConfig,
|
||||
@@ -1548,6 +1574,7 @@ use Symfony\Component\Config\Loader\ParamConfigurator as Param;
|
||||
* security?: SecurityConfig,
|
||||
* monolog?: MonologConfig,
|
||||
* ux_icons?: UxIconsConfig,
|
||||
* knp_paginator?: KnpPaginatorConfig,
|
||||
* },
|
||||
* ...<string, ExtensionType|array{ // extra keys must follow the when@%env% pattern or match an extension alias
|
||||
* imports?: ImportsConfig,
|
||||
|
||||
@@ -9,6 +9,7 @@ use App\Form\Admin\BookingFilterType;
|
||||
use App\Repository\BookingRepository;
|
||||
use App\Repository\BookingSeriesRepository;
|
||||
use App\Security\Role;
|
||||
use Knp\Component\Pager\PaginatorInterface;
|
||||
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
|
||||
use Symfony\Component\HttpFoundation\Request;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
@@ -30,9 +31,16 @@ use Symfony\Component\Security\Http\Attribute\IsGranted;
|
||||
#[IsGranted(Role::ADMIN)]
|
||||
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(
|
||||
private readonly BookingRepository $bookings,
|
||||
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.
|
||||
$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', [
|
||||
'form' => $form,
|
||||
'filter' => $filter,
|
||||
'bookings' => $this->bookings->findForAdmin($filter),
|
||||
'pagination' => $pagination,
|
||||
'series' => $this->series->findActive($filter->fromDate(), $filter->user, $filter->type),
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -61,6 +61,10 @@ final class BookingFilterType extends AbstractType
|
||||
'data_class' => BookingFilter::class,
|
||||
'method' => 'GET',
|
||||
'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,
|
||||
]);
|
||||
}
|
||||
|
||||
|
||||
@@ -216,9 +216,11 @@ class BookingRepository extends ServiceEntityRepository
|
||||
* 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.
|
||||
*
|
||||
* @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')
|
||||
->addSelect('u', 'r', 'g', 's')
|
||||
@@ -241,7 +243,7 @@ class BookingRepository extends ServiceEntityRepository
|
||||
$qb->andWhere('r.type = :type')->setParameter('type', $filter->type);
|
||||
}
|
||||
|
||||
return $qb->getQuery()->getResult();
|
||||
return $qb;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -35,6 +35,9 @@
|
||||
"migrations/.gitignore"
|
||||
]
|
||||
},
|
||||
"knplabs/knp-paginator-bundle": {
|
||||
"version": "v6.11.0"
|
||||
},
|
||||
"phpunit/phpunit": {
|
||||
"version": "13.3",
|
||||
"recipe": {
|
||||
|
||||
@@ -2,4 +2,5 @@
|
||||
@import "components/button.css";
|
||||
@import "components/forms.css";
|
||||
@import "components/badge.css";
|
||||
@import "components/datatable.css";
|
||||
@import "components/toast.css";
|
||||
@@ -6,7 +6,10 @@
|
||||
}
|
||||
|
||||
.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 {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -6,6 +6,9 @@
|
||||
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
|
||||
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' %}
|
||||
|
||||
@@ -25,80 +28,109 @@
|
||||
{{ form_row(form.user) }}
|
||||
</div>
|
||||
<div class="flex justify-end gap-2 pt-2">
|
||||
<a class="button button--neutral" href="{{ path('app_admin_booking') }}">Zurücksetzen</a>
|
||||
<button type="submit" class="button button--primary">Filtern</button>
|
||||
<a class="button button--small button--neutral" href="{{ path('app_admin_booking') }}">Zurücksetzen</a>
|
||||
<button type="submit" class="button button--small button--primary">Filtern</button>
|
||||
</div>
|
||||
{{ form_end(form) }}
|
||||
|
||||
{% if series is not empty %}
|
||||
<h2 class="pt-8">Wiederkehrende Buchungen</h2>
|
||||
<ul class="divide-y divide-gray-200 border-y border-gray-200">
|
||||
<h2 class="pt-8 pb-4">Wiederkehrende Buchungen</h2>
|
||||
<div class="data-table-wrapper">
|
||||
<div class="data-table-wrapper__inner text-sm">
|
||||
<table class="data-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Mitarbeiter:in</th>
|
||||
<th>Ressource</th>
|
||||
<th>Gruppe</th>
|
||||
<th>Wiederholung</th>
|
||||
<th>Bis</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for entry in series %}
|
||||
<li class="flex items-center justify-between gap-4 py-3">
|
||||
<div>
|
||||
<div class="font-semibold">
|
||||
{{ entry.user.name }}
|
||||
<span class="font-normal text-gray-500">· {{ entry.resource.label }}</span>
|
||||
{%- if entry.resource.group %} <span class="font-normal text-gray-500">· {{ entry.resource.group.label }}</span>{% endif %}
|
||||
</div>
|
||||
<div class="text-sm text-gray-500">
|
||||
{{ entry.describe }} · bis {{ entry.endDate|format_datetime(pattern: 'd. MMMM y') }}
|
||||
</div>
|
||||
</div>
|
||||
<button type="button" class="button button--small button--neutral"
|
||||
<tr>
|
||||
<td class="font-semibold">{{ entry.user.name }}</td>
|
||||
<td>{{ entry.resource.label }}</td>
|
||||
<td>{{ entry.resource.group ? entry.resource.group.label : '–' }}</td>
|
||||
<td>{{ entry.describe }}</td>
|
||||
<td>{{ entry.endDate|format_datetime(pattern: 'd. MMMM y') }}</td>
|
||||
<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>
|
||||
</li>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<h2 class="pt-8">Termine</h2>
|
||||
<p class="subline">
|
||||
<p class="subline pb-4">
|
||||
{{ filter.fromDate|format_datetime(pattern: 'd. MMMM y') }} bis
|
||||
{{ filter.toDate|format_datetime(pattern: 'd. MMMM y') }}
|
||||
</p>
|
||||
|
||||
{% if bookings is empty %}
|
||||
<p class="text-sm text-gray-500">Keine Buchungen in diesem Zeitraum.</p>
|
||||
{% else %}
|
||||
<ul class="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.user.name }}
|
||||
<span class="font-normal text-gray-500">
|
||||
· {{ booking.date|format_datetime(pattern: 'EEE, d. MMM y') }}
|
||||
· {{ booking.timeRange }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="text-sm text-gray-500">
|
||||
{{ booking.resource.type.label }} · {{ booking.resource.label }}
|
||||
{%- if booking.resource.group %} · {{ booking.resource.group.label }}{% endif %}
|
||||
<div class="data-table-wrapper">
|
||||
<div class="data-table-wrapper__inner text-sm">
|
||||
<table class="data-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>{{ knp_pagination_sortable(pagination, 'Mitarbeiter:in', 'u.name') }}</th>
|
||||
<th>{{ knp_pagination_sortable(pagination, 'Datum', 'b.date') }}</th>
|
||||
<th>{{ knp_pagination_sortable(pagination, 'Zeit', 'b.startMinute') }}</th>
|
||||
<th>{{ knp_pagination_sortable(pagination, 'Typ', 'r.type') }}</th>
|
||||
<th>{{ knp_pagination_sortable(pagination, 'Ressource', 'r.label') }}</th>
|
||||
<th>Gruppe</th>
|
||||
<th>Status</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for booking in pagination %}
|
||||
<tr>
|
||||
<td class="font-semibold">{{ booking.user.name }}</td>
|
||||
<td class="whitespace-nowrap">{{ booking.date|format_datetime(pattern: 'EEE, d. MMM y') }}</td>
|
||||
<td class="whitespace-nowrap">{{ booking.timeRange }}</td>
|
||||
<td>{{ booking.resource.type.label }}</td>
|
||||
<td>{{ booking.resource.label }}</td>
|
||||
<td>{{ booking.resource.group ? booking.resource.group.label : '–' }}</td>
|
||||
<td>
|
||||
{% if booking.partOfSeries %}
|
||||
<span class="badge bg-gray-100 text-gray-600 ml-1">wiederkehrend</span>
|
||||
<span class="badge bg-gray-100 text-gray-600">wiederkehrend</span>
|
||||
{% endif %}
|
||||
{% if booking.overridden %}
|
||||
<span class="badge bg-amber-100 text-amber-800 ml-1">abweichend</span>
|
||||
<span class="badge bg-amber-100 text-amber-800">abweichend</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center gap-3">
|
||||
<a class="text-sm underline shrink-0"
|
||||
</td>
|
||||
<td class="text-right">
|
||||
<div class="flex items-center justify-end gap-2">
|
||||
<a class="button button--small button--neutral"
|
||||
href="{{ path('app_board', { type: booking.resource.type.value, date: booking.date|date('Y-m-d') }) }}">Im Plan</a>
|
||||
<button type="button" class="button button--small button--neutral"
|
||||
<button type="button" class="button button--small button--warning"
|
||||
hx-get="{{ path('app_booking_release', { id: booking.id, return: 'admin' }) }}"
|
||||
hx-target="body"
|
||||
hx-swap="beforeend">
|
||||
Freigeben
|
||||
</button>
|
||||
</div>
|
||||
</li>
|
||||
</td>
|
||||
</tr>
|
||||
{% else %}
|
||||
<tr>
|
||||
<td colspan="8" class="text-gray-500">Keine Buchungen in diesem Zeitraum.</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
{% endif %}
|
||||
</tbody>
|
||||
</table>
|
||||
{{ knp_pagination_render(pagination) }}
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -125,7 +125,7 @@ class BookingAdminControllerTest extends WebTestCase
|
||||
$crawler = $this->client->request('GET', '/admin/bookings?user='.$bea->getId());
|
||||
|
||||
self::assertResponseIsSuccessful();
|
||||
$text = $crawler->filter('ul')->text();
|
||||
$text = $crawler->filter('tbody')->text();
|
||||
self::assertStringContainsString('Bea Berger', $text);
|
||||
self::assertStringNotContainsString('Carl Conrad', $text);
|
||||
}
|
||||
@@ -139,7 +139,7 @@ class BookingAdminControllerTest extends WebTestCase
|
||||
$crawler = $this->client->request('GET', '/admin/bookings?type=parking');
|
||||
|
||||
self::assertResponseIsSuccessful();
|
||||
$text = $crawler->filter('ul')->text();
|
||||
$text = $crawler->filter('tbody')->text();
|
||||
self::assertStringContainsString('Carl Conrad', $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
|
||||
{
|
||||
$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'];
|
||||
}
|
||||
|
||||
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->flush();
|
||||
|
||||
|
||||
Reference in new issue
Block a user