From e4db9fae8255952c45e4650a8ab793c4a218dfc4 Mon Sep 17 00:00:00 2001 From: DanielS Date: Thu, 12 Nov 2026 00:14:14 +0100 Subject: [PATCH] Add occupancyplan: Belegungsplan (rooms x days) with click-to-reserve New back-office page "Hotel Reservation System > Belegungsplan": - rooms grouped by room type as rows, 14-62 days as columns, weekends and today highlighted, sticky room column, daily occupancy footer - bookings as bars (booked / checked in / checked out / no-show) with guest name, guests, tooltip with dates, order reference and price; click opens the order; stays outside the view are cut - booking source badge from orders.id_source (W website, T phone, B Booking.com, E Expedia ...; portals highlighted), so channel-manager bookings show up with their source - blocked periods (htl_room_disable_dates) hatched with reason - CASPOS badge: nights already posted by casposbridge, "!" on failures - reserve: click a free cell (first night) and a later one (last night) in the same row -> action bar -> "Book Now" opens with dates, hotel and room type prefilled; keyboard: Enter/Space, Escape - docker/test-occupancyplan.php: 15 checks with throw-away bookings, optional HTML preview Co-Authored-By: Claude Opus 5.5 --- docker/test-occupancyplan.php | 109 ++++++ .../classes/OccupancyPlanRenderer.php | 314 ++++++++++++++++++ modules/occupancyplan/classes/index.php | 32 ++ .../admin/AdminOccupancyPlanController.php | 79 +++++ .../occupancyplan/controllers/admin/index.php | 32 ++ modules/occupancyplan/controllers/index.php | 32 ++ modules/occupancyplan/index.php | 32 ++ modules/occupancyplan/logo.png | Bin 0 -> 5839 bytes modules/occupancyplan/occupancyplan.php | 66 ++++ modules/occupancyplan/views/index.php | 32 ++ modules/occupancyplan/views/js/index.php | 32 ++ .../occupancyplan/views/js/occupancyplan.js | 111 +++++++ 12 files changed, 871 insertions(+) create mode 100644 docker/test-occupancyplan.php create mode 100644 modules/occupancyplan/classes/OccupancyPlanRenderer.php create mode 100644 modules/occupancyplan/classes/index.php create mode 100644 modules/occupancyplan/controllers/admin/AdminOccupancyPlanController.php create mode 100644 modules/occupancyplan/controllers/admin/index.php create mode 100644 modules/occupancyplan/controllers/index.php create mode 100644 modules/occupancyplan/index.php create mode 100644 modules/occupancyplan/logo.png create mode 100644 modules/occupancyplan/occupancyplan.php create mode 100644 modules/occupancyplan/views/index.php create mode 100644 modules/occupancyplan/views/js/index.php create mode 100644 modules/occupancyplan/views/js/occupancyplan.js diff --git a/docker/test-occupancyplan.php b/docker/test-occupancyplan.php new file mode 100644 index 000000000..4d9e1ca0c --- /dev/null +++ b/docker/test-occupancyplan.php @@ -0,0 +1,109 @@ +install(), 'module installed'); +} +check((bool)Tab::getIdFromClassName('AdminOccupancyPlan'), 'admin tab "Belegungsplan" exists'); + +$db = Db::getInstance(); +$rooms = $db->executeS('SELECT `id`, `id_product`, `room_num` FROM `'._DB_PREFIX_.'htl_room_information` ORDER BY `id` LIMIT 6'); +$d = function ($n) { return date('Y-m-d', strtotime(($n >= 0 ? '+' : '').$n.' day')); }; +$guests = array( + array($rooms[0], -4, 2, 3, 'Anna', 'Becker', 2, 0), // checked out, ends today+? no: -4..2? see dates below + array($rooms[1], -1, 3, 2, 'Luca', 'Rossi', 2, 1), // checked in + array($rooms[2], 2, 6, 1, 'Mia', 'Schneider', 1, 0), // booked + array($rooms[3], -6, -1, 3, 'Jonas', 'Weber', 2, 0), // checked out + array($rooms[4], 5, 30, 1, 'Familie', 'Mirabile-Test', 2, 2), // long stay beyond the view + array($rooms[1], 3, 5, 1, 'Ole', 'Hansen', 1, 0), // next guest in the same room + array($rooms[5], 0, 1, 4, 'Kein', 'Erscheinen', 2, 0), // no-show +); +$ids = array(); +$customers = array(); +foreach ($guests as $g) { + list($room, $from, $to, $status, $fn, $ln, $ad, $ch) = $g; + $c = new Customer(); + $c->firstname = $fn; + $c->lastname = $ln; + $c->email = 'op-test-'.uniqid().'@hotel.test'; + $c->passwd = Tools::passwdGen(16); + $c->add(); + $customers[] = (int)$c->id; + $db->insert('htl_booking_detail', array( + 'id_product' => (int)$room['id_product'], 'id_order' => 0, 'id_order_detail' => 1, 'id_cart' => 1, 'id_room' => (int)$room['id'], + 'id_hotel' => 1, 'id_customer' => (int)$c->id, 'booking_type' => 1, 'id_status' => $status, + 'check_in' => '0000-00-00 00:00:00', 'check_out' => '0000-00-00 00:00:00', 'planned_check_out' => $d($to).' 11:00:00', + 'date_from' => $d($from).' 00:00:00', 'date_to' => $d($to).' 00:00:00', + 'total_price_tax_excl' => 100, 'total_price_tax_incl' => 107, 'total_paid_amount' => 0, 'is_back_order' => 0, + 'hotel_name' => 'Test', 'room_type_name' => 'Test', 'room_num' => $room['room_num'], 'adults' => $ad, 'children' => $ch, + 'city' => 'Annweiler', 'country' => 'Deutschland', 'phone' => '0', 'email' => 'test@hotel.test', + 'check_in_time' => '15:00', 'check_out_time' => '11:00', 'date_add' => date('Y-m-d H:i:s'), 'date_upd' => date('Y-m-d H:i:s'), + )); + $ids[] = (int)$db->Insert_ID(); +} +$db->insert('htl_room_disable_dates', array('id_room_type' => (int)$rooms[5]['id_product'], 'id_room' => (int)$rooms[5]['id'], + 'date_from' => $d(4), 'date_to' => $d(8), 'reason' => 'Malerarbeiten', 'date_add' => date('Y-m-d H:i:s'), 'date_upd' => date('Y-m-d H:i:s'))); +$idBlock = (int)$db->Insert_ID(); + +try { + $plan = new OccupancyPlanRenderer($d(-2), 21); + $html = $plan->render(function ($id) { return '#order-'.$id; }); + check(substr_count($html, 'class="op-bar op-in') === 2, 'one checked-in bar (+ legend)'); + check(substr_count($html, 'class="op-bar op-assigned') >= 3, 'booked bars incl. legend'); + check(substr_count($html, 'op-cut-l') >= 1, 'stay starting before the view is cut on the left'); + check(substr_count($html, 'op-cut-r') >= 1, 'long stay is cut on the right'); + check(strpos($html, 'Malerarbeiten') !== false, 'blocked period with reason'); + check(substr_count($html, 'op-today') >= 2, 'today highlighted'); + check(strpos($html, ' 50 && substr_count($html, 'op-busy') > 10, 'free and busy cells marked'); + check(strpos($html, 'data-room-type=') !== false && strpos($html, 'data-date="'.$d(0).'"') !== false, 'cells carry room type and date for reserving'); + $s = OccupancyPlanRenderer::source(array('source_code' => 'BOOKING_COM', 'source_name' => 'Booking.com', 'id_business_source' => 2)); + check($s[0] === 'B' && $s[2] === 'op-src-ota', 'Booking.com badge "B" styled as portal'); + $s = OccupancyPlanRenderer::source(array('source_code' => 'PHONE', 'source_name' => 'Telefon', 'id_business_source' => 1)); + check($s[0] === 'T' && $s[2] === '', 'phone badge "T"'); + $found = 0; + foreach ($plan->getBookings() as $list) { + foreach ($list as $bk) { + $found += in_array((int)$bk['id'], $ids, true) ? 1 : 0; + } + } + check($found === 7, 'all 7 test bookings found (got '.$found.')'); + if ($preview) { + file_put_contents($preview, 'Belegungsplan Vorschau' + .'' + .'
' + .'

Belegungsplan (Vorschau mit Testbuchungen)

'.$html.'
' + .''); + echo "Preview: $preview\n"; + } +} finally { + if (!$keep) { + $db->delete('htl_booking_detail', '`id` IN ('.implode(',', $ids).')'); + $db->delete('htl_room_disable_dates', '`id` = '.$idBlock); + foreach ($customers as $idc) { + (new Customer($idc))->delete(); + } + } +} +echo $fail ? "$fail check(s) FAILED\n" : "All checks passed\n"; +exit($fail ? 1 : 0); diff --git a/modules/occupancyplan/classes/OccupancyPlanRenderer.php b/modules/occupancyplan/classes/OccupancyPlanRenderer.php new file mode 100644 index 000000000..6b915d1b9 --- /dev/null +++ b/modules/occupancyplan/classes/OccupancyPlanRenderer.php @@ -0,0 +1,314 @@ +from = date('Y-m-d', strtotime($from)); + $this->days = max(7, min(62, (int)$days)); + $this->idHotel = (int)$idHotel; + $this->idLang = $idLang ?: (int)Configuration::get('PS_LANG_DEFAULT'); + } + + public function getTo() + { + return date('Y-m-d', strtotime($this->from.' +'.$this->days.' day')); + } + + /** @return array rooms grouped by room type */ + public function getRooms() + { + $sql = 'SELECT ri.`id`, ri.`room_num`, ri.`id_status`, ri.`id_product`, ri.`id_hotel`, pl.`name` AS room_type + FROM `'._DB_PREFIX_.'htl_room_information` ri + LEFT JOIN `'._DB_PREFIX_.'product_lang` pl ON pl.`id_product` = ri.`id_product` AND pl.`id_lang` = '.(int)$this->idLang.' + WHERE 1'.($this->idHotel ? ' AND ri.`id_hotel` = '.(int)$this->idHotel : '').' + ORDER BY ri.`id_hotel`, pl.`name`, ri.`room_num`'; + return (array)Db::getInstance()->executeS($sql); + } + + /** @return array bookings overlapping the shown period, keyed by id_room */ + public function getBookings() + { + $to = $this->getTo(); + $hasCaspos = (bool)Db::getInstance()->getValue('SELECT COUNT(*) FROM information_schema.tables + WHERE table_schema = DATABASE() AND table_name = \''._DB_PREFIX_.'casposbridge_job\''); + $sql = 'SELECT bd.`id`, bd.`id_room`, bd.`id_order`, bd.`id_status`, bd.`adults`, bd.`children`, + DATE(bd.`date_from`) AS date_from, DATE(bd.`date_to`) AS date_to, bd.`room_type_name`, + bd.`total_price_tax_incl`, c.`firstname`, c.`lastname`, o.`reference`, + src.`code` AS source_code, srcl.`name` AS source_name, src.`id_business_source`, o.`source` AS source_raw' + .($hasCaspos ? ', (SELECT COUNT(*) FROM `'._DB_PREFIX_.'casposbridge_job` j WHERE j.`id_htl_booking` = bd.`id` AND j.`status` = \'done\') AS pos_done, + (SELECT COUNT(*) FROM `'._DB_PREFIX_.'casposbridge_job` j WHERE j.`id_htl_booking` = bd.`id` AND j.`status` = \'failed\') AS pos_failed' : '').' + FROM `'._DB_PREFIX_.'htl_booking_detail` bd + LEFT JOIN `'._DB_PREFIX_.'customer` c ON c.`id_customer` = bd.`id_customer` + LEFT JOIN `'._DB_PREFIX_.'orders` o ON o.`id_order` = bd.`id_order` + LEFT JOIN `'._DB_PREFIX_.'source` src ON src.`id_source` = o.`id_source` + LEFT JOIN `'._DB_PREFIX_.'source_lang` srcl ON srcl.`id_source` = src.`id_source` AND srcl.`id_lang` = '.(int)$this->idLang.' + WHERE bd.`date_from` < \''.pSQL($to).'\' AND bd.`date_to` > \''.pSQL($this->from).'\' + AND bd.`id_status` <> '.self::STATUS_CANCELLED.' AND bd.`is_back_order` = 0' + .(method_exists('OrderReturn', 'getRefundedBookingIdsSubquery') ? ' AND bd.`id` NOT IN ('.OrderReturn::getRefundedBookingIdsSubquery().')' : '') + .($this->idHotel ? ' AND bd.`id_hotel` = '.(int)$this->idHotel : '').' + ORDER BY bd.`date_from`'; + $out = array(); + foreach ((array)Db::getInstance()->executeS($sql) as $b) { + $out[(int)$b['id_room']][] = $b; + } + return $out; + } + + /** @return array blocked periods keyed by id_room */ + public function getBlocked() + { + $to = $this->getTo(); + $out = array(); + foreach ((array)Db::getInstance()->executeS('SELECT `id_room`, `date_from`, `date_to`, `reason` + FROM `'._DB_PREFIX_.'htl_room_disable_dates` + WHERE `date_from` < \''.pSQL($to).'\' AND `date_to` > \''.pSQL($this->from).'\'') as $d) { + $out[(int)$d['id_room']][] = $d; + } + return $out; + } + + /** column index (0-based) of a date, clamped to the visible period */ + protected function col($date) + { + $d = (int)round((strtotime($date) - strtotime($this->from)) / 86400); + return max(0, min($this->days, $d)); + } + + protected static function e($s) + { + return htmlspecialchars((string)$s, ENT_QUOTES, 'UTF-8'); + } + + /** + * @param callable|null $orderLink function(int $idOrder): string + * @param array $labels translated UI strings + */ + public function render($orderLink = null, array $labels = array()) + { + $L = array_merge(array( + 'room' => 'Zimmer', 'occupied' => 'Belegt', 'blocked' => 'Gesperrt', 'assigned' => 'Gebucht', + 'checked_in' => 'Eingecheckt', 'checked_out' => 'Ausgecheckt', 'no_show' => 'Nicht erschienen', + 'inactive' => 'inaktiv', 'nights' => 'Nächte', 'order' => 'Buchung', 'guests' => 'Gäste', + 'pos' => 'An Kasse gebucht', 'pos_failed' => 'Kassenbuchung fehlgeschlagen', 'no_rooms' => 'Keine Zimmer angelegt.', + ), $labels); + $weekdays = array('So', 'Mo', 'Di', 'Mi', 'Do', 'Fr', 'Sa'); + $months = array(1 => 'Januar', 'Februar', 'März', 'April', 'Mai', 'Juni', 'Juli', 'August', 'September', 'Oktober', 'November', 'Dezember'); + $statusClass = array( + self::STATUS_ASSIGNED => array('op-assigned', $L['assigned']), + self::STATUS_CHECKED_IN => array('op-in', $L['checked_in']), + self::STATUS_CHECKED_OUT => array('op-out', $L['checked_out']), + self::STATUS_NO_SHOW => array('op-noshow', $L['no_show']), + ); + + $rooms = $this->getRooms(); + if (!$rooms) { + return '
'.self::e($L['no_rooms']).'
'; + } + $bookings = $this->getBookings(); + $blocked = $this->getBlocked(); + $today = date('Y-m-d'); + $cols = 'grid-template-columns: 11rem repeat('.$this->days.', minmax(2.4rem, 1fr));'; + + // header: month row + day row + $h = '
'; + $monthStart = 0; + for ($i = 0; $i <= $this->days; $i++) { + $d = date('Y-m-d', strtotime($this->from.' +'.$i.' day')); + if ($i === $this->days || ($i > 0 && date('j', strtotime($d)) == 1)) { + $m = date('Y-m-d', strtotime($this->from.' +'.$monthStart.' day')); + $h .= '
' + .self::e($months[(int)date('n', strtotime($m))].' '.date('Y', strtotime($m))).'
'; + $monthStart = $i; + } + } + $h .= '
'.self::e($L['room']).'
'; + for ($i = 0; $i < $this->days; $i++) { + $d = date('Y-m-d', strtotime($this->from.' +'.$i.' day')); + $w = (int)date('w', strtotime($d)); + $cls = 'op-day'.($w === 0 || $w === 6 ? ' op-we' : '').($d === $today ? ' op-today' : ''); + $h .= '
'.$weekdays[$w].''.date('j', strtotime($d)).'
'; + } + $h .= '
'; + + // rows + $occupied = array_fill(0, $this->days, 0); + $activeRooms = 0; + $body = ''; + $lastType = null; + foreach ($rooms as $r) { + if ($r['room_type'] !== $lastType) { + $body .= '
'.self::e($r['room_type']).'
'; + $lastType = $r['room_type']; + } + $inactive = (int)$r['id_status'] === 2; + if (!$inactive) { + $activeRooms++; + } + $roomBookings = isset($bookings[(int)$r['id']]) ? $bookings[(int)$r['id']] : array(); + $roomBlocked = isset($blocked[(int)$r['id']]) ? $blocked[(int)$r['id']] : array(); + // nights that cannot be reserved (booked or blocked) + $busy = array(); + foreach (array_merge($roomBookings, $roomBlocked) as $x) { + for ($i = $this->col($x['date_from']); $i < $this->col($x['date_to']); $i++) { + $busy[$i] = true; + } + } + $body .= '
'; + $body .= '
'.self::e($r['room_num']).''.($inactive ? ' '.self::e($L['inactive']).'' : '').'
'; + for ($i = 0; $i < $this->days; $i++) { + $d = date('Y-m-d', strtotime($this->from.' +'.$i.' day')); + $w = (int)date('w', strtotime($d)); + $free = !$inactive && empty($busy[$i]) && $d >= $today; + $body .= '
'; + } + foreach ($roomBlocked as $bl) { + $s = $this->col($bl['date_from']); + $e = $this->col($bl['date_to']); + if ($e > $s) { + $body .= '
'.self::e($L['blocked']).'
'; + } + } + foreach ($roomBookings as $b) { + $s = $this->col($b['date_from']); + $e = $this->col($b['date_to']); + if ($e <= $s) { + continue; + } + for ($i = $s; $i < $e; $i++) { + $occupied[$i]++; + } + list($cls, $statusName) = isset($statusClass[(int)$b['id_status']]) ? $statusClass[(int)$b['id_status']] : array('op-assigned', ''); + $name = trim($b['firstname'].' '.$b['lastname']) ?: '#'.$b['id_order']; + $guests = (int)$b['adults'].((int)$b['children'] ? '+'.(int)$b['children'] : ''); + $nights = (int)round((strtotime($b['date_to']) - strtotime($b['date_from'])) / 86400); + $pos = ''; + if (isset($b['pos_done'])) { + if ((int)$b['pos_failed']) { + $pos = '!'; + } elseif ((int)$b['pos_done']) { + $pos = '€'.(int)$b['pos_done'].''; + } + } + list($srcShort, $srcName, $srcClass) = self::source($b); + $title = $name.' – '.$statusName.($srcName ? ' · '.$srcName : '')."\n" + .date('d.m.', strtotime($b['date_from'])).' – '.date('d.m.Y', strtotime($b['date_to'])).' ('.$nights.' '.$L['nights'].')'."\n" + .$L['guests'].': '.$guests.' · '.$L['order'].' '.($b['reference'] ?: '#'.$b['id_order']) + .' · '.number_format((float)$b['total_price_tax_incl'], 2, ',', '.').' €'; + $cut = ($b['date_from'] < $this->from ? ' op-cut-l' : '').($b['date_to'] > $this->getTo() ? ' op-cut-r' : ''); + $tag = $orderLink && (int)$b['id_order'] ? 'a href="'.self::e(call_user_func($orderLink, (int)$b['id_order'])).'"' : 'div'; + $src = $srcShort ? ''.self::e($srcShort).'' : ''; + $body .= '<'.$tag.' class="op-bar '.$cls.$cut.'" style="grid-column:'.($s + 2).' / '.($e + 2).'" title="'.self::e($title).'">' + .$src.''.self::e($name).' ('.self::e($guests).')'.$pos.''; + } + $body .= '
'; + } + + // occupancy footer + $f = '
'.self::e($L['occupied']).'
'; + for ($i = 0; $i < $this->days; $i++) { + $pct = $activeRooms ? round($occupied[$i] * 100 / $activeRooms) : 0; + $f .= '
'.$occupied[$i].'
'; + } + $f .= '
'; + + $legend = '
'; + foreach ($statusClass as $sc) { + $legend .= ''.self::e($sc[1]).''; + } + $legend .= ''.self::e($L['blocked']).''; + $legend .= '€2 '.self::e($L['pos']).''; + $legend .= 'B Booking.comE Expedia' + .'W WebsiteT Telefon@ E-Mail
'; + + return '
'.$h.$body.$f.'
'.$legend.self::css(); + } + + /** + * Short badge for the booking source (orders.id_source -> source.code). + * @return array [short, name, css class] + */ + public static function source(array $b) + { + $map = array( + 'DIRECT_WEBSITE' => 'W', 'WALKIN' => 'L', 'PHONE' => 'T', 'EMAIL' => '@', 'SOCIAL_MEDIA' => 'S', 'WHATSAPP' => 'WA', + 'BOOKING_COM' => 'B', 'EXPEDIA' => 'E', 'AIRBNB' => 'A', 'AGODA' => 'AG', 'TRIP_COM' => 'TC', + ); + $code = isset($b['source_code']) ? (string)$b['source_code'] : ''; + if ($code === '') { + return array('', isset($b['source_raw']) ? (string)$b['source_raw'] : '', ''); + } + $short = isset($map[$code]) ? $map[$code] : Tools::strtoupper(Tools::substr($code, 0, 2)); + $ota = (int)(isset($b['id_business_source']) ? $b['id_business_source'] : 0) === 2; + return array($short, (string)$b['source_name'], $ota ? 'op-src-ota' : ''); + } + + public static function css() + { + return ''; + } +} diff --git a/modules/occupancyplan/classes/index.php b/modules/occupancyplan/classes/index.php new file mode 100644 index 000000000..409280877 --- /dev/null +++ b/modules/occupancyplan/classes/index.php @@ -0,0 +1,32 @@ +bootstrap = true; + $this->display = 'view'; + parent::__construct(); + $this->meta_title = $this->l('Belegungsplan'); + } + + public function initToolbar() + { + parent::initToolbar(); + unset($this->toolbar_btn['new']); + } + + public function setMedia() + { + parent::setMedia(); + $this->addJS(_MODULE_DIR_.'occupancyplan/views/js/occupancyplan.js'); + } + + public function renderView() + { + $days = (int)Tools::getValue('days', 21); + $from = Tools::getValue('from'); + if (!$from || !Validate::isDate($from)) { + $from = date('Y-m-d', strtotime('-2 day')); + } + $idHotel = (int)Tools::getValue('id_hotel'); + $plan = new OccupancyPlanRenderer($from, $days, $idHotel, (int)$this->context->language->id); + + $base = self::$currentIndex.'&token='.$this->token; + $nav = function ($shift) use ($from, $days, $idHotel, $base) { + return $base.'&from='.date('Y-m-d', strtotime($from.' '.$shift)).'&days='.(int)$days.($idHotel ? '&id_hotel='.$idHotel : ''); + }; + $link = $this->context->link; + $orderLink = function ($idOrder) use ($link) { + return $link->getAdminLink('AdminOrders').'&id_order='.(int)$idOrder.'&vieworder'; + }; + + $hotels = Db::getInstance()->executeS('SELECT hbi.`id`, hbil.`hotel_name` FROM `'._DB_PREFIX_.'htl_branch_info` hbi + LEFT JOIN `'._DB_PREFIX_.'htl_branch_info_lang` hbil ON hbil.`id` = hbi.`id` AND hbil.`id_lang` = '.(int)$this->context->language->id.' + WHERE hbi.`active` = 1'); + $e = function ($s) { return htmlspecialchars((string)$s, ENT_QUOTES, 'UTF-8'); }; + + $form = '
' + .'' + .' '.$this->l('Woche').' ' + .''.$this->l('Heute').' ' + .''.$this->l('Woche').' ' + .' ' + .' '; + if (count($hotels) > 1) { + $form .= ' '; + } + $form .= '
'; + + // "Book Now" with dates/hotel/room type taken over from the selection (see views/js/occupancyplan.js) + $reserveUrl = $link->getAdminLink('AdminHotelRoomsBooking'); + $hint = '

'.$this->l('Reservieren: freie Zelle anklicken (erste Nacht), dann die letzte Nacht in derselben Zeile anklicken.').'

'; + + return '
'.$this->l('Belegungsplan').'
' + .$form.$hint.'' + .$plan->render($orderLink).'
'; + } +} diff --git a/modules/occupancyplan/controllers/admin/index.php b/modules/occupancyplan/controllers/admin/index.php new file mode 100644 index 000000000..409280877 --- /dev/null +++ b/modules/occupancyplan/controllers/admin/index.php @@ -0,0 +1,32 @@ +%MbFb#(c+?M5%lSWwefWRwO4M`=luK6tq!?1D!N-F&rZBr&RpkNr!o`_YB z!DgbC|Ep3i)K#E^$=|%ewg``L3f*aG!;{;$Wo5)I&{7M}~4KU>~@DUS?>9o-)N|_S5mQO^wT9bb94w^oY|@V_`^^ z8wz!HLwP+N8Ph$>xi!hgQI$y8Fnicjl|9I+yTZ;;s%{W+$!NJ#Z70q5Pf~rs>o9#% zHwg`%{!V@39OlM7fzElK?1gi9*R^%b8y>JPF&pNIhMenNHfNI9yNHQ*v_}(Cs4>-w z^M2jrwOl1p;bPZfb$&S#i0LlO0XV{heN=6r7u=>M{ z>2*le;Li#oUk=~!Zi%X`w!E=hqV+B+^*P!6G`^wiOTR>-of62>96mdZdPE8k`L*%_ zJ+%2i=*`MEPK~2c4lIqbc6?<|{?TQ@xCI0r4m2wD({|EWA%8sx+k=A(^yOwmRvO8@ zI=(NR<~3*_Qaq!E!+n_l?i}wc6g!%e8Jzi3XE`{J--ia)c|ileVI+&v=wVSQS^XKw zlx9?*6#Sg)@5ev})?yV!*l;PR$ZvsHBfbyq@s~{)zjvms6acaKqMiXv@Gw!f;$(>U zqs)>p!*drjsa-xM4za*+D(Ojl?Y`$brn$2>9jxXFyDt~a(0*LNrutInU3H{t+#E~9 z??)p=s^bfP{IQ>LS{{I*l6j0QDAqS#BR*O)z!$5@}=5 z0Z5T6U(qWa%$c2Cg=p5W@bC4OkF8sw(Kc%-g>&@>)g{A`AzTnYt&a-#pB!+-mS7)* zxqsiapY_QXg1o+eNV(I&0l7Oh>W--5HCI_7?UPQyQlcsCv$xF19;m6x4g5gu5)*CxHVE%G{%VW#B+K~OnszT!~|fiD3s_4m{H_*)cTf`Oi= z-!}YWYWr?OJMTqThlo#oxKouTn?igmdOh<|F0Bnu4gyb3j@DxrG*QH!J3$ndTFPWADhfn zuRzIQO`e6Pn#>@-J)(KGy~bDcu;=XU=Py@(7o-SL@K1w&kv(s*kN`)XDiPW}m)>u- zy&>RHsyj!r2T|3P9q(=qhHja?F$}ON> zXzjKEN5dAWSVp=YsR}3((BP_Jrf|5GfJo+r#p}mv@xXx>Q0QVkAk8yp~;+039 z^$dgHtuEfc{|R}{LExyey2U^eO$4H5E8J0gWLK^CtJ2iO%lsE4Ln=ab;LvEda_(MU zM;MZ-VG@d)o|T^nc^)k4JyXFOJC019eh>sX35(+fn0@C1l15ZC<__Z(&WOcpzdaM{` zW$N6}hnpP#$sm;dV)Qp}8%F6n01F$-&IjkYkoZQ$e6kVB#H@=`Zc&#ei+WiNzflXA z=sn*C22bsMD4!Y`Wjv>mhB^FwNHY6y#$7rBGNbNkd`CFr8^_}8XFq=-%+DQ{3=6fy zl}927s~`9r&IEd);u`P4i7~HQ;1FN&`xS~wHky2RFV@<|MliKYS+2h*eP_m5dC+|7yplH_$C(`Z51o zVC{=Ym#UO;Cr@yGfp46^63L<dO{>CbnO1kk9otML?rV7E!Xd_HC2NC4++(aL~qEli*2 znwKCQxTY;IT`}rxtZARz>OB)9Z*T@fzlg(}35PK2K~GSG9XFsTaVnGd#ZgVLndq9? zkmZ`~+0xkI!EZW5s?pcUp9=SeW(p=!KXSEha)qq13kq#0Pk!dck6&E+$>^$fm?OxY zU%%3XQbA94$3XqA*0f-e?J2`q#?joCfkDAh+7*%2Fmg+g7z>W^#peQG@iqLbr_tQ0 zg{}s2f7<@8zmkZgsro^SaJd&#rCNG1&$les*-aO9-*?fd-{U7v-q_NCUQ785g%fC0 zbVDl|ZJkz^6ZhBHEDdnCyMD|SSv+U2n?R*C)Pr}ojw_j0nQfbrTYQI8W+cj}t3hSsZI;%?HbZD?4j-xk`(NQlDZZ(%~d3k{DUnFMZgip90UC%tT(ApZ>R1z{JOC_DH!KyDHtZl9CXMJSFj_NwOZ(u1&VsFN;}I1L8JrGr!Lkq z?|<$b2HYd`t!pM$S}fV-8rxyazqgX;$qo;`6#Lc&M5{AgGBY9O3^ar;`}%IGh(XxW zZsC5F+%!6dTO>21yxI^>xjhds3MmDyn3;IUOS91oeKPoQg<%WM3({JAKU6VOuNL>L zo*Q7)uGH<}zpM>*m>1_(d$y4e7@E(1qy1VBw=;7?Rd=%hxxW;ix8qJ~RNV+Ea+4))p z34kdp^m3OxmOdl-B9CTv%DPJj3C8GP_Tk4rRTdh_Fa0i~^e{^fe?MuC`k5YRsermE_PpJ=|K!j#v(Nxg=)3czF)=go_=Qr!&OWAZt2pm-oOoESwJc$q}Ot?&__6 zDde4+w+_(bqvu$pIJh((MB65vm7)9##z~~q&H}LnxcinSg7RvDRc9}?WxYI;5=ON?IW`J&}v^7mrs@t zMv62A01;aBLiMr|uyZ_>{nf8Fj(IuBcQCCK-d^^lYjw`BbDIhb%Nvuzy-%|BbhG%b@SQE*`7ii0L|o^pu?TVl?Xu~264l-* z`%@XyW&_BcRNq#SRn;gnhrgGj!D;6`jDjfA9?#FuN8lGb`fJiirP+wVJ3Z(#&|m@e>g-bZewava zbV2}CXny7A?}0|3Ftm?TOj-oR|m%{&~gGdPNtdHXi74 zheUK9@{QL_zaVC$xhpH0u1(3b6IF>##el9(yti>|?CcbwdlJj3(`+-i7NyM7DevDZ zn{qM+9{J%X*z-6W-!3|1h~5M{RaatK_f_Z~+AddR^GSc>I{%DCr6uL|dY0@O1rBOcEnKIDQK$Mc!CEB6%wD?I#J-1t_O_jsr z53FAIgE8vsIj_JN-2OZ41Wxj{`(%AMmAv6-SYXb_+YoSYW0A1U9eyqhl7I8-)hG~S z5(+E4P^P^$a3qYxW8W%XY+yfd9E(bYe^Hm|`;(eWCOsT|V4i8)F5}uVtgnU~8FH28 zIw_tv#wlQrMNjKF8XW2>7k|jMd;&?d?L`ru;84VdpDZteOfgHOIX~`neVm52%Dst{ zWgAOj;{B0`JT)UmZB0VU>pn1`r;7T*cYO%PFskQNDy0|3R!(HKrbeu?cAjo6_o(xt zB)sFb8<%3CI5)@o{vopVt2Eh8;n<|DWuw{Worrcj?KL`K_L2R zEXQxJ6Fcdpcct_Kf<|7gb-~#A06$5fX$V@MTb@*dqsra!AAzUnqJNDK`7rnHCF!LB zT|I#fcKSZ#sZ-z5n;b)E>zv6EMsn1gNh-)Q2`BbJ=&mV-GxQQQ$;c*=tl^6NC4IJw z=)4~&CYz=|td4%<2rHF&&XGf@INalgE)wG4WKv8=Qo!JC#FJ7xxix_lW@9M2o?YEq zJA-ST7jrHN#l;}qM@ZmMPv*zpW<*^A!k2B#!;eXa%Eoe+k~#GzR)t!1l$#D+yFA

FFLg{GkW{cn-I^x*plcZcTAM^^xAqy3-RSLnHGNxNmA{)V3xvb(@@_+LB ze?sPd0nBztt_GnRGJE+Pt)$ z^3EE3`CejwKJB35hYqN+2o7v8n6Uqw#GzL2i6$NDc}$W0EjMdMLxcT`wGP;?HNvdl z?R|tsNmqHeoa(jJi?MQx&7TWP50rtQmCr=jR4Q0U>QjLNM(wt#kzLUF(9eeMV%){ShWDGxv zI;qe0Kf79b94PQbYE$Ooyp#*iRB*|=(W4*zacMPzGPvVlv|Qe_pl!fD>Q}+}j_C^| zL|ne;ZH8H8r>76&LS$(-Zp{4IU?DcrsK4cU}%pIq# zkqaj&p_R6*hpg^*nu8#Zx~|XM))QYxwR|Zxu^h0DH2H`SW-`j4gcuA?gW?$kxcrKW7}*I#Q3r z?-m@!e_W@J=UKj3I=h%FhgV=SZ;7*g{5ttG375$s3t`5aAz}*RRSO8r=^(fnZ-br!2G*=+bd%(T8dyHyajw~^yXfnQLHPK3Io_$#Z?=zG6UYS<%O zdRflX8Z5Cieyeb~b(l7>&*VmuZNe}!Oz0z?@2*t~Vy#6gKfXPQV2X}`2{hZU3=y@+ z-763=Vi_~XLT?=o4^jIh{=AiK5$vT9)FEiIHLhTI1j#^XfRyB&9mX4*m^mai&^{~A zczE;sGfWz=lHLL^tnES)yQD#YbeQky!1#F>m(OX$pRy}{<93Fnx60c`G#mjr5hh^~ zG2TPisg}2qJl!MI;^Dr5@i}^!(IuO_6`d}Z1I#GQX{Gk&+u2y-Hy3_mB+fQYO1*Mx zT<~Cnz2*&_G`QUCp-18l)u0DpBvbgv(e}VBH;gX zC*%HeCt3c#JNaM2>3`dn|0l8XzZE7We>t~qH++$sAI3PeG@a)~6;YYj1rtdvxWv7! x-$9;n3bvLCjo0abX%RUo|HWUW?ENK<&oh3t`)Tyname = 'occupancyplan'; + $this->tab = 'administration'; + $this->version = '1.0.0'; + $this->author = 'Zum alten Wasserrad'; + $this->need_instance = 0; + $this->bootstrap = true; + parent::__construct(); + + $this->displayName = $this->l('Belegungsplan'); + $this->description = $this->l('Zimmer × Tage mit allen Buchungen, Status und Sperrzeiten auf einen Blick.'); + $this->ps_versions_compliancy = array('min' => '1.6', 'max' => '1.6.99.99'); + } + + public function install() + { + return parent::install() && $this->installTab(); + } + + public function uninstall() + { + $idTab = (int)Tab::getIdFromClassName('AdminOccupancyPlan'); + if ($idTab) { + $tab = new Tab($idTab); + $tab->delete(); + } + return parent::uninstall(); + } + + protected function installTab() + { + if (Tab::getIdFromClassName('AdminOccupancyPlan')) { + return true; + } + $tab = new Tab(); + $tab->class_name = 'AdminOccupancyPlan'; + $tab->module = $this->name; + $parent = (int)Tab::getIdFromClassName('AdminHotelReservationSystemManagement'); + $tab->id_parent = $parent ?: 0; + foreach (Language::getLanguages(false) as $lang) { + $tab->name[$lang['id_lang']] = 'Belegungsplan'; + } + // Tab::add() returns initAccess(), which is false without a logged-in employee (CLI); + // the tab itself is created either way. + $tab->add(); + return (bool)$tab->id; + } + + public function getContent() + { + Tools::redirectAdmin($this->context->link->getAdminLink('AdminOccupancyPlan')); + } +} diff --git a/modules/occupancyplan/views/index.php b/modules/occupancyplan/views/index.php new file mode 100644 index 000000000..409280877 --- /dev/null +++ b/modules/occupancyplan/views/index.php @@ -0,0 +1,32 @@ +' + + ' Reservieren' + + ''; + root.appendChild(bar); + + var weekday = ['So', 'Mo', 'Di', 'Mi', 'Do', 'Fr', 'Sa']; + function fmt(iso) { + var p = iso.split('-'); + var d = new Date(Date.UTC(+p[0], +p[1] - 1, +p[2])); + return weekday[d.getUTCDay()] + ' ' + p[2] + '.' + p[1] + '.'; + } + function addDay(iso) { + var p = iso.split('-'); + var d = new Date(Date.UTC(+p[0], +p[1] - 1, +p[2] + 1)); + return d.toISOString().slice(0, 10); + } + function cells(row) { + return Array.prototype.slice.call(row.querySelectorAll('.op-cell')); + } + function clear() { + Array.prototype.forEach.call(root.querySelectorAll('.op-sel'), function (c) { c.classList.remove('op-sel'); }); + sel = null; + bar.hidden = true; + } + function paint() { + Array.prototype.forEach.call(root.querySelectorAll('.op-sel'), function (c) { c.classList.remove('op-sel'); }); + var list = cells(sel.row); + var a = list.indexOf(sel.start), b = list.indexOf(sel.end); + for (var i = a; i <= b; i++) { + list[i].classList.add('op-sel'); + } + var from = sel.start.getAttribute('data-date'); + var to = addDay(sel.end.getAttribute('data-date')); + var nights = b - a + 1; + bar.querySelector('.op-actiontext').textContent = 'Zimmer ' + sel.row.getAttribute('data-room-num') + ' · ' + + fmt(from) + ' – ' + fmt(to) + ' · ' + nights + (nights === 1 ? ' Nacht' : ' Nächte') + + ' (in „Book Now“ dieses Zimmer auswählen)'; + var url = root.getAttribute('data-reserve-url') + + '&date_from=' + from + '&date_to=' + to + + '&id_hotel=' + encodeURIComponent(sel.row.getAttribute('data-hotel')) + + '&id_room_type=' + encodeURIComponent(sel.row.getAttribute('data-room-type')); + bar.querySelector('.op-reserve').setAttribute('href', url); + bar.hidden = false; + } + function pick(cell) { + var row = cell.closest('.op-row'); + if (sel && sel.row === row) { + var list = cells(row); + var a = list.indexOf(sel.start), b = list.indexOf(cell); + if (b >= a) { + for (var i = a; i <= b; i++) { + if (!list[i].classList.contains('op-free')) { + sel = {row: row, start: cell, end: cell}; // range crosses a booking: start over + paint(); + return; + } + } + sel.end = cell; + paint(); + return; + } + } + sel = {row: row, start: cell, end: cell}; + paint(); + } + + root.addEventListener('click', function (e) { + var cell = e.target.closest('.op-free'); + if (cell && root.contains(cell)) { + pick(cell); + } + }); + root.addEventListener('keydown', function (e) { + var cell = e.target.closest && e.target.closest('.op-free'); + if (cell && (e.key === 'Enter' || e.key === ' ')) { + e.preventDefault(); + pick(cell); + } + }); + document.addEventListener('keydown', function (e) { + if (e.key === 'Escape' && sel) { + clear(); + } + }); + bar.querySelector('.op-cancel').addEventListener('click', clear); + } + + if (document.readyState === 'loading') { + document.addEventListener('DOMContentLoaded', init); + } else { + init(); + } +})();