From a9bd46be4a1b7b1be0b9a92fa15530456a827e7e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bj=C3=B6rn=20Fromme?= Date: Mon, 29 Jun 2026 17:34:36 +0200 Subject: [PATCH] wip: integrate with bpn-connect for admin crud, calendars --- assets/controllers/tooltip_controller.js | 3 +- .../Admin/AccommodationController.php | 18 +- src/Entity/Groups/AccommodationPrice.php | 21 -- src/Form/Extension/ModalSubmitExtension.php | 39 +++ src/Twig/Components/Calendar.php | 289 ++++++++++++++++++ .../accommodation/_calendar_section.html.twig | 1 + templates/admin/accommodation/_form.html.twig | 16 +- templates/admin/accommodation/index.html.twig | 10 +- .../accommodation/modal_create.html.twig | 23 ++ templates/components/calendar/grid.html.twig | 56 ++++ templates/components/calendar/index.html.twig | 17 ++ templates/htmx_modal_admin.html.twig | 23 ++ 12 files changed, 481 insertions(+), 35 deletions(-) create mode 100644 src/Form/Extension/ModalSubmitExtension.php create mode 100644 src/Twig/Components/Calendar.php create mode 100644 templates/admin/accommodation/_calendar_section.html.twig create mode 100644 templates/admin/accommodation/modal_create.html.twig create mode 100644 templates/components/calendar/grid.html.twig create mode 100644 templates/components/calendar/index.html.twig create mode 100644 templates/htmx_modal_admin.html.twig diff --git a/assets/controllers/tooltip_controller.js b/assets/controllers/tooltip_controller.js index 96580419..d834da8b 100644 --- a/assets/controllers/tooltip_controller.js +++ b/assets/controllers/tooltip_controller.js @@ -1,5 +1,5 @@ import { Controller } from '@hotwired/stimulus' -import tippy from 'tippy.js' +import tippy, { hideAll } from 'tippy.js' export default class extends Controller { static targets = ['trigger', 'template'] @@ -27,6 +27,7 @@ export default class extends Controller { allowHTML: true, interactive: true, appendTo: document.body, + onShow(instance) { hideAll({ exclude: instance }) }, } } if (this.hasTriggerTarget) { diff --git a/src/Controller/Admin/AccommodationController.php b/src/Controller/Admin/AccommodationController.php index 8d7ede0b..85d75dac 100644 --- a/src/Controller/Admin/AccommodationController.php +++ b/src/Controller/Admin/AccommodationController.php @@ -29,7 +29,7 @@ class AccommodationController extends AbstractController #[Route('/admin/accommodation', name: 'app_admin_accommodation')] public function index(): Response { - $accommodations = $this->accommodationRepository->findAll(); + $accommodations = $this->accommodationRepository->findBy([], ['code' => 'ASC']); return $this->render('admin/accommodation/index.html.twig', [ 'accommodations' => $accommodations, @@ -41,7 +41,7 @@ class AccommodationController extends AbstractController { $accommodation = new Accommodation(); - $form = $this->createForm(AccommodationType::class, $accommodation); + $form = $this->createForm(AccommodationType::class, $accommodation, ['hx_post' => $request->getRequestUri()]); $form->handleRequest($request); if ($form->isSubmitted() && $form->isValid()) { @@ -55,10 +55,11 @@ class AccommodationController extends AbstractController 'code' => $accommodation->getCode(), ]); - return $this->redirectToRoute('app_admin_accommodation'); + return new HxRedirectResponse($this->generateUrl('app_admin_accommodation')); } - return $this->render('admin/accommodation/create.html.twig', [ + return $this->render('admin/accommodation/modal_create.html.twig', [ + 'accommodation' => $accommodation, 'form' => $form, ]); } @@ -97,6 +98,15 @@ class AccommodationController extends AbstractController ]); } + #[Route('/admin/accommodation/{id}/calendar', name: 'app_admin_accommodation_calendar', methods: ['GET'])] + public function calendar(Accommodation $accommodation, Request $request): Response + { + return $this->render('admin/accommodation/_calendar_section.html.twig', [ + 'accommodation' => $accommodation, + 'startMonth' => $request->query->get('month'), + ]); + } + #[Route('/admin/accommodation/{id}/delete', name: 'app_admin_accommodation_delete')] public function delete(Accommodation $accommodation, Request $request): Response { diff --git a/src/Entity/Groups/AccommodationPrice.php b/src/Entity/Groups/AccommodationPrice.php index 5067167b..aaf8a23a 100644 --- a/src/Entity/Groups/AccommodationPrice.php +++ b/src/Entity/Groups/AccommodationPrice.php @@ -200,25 +200,4 @@ class AccommodationPrice implements BlameableEntityInterface, TimestampableEntit return $this; } - - #[Assert\Callback] - public function assertDatesValid(ExecutionContextInterface $context): void - { - // we only check new entities - if (null !== $this->getId()) { - return; - } - - // come on, we need something to work with - if (null === $this->getDateFrom() || null === $this->getDateTo()) { - return; - } - - if ($this->getDateFrom() < new \DateTimeImmutable()) { - $context - ->buildViolation('Das Von-Datum darf nicht in der Vergangenheit liegen') - ->atPath('dateFrom') - ->addViolation(); - } - } } diff --git a/src/Form/Extension/ModalSubmitExtension.php b/src/Form/Extension/ModalSubmitExtension.php new file mode 100644 index 00000000..fe61f932 --- /dev/null +++ b/src/Form/Extension/ModalSubmitExtension.php @@ -0,0 +1,39 @@ +setDefaults([ + 'hx_post' => null, + 'hx_target' => '#htmx-modal', + 'hx_swap' => 'outerHTML', + ]); + } + + public function buildView(FormView $view, FormInterface $form, array $options): void + { + if (null !== $options['hx_post']) { + $attr = [ + 'hx-post' => $options['hx_post'], + 'hx-target' => $options['hx_target'], + 'hx-swap' => $options['hx_swap'], + 'hx-indicator' => '#htmx-modal-indicator', + ]; + $view->vars['attr'] = array_merge($view->vars['attr'], $attr); + } + } + + public static function getExtendedTypes(): iterable + { + return [FormType::class]; + } +} diff --git a/src/Twig/Components/Calendar.php b/src/Twig/Components/Calendar.php new file mode 100644 index 00000000..f118a192 --- /dev/null +++ b/src/Twig/Components/Calendar.php @@ -0,0 +1,289 @@ + 'Januar', 2 => 'Februar', 3 => 'März', 4 => 'April', + 5 => 'Mai', 6 => 'Juni', 7 => 'Juli', 8 => 'August', + 9 => 'September', 10 => 'Oktober', 11 => 'November', 12 => 'Dezember', + ]; + + private const TYPE_LABELS = [ + 'flat' => 'pauschal', + 'per_person' => 'p.P.', + 'per_night' => 'p.Nacht', + 'per_person_per_night' => 'p.P./Nacht', + ]; + + public Accommodation $accommodation; + public ?string $startMonth = null; + + public array $months = []; + public string $prevMonth = ''; + public string $nextMonth = ''; + public string $prevLabel = ''; + public string $nextLabel = ''; + + public function __construct( + private readonly ContingentsClient $contingentsClient, + private readonly CacheInterface $cache, + ) { + } + + public function mount(Accommodation $accommodation, ?string $startMonth = null): void + { + $this->accommodation = $accommodation; + $this->startMonth = $startMonth; + + if ($startMonth !== null && preg_match('/^\d{4}-\d{2}$/', $startMonth)) { + $start = CarbonImmutable::createFromFormat('Y-m-d', $startMonth . '-01'); + } else { + $allDates = $this->collectAllDates(); + $start = empty($allDates) + ? CarbonImmutable::now()->startOfMonth() + : $this->determineStartMonth($allDates); + } + + $map = $this->buildCoverageMap(); + $blockedDates = $this->fetchBlockedDates( + $accommodation->getCode() ?? '', + $start->format('Y-m-d'), + $start->addMonths(3)->subDay()->format('Y-m-d'), + ); + + $months = []; + $current = $start; + for ($i = 0; $i < 3; $i++) { + $months[] = $this->buildMonth($current, $map, $blockedDates); + $current = $current->addMonth(); + } + + $this->months = $months; + + $prevStart = $start->subMonths(3); + $prevEnd = $prevStart->addMonths(2); + $this->prevMonth = $prevStart->format('Y-m'); + $this->prevLabel = self::MONTHS[$prevStart->month] . ' – ' . self::MONTHS[$prevEnd->month] . ' ' . $prevEnd->year; + + $nextStart = $start->addMonths(3); + $nextEnd = $nextStart->addMonths(2); + $this->nextMonth = $nextStart->format('Y-m'); + $this->nextLabel = self::MONTHS[$nextStart->month] . ' – ' . self::MONTHS[$nextEnd->month] . ' ' . $nextEnd->year; + } + + /** + * @return array keyed by Y-m-d, only BLOCKED dates present + */ + private function fetchBlockedDates(string $hotelCode, string $dateFrom, string $dateTo): array + { + if ($hotelCode === '') { + return []; + } + + try { + $cacheKey = sprintf('contingents_calendar_%s_%s_%s', $hotelCode, $dateFrom, $dateTo); + $response = $this->cache->get( + $cacheKey, + function (ItemInterface $item) use ($hotelCode, $dateFrom, $dateTo) { + $item->expiresAfter(3600); + + return $this->contingentsClient->getContingentCalendar($hotelCode, $dateFrom, $dateTo); + }, + ); + } catch (BpnConnectException|InvalidArgumentException) { + return []; + } + + $blocked = []; + foreach ($response->data as $entry) { + if ($entry->status === ContingentStatus::Blocked) { + $blocked[(new \DateTimeImmutable($entry->date))->format('Y-m-d')] = true; + } + } + + return $blocked; + } + + private function collectAllDates(): array + { + $dates = []; + + foreach ($this->accommodation->getAccommodationPrices() as $price) { + if ($price->getDateFrom() !== null) { + $dates[] = CarbonImmutable::instance($price->getDateFrom()); + } + if ($price->getDateTo() !== null) { + $dates[] = CarbonImmutable::instance($price->getDateTo()); + } + } + foreach ($this->accommodation->getBoardServices() as $service) { + if ($service->getDateFrom() !== null) { + $dates[] = CarbonImmutable::instance($service->getDateFrom()); + } + if ($service->getDateTo() !== null) { + $dates[] = CarbonImmutable::instance($service->getDateTo()); + } + } + foreach ($this->accommodation->getAdditionalServices() as $service) { + if ($service->getDateFrom() !== null) { + $dates[] = CarbonImmutable::instance($service->getDateFrom()); + } + if ($service->getDateTo() !== null) { + $dates[] = CarbonImmutable::instance($service->getDateTo()); + } + } + + return $dates; + } + + private function determineStartMonth(array $dates): CarbonImmutable + { + $earliest = min($dates); + $cutoff = CarbonImmutable::now()->subDays(60); + + if ($earliest >= $cutoff) { + return $earliest->startOfMonth(); + } + + return CarbonImmutable::now()->startOfMonth(); + } + + private function buildCoverageMap(): array + { + $map = []; + + foreach ($this->accommodation->getAccommodationPrices() as $price) { + if ($price->getDateFrom() === null || $price->getDateTo() === null) { + continue; + } + + $priceFormatted = number_format($price->getEffectivePricePerNight() / 100, 2, ',', '.') . ' €'; + $label = sprintf('Preis: %s/Nacht (min. %d Nächte)', $priceFormatted, $price->getMinNights()); + + $day = CarbonImmutable::instance($price->getDateFrom()); + $end = CarbonImmutable::instance($price->getDateTo()); + $span = $day->diffInDays($end); + $limit = $day->addYears(3); + + while ($day <= $end && $day <= $limit) { + $key = $day->format('Y-m-d'); + $map[$key] ??= ['price' => null, 'board' => null, 'additional' => null]; + if ($map[$key]['price'] === null || $span < $map[$key]['price']['span']) { + $map[$key]['price'] = ['label' => $label, 'span' => $span]; + } + $day = $day->addDay(); + } + } + + foreach ($this->accommodation->getBoardServices() as $service) { + if ($service->getDateFrom() === null || $service->getDateTo() === null) { + continue; + } + + $label = 'Verpflegung: ' . $service->getLabel(); + + $day = CarbonImmutable::instance($service->getDateFrom()); + $end = CarbonImmutable::instance($service->getDateTo()); + $span = $day->diffInDays($end); + $limit = $day->addYears(3); + + while ($day <= $end && $day <= $limit) { + $key = $day->format('Y-m-d'); + $map[$key] ??= ['price' => null, 'board' => null, 'additional' => null]; + if ($map[$key]['board'] === null || $span < $map[$key]['board']['span']) { + $map[$key]['board'] = ['label' => $label, 'span' => $span]; + } + $day = $day->addDay(); + } + } + + foreach ($this->accommodation->getAdditionalServices() as $service) { + if ($service->getDateFrom() === null || $service->getDateTo() === null) { + continue; + } + + $typeLabel = $service->getType() !== null + ? (self::TYPE_LABELS[$service->getType()->value] ?? $service->getType()->value) + : ''; + $label = sprintf('Zusatz: %s%s', $service->getLabel(), $typeLabel !== '' ? ' (' . $typeLabel . ')' : ''); + + $day = CarbonImmutable::instance($service->getDateFrom()); + $end = CarbonImmutable::instance($service->getDateTo()); + $span = $day->diffInDays($end); + $limit = $day->addYears(3); + + while ($day <= $end && $day <= $limit) { + $key = $day->format('Y-m-d'); + $map[$key] ??= ['price' => null, 'board' => null, 'additional' => null]; + if ($map[$key]['additional'] === null || $span < $map[$key]['additional']['span']) { + $map[$key]['additional'] = ['label' => $label, 'span' => $span]; + } + $day = $day->addDay(); + } + } + + return $map; + } + + private function buildMonth(CarbonImmutable $month, array $map, array $blockedDates): array + { + $start = $month->startOfMonth(); + $today = CarbonImmutable::now()->format('Y-m-d'); + + // Mon=0 … Sun=6 (Carbon: 0=Sunday, 1=Monday … 6=Saturday) + $firstDow = ($start->dayOfWeek + 6) % 7; + + $days = []; + for ($d = 1; $d <= $start->daysInMonth; $d++) { + $dateStr = $month->format('Y-m') . '-' . str_pad((string) $d, 2, '0', STR_PAD_LEFT); + $coverage = $map[$dateStr] ?? []; + + $parts = []; + if (isset($coverage['price'])) { + $parts[] = $coverage['price']['label']; + } + if (isset($coverage['board'])) { + $parts[] = $coverage['board']['label']; + } + if (isset($coverage['additional'])) { + $parts[] = $coverage['additional']['label']; + } + + $isBlocked = isset($blockedDates[$dateStr]); + if ($isBlocked) { + $parts[] = 'Buchungsstopp'; + } + + $days[$d] = [ + 'date' => $dateStr, + 'hasPrice' => isset($coverage['price']), + 'hasBoard' => isset($coverage['board']), + 'hasAdditional' => isset($coverage['additional']), + 'isBlocked' => $isBlocked, + 'tooltip' => !empty($parts) ? implode(' | ', $parts) : null, + 'isToday' => $dateStr === $today, + ]; + } + + return [ + 'label' => self::MONTHS[$month->month] . ' ' . $month->year, + 'firstDow' => $firstDow, + 'days' => $days, + ]; + } +} diff --git a/templates/admin/accommodation/_calendar_section.html.twig b/templates/admin/accommodation/_calendar_section.html.twig new file mode 100644 index 00000000..e28bc0f5 --- /dev/null +++ b/templates/admin/accommodation/_calendar_section.html.twig @@ -0,0 +1 @@ + diff --git a/templates/admin/accommodation/_form.html.twig b/templates/admin/accommodation/_form.html.twig index 6a02bb5e..db7a8f24 100644 --- a/templates/admin/accommodation/_form.html.twig +++ b/templates/admin/accommodation/_form.html.twig @@ -25,6 +25,10 @@
+ + +
+

Preise

@@ -41,7 +45,7 @@ Preis pro Nacht - Preise weitere Person + Preis weitere Person Mindestbelegung (Nächte) @@ -95,7 +99,7 @@ {% else %} - + Keine Daten @@ -165,7 +169,7 @@ {% else %} - + Keine Daten @@ -241,7 +245,7 @@ {% else %} - + Keine Daten @@ -256,9 +260,9 @@ -
+{% if cmsData is defined and cmsData.success is true %} +
-{% if cmsData is defined %}

CMS-Daten

diff --git a/templates/admin/accommodation/index.html.twig b/templates/admin/accommodation/index.html.twig index fd277286..91369d3a 100644 --- a/templates/admin/accommodation/index.html.twig +++ b/templates/admin/accommodation/index.html.twig @@ -54,7 +54,7 @@ {% else %} - + Keine Daten... @@ -64,8 +64,12 @@ {% endblock %} diff --git a/templates/admin/accommodation/modal_create.html.twig b/templates/admin/accommodation/modal_create.html.twig new file mode 100644 index 00000000..bf038563 --- /dev/null +++ b/templates/admin/accommodation/modal_create.html.twig @@ -0,0 +1,23 @@ +{% extends 'htmx_modal_admin.html.twig' %} +{% form_theme form 'forms_admin.html.twig' %} + +{% block content %} +
+ Neues Gruppenhaus +
+ {{ form_start(form) }} +
+ {{ form_row(form.name) }} + {{ form_row(form.code) }} + {{ form_row(form.minPaxSummer) }} + {{ form_row(form.maxAdolescentAge) }} + {{ form_row(form.currency) }} +
+
+ +
+ {{ form_rest(form) }} + {{ form_end(form) }} +{% endblock %} diff --git a/templates/components/calendar/grid.html.twig b/templates/components/calendar/grid.html.twig new file mode 100644 index 00000000..025cd875 --- /dev/null +++ b/templates/components/calendar/grid.html.twig @@ -0,0 +1,56 @@ +
+
+ Preiskonfigurationen +
+
+ Verpflegungsleistungen +
+
+ optionale Zusatzleistungen +
+
+ Buchungsstopp +
+
+ +
+ {% for month in months %} +
+
{{ month.label }}
+ +
+ {% for header in ['Mo', 'Di', 'Mi', 'Do', 'Fr', 'Sa', 'So'] %} +
{{ header }}
+ {% endfor %} + + {% if month.firstDow > 0 %} + {% for i in 1..month.firstDow %} +
+ {% endfor %} + {% endif %} + + {% for dayNum, day in month.days %} +
+ {{ dayNum }} +
+ {% if day.hasPrice %} + + {% endif %} + {% if day.hasBoard %} + + {% endif %} + {% if day.hasAdditional %} + + {% endif %} + {% if day.isBlocked %} + + {% endif %} +
+
+ {% endfor %} +
+
+ {% endfor %} +
diff --git a/templates/components/calendar/index.html.twig b/templates/components/calendar/index.html.twig new file mode 100644 index 00000000..ab195eef --- /dev/null +++ b/templates/components/calendar/index.html.twig @@ -0,0 +1,17 @@ +
+ +
+ + +
+
diff --git a/templates/htmx_modal_admin.html.twig b/templates/htmx_modal_admin.html.twig new file mode 100644 index 00000000..7cacb9ec --- /dev/null +++ b/templates/htmx_modal_admin.html.twig @@ -0,0 +1,23 @@ +
+
+
+
+
+
+ {% block title %}{% endblock %} +
+ +
+
+
+ {% block content %}{% endblock %} +
+
+
+
+