Files
hotel/modules/frontdesk/classes/FrontDeskPage.php
DanielS f3b1838402 Add frontdesk: simple "Rezeption" page and staff profile
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>
2026-11-12 00:26:13 +01:00

199 lines
11 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?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>';
}
}