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 @@ +name = '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(); + } +})();