One large-type page for the front desk (Hotel Reservation System > Rezeption, first menu entry): - today's arrivals, departures, guests in house and overdue arrivals, each guest as a card with big room number, name, people, nights, room type and booking source - one obvious button per guest: Einchecken / Auschecken (and "Früher abreisen"); after each action a big confirmation with "Rückgängig" instead of confirm dialogs; Post/Redirect/Get so reload never repeats - uses HotelBookingDetail::changeStatus(), so status history and hooks (casposbridge posts the nights) run as in the order page; check-out does not require payment in QloApps because guests pay at the POS - shows the CASPOS state per guest; other days are read-only - tiles to Belegungsplan and Neue Reservierung (Book Now) - employee profile "Rezeption" that only sees Rezeption, Belegungsplan, Book Now, orders, customers, carts and guest registration cards - docker/test-frontdesk.php: 21 checks with throw-away guests Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
199 lines
11 KiB
PHP
199 lines
11 KiB
PHP
<?php
|
||
/**
|
||
* HTML for the reception page: one screen, large type, one obvious button per guest.
|
||
*/
|
||
require_once dirname(__FILE__).'/FrontDeskService.php';
|
||
|
||
class FrontDeskPage
|
||
{
|
||
protected $service;
|
||
protected $day;
|
||
protected $today;
|
||
/** @var string form action URL (with admin token) */
|
||
protected $actionUrl;
|
||
/** @var array planUrl, reserveUrl, orderUrl(callable) */
|
||
protected $links;
|
||
|
||
public function __construct(FrontDeskService $service, $day, $actionUrl, array $links = array())
|
||
{
|
||
$this->service = $service;
|
||
$this->day = $day;
|
||
$this->today = date('Y-m-d');
|
||
$this->actionUrl = $actionUrl;
|
||
$this->links = $links;
|
||
}
|
||
|
||
protected static function e($s)
|
||
{
|
||
return htmlspecialchars((string)$s, ENT_QUOTES, 'UTF-8');
|
||
}
|
||
|
||
public static function longDate($day)
|
||
{
|
||
$wd = array('Sonntag', 'Montag', 'Dienstag', 'Mittwoch', 'Donnerstag', 'Freitag', 'Samstag');
|
||
$mo = array(1 => 'Januar', 'Februar', 'März', 'April', 'Mai', 'Juni', 'Juli', 'August', 'September', 'Oktober', 'November', 'Dezember');
|
||
$t = strtotime($day);
|
||
return $wd[(int)date('w', $t)].', '.(int)date('j', $t).'. '.$mo[(int)date('n', $t)].' '.date('Y', $t);
|
||
}
|
||
|
||
/**
|
||
* @param array|null $flash ['ok' => bool, 'msg' => string, 'undo' => [id, action]]
|
||
*/
|
||
public function render($flash = null)
|
||
{
|
||
$isToday = $this->day === $this->today;
|
||
$prev = date('Y-m-d', strtotime($this->day.' -1 day'));
|
||
$next = date('Y-m-d', strtotime($this->day.' +1 day'));
|
||
$dayUrl = function ($d) {
|
||
return $this->actionUrl.'&day='.$d;
|
||
};
|
||
|
||
$h = '<div class="fd">';
|
||
$h .= '<div class="fd-top"><div><h1 class="fd-title">Rezeption</h1><p class="fd-date">'.self::e(self::longDate($this->day))
|
||
.($isToday ? ' <span class="fd-today">Heute</span>' : '').'</p></div>'
|
||
.'<nav class="fd-days" aria-label="Tag wählen">'
|
||
.'<a class="fd-btn fd-btn-light" href="'.self::e($dayUrl($prev)).'">‹ Vortag</a>'
|
||
.($isToday ? '' : '<a class="fd-btn fd-btn-light" href="'.self::e($dayUrl($this->today)).'">Heute</a>')
|
||
.'<a class="fd-btn fd-btn-light" href="'.self::e($dayUrl($next)).'">Folgetag ›</a></nav></div>';
|
||
|
||
if ($flash) {
|
||
$h .= '<div class="fd-flash '.($flash['ok'] ? 'fd-flash-ok' : 'fd-flash-err').'" role="status">'
|
||
.'<span>'.($flash['ok'] ? '✓ ' : '✗ ').self::e($flash['msg']).'</span>';
|
||
if (!empty($flash['undo'])) {
|
||
$h .= $this->button($flash['undo'][0], $flash['undo'][1], 'Rückgängig', 'fd-btn-light');
|
||
}
|
||
$h .= '</div>';
|
||
}
|
||
|
||
$h .= '<div class="fd-tiles">';
|
||
if (!empty($this->links['planUrl'])) {
|
||
$h .= '<a class="fd-tile" href="'.self::e($this->links['planUrl']).'"><span aria-hidden="true">▦</span>Belegungsplan</a>';
|
||
}
|
||
if (!empty($this->links['reserveUrl'])) {
|
||
$h .= '<a class="fd-tile" href="'.self::e($this->links['reserveUrl']).'"><span aria-hidden="true">+</span>Neue Reservierung</a>';
|
||
}
|
||
$h .= '</div>';
|
||
|
||
if (!$isToday) {
|
||
$h .= '<p class="fd-note">Sie sehen einen anderen Tag. Ein- und Auschecken geht nur heute.</p>';
|
||
}
|
||
|
||
$overdue = $isToday ? $this->service->overdue($this->day) : array();
|
||
if ($overdue) {
|
||
$h .= $this->section('Noch nicht angekommen', 'Diese Gäste hätten schon anreisen sollen.', $overdue, 'arrive', $isToday, 'fd-warn');
|
||
}
|
||
$h .= $this->section('Anreise', 'Gäste, die heute ankommen.', $this->service->arrivals($this->day), 'arrive', $isToday);
|
||
$h .= $this->section('Abreise', 'Gäste, die heute abreisen. Bezahlt wird an der Kasse.', $this->service->departures($this->day), 'depart', $isToday);
|
||
$h .= $this->section('Im Haus', 'Gäste, die über Nacht bleiben.', $this->service->inHouse($this->day), 'stay', $isToday);
|
||
$h .= '</div>'.self::css();
|
||
return $h;
|
||
}
|
||
|
||
protected function section($title, $sub, array $rows, $kind, $canAct, $extra = '')
|
||
{
|
||
$h = '<section class="fd-sec '.$extra.'"><h2>'.self::e($title).' <span class="fd-count">'.count($rows).'</span></h2><p class="fd-sub">'.self::e($sub).'</p>';
|
||
if (!$rows) {
|
||
return $h.'<p class="fd-empty">Niemand.</p></section>';
|
||
}
|
||
$h .= '<ul class="fd-list">';
|
||
foreach ($rows as $r) {
|
||
$h .= $this->card($r, $kind, $canAct);
|
||
}
|
||
return $h.'</ul></section>';
|
||
}
|
||
|
||
protected function card(array $r, $kind, $canAct)
|
||
{
|
||
$name = trim($r['firstname'].' '.$r['lastname']) ?: 'Buchung #'.(int)$r['id_order'];
|
||
$nights = (int)round((strtotime($r['date_to']) - strtotime($r['date_from'])) / 86400);
|
||
$people = (int)$r['adults'].' '.((int)$r['adults'] === 1 ? 'Erwachsener' : 'Erwachsene')
|
||
.((int)$r['children'] ? ', '.(int)$r['children'].' '.((int)$r['children'] === 1 ? 'Kind' : 'Kinder') : '');
|
||
$facts = $people.' · '.$nights.' '.($nights === 1 ? 'Nacht' : 'Nächte').' · '.date('d.m.', strtotime($r['date_from'])).'–'.date('d.m.', strtotime($r['date_to']))
|
||
.' · '.$r['room_type_name'].($r['source_name'] ? ' · '.$r['source_name'] : '');
|
||
$status = (int)$r['id_status'];
|
||
|
||
$action = '';
|
||
if ($kind === 'arrive') {
|
||
if ($status === FrontDeskService::ASSIGNED) {
|
||
$action = $canAct ? $this->button($r['id'], 'checkin', 'Einchecken', 'fd-btn-go') : '<span class="fd-state">erwartet</span>';
|
||
} else {
|
||
$action = '<span class="fd-state fd-done">✓ Eingecheckt'.($r['check_in'] && $r['check_in'] !== '0000-00-00 00:00:00' ? ' '.date('H:i', strtotime($r['check_in'])) : '').'</span>';
|
||
}
|
||
} elseif ($kind === 'depart') {
|
||
if ($status === FrontDeskService::CHECKED_IN) {
|
||
$action = $canAct ? $this->button($r['id'], 'checkout', 'Auschecken', 'fd-btn-out') : '<span class="fd-state">reist ab</span>';
|
||
} else {
|
||
$action = '<span class="fd-state fd-done">✓ Ausgecheckt'.($r['check_out'] && $r['check_out'] !== '0000-00-00 00:00:00' ? ' '.date('H:i', strtotime($r['check_out'])) : '').'</span>';
|
||
}
|
||
} else {
|
||
$action = $canAct ? $this->button($r['id'], 'checkout', 'Früher abreisen', 'fd-btn-light') : '';
|
||
}
|
||
|
||
$pos = '';
|
||
if (isset($r['pos_done'])) {
|
||
if ((int)$r['pos_failed']) {
|
||
$pos = '<span class="fd-pos fd-pos-bad">Kasse: Fehler – bitte Kassenanbindung prüfen</span>';
|
||
} elseif ((int)$r['pos_done']) {
|
||
$pos = '<span class="fd-pos">Kasse: '.(int)$r['pos_done'].' '.((int)$r['pos_done'] === 1 ? 'Nacht' : 'Nächte').' auf Tisch '.self::e($r['room_num']).'</span>';
|
||
}
|
||
}
|
||
$details = !empty($this->links['orderUrl']) && (int)$r['id_order']
|
||
? '<a class="fd-details" href="'.self::e(call_user_func($this->links['orderUrl'], (int)$r['id_order'])).'">Details</a>' : '';
|
||
|
||
return '<li class="fd-card"><div class="fd-room" aria-label="Zimmer">'.self::e($r['room_num']).'</div>'
|
||
.'<div class="fd-info"><div class="fd-name">'.self::e($name).'</div><div class="fd-facts">'.self::e($facts).'</div>'.$pos.$details.'</div>'
|
||
.'<div class="fd-act">'.$action.'</div></li>';
|
||
}
|
||
|
||
protected function button($idBooking, $action, $label, $class)
|
||
{
|
||
return '<form method="post" action="'.self::e($this->actionUrl.'&day='.$this->day).'" class="fd-form">'
|
||
.'<input type="hidden" name="id_booking" value="'.(int)$idBooking.'">'
|
||
.'<input type="hidden" name="fd_action" value="'.self::e($action).'">'
|
||
.'<button type="submit" name="submitFrontDesk" value="1" class="fd-btn '.$class.'">'.self::e($label).'</button></form>';
|
||
}
|
||
|
||
public static function css()
|
||
{
|
||
return '<style>
|
||
.fd{font-size:18px;line-height:1.45;color:#1b2631;max-width:1100px}
|
||
.fd-top{display:flex;flex-wrap:wrap;justify-content:space-between;align-items:flex-end;gap:12px;margin-bottom:14px}
|
||
.fd-title{font-size:34px;font-weight:700;margin:0}
|
||
.fd-date{font-size:22px;margin:2px 0 0}
|
||
.fd-today{display:inline-block;background:#1b2631;color:#fff;border-radius:6px;padding:0 10px;font-size:16px;vertical-align:middle}
|
||
.fd-days{display:flex;gap:8px;flex-wrap:wrap}
|
||
.fd-btn{display:inline-flex;align-items:center;justify-content:center;min-height:56px;min-width:140px;padding:10px 22px;border-radius:10px;border:2px solid transparent;font-size:20px;font-weight:700;cursor:pointer;text-decoration:none;line-height:1.2}
|
||
.fd-btn:focus-visible,.fd-tile:focus-visible,.fd-details:focus-visible{outline:4px solid #f0b400;outline-offset:2px}
|
||
.fd-btn-go{background:#1e7d32;color:#fff}.fd-btn-go:hover{background:#17652a;color:#fff}
|
||
.fd-btn-out{background:#1557a0;color:#fff}.fd-btn-out:hover{background:#114a88;color:#fff}
|
||
.fd-btn-light{background:#fff;color:#1b2631;border-color:#9aa7b4}.fd-btn-light:hover{background:#eef2f6;color:#1b2631;text-decoration:none}
|
||
.fd-tiles{display:flex;gap:12px;flex-wrap:wrap;margin:6px 0 18px}
|
||
.fd-tile{display:flex;align-items:center;gap:12px;flex:1 1 240px;min-height:72px;padding:14px 20px;border-radius:12px;background:#fff;border:2px solid #c9d2db;font-size:22px;font-weight:700;color:#1b2631;text-decoration:none}
|
||
.fd-tile:hover{border-color:#1557a0;color:#1557a0;text-decoration:none}
|
||
.fd-tile span{font-size:30px;line-height:1}
|
||
.fd-flash{display:flex;flex-wrap:wrap;align-items:center;justify-content:space-between;gap:12px;padding:14px 18px;border-radius:12px;font-size:22px;font-weight:700;margin-bottom:16px}
|
||
.fd-flash-ok{background:#dff3e2;border:2px solid #1e7d32;color:#0f4419}
|
||
.fd-flash-err{background:#fde3e1;border:2px solid #b3261e;color:#6b1510}
|
||
.fd-note{font-size:20px;background:#fff6d6;border:2px solid #e0b100;border-radius:10px;padding:10px 16px}
|
||
.fd-sec{background:#fff;border:2px solid #dbe2e8;border-radius:14px;padding:16px 18px;margin-bottom:18px}
|
||
.fd-sec h2{font-size:26px;font-weight:700;margin:0;display:flex;align-items:center;gap:10px}
|
||
.fd-count{background:#1b2631;color:#fff;border-radius:999px;min-width:36px;text-align:center;font-size:18px;padding:2px 10px}
|
||
.fd-sub{margin:2px 0 12px;color:#4a5866}
|
||
.fd-warn{border-color:#e0b100;background:#fffbea}
|
||
.fd-empty{font-size:20px;color:#4a5866;margin:0}
|
||
.fd-list{list-style:none;margin:0;padding:0;display:grid;gap:10px}
|
||
.fd-card{display:grid;grid-template-columns:auto 1fr auto;align-items:center;gap:16px;padding:12px 14px;border:2px solid #e3e8ed;border-radius:12px}
|
||
.fd-room{font-size:30px;font-weight:800;min-width:84px;text-align:center;background:#eef2f6;border-radius:10px;padding:8px 10px}
|
||
.fd-name{font-size:24px;font-weight:700}
|
||
.fd-facts{color:#3c4a57}
|
||
.fd-pos{display:inline-block;margin-top:4px;font-size:16px;color:#1e7d32;font-weight:600}
|
||
.fd-pos-bad{color:#b3261e}
|
||
.fd-details{display:inline-block;margin:4px 0 0 12px;font-size:16px}
|
||
.fd-state{font-size:20px;font-weight:700;color:#4a5866}
|
||
.fd-done{color:#1e7d32}
|
||
.fd-form{margin:0}
|
||
@media (max-width:700px){.fd-card{grid-template-columns:auto 1fr}.fd-act{grid-column:1 / -1}.fd-act .fd-btn{width:100%}}
|
||
</style>';
|
||
}
|
||
}
|