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 <noreply@anthropic.com>
This commit is contained in:
DanielS
2026-11-12 00:14:14 +01:00
parent f038311614
commit e4db9fae82
12 changed files with 871 additions and 0 deletions

View File

@@ -0,0 +1,109 @@
<?php
/**
* Checks modules/occupancyplan with throw-away bookings and writes an HTML preview.
* docker compose ... exec -u www-data app php docker/test-occupancyplan.php [preview-file]
* Bookings, the blocked period and the preview are removed again unless --keep is given.
*/
if (PHP_SAPI !== 'cli') {
exit;
}
define('_PS_ADMIN_DIR_', __DIR__);
require __DIR__.'/../config/config.inc.php';
require_once _PS_MODULE_DIR_.'occupancyplan/classes/OccupancyPlanRenderer.php';
$keep = in_array('--keep', $argv, true);
$preview = isset($argv[1]) && $argv[1] !== '--keep' ? $argv[1] : null;
$fail = 0;
function check($c, $m)
{
global $fail;
echo ($c ? ' [ok] ' : ' [FAIL] ').$m.PHP_EOL;
$fail += $c ? 0 : 1;
}
if (!Module::isInstalled('occupancyplan')) {
check(Module::getInstanceByName('occupancyplan')->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, '<script') === false, 'no script in output');
check(substr_count($html, 'op-free') > 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, '<!doctype html><meta charset="utf-8"><title>Belegungsplan Vorschau</title>'
.'<body style="font-family:Open Sans,Arial,sans-serif;margin:16px;background:#eff1f2">'
.'<div data-reserve-url="#book-now?controller=AdminHotelRoomsBooking" style="background:#fff;padding:12px;border-radius:4px">'
.'<h3 style="margin-top:0">Belegungsplan (Vorschau mit Testbuchungen)</h3>'.$html.'</div>'
.'<script>'.file_get_contents(_PS_MODULE_DIR_.'occupancyplan/views/js/occupancyplan.js').'</script></body>');
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);

View File

@@ -0,0 +1,314 @@
<?php
/**
* Occupancy plan (tape chart): rooms as rows, days as columns, bookings as bars.
* Pure data + HTML rendering so it can be used by the admin controller and tested from CLI.
*/
class OccupancyPlanRenderer
{
const STATUS_ASSIGNED = 1;
const STATUS_CHECKED_IN = 2;
const STATUS_CHECKED_OUT = 3;
const STATUS_NO_SHOW = 4;
const STATUS_CANCELLED = 5;
protected $from;
protected $days;
protected $idHotel;
protected $idLang;
/**
* @param string $from first day (Y-m-d)
* @param int $days number of days shown
*/
public function __construct($from, $days, $idHotel = 0, $idLang = null)
{
$this->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 '<div class="alert alert-info">'.self::e($L['no_rooms']).'</div>';
}
$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 = '<div class="op-row op-head" style="'.$cols.'"><div class="op-label"></div>';
$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 .= '<div class="op-month" style="grid-column:'.($monthStart + 2).' / '.($i + 2).'">'
.self::e($months[(int)date('n', strtotime($m))].' '.date('Y', strtotime($m))).'</div>';
$monthStart = $i;
}
}
$h .= '</div><div class="op-row op-head" style="'.$cols.'"><div class="op-label">'.self::e($L['room']).'</div>';
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 .= '<div class="'.$cls.'" title="'.self::e(date('d.m.Y', strtotime($d))).'"><span>'.$weekdays[$w].'</span><b>'.date('j', strtotime($d)).'</b></div>';
}
$h .= '</div>';
// rows
$occupied = array_fill(0, $this->days, 0);
$activeRooms = 0;
$body = '';
$lastType = null;
foreach ($rooms as $r) {
if ($r['room_type'] !== $lastType) {
$body .= '<div class="op-group">'.self::e($r['room_type']).'</div>';
$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 .= '<div class="op-row'.($inactive ? ' op-inactive' : '').'" style="'.$cols.'" data-room="'.(int)$r['id'].'"'
.' data-room-num="'.self::e($r['room_num']).'" data-room-type="'.(int)$r['id_product'].'" data-hotel="'.(int)$r['id_hotel'].'">';
$body .= '<div class="op-label"><b>'.self::e($r['room_num']).'</b>'.($inactive ? ' <small>'.self::e($L['inactive']).'</small>' : '').'</div>';
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 .= '<div class="op-cell'.($w === 0 || $w === 6 ? ' op-we' : '').($d === $today ? ' op-today' : '').($free ? ' op-free' : ' op-busy').'"'
.' style="grid-column:'.($i + 2).'" data-date="'.$d.'"'.($free ? ' tabindex="0" role="button" aria-label="'.self::e($r['room_num'].' '.date('d.m.Y', strtotime($d))).'"' : '').'></div>';
}
foreach ($roomBlocked as $bl) {
$s = $this->col($bl['date_from']);
$e = $this->col($bl['date_to']);
if ($e > $s) {
$body .= '<div class="op-bar op-blocked" style="grid-column:'.($s + 2).' / '.($e + 2).'" title="'.self::e($L['blocked'].': '.date('d.m.', strtotime($bl['date_from'])).'–'.date('d.m.Y', strtotime($bl['date_to'])).($bl['reason'] ? ' – '.$bl['reason'] : '')).'">'.self::e($L['blocked']).'</div>';
}
}
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 = '<i class="op-pos op-pos-failed" title="'.self::e($L['pos_failed']).'">!</i>';
} elseif ((int)$b['pos_done']) {
$pos = '<i class="op-pos" title="'.self::e($L['pos'].': '.(int)$b['pos_done'].' '.$L['nights']).'">€'.(int)$b['pos_done'].'</i>';
}
}
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 ? '<i class="op-src '.$srcClass.'">'.self::e($srcShort).'</i>' : '';
$body .= '<'.$tag.' class="op-bar '.$cls.$cut.'" style="grid-column:'.($s + 2).' / '.($e + 2).'" title="'.self::e($title).'">'
.$src.'<span>'.self::e($name).' <small>('.self::e($guests).')</small></span>'.$pos.'</'.explode(' ', $tag)[0].'>';
}
$body .= '</div>';
}
// occupancy footer
$f = '<div class="op-row op-foot" style="'.$cols.'"><div class="op-label">'.self::e($L['occupied']).'</div>';
for ($i = 0; $i < $this->days; $i++) {
$pct = $activeRooms ? round($occupied[$i] * 100 / $activeRooms) : 0;
$f .= '<div class="op-occ" title="'.$occupied[$i].' / '.$activeRooms.'" style="--p:'.$pct.'%"><span>'.$occupied[$i].'</span></div>';
}
$f .= '</div>';
$legend = '<div class="op-legend">';
foreach ($statusClass as $sc) {
$legend .= '<span class="op-bar '.$sc[0].'">'.self::e($sc[1]).'</span>';
}
$legend .= '<span class="op-bar op-blocked">'.self::e($L['blocked']).'</span>';
$legend .= '<span><i class="op-pos">€2</i> '.self::e($L['pos']).'</span>';
$legend .= '<span><i class="op-src op-src-ota">B</i> Booking.com</span><span><i class="op-src op-src-ota">E</i> Expedia</span>'
.'<span><i class="op-src">W</i> Website</span><span><i class="op-src">T</i> Telefon</span><span><i class="op-src">@</i> E-Mail</span></div>';
return '<div class="op-wrap"><div class="op-grid" style="min-width:calc(11rem + '.$this->days.' * 2.4rem)">'.$h.$body.$f.'</div></div>'.$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 '<style>
.op-wrap{overflow-x:auto;border:1px solid #d9dee3;border-radius:4px;background:#fff}
.op-grid{font-size:12px}
.op-row{display:grid;align-items:stretch;border-bottom:1px solid #eef1f4;min-height:34px;position:relative}
.op-row:hover .op-label{background:#f3f6f9}
.op-label{position:sticky;left:0;z-index:3;background:#fff;padding:6px 8px;border-right:1px solid #d9dee3;display:flex;align-items:center;gap:6px;grid-row:1}
.op-head{min-height:0;background:#f8fafb}
.op-head .op-label{background:#f8fafb;font-weight:600}
.op-month{grid-row:1;padding:4px 6px;font-weight:600;border-left:1px solid #d9dee3;white-space:nowrap}
.op-day{grid-row:1;text-align:center;padding:3px 0;border-left:1px solid #eef1f4;line-height:1.2}
.op-day span{display:block;color:#7a8794;font-size:10px}
.op-cell{grid-row:1;border-left:1px solid #eef1f4}
.op-we{background:#f6f7f9}
.op-today{background:#fff4d6!important;box-shadow:inset 2px 0 0 #e0a800}
.op-group{padding:5px 8px;background:#eef2f6;font-weight:600;border-bottom:1px solid #d9dee3;position:sticky;left:0}
.op-inactive .op-label{color:#9aa5b1}
.op-bar{grid-row:1;z-index:2;margin:5px 2px;border-radius:4px;padding:2px 6px;color:#fff;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;display:flex;align-items:center;justify-content:space-between;gap:4px;text-decoration:none;font-weight:600;min-width:0}
a.op-bar:hover,a.op-bar:focus{color:#fff;text-decoration:none;filter:brightness(1.08);outline:2px solid #1f2d3d;outline-offset:1px}
.op-bar span{overflow:hidden;text-overflow:ellipsis}
.op-bar small{font-weight:400;opacity:.9}
.op-assigned{background:#2f7fc1}
.op-in{background:#2e8b57}
.op-out{background:#8a96a3}
.op-noshow{background:repeating-linear-gradient(135deg,#d9822b 0 6px,#c06f1f 6px 12px)}
.op-blocked{background:repeating-linear-gradient(135deg,#b8c0c8 0 5px,#a3acb6 5px 10px);color:#1f2d3d;font-weight:400}
.op-cut-l{border-top-left-radius:0;border-bottom-left-radius:0;margin-left:0}
.op-cut-r{border-top-right-radius:0;border-bottom-right-radius:0;margin-right:0}
.op-pos{font-style:normal;background:rgba(255,255,255,.25);border-radius:3px;padding:0 4px;font-size:10px;flex:none}
.op-pos-failed{background:#c0392b}
.op-src{font-style:normal;flex:none;background:#fff;color:#1f2d3d;border-radius:3px;padding:0 3px;font-size:10px;font-weight:700;line-height:15px}
.op-src-ota{background:#003580;color:#fff}
.op-free{cursor:pointer}
.op-free:hover,.op-free:focus{background:#e3f0fb;outline:none}
.op-sel{background:#cfe6fa!important;box-shadow:inset 0 0 0 1px #2f7fc1}
.op-actionbar{position:sticky;bottom:0;z-index:5;display:flex;flex-wrap:wrap;gap:8px;align-items:center;margin-top:10px;padding:10px 12px;background:#1f2d3d;color:#fff;border-radius:4px;box-shadow:0 -4px 12px rgba(0,0,0,.15)}
.op-actionbar[hidden]{display:none}
.op-actiontext{flex:1 1 20rem;font-weight:600}
.op-foot{background:#f8fafb;min-height:26px}
.op-foot .op-label{background:#f8fafb;font-weight:600}
.op-occ{grid-row:1;border-left:1px solid #eef1f4;display:flex;align-items:flex-end;justify-content:center;background:linear-gradient(to top,#cfe3f3 var(--p),transparent var(--p));font-size:11px;padding-bottom:2px}
.op-legend{display:flex;flex-wrap:wrap;gap:8px;margin:10px 0 0;align-items:center}
.op-legend .op-bar{display:inline-block;margin:0}
.op-legend .op-pos{background:#2e8b57;color:#fff}
@media (prefers-reduced-motion:reduce){*{transition:none!important}}
</style>';
}
}

View File

@@ -0,0 +1,32 @@
<?php
/**
* NOTICE OF LICENSE
*
* This source file is subject to the Open Software License version 3.0
* that is bundled with this package in the file LICENSE.md
* It is also available through the world-wide-web at this URL:
* https://opensource.org/license/osl-3-0-php
* If you did not receive a copy of the license and are unable to
* obtain it through the world-wide-web, please send an email
* to support@qloapps.com so we can send you a copy immediately.
*
* DISCLAIMER
*
* Do not edit or add to this file if you wish to upgrade this module to a newer
* versions in the future. If you wish to customize this module for your needs
* please refer to https://store.webkul.com/customisation-guidelines for more information.
*
* @author Webkul IN
* @copyright Since 2010 Webkul
* @license https://opensource.org/license/osl-3-0-php Open Software License version 3.0
*/
header('Expires: Mon, 26 Jul 1997 05:00:00 GMT');
header('Last-Modified: '.gmdate('D, d M Y H:i:s').' GMT');
header('Cache-Control: no-store, no-cache, must-revalidate');
header('Cache-Control: post-check=0, pre-check=0', false);
header('Pragma: no-cache');
header('Location: ../../../');
exit;

View File

@@ -0,0 +1,79 @@
<?php
require_once _PS_MODULE_DIR_.'occupancyplan/classes/OccupancyPlanRenderer.php';
class AdminOccupancyPlanController extends ModuleAdminController
{
public function __construct()
{
$this->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 = '<form method="get" action="index.php" class="form-inline op-nav">'
.'<input type="hidden" name="controller" value="AdminOccupancyPlan"><input type="hidden" name="token" value="'.$e($this->token).'">'
.'<a class="btn btn-default" href="'.$e($nav('-7 day')).'"><i class="icon-chevron-left"></i> '.$this->l('Woche').'</a> '
.'<a class="btn btn-default" href="'.$e($base.'&days='.(int)$days.($idHotel ? '&id_hotel='.$idHotel : '')).'">'.$this->l('Heute').'</a> '
.'<a class="btn btn-default" href="'.$e($nav('+7 day')).'">'.$this->l('Woche').' <i class="icon-chevron-right"></i></a> '
.'<label for="op-from">'.$this->l('ab').'</label> <input id="op-from" type="date" class="form-control" name="from" value="'.$e($from).'"> '
.'<label for="op-days">'.$this->l('Tage').'</label> <select id="op-days" class="form-control" name="days">';
foreach (array(14, 21, 31, 45, 62) as $d) {
$form .= '<option value="'.$d.'"'.($d == $days ? ' selected' : '').'>'.$d.'</option>';
}
$form .= '</select> ';
if (count($hotels) > 1) {
$form .= '<select class="form-control" name="id_hotel" aria-label="'.$e($this->l('Hotel')).'"><option value="0">'.$this->l('Alle Hotels').'</option>';
foreach ($hotels as $h) {
$form .= '<option value="'.(int)$h['id'].'"'.($h['id'] == $idHotel ? ' selected' : '').'>'.$e($h['hotel_name']).'</option>';
}
$form .= '</select> ';
}
$form .= '<button class="btn btn-primary" type="submit">'.$this->l('Anzeigen').'</button></form>';
// "Book Now" with dates/hotel/room type taken over from the selection (see views/js/occupancyplan.js)
$reserveUrl = $link->getAdminLink('AdminHotelRoomsBooking');
$hint = '<p class="help-block">'.$this->l('Reservieren: freie Zelle anklicken (erste Nacht), dann die letzte Nacht in derselben Zeile anklicken.').'</p>';
return '<div class="panel" data-reserve-url="'.$e($reserveUrl).'"><div class="panel-heading"><i class="icon-calendar"></i> '.$this->l('Belegungsplan').'</div>'
.$form.$hint.'<style>.op-nav{margin-bottom:12px;display:flex;flex-wrap:wrap;gap:6px;align-items:center}</style>'
.$plan->render($orderLink).'</div>';
}
}

View File

@@ -0,0 +1,32 @@
<?php
/**
* NOTICE OF LICENSE
*
* This source file is subject to the Open Software License version 3.0
* that is bundled with this package in the file LICENSE.md
* It is also available through the world-wide-web at this URL:
* https://opensource.org/license/osl-3-0-php
* If you did not receive a copy of the license and are unable to
* obtain it through the world-wide-web, please send an email
* to support@qloapps.com so we can send you a copy immediately.
*
* DISCLAIMER
*
* Do not edit or add to this file if you wish to upgrade this module to a newer
* versions in the future. If you wish to customize this module for your needs
* please refer to https://store.webkul.com/customisation-guidelines for more information.
*
* @author Webkul IN
* @copyright Since 2010 Webkul
* @license https://opensource.org/license/osl-3-0-php Open Software License version 3.0
*/
header('Expires: Mon, 26 Jul 1997 05:00:00 GMT');
header('Last-Modified: '.gmdate('D, d M Y H:i:s').' GMT');
header('Cache-Control: no-store, no-cache, must-revalidate');
header('Cache-Control: post-check=0, pre-check=0', false);
header('Pragma: no-cache');
header('Location: ../../../');
exit;

View File

@@ -0,0 +1,32 @@
<?php
/**
* NOTICE OF LICENSE
*
* This source file is subject to the Open Software License version 3.0
* that is bundled with this package in the file LICENSE.md
* It is also available through the world-wide-web at this URL:
* https://opensource.org/license/osl-3-0-php
* If you did not receive a copy of the license and are unable to
* obtain it through the world-wide-web, please send an email
* to support@qloapps.com so we can send you a copy immediately.
*
* DISCLAIMER
*
* Do not edit or add to this file if you wish to upgrade this module to a newer
* versions in the future. If you wish to customize this module for your needs
* please refer to https://store.webkul.com/customisation-guidelines for more information.
*
* @author Webkul IN
* @copyright Since 2010 Webkul
* @license https://opensource.org/license/osl-3-0-php Open Software License version 3.0
*/
header('Expires: Mon, 26 Jul 1997 05:00:00 GMT');
header('Last-Modified: '.gmdate('D, d M Y H:i:s').' GMT');
header('Cache-Control: no-store, no-cache, must-revalidate');
header('Cache-Control: post-check=0, pre-check=0', false);
header('Pragma: no-cache');
header('Location: ../../../');
exit;

View File

@@ -0,0 +1,32 @@
<?php
/**
* NOTICE OF LICENSE
*
* This source file is subject to the Open Software License version 3.0
* that is bundled with this package in the file LICENSE.md
* It is also available through the world-wide-web at this URL:
* https://opensource.org/license/osl-3-0-php
* If you did not receive a copy of the license and are unable to
* obtain it through the world-wide-web, please send an email
* to support@qloapps.com so we can send you a copy immediately.
*
* DISCLAIMER
*
* Do not edit or add to this file if you wish to upgrade this module to a newer
* versions in the future. If you wish to customize this module for your needs
* please refer to https://store.webkul.com/customisation-guidelines for more information.
*
* @author Webkul IN
* @copyright Since 2010 Webkul
* @license https://opensource.org/license/osl-3-0-php Open Software License version 3.0
*/
header('Expires: Mon, 26 Jul 1997 05:00:00 GMT');
header('Last-Modified: '.gmdate('D, d M Y H:i:s').' GMT');
header('Cache-Control: no-store, no-cache, must-revalidate');
header('Cache-Control: post-check=0, pre-check=0', false);
header('Pragma: no-cache');
header('Location: ../../../');
exit;

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.7 KiB

View File

@@ -0,0 +1,66 @@
<?php
/**
* Belegungsplan: back-office tape chart (rooms × days) under "Hotel Reservation System".
*/
if (!defined('_PS_VERSION_')) {
exit;
}
class Occupancyplan extends Module
{
public function __construct()
{
$this->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'));
}
}

View File

@@ -0,0 +1,32 @@
<?php
/**
* NOTICE OF LICENSE
*
* This source file is subject to the Open Software License version 3.0
* that is bundled with this package in the file LICENSE.md
* It is also available through the world-wide-web at this URL:
* https://opensource.org/license/osl-3-0-php
* If you did not receive a copy of the license and are unable to
* obtain it through the world-wide-web, please send an email
* to support@qloapps.com so we can send you a copy immediately.
*
* DISCLAIMER
*
* Do not edit or add to this file if you wish to upgrade this module to a newer
* versions in the future. If you wish to customize this module for your needs
* please refer to https://store.webkul.com/customisation-guidelines for more information.
*
* @author Webkul IN
* @copyright Since 2010 Webkul
* @license https://opensource.org/license/osl-3-0-php Open Software License version 3.0
*/
header('Expires: Mon, 26 Jul 1997 05:00:00 GMT');
header('Last-Modified: '.gmdate('D, d M Y H:i:s').' GMT');
header('Cache-Control: no-store, no-cache, must-revalidate');
header('Cache-Control: post-check=0, pre-check=0', false);
header('Pragma: no-cache');
header('Location: ../../../');
exit;

View File

@@ -0,0 +1,32 @@
<?php
/**
* NOTICE OF LICENSE
*
* This source file is subject to the Open Software License version 3.0
* that is bundled with this package in the file LICENSE.md
* It is also available through the world-wide-web at this URL:
* https://opensource.org/license/osl-3-0-php
* If you did not receive a copy of the license and are unable to
* obtain it through the world-wide-web, please send an email
* to support@qloapps.com so we can send you a copy immediately.
*
* DISCLAIMER
*
* Do not edit or add to this file if you wish to upgrade this module to a newer
* versions in the future. If you wish to customize this module for your needs
* please refer to https://store.webkul.com/customisation-guidelines for more information.
*
* @author Webkul IN
* @copyright Since 2010 Webkul
* @license https://opensource.org/license/osl-3-0-php Open Software License version 3.0
*/
header('Expires: Mon, 26 Jul 1997 05:00:00 GMT');
header('Last-Modified: '.gmdate('D, d M Y H:i:s').' GMT');
header('Cache-Control: no-store, no-cache, must-revalidate');
header('Cache-Control: post-check=0, pre-check=0', false);
header('Pragma: no-cache');
header('Location: ../../../');
exit;

View File

@@ -0,0 +1,111 @@
/**
* Belegungsplan: select free nights in one room row and hand them to "Book Now".
* Click (or Enter/Space) a free cell = first night, click a later free cell in the same row = last night.
* Escape cancels. The bar's "Reservieren" link opens AdminHotelRoomsBooking with dates and room type.
*/
(function () {
'use strict';
function init() {
var root = document.querySelector('[data-reserve-url]');
if (!root) {
return;
}
var sel = null; // {row, start, end} cells
var bar = document.createElement('div');
bar.className = 'op-actionbar';
bar.setAttribute('role', 'status');
bar.hidden = true;
bar.innerHTML = '<span class="op-actiontext"></span>'
+ '<a class="btn btn-primary op-reserve" href="#"><i class="icon-plus"></i> Reservieren</a>'
+ '<button type="button" class="btn btn-default op-cancel">Abbrechen</button>';
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();
}
})();