From 1ec4b92caf166bbf7739fb9304e39bdff080099e Mon Sep 17 00:00:00 2001 From: Alexander Elchlepp Date: Wed, 21 Jan 2026 12:32:23 +0100 Subject: [PATCH 01/24] #74 removed conflitced reservations from export --- src/Service/CalendarService.php | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/Service/CalendarService.php b/src/Service/CalendarService.php index 13ca3793..ce37667a 100644 --- a/src/Service/CalendarService.php +++ b/src/Service/CalendarService.php @@ -128,6 +128,10 @@ public function getIcalContent(CalendarSync $sync): string /* @var $reservation Reservation */ foreach ($room->getReservations() as $reservation) { + // Exclude conflict entries from the export feed. + if ($reservation->isConflict() || $reservation->isConflictIgnored()) { + continue; + } // filter reservation status if ($sync->getReservationStatus()->contains($reservation->getReservationStatus())) { $content .= $this->getIcalEventBody($reservation, $sync); From 0dcdddb38f8daa325deeb20af6833d406eb240c1 Mon Sep 17 00:00:00 2001 From: Alexander Elchlepp Date: Thu, 22 Jan 2026 12:12:12 +0100 Subject: [PATCH 02/24] added new module for housekeeping --- assets/bootstrap.js | 2 + assets/controllers/housekeeping_controller.js | 51 +++ assets/styles/app.css | 4 + config/packages/security.yaml | 2 + config/packages/translation.yaml | 1 + migrations/Version20260121120000.php | 36 ++ .../DashboardRedirectController.php | 1 + src/Controller/HousekeepingController.php | 220 ++++++++++ src/Entity/Enum/HousekeepingStatus.php | 14 + src/Entity/RoomDayStatus.php | 186 ++++++++ src/Form/HousekeepingRowType.php | 59 +++ src/Repository/ReservationRepository.php | 10 +- src/Repository/RoomDayStatusRepository.php | 60 +++ src/Service/HousekeepingExportService.php | 211 +++++++++ src/Service/HousekeepingViewService.php | 404 ++++++++++++++++++ .../Housekeeping/_day_view.html.twig | 127 ++++++ .../Housekeeping/_week_view.html.twig | 47 ++ .../Operations/Housekeeping/index.html.twig | 93 ++++ templates/base.html.twig | 6 +- tests/Functional/RoleAccessTest.php | 2 + translations/Base/messages.de.xlf | 12 + translations/Base/messages.en.yaml | 3 + translations/Housekeeping/Housekeeping.de.xlf | 135 ++++++ .../Housekeeping/Housekeeping.en.yaml | 32 ++ 24 files changed, 1715 insertions(+), 3 deletions(-) create mode 100644 assets/controllers/housekeeping_controller.js create mode 100644 migrations/Version20260121120000.php create mode 100644 src/Controller/HousekeepingController.php create mode 100644 src/Entity/Enum/HousekeepingStatus.php create mode 100644 src/Entity/RoomDayStatus.php create mode 100644 src/Form/HousekeepingRowType.php create mode 100644 src/Repository/RoomDayStatusRepository.php create mode 100644 src/Service/HousekeepingExportService.php create mode 100644 src/Service/HousekeepingViewService.php create mode 100644 templates/Operations/Housekeeping/_day_view.html.twig create mode 100644 templates/Operations/Housekeeping/_week_view.html.twig create mode 100644 templates/Operations/Housekeeping/index.html.twig create mode 100644 translations/Housekeeping/Housekeeping.de.xlf create mode 100644 translations/Housekeeping/Housekeeping.en.yaml diff --git a/assets/bootstrap.js b/assets/bootstrap.js index f90ffc34..5c0b416f 100644 --- a/assets/bootstrap.js +++ b/assets/bootstrap.js @@ -14,6 +14,7 @@ import TemplatesController from './controllers/templates_controller.js'; import PricesController from './controllers/prices_controller.js'; import ThemeController from './controllers/theme_controller.js'; import CalendarImportsController from './controllers/calendar_imports_controller.js'; +import HousekeepingController from './controllers/housekeeping_controller.js'; const app = startStimulusApp(); app.register('login', LoginController); @@ -31,3 +32,4 @@ app.register('templates', TemplatesController); app.register('prices', PricesController); app.register('theme', ThemeController); app.register('calendar-imports', CalendarImportsController); +app.register('housekeeping', HousekeepingController); diff --git a/assets/controllers/housekeeping_controller.js b/assets/controllers/housekeeping_controller.js new file mode 100644 index 00000000..ffcb1050 --- /dev/null +++ b/assets/controllers/housekeeping_controller.js @@ -0,0 +1,51 @@ +import { Controller } from '@hotwired/stimulus'; +import { request as httpRequest, serializeForm as httpSerializeForm } from './http_controller.js'; + +export default class extends Controller { + static targets = ['form', 'spinner']; + + submitFilters(event) { + this.spin(); + if (event) { + event.preventDefault(); + } + + if (this.formTarget && typeof this.formTarget.requestSubmit === 'function') { + this.formTarget.requestSubmit(); + } else if (this.formTarget) { + this.formTarget.submit(); + } + } + + spin() { + if (this.hasSpinnerTarget) { + this.spinnerTarget.classList.add('fa-spin'); + } + } + + async saveRow(event) { + event.preventDefault(); + const form = event.target; + const submitter = event.submitter || form.querySelector('button[type="submit"]'); + + if (submitter) { + submitter.disabled = true; + } + + httpRequest({ + url: form.action, + method: form.method || 'POST', + data: httpSerializeForm(form), + loader: false, + onSuccess: () => {}, + onComplete: () => { + if (submitter) { + submitter.disabled = false; + } + }, + onError: (message) => { + console.warn('[housekeeping] save failed', message); + }, + }); + } +} diff --git a/assets/styles/app.css b/assets/styles/app.css index e436e314..5882902e 100644 --- a/assets/styles/app.css +++ b/assets/styles/app.css @@ -64,6 +64,10 @@ body { font-size: 0.9rem } +.hk-row-form { + display: contents; +} + .btn { font-size: 0.9rem; } diff --git a/config/packages/security.yaml b/config/packages/security.yaml index cc292395..b3a7d2f4 100644 --- a/config/packages/security.yaml +++ b/config/packages/security.yaml @@ -50,6 +50,7 @@ security: - ROLE_REGISTRATIONBOOK - ROLE_STATISTICS - ROLE_CASHJOURNAL + - ROLE_HOUSEKEEPING # Easy way to control access for large sections of your site # Note: Only the *first* access control that matches will be used @@ -59,6 +60,7 @@ security: - { path: ^/register, roles: PUBLIC_ACCESS} - { path: ^/apartments/calendar, roles: PUBLIC_ACCESS } - { path: ^/reservation/*, roles: [ROLE_RESERVATIONS_RO, ROLE_RESERVATIONS] } + - { path: ^/operations/housekeeping, roles: ROLE_HOUSEKEEPING } - { path: ^/customers, roles: ROLE_CUSTOMERS } - { path: ^/invoices, roles: ROLE_INVOICES } - { path: ^/registrationbook, roles: ROLE_REGISTRATIONBOOK } diff --git a/config/packages/translation.yaml b/config/packages/translation.yaml index 2b8420d1..9201003d 100644 --- a/config/packages/translation.yaml +++ b/config/packages/translation.yaml @@ -20,3 +20,4 @@ framework: - '%kernel.project_dir%/translations/Users' - '%kernel.project_dir%/translations/RoomCategory' - '%kernel.project_dir%/translations/ReservationStatus' + - '%kernel.project_dir%/translations/Housekeeping' diff --git a/migrations/Version20260121120000.php b/migrations/Version20260121120000.php new file mode 100644 index 00000000..bf8ce08a --- /dev/null +++ b/migrations/Version20260121120000.php @@ -0,0 +1,36 @@ +addSql('CREATE TABLE room_day_statuses (id INT AUTO_INCREMENT NOT NULL, appartment_id INT NOT NULL, assigned_to_id INT DEFAULT NULL, updated_by_id INT DEFAULT NULL, date DATE NOT NULL, hk_status VARCHAR(20) NOT NULL, note LONGTEXT DEFAULT NULL, updated_at DATETIME NOT NULL, UNIQUE INDEX uniq_room_day (appartment_id, date), INDEX IDX_1C76B19393362AA5 (appartment_id), INDEX IDX_1C76B193F91F2105 (assigned_to_id), INDEX IDX_1C76B193896DBBDE (updated_by_id), PRIMARY KEY(id)) DEFAULT CHARACTER SET utf8mb4 COLLATE `utf8mb4_unicode_ci` ENGINE = InnoDB'); + $this->addSql('ALTER TABLE room_day_statuses ADD CONSTRAINT FK_1C76B19393362AA5 FOREIGN KEY (appartment_id) REFERENCES appartments (id)'); + $this->addSql('ALTER TABLE room_day_statuses ADD CONSTRAINT FK_1C76B193F91F2105 FOREIGN KEY (assigned_to_id) REFERENCES users (id)'); + $this->addSql('ALTER TABLE room_day_statuses ADD CONSTRAINT FK_1C76B193896DBBDE FOREIGN KEY (updated_by_id) REFERENCES users (id)'); + $this->addSql('INSERT INTO roles (id, name, role) VALUES (NULL, "Housekeeping", "ROLE_HOUSEKEEPING")'); + } + + public function down(Schema $schema): void + { + $this->addSql('DROP TABLE room_day_statuses'); + $this->addSql('DELETE FROM roles WHERE roles.role = "ROLE_HOUSEKEEPING"'); + } + + public function isTransactional(): bool + { + return false; + } +} diff --git a/src/Controller/DashboardRedirectController.php b/src/Controller/DashboardRedirectController.php index 1da54cc5..f8b04f2c 100644 --- a/src/Controller/DashboardRedirectController.php +++ b/src/Controller/DashboardRedirectController.php @@ -25,6 +25,7 @@ public function __invoke(): RedirectResponse { $roleRouteMap = [ 'ROLE_RESERVATIONS' => 'start', + 'ROLE_HOUSEKEEPING' => 'operations.housekeeping', 'ROLE_CUSTOMERS' => 'customers.overview', 'ROLE_INVOICES' => 'invoices.overview', 'ROLE_REGISTRATIONBOOK' => 'registrationbook.overview', diff --git a/src/Controller/HousekeepingController.php b/src/Controller/HousekeepingController.php new file mode 100644 index 00000000..4f30ccff --- /dev/null +++ b/src/Controller/HousekeepingController.php @@ -0,0 +1,220 @@ +getManager(); + $subsidiaries = $em->getRepository(Subsidiary::class)->findAll(); + $subsidiaryId = (string) $request->query->get('subsidiary', 'all'); + $selectedSubsidiary = $this->resolveSubsidiary($em, $subsidiaryId); + $selectedDate = $this->resolveDate($request->query->get('date')); + $view = $request->query->get('view', 'day'); + + $dayView = null; + $weekView = null; + if ('week' === $view) { + $weekStart = $this->resolveWeekStart($selectedDate); + $weekEnd = $weekStart->modify('+6 days'); + $weekView = $viewService->buildWeekView($weekStart, $weekEnd, $selectedSubsidiary); + } else { + $view = 'day'; + $dayView = $viewService->buildDayView($selectedDate, $selectedSubsidiary); + } + + $rowForms = []; + $rowFormsMobile = []; + if ('day' === $view && $dayView) { + foreach ($dayView['rows'] as $row) { + $status = $row['status'] ?? new RoomDayStatus(); + $form = $this->createForm(HousekeepingRowType::class, $status, [ + 'date' => $selectedDate->format('Y-m-d'), + ]); + $mobileForm = $this->createForm(HousekeepingRowType::class, $status, [ + 'date' => $selectedDate->format('Y-m-d'), + ]); + $rowForms[$row['apartment']->getId()] = $form->createView(); + $rowFormsMobile[$row['apartment']->getId()] = $mobileForm->createView(); + } + } + + return $this->render('Operations/Housekeeping/index.html.twig', [ + 'subsidiaries' => $subsidiaries, + 'selectedSubsidiaryId' => $subsidiaryId, + 'selectedDate' => $selectedDate, + 'view' => $view, + 'dayView' => $dayView, + 'weekView' => $weekView, + 'rowForms' => $rowForms, + 'rowFormsMobile' => $rowFormsMobile, + 'statusLabels' => $viewService->getStatusLabels(), + 'occupancyLabels' => $viewService->getOccupancyLabels(), + 'occupancyClasses' => $this->getOccupancyClasses(), + ]); + } + + /** + * Persist changes to the housekeeping status for a room and date. + */ + #[Route('/update/{id}', name: 'operations.housekeeping.update', methods: ['POST'])] + public function updateAction( + ManagerRegistry $doctrine, + Request $request, + Appartment $apartment + ): Response { + $em = $doctrine->getManager(); + $formPayload = $request->request->all('housekeeping_row'); + $dateValue = is_array($formPayload) ? (string) ($formPayload['date'] ?? '') : ''; + $date = $this->resolveDate($dateValue); + + $status = $em->getRepository(RoomDayStatus::class)->findOneBy([ + 'appartment' => $apartment, + 'date' => $date, + ]) ?? new RoomDayStatus(); + + $status->setAppartment($apartment); + $status->setDate($date); + + $form = $this->createForm(HousekeepingRowType::class, $status, [ + 'date' => $date->format('Y-m-d'), + ]); + $form->handleRequest($request); + if (!$form->isSubmitted() || !$form->isValid()) { + if ($form->has('_token') && $form->get('_token')->getErrors()->count() > 0) { + $this->addFlash('warning', 'flash.invalidtoken'); + } + + return new JsonResponse([ + 'ok' => false, + 'message' => 'flash.invalidtoken', + ], Response::HTTP_BAD_REQUEST); + } + + $status->setUpdatedAt(new \DateTimeImmutable('now', new \DateTimeZone('UTC'))); + $status->setUpdatedBy($this->getUser() instanceof User ? $this->getUser() : null); + + $em->persist($status); + $em->flush(); + + return new JsonResponse([ + 'ok' => true, + 'hkStatus' => $status->getHkStatus()->value, + ]); + } + + /** + * Stream a CSV export for the selected day or week. + */ + #[Route('/export', name: 'operations.housekeeping.export', methods: ['GET'])] + public function exportAction( + ManagerRegistry $doctrine, + Request $request, + HousekeepingViewService $viewService, + HousekeepingExportService $exportService + ): Response { + $em = $doctrine->getManager(); + $subsidiaryId = (string) $request->query->get('subsidiary', 'all'); + $subsidiary = $this->resolveSubsidiary($em, $subsidiaryId); + $selectedDate = $this->resolveDate($request->query->get('date')); + $range = (string) $request->query->get('range', 'day'); + $locale = $request->getLocale(); + + if ('week' === $range) { + $weekStart = $this->resolveWeekStart($selectedDate); + $weekEnd = $weekStart->modify('+6 days'); + $weekView = $viewService->buildWeekView($weekStart, $weekEnd, $subsidiary); + + return $exportService->buildWeekCsvResponse($weekView, $subsidiaryId, $locale); + } + + $dayView = $viewService->buildDayView($selectedDate, $subsidiary); + + return $exportService->buildDayCsvResponse($dayView, $subsidiaryId, $locale); + } + + /** + * Resolve the selected date from query input, defaulting to today (UTC). + */ + private function resolveDate(?string $dateParam): \DateTimeImmutable + { + $timezone = new \DateTimeZone('UTC'); + if ($dateParam) { + $parsed = \DateTimeImmutable::createFromFormat('Y-m-d', $dateParam, $timezone); + if ($parsed instanceof \DateTimeImmutable) { + return $parsed->setTime(0, 0, 0); + } + } + + return (new \DateTimeImmutable('today', $timezone))->setTime(0, 0, 0); + } + + /** + * Resolve the requested subsidiary entity, if any. + */ + private function resolveSubsidiary(EntityManagerInterface $em, string $subsidiaryId): ?Subsidiary + { + if ('all' === $subsidiaryId || '' === $subsidiaryId) { + return null; + } + + $subsidiary = $em->getRepository(Subsidiary::class)->find($subsidiaryId); + + return $subsidiary instanceof Subsidiary ? $subsidiary : null; + } + + /** + * Normalize a date into its Monday week start. + */ + private function resolveWeekStart(\DateTimeImmutable $date): \DateTimeImmutable + { + return $date->modify('monday this week')->setTime(0, 0, 0); + } + + + + /** + * Define CSS classes for occupancy badges. + */ + private function getOccupancyClasses(): array + { + return [ + 'FREE' => 'bg-secondary', + 'STAYOVER' => 'bg-info', + 'ARRIVAL' => 'bg-success', + 'DEPARTURE' => 'bg-warning text-dark', + 'TURNOVER' => 'bg-danger', + ]; + } +} diff --git a/src/Entity/Enum/HousekeepingStatus.php b/src/Entity/Enum/HousekeepingStatus.php new file mode 100644 index 00000000..f0196bcd --- /dev/null +++ b/src/Entity/Enum/HousekeepingStatus.php @@ -0,0 +1,14 @@ +updatedAt = new \DateTimeImmutable('now', new \DateTimeZone('UTC')); + } + + /** + * Return the primary database identifier. + */ + public function getId(): ?int + { + return $this->id; + } + + /** + * Return the apartment this status belongs to. + */ + public function getAppartment(): Appartment + { + return $this->appartment; + } + + /** + * Assign the apartment this status belongs to. + */ + public function setAppartment(Appartment $appartment): self + { + $this->appartment = $appartment; + + return $this; + } + + /** + * Return the date this status entry represents. + */ + public function getDate(): \DateTimeImmutable + { + return $this->date; + } + + /** + * Set the date this status entry represents. + */ + public function setDate(\DateTimeImmutable $date): self + { + $this->date = $date; + + return $this; + } + + /** + * Return the current housekeeping status. + */ + public function getHkStatus(): HousekeepingStatus + { + return $this->hkStatus; + } + + /** + * Set the housekeeping status for the day. + */ + public function setHkStatus(HousekeepingStatus $hkStatus): self + { + $this->hkStatus = $hkStatus; + + return $this; + } + + /** + * Return the assigned user, if any. + */ + public function getAssignedTo(): ?User + { + return $this->assignedTo; + } + + /** + * Assign a user to this housekeeping task. + */ + public function setAssignedTo(?User $assignedTo): self + { + $this->assignedTo = $assignedTo; + + return $this; + } + + /** + * Return the housekeeping note. + */ + public function getNote(): ?string + { + return $this->note; + } + + /** + * Set the housekeeping note. + */ + public function setNote(?string $note): self + { + $this->note = $note; + + return $this; + } + + /** + * Return the last update timestamp. + */ + public function getUpdatedAt(): \DateTimeImmutable + { + return $this->updatedAt; + } + + /** + * Update the timestamp for this entry. + */ + public function setUpdatedAt(\DateTimeImmutable $updatedAt): self + { + $this->updatedAt = $updatedAt; + + return $this; + } + + /** + * Return the user who last updated this entry. + */ + public function getUpdatedBy(): ?User + { + return $this->updatedBy; + } + + /** + * Set the user who last updated this entry. + */ + public function setUpdatedBy(?User $updatedBy): self + { + $this->updatedBy = $updatedBy; + + return $this; + } +} diff --git a/src/Form/HousekeepingRowType.php b/src/Form/HousekeepingRowType.php new file mode 100644 index 00000000..37afa635 --- /dev/null +++ b/src/Form/HousekeepingRowType.php @@ -0,0 +1,59 @@ +add('date', HiddenType::class, [ + 'mapped' => false, + 'data' => $options['date'] ?? null, + ]) + ->add('hkStatus', ChoiceType::class, [ + 'label' => false, + 'choices' => HousekeepingStatus::cases(), + 'choice_value' => static fn (?HousekeepingStatus $status): ?string => $status?->value, + 'choice_label' => static fn (HousekeepingStatus $status): string => 'housekeeping.status.'.strtolower($status->value), + 'choice_translation_domain' => 'Housekeeping', + ]) + ->add('assignedTo', EntityType::class, [ + 'label' => false, + 'class' => User::class, + 'required' => false, + 'placeholder' => 'housekeeping.unassigned', + 'translation_domain' => 'Housekeeping', + 'choice_label' => static fn (User $user): string => trim(sprintf('%s %s', $user->getFirstname(), $user->getLastname())), + ]) + ->add('note', TextType::class, [ + 'label' => false, + 'required' => false, + ]); + } + + public function configureOptions(OptionsResolver $resolver): void + { + $resolver->setDefaults([ + 'data_class' => RoomDayStatus::class, + 'csrf_token_id' => 'housekeeping_update', + 'date' => null, + ]); + } +} diff --git a/src/Repository/ReservationRepository.php b/src/Repository/ReservationRepository.php index 59485b73..80083415 100644 --- a/src/Repository/ReservationRepository.php +++ b/src/Repository/ReservationRepository.php @@ -7,8 +7,9 @@ use App\Entity\Appartment; use App\Entity\CalendarSyncImport; use App\Entity\Reservation; -use Doctrine\ORM\EntityRepository; +use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository; use Doctrine\ORM\NoResultException; +use Doctrine\Persistence\ManagerRegistry; /** * ReservationRepository. @@ -16,8 +17,13 @@ * This class was generated by the Doctrine ORM. Add your own custom * repository methods below. */ -class ReservationRepository extends EntityRepository +class ReservationRepository extends ServiceEntityRepository { + public function __construct(ManagerRegistry $registry) + { + parent::__construct($registry, Reservation::class); + } + public function loadReservationsForPeriod($startDate, $endDate) { $start = date('Y-m-d', strtotime($startDate)); diff --git a/src/Repository/RoomDayStatusRepository.php b/src/Repository/RoomDayStatusRepository.php new file mode 100644 index 00000000..7dc90aa2 --- /dev/null +++ b/src/Repository/RoomDayStatusRepository.php @@ -0,0 +1,60 @@ +> + */ + public function findForApartmentsAndDates(array $apartments, \DateTimeImmutable $start, \DateTimeImmutable $end): array + { + if (0 === count($apartments)) { + return []; + } + + $entries = $this->createQueryBuilder('rds') + ->addSelect('a') + ->addSelect('assignee') + ->addSelect('updatedBy') + ->leftJoin('rds.appartment', 'a') + ->leftJoin('rds.assignedTo', 'assignee') + ->leftJoin('rds.updatedBy', 'updatedBy') + ->andWhere('rds.appartment IN (:apartments)') + ->andWhere('rds.date >= :start') + ->andWhere('rds.date <= :end') + ->setParameter('apartments', $apartments) + ->setParameter('start', $start, Types::DATE_IMMUTABLE) + ->setParameter('end', $end, Types::DATE_IMMUTABLE) + ->getQuery() + ->getResult(); + + $map = []; + foreach ($entries as $entry) { + $dateKey = $entry->getDate()->format('Y-m-d'); + $map[$entry->getAppartment()->getId()][$dateKey] = $entry; + } + + return $map; + } +} diff --git a/src/Service/HousekeepingExportService.php b/src/Service/HousekeepingExportService.php new file mode 100644 index 00000000..6665a404 --- /dev/null +++ b/src/Service/HousekeepingExportService.php @@ -0,0 +1,211 @@ + + * } $dayView + */ + public function buildDayCsvResponse(array $dayView, string $subsidiaryId, string $locale): StreamedResponse + { + $filename = sprintf('housekeeping_%s.csv', $dayView['date']->format('Y-m-d')); + $headers = [ + 'Content-Type' => 'text/csv; charset=UTF-8', + 'Content-Disposition' => sprintf('attachment; filename="%s"', $filename), + ]; + + $response = new StreamedResponse(function () use ($dayView, $locale) { + $handle = fopen('php://output', 'w'); + $occupancyLabels = $this->viewService->getOccupancyLabels(); + $statusLabels = $this->viewService->getStatusLabels(); + fputcsv($handle, [ + $this->translator->trans('housekeeping.date', [], 'Housekeeping', $locale), + $this->translator->trans('housekeeping.room', [], 'Housekeeping', $locale), + $this->translator->trans('housekeeping.occupancy', [], 'Housekeeping', $locale), + $this->translator->trans('housekeeping.guests', [], 'Housekeeping', $locale), + $this->translator->trans('housekeeping.reservation', [], 'Housekeeping', $locale), + $this->translator->trans('housekeeping.status', [], 'Housekeeping', $locale), + $this->translator->trans('housekeeping.assigned_to', [], 'Housekeeping', $locale), + $this->translator->trans('housekeeping.note', [], 'Housekeeping', $locale), + ], ';'); + + foreach ($dayView['rows'] as $row) { + $status = $row['status']; + $assigned = $status instanceof RoomDayStatus ? $status->getAssignedTo() : null; + $occupancyLabel = $this->translator->trans( + $occupancyLabels[$row['occupancyType']] ?? $row['occupancyType'], + [], + 'Housekeeping', + $locale + ); + $statusLabel = $status instanceof RoomDayStatus + ? $this->translator->trans( + $statusLabels[$status->getHkStatus()->value] ?? $status->getHkStatus()->value, + [], + 'Housekeeping', + $locale + ) + : ''; + + fputcsv($handle, [ + $dayView['date']->format('Y-m-d'), + $this->formatApartmentLabel($row['apartment']), + $occupancyLabel, + $row['guestCount'] ?? '', + $row['reservationSummary'] ?? '', + $statusLabel, + $assigned instanceof User ? $this->formatUserName($assigned) : '', + $status instanceof RoomDayStatus ? ($status->getNote() ?? '') : '', + ], ';'); + } + + fclose($handle); + }, Response::HTTP_OK, $headers); + + $response->headers->set('X-Selected-Subsidiary', $subsidiaryId); + + return $response; + } + + /** + * Build the CSV response for a week view export. + * + * @param array{ + * start: \DateTimeImmutable, + * days: \DateTimeImmutable[], + * rows: array + * }> + * } $weekView + */ + public function buildWeekCsvResponse(array $weekView, string $subsidiaryId, string $locale): StreamedResponse + { + $filename = sprintf('housekeeping_week_%s.csv', $weekView['start']->format('Y-m-d')); + $headers = [ + 'Content-Type' => 'text/csv; charset=UTF-8', + 'Content-Disposition' => sprintf('attachment; filename="%s"', $filename), + ]; + + $response = new StreamedResponse(function () use ($weekView, $locale) { + $handle = fopen('php://output', 'w'); + $header = [$this->translator->trans('housekeeping.room', [], 'Housekeeping', $locale)]; + foreach ($weekView['days'] as $day) { + $header[] = sprintf('%s %s', $this->formatWeekdayLabel($day, $locale), $day->format('Y-m-d')); + } + fputcsv($handle, $header, ';'); + + $occupancyLabels = $this->viewService->getOccupancyLabels(); + $statusLabels = $this->viewService->getStatusLabels(); + foreach ($weekView['rows'] as $row) { + $line = [$this->formatApartmentLabel($row['apartment'])]; + foreach ($weekView['days'] as $day) { + $dateKey = $day->format('Y-m-d'); + $cell = $row['days'][$dateKey] ?? null; + if (null === $cell) { + $line[] = ''; + continue; + } + $statusValue = $cell['status'] instanceof RoomDayStatus ? $cell['status']->getHkStatus()->value : ''; + $statusLabel = '' === $statusValue + ? '' + : $this->translator->trans( + $statusLabels[$statusValue] ?? $statusValue, + [], + 'Housekeeping', + $locale + ); + $occupancyLabel = $this->translator->trans( + $occupancyLabels[$cell['occupancyType']] ?? $cell['occupancyType'], + [], + 'Housekeeping', + $locale + ); + $guestCount = $cell['guestCount'] ?? ''; + $line[] = trim(sprintf('%s / %s / %s', $occupancyLabel, $statusLabel, $guestCount)); + } + fputcsv($handle, $line, ';'); + } + + fclose($handle); + }, Response::HTTP_OK, $headers); + + $response->headers->set('X-Selected-Subsidiary', $subsidiaryId); + + return $response; + } + + /** + * Format a user name for display and exports. + */ + private function formatUserName(User $user): string + { + return trim(sprintf('%s %s', $user->getFirstname(), $user->getLastname())); + } + + /** + * Format the apartment label for tables and exports. + */ + private function formatApartmentLabel(Appartment $apartment): string + { + $label = trim(sprintf('%s %s', $apartment->getNumber(), $apartment->getDescription())); + + return '' === $label ? (string) $apartment->getId() : $label; + } + + /** + * Format a localized short weekday label for CSV exports. + */ + private function formatWeekdayLabel(\DateTimeImmutable $date, string $locale): string + { + if (!class_exists(\IntlDateFormatter::class)) { + return $date->format('D'); + } + + $formatter = new \IntlDateFormatter( + $locale, + \IntlDateFormatter::FULL, + \IntlDateFormatter::NONE, + 'UTC', + null, + 'EEE' + ); + + return $formatter->format($date) ?: $date->format('D'); + } +} diff --git a/src/Service/HousekeepingViewService.php b/src/Service/HousekeepingViewService.php new file mode 100644 index 00000000..08e532a2 --- /dev/null +++ b/src/Service/HousekeepingViewService.php @@ -0,0 +1,404 @@ + + * } + */ + public function buildDayView(\DateTimeImmutable $date, ?Subsidiary $subsidiary): array + { + $apartments = $this->loadApartments($subsidiary); + $reservations = $this->loadReservations($date, $date->modify('+1 day'), $subsidiary); + $reservationsByApartment = $this->groupReservationsByApartment($reservations); + $statusMap = $this->loadStatusMap($apartments, $date, $date); + $dateKey = $date->format('Y-m-d'); + + $rows = []; + foreach ($apartments as $apartment) { + $apartmentReservations = $reservationsByApartment[$apartment->getId()] ?? []; + $occupancy = $this->resolveOccupancyForDay($date, $apartmentReservations); + $rows[] = [ + 'apartment' => $apartment, + 'occupancyType' => $occupancy['type'], + 'guestCount' => $occupancy['guestCount'], + 'reservationSummary' => $occupancy['summary'], + 'status' => $statusMap[$apartment->getId()][$dateKey] ?? null, + ]; + } + + return [ + 'date' => $date, + 'apartments' => $apartments, + 'rows' => $rows, + ]; + } + + /** + * Build the housekeeping view model for a Monday-Sunday week. + * + * @return array{ + * start: \DateTimeImmutable, + * end: \DateTimeImmutable, + * days: \DateTimeImmutable[], + * apartments: Appartment[], + * rows: array + * }> + * } + */ + public function buildWeekView(\DateTimeImmutable $start, \DateTimeImmutable $end, ?Subsidiary $subsidiary): array + { + $apartments = $this->loadApartments($subsidiary); + $reservations = $this->loadReservations($start, $end->modify('+1 day'), $subsidiary); + $reservationsByApartment = $this->groupReservationsByApartment($reservations); + $statusMap = $this->loadStatusMap($apartments, $start, $end); + $days = $this->buildDaysRange($start, $end); + + $rows = []; + foreach ($apartments as $apartment) { + $apartmentReservations = $reservationsByApartment[$apartment->getId()] ?? []; + $dayEntries = []; + foreach ($days as $day) { + $dateKey = $day->format('Y-m-d'); + $occupancy = $this->resolveOccupancyForDay($day, $apartmentReservations); + $dayEntries[$dateKey] = [ + 'occupancyType' => $occupancy['type'], + 'guestCount' => $occupancy['guestCount'], + 'reservationSummary' => $occupancy['summary'], + 'status' => $statusMap[$apartment->getId()][$dateKey] ?? null, + ]; + } + $rows[] = [ + 'apartment' => $apartment, + 'days' => $dayEntries, + ]; + } + + return [ + 'start' => $start, + 'end' => $end, + 'days' => $days, + 'apartments' => $apartments, + 'rows' => $rows, + ]; + } + + /** + * Resolve the occupancy type and display details for a single day. + * + * @param Reservation[] $reservations + * + * @return array{type: string, guestCount: int|null, summary: string|null} + */ + public function resolveOccupancyForDay(\DateTimeImmutable $date, array $reservations): array + { + $arrivals = []; + $departures = []; + $stayovers = []; + + $dateKey = $date->format('Y-m-d'); + foreach ($reservations as $reservation) { + $startKey = $reservation->getStartDate()->format('Y-m-d'); + $endKey = $reservation->getEndDate()->format('Y-m-d'); + + if ($startKey === $dateKey) { + $arrivals[] = $reservation; + } + if ($endKey === $dateKey) { + $departures[] = $reservation; + } + if ($startKey < $dateKey && $endKey > $dateKey) { + $stayovers[] = $reservation; + } + } + + if (!empty($arrivals) && !empty($departures)) { + $primary = $arrivals[0]; + + return [ + 'type' => 'TURNOVER', + 'guestCount' => $primary->getPersons(), + 'summary' => $this->buildReservationSummary($primary, $arrivals, $departures), + ]; + } + + if (!empty($arrivals)) { + $primary = $arrivals[0]; + + return [ + 'type' => 'ARRIVAL', + 'guestCount' => $primary->getPersons(), + 'summary' => $this->buildReservationSummary($primary, $arrivals, $departures), + ]; + } + + if (!empty($departures)) { + $primary = $departures[0]; + + return [ + 'type' => 'DEPARTURE', + 'guestCount' => $primary->getPersons(), + 'summary' => $this->buildReservationSummary($primary, $arrivals, $departures), + ]; + } + + if (!empty($stayovers)) { + $primary = $stayovers[0]; + + return [ + 'type' => 'STAYOVER', + 'guestCount' => $primary->getPersons(), + 'summary' => $this->buildReservationSummary($primary, $arrivals, $departures), + ]; + } + + return [ + 'type' => 'FREE', + 'guestCount' => null, + 'summary' => null, + ]; + } + + /** + * Define translation keys for housekeeping status values. + */ + public function getStatusLabels(): array + { + return [ + 'OPEN' => 'housekeeping.status.open', + 'IN_PROGRESS' => 'housekeeping.status.in_progress', + 'CLEANED' => 'housekeeping.status.cleaned', + 'INSPECTED' => 'housekeeping.status.inspected', + ]; + } + + /** + * Define translation keys for occupancy types. + */ + public function getOccupancyLabels(): array + { + return [ + 'FREE' => 'housekeeping.occupancy.free', + 'STAYOVER' => 'housekeeping.occupancy.stayover', + 'ARRIVAL' => 'housekeeping.occupancy.arrival', + 'DEPARTURE' => 'housekeeping.occupancy.departure', + 'TURNOVER' => 'housekeeping.occupancy.turnover', + ]; + } + + /** + * Load apartments filtered by subsidiary if provided. + * + * @return Appartment[] + */ + private function loadApartments(?Subsidiary $subsidiary): array + { + if (!$subsidiary instanceof Subsidiary) { + return $this->appartmentRepository->findAll(); + } + + return $this->appartmentRepository->findAllByProperty($subsidiary->getId()); + } + + /** + * Load reservations covering the given date range and subsidiary selection. + * + * @return Reservation[] + */ + private function loadReservations(\DateTimeImmutable $start, \DateTimeImmutable $end, ?Subsidiary $subsidiary): array + { + $qb = $this->reservationRepository->createQueryBuilder('r') + ->addSelect('a') + ->addSelect('booker') + ->addSelect('customer') + ->leftJoin('r.appartment', 'a') + ->leftJoin('r.booker', 'booker') + ->leftJoin('r.customers', 'customer') + ->distinct() + ->andWhere('r.startDate < :end') + ->andWhere('r.endDate >= :start') + ->andWhere('r.isConflict = 0') + ->andWhere('r.isConflictIgnored = 0') + ->setParameter('start', $start) + ->setParameter('end', $end) + ->addOrderBy('r.startDate', 'ASC'); + + if ($subsidiary instanceof Subsidiary) { + $qb->andWhere('a.object = :subsidiary') + ->setParameter('subsidiary', $subsidiary->getId()); + } + + return $qb->getQuery()->getResult(); + } + + /** + * Group reservations by apartment id for faster lookups. + * + * @param Reservation[] $reservations + * + * @return array + */ + private function groupReservationsByApartment(array $reservations): array + { + $grouped = []; + foreach ($reservations as $reservation) { + $apartment = $reservation->getAppartment(); + if (!$apartment instanceof Appartment) { + continue; + } + $grouped[$apartment->getId()][] = $reservation; + } + + return $grouped; + } + + /** + * Build a readable reservation summary for quick scanning. + * + * @param Reservation[] $arrivals + * @param Reservation[] $departures + * + * @return string|null + */ + private function buildReservationSummary(Reservation $primary, array $arrivals, array $departures): ?string + { + $name = $this->resolveReservationName($primary); + if ('' === $name) { + return null; + } + + if (!empty($arrivals) && !empty($departures)) { + $departureName = $this->resolveReservationName($departures[0]); + if ('' !== $departureName && $departureName !== $name) { + $departLabel = $this->translator->trans('housekeeping.summary.depart', [], 'Housekeeping'); + $arriveLabel = $this->translator->trans('housekeeping.summary.arrive', [], 'Housekeeping'); + + return sprintf('%s: %s / %s: %s', $departLabel, $departureName, $arriveLabel, $name); + } + } + + return $name; + } + + /** + * Resolve a display name for a reservation from booker/import data. + * + * @return string + */ + private function resolveReservationName(Reservation $reservation): string + { + $booker = $reservation->getBooker(); + if ($booker instanceof \App\Entity\Customer) { + $business = $this->resolveBusinessCompany($booker); + if (null !== $business) { + $lastname = trim((string) $booker->getLastname()); + if ('' !== $lastname) { + return sprintf('%s (%s)', $business, $lastname); + } + + return $business; + } + + return trim(sprintf('%s %s', (string) $booker->getLastname(), (string) $booker->getFirstname())); + } + + $import = $reservation->getCalendarSyncImport(); + if ($import instanceof \App\Entity\CalendarSyncImport) { + $name = trim($import->getName()); + if ('' !== $name) { + return $name; + } + } + + return ''; + } + + /** + * Resolve the business company name for a customer if available. + */ + private function resolveBusinessCompany(\App\Entity\Customer $customer): ?string + { + foreach ($customer->getCustomerAddresses() as $address) { + if ('CUSTOMER_ADDRESS_TYPE_BUSINESS' === $address->getType()) { + $company = trim((string) $address->getCompany()); + if ('' !== $company) { + return $company; + } + } + } + + return null; + } + + + /** + * Build an inclusive list of days between start and end. + * + * @return \DateTimeImmutable[] + */ + private function buildDaysRange(\DateTimeImmutable $start, \DateTimeImmutable $end): array + { + $days = []; + $cursor = $start; + while ($cursor <= $end) { + $days[] = $cursor; + $cursor = $cursor->modify('+1 day'); + } + + return $days; + } + + /** + * Load existing housekeeping status entries for the given apartments and date range. + * + * @param Appartment[] $apartments + * + * @return array> + */ + private function loadStatusMap(array $apartments, \DateTimeImmutable $start, \DateTimeImmutable $end): array + { + return $this->roomDayStatusRepository->findForApartmentsAndDates($apartments, $start, $end); + } +} diff --git a/templates/Operations/Housekeeping/_day_view.html.twig b/templates/Operations/Housekeeping/_day_view.html.twig new file mode 100644 index 00000000..2411b77e --- /dev/null +++ b/templates/Operations/Housekeeping/_day_view.html.twig @@ -0,0 +1,127 @@ +{% set roomLabel = 'housekeeping.room'|trans({}, 'Housekeeping') %} +{% set occupancyLabel = 'housekeeping.occupancy'|trans({}, 'Housekeeping') %} +{% set guestsLabel = 'housekeeping.guests'|trans({}, 'Housekeeping') %} +{% set reservationLabel = 'housekeeping.reservation'|trans({}, 'Housekeeping') %} +{% set statusLabel = 'housekeeping.status'|trans({}, 'Housekeeping') %} +{% set assignedLabel = 'housekeeping.assigned_to'|trans({}, 'Housekeeping') %} +{% set noteLabel = 'housekeeping.note'|trans({}, 'Housekeeping') %} + +
+ + + + + + + + + + + + + + + {% for row in dayView.rows %} + {% set formView = rowForms[row.apartment.id] %} + {{ form_start(formView, { + action: path('operations.housekeeping.update', { id: row.apartment.id }), + attr: { class: 'hk-row-form', 'data-action': 'submit->housekeeping#saveRow' } + }) }} + {{ form_widget(formView.date) }} + {{ form_widget(formView._token) }} + + + + + + + + + + + {{ form_end(formView, { render_rest: false }) }} + {% else %} + + + + {% endfor %} + +
{{ roomLabel }}{{ occupancyLabel }}{{ guestsLabel }}{{ reservationLabel }}{{ statusLabel }}{{ assignedLabel }}{{ noteLabel }}
+
{{ row.apartment.number }}
+
{{ row.apartment.description }}
+
+ {% set occClass = occupancyClasses[row.occupancyType]|default('bg-secondary') %} + + {{ occupancyLabels[row.occupancyType]|trans({}, 'Housekeeping') }} + + {{ row.guestCount ?? '-' }}{{ row.reservationSummary ?? '-' }} + {{ form_widget(formView.hkStatus, { attr: { class: 'form-select form-select-sm' } }) }} + + {{ form_widget(formView.assignedTo, { attr: { class: 'form-select form-select-sm' } }) }} + + {{ form_widget(formView.note, { attr: { class: 'form-control form-control-sm' } }) }} + + +
+ {{ 'housekeeping.no_rooms'|trans({}, 'Housekeeping') }} +
+
+ +
+ {% for row in dayView.rows %} +
+
+
+
+
{{ row.apartment.number }}
+
{{ row.apartment.description }}
+
+ {% set occClass = occupancyClasses[row.occupancyType]|default('bg-secondary') %} + + {{ occupancyLabels[row.occupancyType]|trans({}, 'Housekeeping') }} + +
+ +
+ {{ reservationLabel }}: {{ row.reservationSummary ?? '-' }} +
+
+ {{ guestsLabel }}: {{ row.guestCount ?? '-' }} +
+ + {% set formView = rowFormsMobile[row.apartment.id] %} + {{ form_start(formView, { + action: path('operations.housekeeping.update', { id: row.apartment.id }), + attr: { 'data-action': 'submit->housekeeping#saveRow' } + }) }} + {{ form_widget(formView.date) }} + {{ form_widget(formView._token) }} + +
+ + {{ form_widget(formView.hkStatus, { attr: { class: 'form-select form-select-sm' } }) }} +
+
+ + {{ form_widget(formView.assignedTo, { attr: { class: 'form-select form-select-sm' } }) }} +
+
+ + {{ form_widget(formView.note, { attr: { class: 'form-control form-control-sm' } }) }} +
+ + {{ form_end(formView, { render_rest: false }) }} +
+
+ {% else %} +
+ {{ 'housekeeping.no_rooms'|trans({}, 'Housekeeping') }} +
+ {% endfor %} +
diff --git a/templates/Operations/Housekeeping/_week_view.html.twig b/templates/Operations/Housekeeping/_week_view.html.twig new file mode 100644 index 00000000..21d51e79 --- /dev/null +++ b/templates/Operations/Housekeeping/_week_view.html.twig @@ -0,0 +1,47 @@ +
+ + + + + {% for day in weekView.days %} + + {% endfor %} + + + + {% for row in weekView.rows %} + + + {% for day in weekView.days %} + {% set dateKey = day|date('Y-m-d') %} + {% set cell = row.days[dateKey] %} + + {% endfor %} + + {% else %} + + + + {% endfor %} + +
{{ 'housekeeping.room'|trans({}, 'Housekeeping') }} +
{{ getLocalizedDate(day, 'EEE', app.request.locale) }}
+
{{ day|date('d.m.') }}
+
+
{{ row.apartment.number }}
+
{{ row.apartment.description }}
+
+
+ {{ occupancyLabels[cell.occupancyType]|trans({}, 'Housekeeping') }} +
+
+ {{ statusLabels[cell.status ? cell.status.hkStatus.value : 'OPEN']|trans({}, 'Housekeeping') }} +
+ {% if cell.guestCount is not null %} + {% set guestKey = cell.guestCount == 1 ? 'housekeeping.guest.short' : 'housekeeping.guests.short' %} +
{{ cell.guestCount }} {{ guestKey|trans({}, 'Housekeeping') }}
+ {% endif %} +
+ {{ 'housekeeping.no_rooms'|trans({}, 'Housekeeping') }} +
+
diff --git a/templates/Operations/Housekeeping/index.html.twig b/templates/Operations/Housekeeping/index.html.twig new file mode 100644 index 00000000..1b107ecc --- /dev/null +++ b/templates/Operations/Housekeeping/index.html.twig @@ -0,0 +1,93 @@ +{% extends 'base.html.twig' %} + +{% block title %}{{ 'housekeeping.title'|trans({}, 'Housekeeping') }}{% endblock %} + +{% block content %} +
+
+
+

{{ 'housekeeping.title'|trans({}, 'Housekeeping') }}

+
+ {{ selectedDate|date('d.m.Y') }} +
+
+ +
+ +
+
+ + +
+
+ + +
+ +
+ + + +
+ +
+ + {% if view == 'week' %} + {% include 'Operations/Housekeeping/_week_view.html.twig' with { + weekView: weekView, + occupancyLabels: occupancyLabels, + statusLabels: statusLabels + } %} + {% else %} + {% include 'Operations/Housekeeping/_day_view.html.twig' with { + dayView: dayView, + rowForms: rowForms, + rowFormsMobile: rowFormsMobile, + occupancyLabels: occupancyLabels, + occupancyClasses: occupancyClasses, + statusLabels: statusLabels, + selectedDate: selectedDate, + selectedSubsidiaryId: selectedSubsidiaryId, + view: view + } %} + {% endif %} +
+{% endblock %} diff --git a/templates/base.html.twig b/templates/base.html.twig index 9da76f12..1536c7ff 100644 --- a/templates/base.html.twig +++ b/templates/base.html.twig @@ -39,13 +39,17 @@ diff --git a/templates/base.html.twig b/templates/base.html.twig index 1536c7ff..5c531ee9 100644 --- a/templates/base.html.twig +++ b/templates/base.html.twig @@ -40,15 +40,24 @@