diff --git a/assets/controllers/sortable_controller.js b/assets/controllers/sortable_controller.js new file mode 100644 index 0000000..6537c27 --- /dev/null +++ b/assets/controllers/sortable_controller.js @@ -0,0 +1,90 @@ +import { Controller } from '@hotwired/stimulus' +import Sortable from 'sortablejs' + +export default class extends Controller { + + static targets = [ 'list', 'item', 'handle', 'positionField', 'positionLabel', 'status' ] + + static values = { + url: String, + } + + connect() { + this.abortController = null + + // The sortable container may be nested (e.g. a tbody) when the controller element also + // has to wrap elements that must not be dragged, like the status message. + new Sortable(this.hasListTarget ? this.listTarget : this.element, { + draggable: '[data-sortable-target="item"]', + handle: '[data-sortable-target="handle"]', + sort: true, + fallbackOnBody: true, + onEnd: e => { + this.updateOrdering() + this.persist() + } + }) + } + + disconnect() { + this.abortController?.abort() + } + + updateOrdering() { + this.positionFieldTargets.forEach((field, index) => { + field.value = index + 1 + }) + this.positionLabelTargets.forEach((label, index) => { + label.innerText = index + 1 + }) + } + + /** + * Sends the new order to the server. Only active when a url value is present, so the + * controller stays usable for plain form ordering. + */ + persist() { + if (!this.hasUrlValue || this.urlValue === '') { + return + } + + // A drag while the previous request is still running makes that request obsolete. + this.abortController?.abort() + this.abortController = new AbortController() + + const ids = this.itemTargets + .map(item => Number(item.dataset.sortableId)) + .filter(id => Number.isInteger(id) && id > 0) + + this.setStatus('speichert …') + + fetch(this.urlValue, { + method: 'POST', + credentials: 'include', + signal: this.abortController.signal, + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ ids }), + }) + .then(response => { + if (!response.ok) { + throw new Error(`Unexpected response ${response.status}`) + } + this.setStatus('Reihenfolge gespeichert') + }) + .catch(error => { + if (error.name === 'AbortError') { + return + } + console.error('Failed to persist ordering', error) + this.setStatus('Speichern fehlgeschlagen') + }) + } + + setStatus(message) { + this.statusTargets.forEach(status => { + status.innerText = message + }) + } +} diff --git a/migrations/Version20260804140000.php b/migrations/Version20260804140000.php new file mode 100644 index 0000000..b46c8a2 --- /dev/null +++ b/migrations/Version20260804140000.php @@ -0,0 +1,42 @@ +addSql('ALTER TABLE additional_service ADD position INT DEFAULT 0 NOT NULL'); + $this->addSql('ALTER TABLE additional_service ALTER COLUMN position DROP DEFAULT'); + + // Seed the positions with the ordering the admin overview used before, so nothing changes + // visually until someone actually drags a row. + $this->addSql(' + UPDATE additional_service s + JOIN ( + SELECT id, ROW_NUMBER() OVER ( + PARTITION BY accommodation_id + ORDER BY date_from ASC, (price <> 0) ASC, label ASC + ) AS rn + FROM additional_service + ) o ON o.id = s.id + SET s.position = o.rn + '); + } + + public function down(Schema $schema): void + { + $this->addSql('ALTER TABLE additional_service DROP position'); + } +} diff --git a/src/Controller/Admin/Accommodation/EditController.php b/src/Controller/Admin/Accommodation/EditController.php index dbd68dd..ab0a6b6 100644 --- a/src/Controller/Admin/Accommodation/EditController.php +++ b/src/Controller/Admin/Accommodation/EditController.php @@ -12,6 +12,7 @@ use App\Service\CmsDataProvider; use Doctrine\ORM\EntityManagerInterface; use Psr\Log\LoggerInterface; use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; +use Symfony\Component\HttpFoundation\JsonResponse; use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Response; use Symfony\Component\Routing\Attribute\Route; @@ -57,8 +58,8 @@ class EditController extends AbstractController $boardServices = $accommodation->getBoardServices()->toArray(); usort($boardServices, $this->compareServices(...)); + // Additional services are ordered manually, see #[ORM\OrderBy] on the association. $additionalServices = $accommodation->getAdditionalServices()->toArray(); - usort($additionalServices, $this->compareServices(...)); return $this->render('admin/accommodation/edit.html.twig', [ 'accommodation' => $accommodation, @@ -69,10 +70,59 @@ class EditController extends AbstractController ]); } + /** + * Receives the additional service ids in their new order and renumbers the positions. + * + * Ids are only accepted when they belong to the given accommodation; services missing from the + * payload (e.g. created in another tab in the meantime) are appended in their previous order. + */ + #[Route('/admin/accommodation/{id}/additional-services/order', name: 'app_admin_accommodation_additionalservices_order', methods: ['POST'])] + public function orderAdditionalServices(Accommodation $accommodation, Request $request): Response + { + try { + $payload = json_decode($request->getContent(), true, 512, \JSON_THROW_ON_ERROR); + } catch (\JsonException) { + return new JsonResponse(['error' => 'Malformed payload'], Response::HTTP_BAD_REQUEST); + } + + if (!\is_array($payload) || !\is_array($payload['ids'] ?? null)) { + return new JsonResponse(['error' => 'Malformed payload'], Response::HTTP_BAD_REQUEST); + } + + /** @var array $remaining */ + $remaining = []; + + foreach ($accommodation->getAdditionalServices() as $additionalService) { + $remaining[$additionalService->getId()] = $additionalService; + } + + $position = 0; + + foreach ($payload['ids'] as $id) { + $id = \is_numeric($id) ? (int) $id : null; + + if (null === $id || !isset($remaining[$id])) { + continue; + } + + $remaining[$id]->setPosition(++$position); + unset($remaining[$id]); + } + + // Whatever was not part of the payload keeps its relative order behind the sorted ones. + foreach ($remaining as $additionalService) { + $additionalService->setPosition(++$position); + } + + $this->entityManager->flush(); + + return new JsonResponse(['status' => 'ok']); + } + /** * DateFrom ascending, then free (price 0) before paid, then alphabetically by label. */ - private function compareServices(BoardService|AdditionalService $a, BoardService|AdditionalService $b): int + private function compareServices(BoardService $a, BoardService $b): int { $aFree = 0 === $a->getPrice() ? 0 : 1; $bFree = 0 === $b->getPrice() ? 0 : 1; diff --git a/src/Entity/Groups/Accommodation.php b/src/Entity/Groups/Accommodation.php index 09b22b6..f94b7cd 100644 --- a/src/Entity/Groups/Accommodation.php +++ b/src/Entity/Groups/Accommodation.php @@ -52,7 +52,7 @@ class Accommodation implements BlameableEntityInterface, TimestampableEntityInte * @var Collection */ #[ORM\OneToMany(targetEntity: AdditionalService::class, mappedBy: 'accommodation', cascade: ['all'])] - #[ORM\OrderBy(['dateFrom' => 'ASC', 'label' => 'ASC'])] + #[ORM\OrderBy(['position' => 'ASC', 'dateFrom' => 'ASC', 'id' => 'ASC'])] private Collection $additionalServices; #[ORM\Column] diff --git a/src/Entity/Groups/AdditionalService.php b/src/Entity/Groups/AdditionalService.php index 74a6ee3..7cdba23 100644 --- a/src/Entity/Groups/AdditionalService.php +++ b/src/Entity/Groups/AdditionalService.php @@ -59,6 +59,12 @@ class AdditionalService implements BlameableEntityInterface, TimestampableEntity #[ORM\Column] private bool $isExclusive = false; + /** + * Manual presentation order within the accommodation, maintained by drag & drop in the admin. + */ + #[ORM\Column] + private int $position = 0; + public function __clone() { $this->id = null; @@ -188,4 +194,16 @@ class AdditionalService implements BlameableEntityInterface, TimestampableEntity return $this; } + + public function getPosition(): int + { + return $this->position; + } + + public function setPosition(int $position): self + { + $this->position = $position; + + return $this; + } } diff --git a/src/Repository/Groups/AdditionalServiceRepository.php b/src/Repository/Groups/AdditionalServiceRepository.php index 46e91d6..25421a4 100644 --- a/src/Repository/Groups/AdditionalServiceRepository.php +++ b/src/Repository/Groups/AdditionalServiceRepository.php @@ -6,6 +6,7 @@ namespace App\Repository\Groups; use App\Entity\Groups\AdditionalService; use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository; +use Doctrine\ORM\QueryBuilder; use Doctrine\Persistence\ManagerRegistry; /** @@ -20,4 +21,16 @@ class AdditionalServiceRepository extends ServiceEntityRepository { parent::__construct($registry, AdditionalService::class); } + + /** + * Additional services are ordered manually in the admin; dateFrom and id only break ties + * between records that share a position (newly created or duplicated ones). + */ + protected function applyOrdering(QueryBuilder $queryBuilder): void + { + $queryBuilder + ->orderBy('s.position', 'ASC') + ->addOrderBy('s.dateFrom', 'ASC') + ->addOrderBy('s.id', 'ASC'); + } } diff --git a/src/Repository/Groups/FindsByAccommodationAndDateRangeTrait.php b/src/Repository/Groups/FindsByAccommodationAndDateRangeTrait.php index a69e452..708d9d1 100644 --- a/src/Repository/Groups/FindsByAccommodationAndDateRangeTrait.php +++ b/src/Repository/Groups/FindsByAccommodationAndDateRangeTrait.php @@ -6,6 +6,7 @@ namespace App\Repository\Groups; use App\Entity\Groups\Accommodation; use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository; +use Doctrine\ORM\QueryBuilder; /** * @template T of object @@ -26,16 +27,26 @@ trait FindsByAccommodationAndDateRangeTrait \DateTimeImmutable $dateFrom, \DateTimeImmutable $dateTo, ): array { - return $this->createQueryBuilder('s') + $queryBuilder = $this->createQueryBuilder('s') ->where('s.accommodation = :accommodation') ->andWhere('s.dateFrom <= :dateTo') ->andWhere('s.dateTo >= :dateFrom') ->setParameter('accommodation', $accommodation) ->setParameter('dateFrom', $dateFrom) - ->setParameter('dateTo', $dateTo) + ->setParameter('dateTo', $dateTo); + + $this->applyOrdering($queryBuilder); + + return $queryBuilder->getQuery()->getResult(); + } + + /** + * Ordering applied to the query above; override to sort by something else. + */ + protected function applyOrdering(QueryBuilder $queryBuilder): void + { + $queryBuilder ->orderBy('s.dateFrom', 'ASC') - ->addOrderBy('s.label', 'ASC') - ->getQuery() - ->getResult(); + ->addOrderBy('s.label', 'ASC'); } } diff --git a/src/Service/AccommodationBookingService.php b/src/Service/AccommodationBookingService.php index 5c3dc4c..b8eac2b 100644 --- a/src/Service/AccommodationBookingService.php +++ b/src/Service/AccommodationBookingService.php @@ -156,11 +156,9 @@ class AccommodationBookingService } } + // Additional services keep the repository order (manual position), which also makes the + // groups appear in the order of their lowest-positioned member. $boardServices = $this->sortServicesByPriceThenLabel($boardServices); - $ungrouped = $this->sortServicesByPriceThenLabel($ungrouped); - foreach ($grouped as $groupName => $groupServices) { - $grouped[$groupName] = $this->sortServicesByPriceThenLabel($groupServices); - } return [ 'boardServices' => $boardServices, @@ -171,17 +169,15 @@ class AccommodationBookingService } /** - * Free services (price 0) first, then alphabetically; each group sorted alphabetically. + * Free services (price 0) first, then alphabetically. * - * @template T of BoardService|AdditionalService + * @param BoardService[] $services * - * @param T[] $services - * - * @return T[] + * @return BoardService[] */ private function sortServicesByPriceThenLabel(array $services): array { - usort($services, static function (BoardService|AdditionalService $a, BoardService|AdditionalService $b): int { + usort($services, static function (BoardService $a, BoardService $b): int { $aFree = 0 === $a->getPrice() ? 0 : 1; $bFree = 0 === $b->getPrice() ? 0 : 1; diff --git a/templates/admin/accommodation/_form.html.twig b/templates/admin/accommodation/_form.html.twig index 14a30a5..e41f957 100644 --- a/templates/admin/accommodation/_form.html.twig +++ b/templates/admin/accommodation/_form.html.twig @@ -233,15 +233,21 @@
-
+

Optionale Zusatzleistungen

+

+ Die Reihenfolge wird per Drag & Drop festgelegt und gilt auch für das Buchungsformular. +

+ @@ -257,9 +263,12 @@ - + {% for service in additionalServices %} - + + @@ -288,7 +297,7 @@ {% else %} - @@ -297,7 +306,8 @@
Datums-Bereich
+ {{ icon('draggable', 'w-4 h-4') }} + {{ service.dateFrom | date('d.m.Y') }} - {{ service.dateTo | date('d.m.Y') }}
+ Keine Daten
-
+