true, 'cookie_samesite' => 'Lax']);
// =====================================================================
// 1. CONFIGURATION — edit these values for your server / company
// =====================================================================
define('DB_HOST', getenv('LFC_DB_HOST') ?: 'localhost');
define('DB_USER', getenv('LFC_DB_USER') ?: 'ashokbhu_training');
define('DB_PASS', getenv('LFC_DB_PASS') ?: 'Hello@312345');
define('DB_NAME', getenv('LFC_DB_NAME') ?: 'ashokbhu_training');
const COMPANY_NAME = 'Leapfrog Connect Pvt. Ltd.';
const COMPANY_SHORT = 'Leapfrog Connect';
const COMPANY_ADDRESS = 'Dillibazar-30, Kathmandu, Nepal';
const COMPANY_PHONE = '9818981587';
const COMPANY_EMAIL = 'booking@leapfrogconnect.co';
const COMPANY_PAN = ''; // company PAN / VAT no. for invoices (shown when filled)
const PORTAL_URL = 'https://leapfrogconnect.edu.np/booking/';
const LOGO_FULL = 'https://leapfrogconnect.edu.np/booking/connectlogo.png';
const LOGO_ROUND = 'https://leapfrogconnect.edu.np/booking/connectlogo1.png';
const MAIL_FROM = 'no-reply@leapfrogconnect.co';
const ADMIN_NOTIFY_TO = 'booking@leapfrogconnect.co'; // new booking requests are emailed here…
const ADMIN_NOTIFY_CC = 'ashok@leapfrogconnect.co'; // …with a copy here
const OWNER_EMAILS = ['ashok@leapfrogconnect.co', 'booking@leapfrogconnect.co']; // only these may create the FIRST admin account
const LINK_BULK = 'multiple_booking.php';
const LINK_PROPERTIES = 'manage_properties.php';
const LINK_REVENUE = 'revanalysis.php';
const VAT_RATE = 13;
const INVOICE_PREFIX = 'LFH/CW/';
const BOOKING_PREFIX = '#LF-';
const DAY_START_HOUR = 7; // daily schedule 7 AM …
const DAY_END_HOUR = 22; // … to 10 PM
const PER_PAGE = 25;
const DEFAULT_AMENITIES = 'High-Speed Wi-Fi, Air Conditioning, Ergonomic Chairs, Whiteboard, Power Backup';
const INVOICE_NOTE = 'Please settle the invoice within 7 business days. Thank you for your business.';
const FACILITY_NOTE = 'We strictly provide workspace facilities. Additional services, such as catering or food, are not provided unless explicitly pre-agreed.';
const BANK_DETAILS = [
'Bank Name' => 'Nabil Bank Ltd.',
'Branch' => 'Putalisadak Branch, Kathmandu',
'SWIFT Code' => 'NARBNPKA',
'Account Number' => '27201017500451',
'Account Name' => 'Leapfrog Connect Pvt. Ltd.',
];
const TERMS = [
'The client agrees to use only the assigned hall and rooms. Access to unauthorized or restricted areas is not permitted without prior approval from management.',
'Any damage, loss, misuse, or theft of venue property, equipment, or facilities caused during the event shall be charged to the client.',
'The client is responsible for maintaining proper discipline and conduct of all guests, visitors, and vendors during the event period.',
'Overtime use beyond the confirmed booking schedule may be subject to additional charges, and all venue rules and safety guidelines must be followed.',
];
const NOTES = [
'Please note that we operate exclusively as a venue space rather than a hotel. As such, we do not provide catering, food, accommodation, or other hotel amenities.',
];
const ETIQUETTE_DO = [
'Respect others and keep noise levels low.',
'Use headphones during calls or meetings.',
'Keep shared spaces and common areas clean.',
'Book meeting rooms in advance.',
'Tag your food packages when storing them in the kitchen.',
];
const ETIQUETTE_DONT = [
'Don’t overcrowd the workspace assigned to you.',
'Don’t tamper with AC, Wi-Fi, or electricals.',
'Don’t leave food or mess behind in the kitchen.',
'Don’t smoke or bring hazardous items.',
'Don’t misuse CCTV or safety equipment.',
];
// Fixed-order room colours (validated for colour-blind separation); each room keeps its own colour.
const ROOM_COLORS = ['#2a78d6', '#eb6834', '#1baf7a', '#eda100', '#e87ba4', '#008300', '#4a3aa7'];
const STATUSES = ['Pending', 'Approved', 'Tentative', 'Rejected', 'Cancelled'];
const RATE_TYPES = ['day' => 'Per day', 'hour' => 'Per hour', 'flat' => 'Flat / package'];
const PAY_METHODS = ['Cash', 'Bank transfer', 'Cheque', 'Fonepay / QR', 'eSewa', 'Khalti', 'Card'];
const PURPOSES = ['Meeting', 'Training / Workshop', 'Co-working', 'Conference', 'Seminar', 'Interview', 'Exam / Assessment', 'Event / Celebration', 'Photo / Video shoot'];
const SCHEMA_VERSION = 1;
// Approved and Tentative bookings hold the space; Pending requests do not (staff decide between them).
const SQL_BLOCKING = "b.status IN ('Approved','Tentative')";
// =====================================================================
// 2. ERROR HANDLING + DATABASE
// =====================================================================
function fatal_page(string $title, string $detail): void
{
http_response_code(500);
echo '
' . htmlspecialchars($title) . ' '
. ''
. '';
exit;
}
set_exception_handler(function ($e) {
error_log('[LFC booking] ' . $e->getMessage() . ' @ ' . $e->getFile() . ':' . $e->getLine());
if (isset($_GET['api'])) {
http_response_code(500);
header('Content-Type: application/json');
echo json_encode(['ok' => false, 'error' => 'Server error. Please try again.']);
exit;
}
fatal_page('Something went wrong', 'The action could not be completed. Please go back and try again. (Details were written to the server error log.)');
});
mysqli_report(MYSQLI_REPORT_ERROR | MYSQLI_REPORT_STRICT);
try {
$conn = new mysqli(DB_HOST, DB_USER, DB_PASS, DB_NAME);
$conn->set_charset('utf8mb4');
} catch (mysqli_sql_exception $e) {
error_log('[LFC booking] DB connect failed: ' . $e->getMessage());
fatal_page('Database connection failed', 'Please check the database settings (DB_HOST, DB_USER, DB_PASS, DB_NAME) at the top of this file.');
}
try { $conn->query("SET time_zone = '+05:45'"); } catch (Throwable $e) { /* not critical */ }
/** Run a query; with $params it is a prepared statement (safe against SQL injection). */
function db_query(string $sql, array $params = [])
{
global $conn;
if (!$params) {
return $conn->query($sql);
}
$stmt = $conn->prepare($sql);
$types = '';
foreach ($params as $p) {
$types .= is_int($p) ? 'i' : (is_float($p) ? 'd' : 's');
}
$stmt->bind_param($types, ...$params);
$stmt->execute();
$res = $stmt->get_result();
return $res === false ? $stmt : $res;
}
function db_all(string $sql, array $params = []): array
{
$r = db_query($sql, $params);
return ($r instanceof mysqli_result) ? $r->fetch_all(MYSQLI_ASSOC) : [];
}
function db_one(string $sql, array $params = []): ?array
{
$rows = db_all($sql, $params);
return $rows[0] ?? null;
}
function db_val(string $sql, array $params = [])
{
$row = db_one($sql, $params);
return $row ? reset($row) : null;
}
function db_exec(string $sql, array $params = []): int
{
global $conn;
$r = db_query($sql, $params);
if ($r instanceof mysqli_stmt) {
return $r->insert_id ?: $r->affected_rows;
}
return $conn->insert_id ?: $conn->affected_rows;
}
// =====================================================================
// 3. AUTOMATIC SCHEMA (creates tables + adds any missing columns)
// =====================================================================
function ensure_column(string $table, string $col, string $definition): void
{
global $conn;
$r = $conn->query("SHOW COLUMNS FROM `$table` LIKE '" . $conn->real_escape_string($col) . "'");
if ($r && $r->num_rows === 0) {
$conn->query("ALTER TABLE `$table` ADD COLUMN `$col` $definition");
}
}
function ensure_index(string $table, string $name, string $cols): void
{
global $conn;
$r = $conn->query("SHOW INDEX FROM `$table` WHERE Key_name = '" . $conn->real_escape_string($name) . "'");
if ($r && $r->num_rows === 0) {
try { $conn->query("CREATE INDEX `$name` ON `$table` ($cols)"); } catch (Throwable $e) { /* optional */ }
}
}
function ensure_schema(): void
{
global $conn;
$conn->query("CREATE TABLE IF NOT EXISTS app_meta (meta_key VARCHAR(64) PRIMARY KEY, meta_value TEXT)");
$ver = (int) db_val("SELECT meta_value FROM app_meta WHERE meta_key = 'lfc_schema_version'");
if ($ver >= SCHEMA_VERSION) {
return;
}
$conn->query("CREATE TABLE IF NOT EXISTS rooms (
id INT AUTO_INCREMENT PRIMARY KEY,
name VARCHAR(150) NOT NULL,
base_rate DECIMAL(10,2) NOT NULL DEFAULT 0
)");
$conn->query("CREATE TABLE IF NOT EXISTS bookings (
id INT AUTO_INCREMENT PRIMARY KEY,
room_id INT NOT NULL,
guest_name VARCHAR(255) NOT NULL,
guest_email VARCHAR(255),
guest_pan VARCHAR(50),
guest_address TEXT,
guest_phone VARCHAR(50),
booking_date DATE NOT NULL,
end_date DATE NOT NULL,
start_time TIME NOT NULL,
end_time TIME NOT NULL,
custom_rate DECIMAL(10,2) NOT NULL DEFAULT 0,
total_amount DECIMAL(10,2) NOT NULL DEFAULT 0
)");
$conn->query("CREATE TABLE IF NOT EXISTS generated_invoices (
id INT AUTO_INCREMENT PRIMARY KEY,
invoice_number VARCHAR(50) NOT NULL,
invoice_date DATE NOT NULL,
guest_name VARCHAR(255) NOT NULL,
guest_email VARCHAR(255),
guest_address TEXT,
start_date DATE,
end_date DATE,
sub_total DECIMAL(10,2),
vat_amount DECIMAL(10,2),
total_amount DECIMAL(10,2),
items_json TEXT,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
)");
$conn->query("CREATE TABLE IF NOT EXISTS booking_payments (
id INT AUTO_INCREMENT PRIMARY KEY,
booking_id INT NOT NULL,
amount DECIMAL(10,2) NOT NULL,
method VARCHAR(40),
reference VARCHAR(120),
paid_on DATE,
created_by VARCHAR(100),
created_at DATETIME
)");
$conn->query("CREATE TABLE IF NOT EXISTS app_users (
id INT AUTO_INCREMENT PRIMARY KEY,
name VARCHAR(100) NOT NULL,
email VARCHAR(191) NOT NULL UNIQUE,
password_hash VARCHAR(255) NOT NULL,
role VARCHAR(20) NOT NULL DEFAULT 'staff',
last_login DATETIME NULL,
created_at DATETIME NULL
)");
// Room details (amenities / images / description are shared with manage_properties.php)
ensure_column('rooms', 'amenities', 'TEXT NULL');
ensure_column('rooms', 'images', 'TEXT NULL');
ensure_column('rooms', 'description', 'TEXT NULL');
ensure_column('rooms', 'hourly_rate', 'DECIMAL(10,2) NULL');
ensure_column('rooms', 'capacity', 'INT NULL');
ensure_column('rooms', 'color', 'VARCHAR(7) NULL');
ensure_column('rooms', 'is_active', 'TINYINT(1) NOT NULL DEFAULT 1');
// Booking workflow + details (existing rows keep working)
ensure_column('bookings', 'status', "VARCHAR(20) DEFAULT 'Pending'");
ensure_column('bookings', 'admin_remarks', 'TEXT NULL');
ensure_column('bookings', 'rate_type', "VARCHAR(10) NOT NULL DEFAULT 'day'");
ensure_column('bookings', 'discount', 'DECIMAL(10,2) NOT NULL DEFAULT 0');
ensure_column('bookings', 'amount_paid', 'DECIMAL(10,2) NOT NULL DEFAULT 0');
ensure_column('bookings', 'purpose', 'VARCHAR(255) NULL');
ensure_column('bookings', 'attendees', 'INT NULL');
ensure_column('bookings', 'notes', 'TEXT NULL');
ensure_column('bookings', 'created_by', 'VARCHAR(100) NULL');
ensure_column('bookings', 'created_at', 'DATETIME NULL');
ensure_column('bookings', 'updated_at', 'DATETIME NULL');
// Invoice details
ensure_column('generated_invoices', 'guest_pan', 'VARCHAR(50) NULL');
ensure_column('generated_invoices', 'discount', 'DECIMAL(10,2) NOT NULL DEFAULT 0');
ensure_column('generated_invoices', 'vat_rate', 'DECIMAL(5,2) NOT NULL DEFAULT 13');
ensure_column('generated_invoices', 'status', "VARCHAR(20) NOT NULL DEFAULT 'Unpaid'");
ensure_column('generated_invoices', 'notes', 'TEXT NULL');
ensure_column('generated_invoices', 'created_by', 'VARCHAR(100) NULL');
ensure_index('bookings', 'idx_lfc_dates', 'booking_date, end_date');
ensure_index('bookings', 'idx_lfc_room', 'room_id');
ensure_index('bookings', 'idx_lfc_status', 'status');
ensure_index('booking_payments', 'idx_lfc_booking', 'booking_id');
// One-time defaults (the old code overwrote empty amenities on every page load)
$conn->query("UPDATE rooms SET amenities = '" . $conn->real_escape_string(DEFAULT_AMENITIES) . "' WHERE amenities IS NULL OR amenities = ''");
$conn->query("UPDATE bookings SET status = 'Pending' WHERE status IS NULL OR status = ''");
$i = 0;
foreach (db_all("SELECT id, color FROM rooms ORDER BY id") as $rm) {
if (empty($rm['color'])) {
db_exec("UPDATE rooms SET color = ? WHERE id = ?", [ROOM_COLORS[$i % count(ROOM_COLORS)], (int) $rm['id']]);
}
$i++;
}
db_exec("INSERT INTO app_meta (meta_key, meta_value) VALUES ('lfc_schema_version', ?) ON DUPLICATE KEY UPDATE meta_value = VALUES(meta_value)", [(string) SCHEMA_VERSION]);
}
ensure_schema();
// =====================================================================
// 4. HELPERS
// =====================================================================
function h($v): string { return htmlspecialchars((string) ($v ?? ''), ENT_QUOTES, 'UTF-8'); }
/** Nepali / South-Asian digit grouping: 1,50,000.00 */
function nformat($n, int $dec = 0): string
{
$n = (float) $n;
$neg = $n < 0;
$s = number_format(abs($n), $dec, '.', '');
$parts = explode('.', $s);
$int = $parts[0];
if (strlen($int) > 3) {
$last = substr($int, -3);
$rest = substr($int, 0, -3);
$rest = preg_replace('/\B(?=(\d{2})+(?!\d))/', ',', $rest);
$int = $rest . ',' . $last;
}
return ($neg ? '-' : '') . $int . (isset($parts[1]) ? '.' . $parts[1] : '');
}
function money($n, ?int $dec = null): string
{
$n = (float) $n;
if ($dec === null) {
$dec = abs($n - round($n)) > 0.004 ? 2 : 0;
}
return 'Rs. ' . nformat($n, $dec);
}
function valid_date($v, $default = null)
{
if (is_string($v) && preg_match('/^(\d{4})-(\d{2})-(\d{2})$/', $v, $m) && checkdate((int) $m[2], (int) $m[3], (int) $m[1])) {
return $v;
}
return $default;
}
function valid_time($v, $default = null)
{
if (is_string($v) && preg_match('/^([01]\d|2[0-3]):([0-5]\d)(:[0-5]\d)?$/', $v)) {
return substr($v, 0, 5);
}
return $default;
}
function fmt_date($d, string $f = 'd M Y'): string { return $d ? date($f, strtotime((string) $d)) : '—'; }
function fmt_time($t): string { return $t ? date('g:i A', strtotime((string) $t)) : '—'; }
function fmt_range($sd, $ed): string
{
if ($sd === $ed) {
return fmt_date($sd, 'D, d M Y');
}
$a = strtotime((string) $sd);
$b = strtotime((string) $ed);
if (date('Y-m', $a) === date('Y-m', $b)) {
return date('d', $a) . '–' . date('d M Y', $b);
}
if (date('Y', $a) === date('Y', $b)) {
return date('d M', $a) . ' – ' . date('d M Y', $b);
}
return date('d M Y', $a) . ' – ' . date('d M Y', $b);
}
function booking_ref($id): string { return BOOKING_PREFIX . str_pad((string) (int) $id, 4, '0', STR_PAD_LEFT); }
function days_between($sd, $ed): int
{
$d = (new DateTime((string) $sd))->diff(new DateTime((string) $ed));
return $d->invert ? 0 : $d->days + 1;
}
function hours_between($st, $et): float
{
$h = (strtotime('2000-01-01 ' . $et) - strtotime('2000-01-01 ' . $st)) / 3600;
return max(0, round($h, 2));
}
function short_text($s, int $n): string
{
$s = trim((string) $s);
if (function_exists('mb_strimwidth')) return mb_strimwidth($s, 0, $n, '…', 'UTF-8');
return strlen($s) > $n ? substr($s, 0, $n - 1) . '…' : $s;
}
function num_clean($n): string { return rtrim(rtrim(number_format((float) $n, 2), '0'), '.'); }
/** All charge figures for a booking. total = pre-VAT amount stored in bookings.total_amount (same as before). */
function compute_totals(float $rate, string $type, $sd, $ed, $st, $et, float $discount = 0, float $paid = 0): array
{
$days = days_between($sd, $ed);
$hours = hours_between($st, $et);
$qty = $type === 'hour' ? round($days * $hours, 2) : ($type === 'flat' ? 1 : $days);
$gross = round($rate * $qty, 2);
$discount = min(max(0, $discount), $gross);
$total = round($gross - $discount, 2);
$vat = round($total * VAT_RATE / 100, 2);
$grand = round($total + $vat, 2);
return compact('days', 'hours', 'qty', 'gross', 'discount', 'total', 'vat', 'grand') + ['paid' => $paid, 'balance' => round($grand - $paid, 2)];
}
function booking_totals(array $b): array
{
return compute_totals((float) $b['custom_rate'], $b['rate_type'] ?: 'day', $b['booking_date'], $b['end_date'], $b['start_time'], $b['end_time'], (float) $b['discount'], (float) $b['amount_paid']);
}
function is_blocking($status): bool { return in_array($status, ['Approved', 'Tentative'], true); }
function pay_status(array $t): string
{
if ($t['grand'] <= 0) return 'No charge';
if ($t['paid'] >= $t['grand'] - 0.5) return 'Paid';
return $t['paid'] > 0 ? 'Partial' : 'Unpaid';
}
function time_status(array $b): string
{
$now = date('Y-m-d H:i:s');
if ($now < $b['booking_date'] . ' ' . $b['start_time']) return 'Upcoming';
if ($now > $b['end_date'] . ' ' . $b['end_time']) return 'Completed';
return 'In progress';
}
function badge(string $label, string $kind = '', string $text = ''): string
{
$map = [
'Approved' => 'b-ok', 'Tentative' => 'b-warn', 'Pending' => 'b-pend', 'Rejected' => 'b-bad', 'Cancelled' => 'b-muted',
'Paid' => 'b-ok', 'Partial' => 'b-warn', 'Unpaid' => 'b-bad', 'No charge' => 'b-muted',
'Upcoming' => 'b-info', 'In progress' => 'b-live', 'Completed' => 'b-muted',
];
$icons = [
'Approved' => 'check_circle', 'Tentative' => 'schedule', 'Pending' => 'hourglass_top', 'Rejected' => 'block', 'Cancelled' => 'cancel',
'Paid' => 'paid', 'Partial' => 'hourglass_top', 'Unpaid' => 'error', 'No charge' => 'money_off',
'Upcoming' => 'event_upcoming', 'In progress' => 'radio_button_checked', 'Completed' => 'task_alt',
];
$cls = $kind ?: ($map[$label] ?? 'b-muted');
$icon = isset($icons[$label]) ? '' . $icons[$label] . ' ' : '';
return '' . $icon . h($text !== '' ? $text : $label) . ' ';
}
function status_label(string $s): string { return $s === 'Pending' ? 'Awaiting review' : ($s === 'Rejected' ? 'Declined' : $s); }
function status_badge(string $s): string { return badge($s, '', status_label($s)); }
function room_dot($color): string { return ' '; }
function initials(string $name): string
{
$p = preg_split('/\s+/', trim($name));
$s = strtoupper(substr($p[0] ?? '', 0, 1) . substr($p[1] ?? '', 0, 1));
return $s ?: 'U';
}
function wa_link(string $phone, string $text): string
{
$d = preg_replace('/\D+/', '', $phone);
if (strlen($d) === 10 && $d[0] === '9') $d = '977' . $d;
return 'https://wa.me/' . $d . '?text=' . rawurlencode($text);
}
function amenity_list($csv): array
{
return array_values(array_filter(array_map('trim', explode(',', (string) $csv)), 'strlen'));
}
function room_images($json): array
{
$a = json_decode((string) $json, true);
return is_array($a) ? array_values(array_filter($a, function ($x) { return is_string($x) && $x !== '' && stripos($x, 'javascript:') !== 0; })) : [];
}
/** Material Symbols icon for a facility name. */
function amenity_icon(string $name): string
{
$f = strtolower($name);
$map = [['wi-fi', 'wifi'], ['wifi', 'wifi'], ['internet', 'wifi'], ['air condition', 'ac_unit'], ['ac', 'ac_unit'], ['chair', 'chair'], ['seating', 'chair'],
['whiteboard', 'draw'], ['board', 'draw'], ['power', 'power'], ['backup', 'power'], ['electric', 'power'], ['projector', 'videocam'], ['screen', 'tv'], ['tv', 'tv'],
['coffee', 'coffee'], ['tea', 'emoji_food_beverage'], ['water', 'water_drop'], ['sound', 'speaker'], ['mic', 'mic'], ['parking', 'local_parking'], ['print', 'print'], ['locker', 'lock']];
foreach ($map as [$k, $icon]) {
if ($k === 'ac' ? preg_match('/\bac\b/', $f) : strpos($f, $k) !== false) return $icon;
}
return 'check_circle';
}
/** Amount in words — Nepali numbering (lakh / crore). */
function amount_in_words(float $amount): string
{
$amount = round($amount, 2);
$rupees = (int) floor($amount);
$paisa = (int) round(($amount - $rupees) * 100);
$w = 'Rupees ' . ($rupees ? num_words($rupees) : 'Zero');
if ($paisa) $w .= ' and ' . num_words($paisa) . ' Paisa';
return $w . ' Only';
}
function num_words(int $n): string
{
$ones = ['', 'One', 'Two', 'Three', 'Four', 'Five', 'Six', 'Seven', 'Eight', 'Nine', 'Ten', 'Eleven', 'Twelve', 'Thirteen', 'Fourteen', 'Fifteen', 'Sixteen', 'Seventeen', 'Eighteen', 'Nineteen'];
$tens = ['', '', 'Twenty', 'Thirty', 'Forty', 'Fifty', 'Sixty', 'Seventy', 'Eighty', 'Ninety'];
$two = function ($x) use ($ones, $tens) { return $x < 20 ? $ones[$x] : trim($tens[intdiv($x, 10)] . ' ' . $ones[$x % 10]); };
$out = [];
foreach ([[10000000, 'Crore'], [100000, 'Lakh'], [1000, 'Thousand'], [100, 'Hundred']] as $u) {
if ($n >= $u[0]) {
$q = intdiv($n, $u[0]);
$out[] = ($u[0] === 10000000 && $q >= 100 ? num_words($q) : $two($q)) . ' ' . $u[1];
$n %= $u[0];
}
}
if ($n) $out[] = $two($n);
return trim(implode(' ', $out));
}
// ---------- CSRF, flash, redirect ----------
if (empty($_SESSION['csrf'])) $_SESSION['csrf'] = bin2hex(random_bytes(16));
function csrf_field(): string { return ' '; }
function csrf_ok(): bool { return isset($_POST['csrf']) && hash_equals($_SESSION['csrf'], (string) $_POST['csrf']); }
function flash(string $type, string $msg): void { $_SESSION['flash'][] = ['type' => $type, 'msg' => $msg]; }
function redirect(string $url): void { header('Location: ' . $url); exit; }
function back_to(string $fallback): string
{
$r = $_POST['return_to'] ?? '';
return (is_string($r) && $r !== '' && $r[0] === '?' && strpbrk($r, "\r\n") === false) ? $r : $fallback;
}
function json_out(array $data, int $code = 200): void
{
http_response_code($code);
header('Content-Type: application/json; charset=utf-8');
echo json_encode($data);
exit;
}
// =====================================================================
// 5. SIGN-IN — clients (name + email) and staff (email + password)
// =====================================================================
$auth_error = '';
$auth_tab = 'client';
$staff_count = (int) db_val("SELECT COUNT(*) FROM app_users");
if (isset($_GET['logout'])) {
$_SESSION = [];
session_regenerate_id(true);
redirect('?');
}
function start_session_as(string $kind, array $data): void
{
session_regenerate_id(true);
unset($_SESSION['lfc_uid'], $_SESSION['lfc_client']);
if ($kind === 'staff') {
$_SESSION['lfc_uid'] = (int) $data['id'];
$_SESSION['logged_user'] = $data['name']; // legacy keys used by the sibling pages
$_SESSION['role'] = 'admin';
} else {
$_SESSION['lfc_client'] = $data;
$_SESSION['logged_user'] = $data['name'];
$_SESSION['role'] = 'user';
}
}
if ($_SERVER['REQUEST_METHOD'] === 'POST' && in_array($_POST['action'] ?? '', ['client_login', 'staff_login', 'setup'], true)) {
$act = $_POST['action'];
$auth_tab = $act === 'client_login' ? 'client' : 'staff';
if (!csrf_ok()) {
$auth_error = 'Your session expired. Please try again.';
} elseif ($act === 'client_login') {
$n = trim((string) ($_POST['name'] ?? ''));
$e = strtolower(trim((string) ($_POST['email'] ?? '')));
$p = trim((string) ($_POST['phone'] ?? ''));
if ($n === '' || !filter_var($e, FILTER_VALIDATE_EMAIL)) {
$auth_error = 'Please enter your full name and a valid email address.';
} else {
start_session_as('client', ['name' => $n, 'email' => $e, 'phone' => $p]);
redirect($_SESSION['after_login'] ?? '?view=dashboard');
}
} elseif ($act === 'setup' && $staff_count === 0) {
$n = trim((string) ($_POST['name'] ?? ''));
$e = strtolower(trim((string) ($_POST['email'] ?? '')));
$p = (string) ($_POST['password'] ?? '');
if ($n === '' || !filter_var($e, FILTER_VALIDATE_EMAIL)) {
$auth_error = 'Please enter your name and a valid email.';
} elseif (!in_array($e, array_map('strtolower', OWNER_EMAILS), true)) {
$auth_error = 'The first admin account must use one of the owner emails set in OWNER_EMAILS at the top of the file.';
} elseif (strlen($p) < 8) {
$auth_error = 'Password must be at least 8 characters.';
} elseif ($p !== ($_POST['password2'] ?? '')) {
$auth_error = 'Passwords do not match.';
} else {
$uid = db_exec("INSERT INTO app_users (name, email, password_hash, role, created_at) VALUES (?, ?, ?, 'admin', ?)", [$n, $e, password_hash($p, PASSWORD_DEFAULT), date('Y-m-d H:i:s')]);
start_session_as('staff', ['id' => $uid, 'name' => $n]);
flash('success', 'Welcome, ' . $n . '! Your admin account is ready. Add staff under Rooms & settings → Users.');
redirect('?view=dashboard');
}
} elseif ($act === 'staff_login') {
$e = strtolower(trim((string) ($_POST['email'] ?? '')));
$u = db_one("SELECT * FROM app_users WHERE email = ?", [$e]);
if ($u && password_verify((string) ($_POST['password'] ?? ''), $u['password_hash'])) {
start_session_as('staff', $u);
db_exec("UPDATE app_users SET last_login = ? WHERE id = ?", [date('Y-m-d H:i:s'), (int) $u['id']]);
$to = $_SESSION['after_login'] ?? '?view=dashboard';
unset($_SESSION['after_login']);
redirect($to);
}
usleep(400000);
$auth_error = 'Incorrect email or password.';
}
}
$current_user = null;
$client = null;
if (!empty($_SESSION['lfc_uid'])) {
$current_user = db_one("SELECT id, name, email, role FROM app_users WHERE id = ?", [(int) $_SESSION['lfc_uid']]);
} elseif (!empty($_SESSION['lfc_client']['email'])) {
$client = $_SESSION['lfc_client'];
}
if (!$current_user && !$client) {
if (isset($_GET['api']) || isset($_GET['export']) || isset($_GET['export_csv']) || isset($_GET['download_ics'])) json_out(['ok' => false, 'error' => 'Please sign in again.'], 401);
if ($_SERVER['REQUEST_METHOD'] === 'GET' && !empty($_SERVER['QUERY_STRING'])) $_SESSION['after_login'] = '?' . $_SERVER['QUERY_STRING'];
render_auth_page($auth_tab, $auth_error, $staff_count === 0);
exit;
}
$is_staff = (bool) $current_user; // booking team (any staff account)
$is_admin = $is_staff && $current_user['role'] === 'admin'; // can delete, manage rooms and users
$me = $is_staff ? $current_user['name'] : $client['name'];
// =====================================================================
// 6. DOMAIN LOGIC
// =====================================================================
function get_rooms(bool $active_only = true): array
{
return db_all("SELECT * FROM rooms" . ($active_only ? " WHERE COALESCE(is_active,1) = 1" : "") . " ORDER BY id");
}
function get_booking(int $id): ?array
{
return db_one("SELECT b.*, r.name AS rname, r.color AS rcolor, r.capacity AS rcapacity, r.amenities AS ramenities, r.base_rate AS rbase, r.hourly_rate AS rhourly
FROM bookings b LEFT JOIN rooms r ON r.id = b.room_id WHERE b.id = ?", [$id]);
}
/** Approved / tentative bookings that overlap a room, date range and daily time window. */
function find_conflicts(int $room_id, string $sd, string $ed, string $st, string $et, int $exclude = 0): array
{
return db_all(
"SELECT b.id, b.guest_name, b.booking_date, b.end_date, b.start_time, b.end_time, b.status FROM bookings b
WHERE b.room_id = ? AND " . SQL_BLOCKING . " AND b.booking_date <= ? AND b.end_date >= ? AND b.start_time < ? AND b.end_time > ? AND b.id <> ?
ORDER BY b.booking_date, b.start_time",
[$room_id, $ed, $sd, substr($et, 0, 5) . ':00', substr($st, 0, 5) . ':00', $exclude]
);
}
/** Other pending requests for the same room and time (staff choose between them). */
function find_pending_overlaps(int $room_id, string $sd, string $ed, string $st, string $et, int $exclude = 0): array
{
return db_all(
"SELECT b.id, b.guest_name FROM bookings b WHERE b.room_id = ? AND b.status = 'Pending' AND b.booking_date <= ? AND b.end_date >= ?
AND b.start_time < ? AND b.end_time > ? AND b.id <> ? ORDER BY b.id",
[$room_id, $ed, $sd, substr($et, 0, 5) . ':00', substr($st, 0, 5) . ':00', $exclude]
);
}
/** Live "right now" status for every room (one query). */
function live_room_status(): array
{
$today = date('Y-m-d');
$now = date('H:i:s');
$out = [];
foreach (db_all("SELECT b.room_id, b.start_time, b.end_time FROM bookings b WHERE ? BETWEEN b.booking_date AND b.end_date AND " . SQL_BLOCKING . " ORDER BY b.start_time", [$today]) as $b) {
$rid = (int) $b['room_id'];
if (isset($out[$rid]) && $out[$rid]['state'] === 'busy') continue;
if ($b['start_time'] <= $now && $b['end_time'] > $now) {
$out[$rid] = ['state' => 'busy', 'text' => 'In use until ' . fmt_time($b['end_time'])];
} elseif ($b['start_time'] > $now && !isset($out[$rid])) {
$mins = (int) floor((strtotime($b['start_time']) - strtotime($now)) / 60);
$dur = $mins >= 60 ? floor($mins / 60) . ' hr' . (floor($mins / 60) > 1 ? 's' : '') . ($mins % 60 ? ' ' . ($mins % 60) . ' min' : '') : $mins . ' min';
$out[$rid] = ['state' => 'soon', 'text' => 'Free for the next ' . $dur . ' (booked at ' . fmt_time($b['start_time']) . ')'];
}
}
return $out;
}
function availability(string $sd, string $ed, string $st, string $et, int $exclude, bool $staff): array
{
$live = live_room_status();
$out = [];
foreach (get_rooms() as $r) {
$c = find_conflicts((int) $r['id'], $sd, $ed, $st, $et, $exclude);
$row = [
'id' => (int) $r['id'], 'name' => $r['name'], 'color' => $r['color'] ?: '#9BEF6B',
'capacity' => $r['capacity'] !== null ? (int) $r['capacity'] : null,
'description' => (string) ($r['description'] ?? ''),
'amenities' => array_map(function ($a) { return ['name' => $a, 'icon' => amenity_icon($a)]; }, amenity_list($r['amenities'])),
'images' => room_images($r['images']),
'live' => $live[(int) $r['id']] ?? ['state' => 'free', 'text' => 'Free for the rest of today'],
'available' => !$c,
'conflicts' => array_map(function ($x) use ($staff) {
$row = ['dates' => fmt_range($x['booking_date'], $x['end_date']), 'time' => fmt_time($x['start_time']) . ' – ' . fmt_time($x['end_time'])];
if ($staff) $row += ['id' => (int) $x['id'], 'ref' => booking_ref($x['id']), 'guest' => $x['guest_name'], 'status' => $x['status']];
return $row;
}, $c),
];
if ($staff) {
$row['base_rate'] = (float) $r['base_rate'];
$row['hourly_rate'] = $r['hourly_rate'] !== null ? (float) $r['hourly_rate'] : round((float) $r['base_rate'] / 8);
$row['pending'] = count(find_pending_overlaps((int) $r['id'], $sd, $ed, $st, $et, $exclude));
}
$out[] = $row;
}
return $out;
}
/** Validate booking form input. Returns [data, errors]. Clients always create Pending requests without prices. */
function read_booking_input(array $in, int $exclude, bool $staff): array
{
$e = [];
$d = [
'room_id' => (int) ($in['room_id'] ?? 0),
'guest_name' => trim((string) ($in['g_name'] ?? '')),
'guest_email' => strtolower(trim((string) ($in['g_email'] ?? ''))),
'guest_phone' => trim((string) ($in['g_phone'] ?? '')),
'guest_pan' => trim((string) ($in['g_pan'] ?? '')),
'guest_address' => trim((string) ($in['g_address'] ?? '')),
'booking_date' => valid_date($in['start_date'] ?? ''),
'end_date' => valid_date($in['end_date'] ?? ''),
'start_time' => valid_time($in['s_time'] ?? ''),
'end_time' => valid_time($in['e_time'] ?? ''),
'rate_type' => $staff && array_key_exists($in['rate_type'] ?? '', RATE_TYPES) ? $in['rate_type'] : 'day',
'custom_rate' => $staff ? round((float) ($in['custom_rate'] ?? 0), 2) : 0.0,
'discount' => $staff ? round((float) ($in['discount'] ?? 0), 2) : 0.0,
'status' => $staff ? (in_array($in['status'] ?? '', STATUSES, true) ? $in['status'] : 'Approved') : 'Pending',
'purpose' => trim((string) ($in['purpose'] ?? '')),
'attendees' => ($in['attendees'] ?? '') === '' ? null : max(0, (int) $in['attendees']),
'notes' => trim((string) ($in['notes'] ?? '')),
'admin_remarks' => $staff ? trim((string) ($in['admin_remarks'] ?? '')) : null,
];
$room = $d['room_id'] ? db_one("SELECT * FROM rooms WHERE id = ?", [$d['room_id']]) : null;
if (!$room) $e[] = 'Please choose a valid space.';
if ($d['guest_name'] === '') $e[] = 'Guest / organisation name is required.';
if ($d['guest_phone'] === '') $e[] = 'Phone number is required.';
if (!$staff && $d['guest_email'] === '') $e[] = 'Email address is required.';
if ($d['guest_email'] !== '' && !filter_var($d['guest_email'], FILTER_VALIDATE_EMAIL)) $e[] = 'The email address is not valid.';
if (!$d['booking_date'] || !$d['end_date']) $e[] = 'Please choose valid dates.';
elseif ($d['end_date'] < $d['booking_date']) $e[] = 'The end date cannot be before the start date.';
elseif (!$staff && $d['end_date'] < date('Y-m-d')) $e[] = 'Please choose dates from today onwards.';
if (!$d['start_time'] || !$d['end_time']) $e[] = 'Please choose valid start and end times.';
elseif ($d['end_time'] <= $d['start_time']) $e[] = 'The end time must be after the start time.';
if ($d['custom_rate'] < 0 || $d['discount'] < 0) $e[] = 'Rate and discount cannot be negative.';
// Over-capacity is allowed on purpose (shown as a warning in the form and on the booking).
if (!$e && ($d['status'] === 'Pending' || is_blocking($d['status']))) {
$c = find_conflicts($d['room_id'], $d['booking_date'], $d['end_date'], $d['start_time'], $d['end_time'], $exclude);
if ($c) {
if ($staff) {
$list = array_map(function ($x) { return booking_ref($x['id']) . ' ' . $x['guest_name'] . ' (' . fmt_range($x['booking_date'], $x['end_date']) . ', ' . fmt_time($x['start_time']) . '–' . fmt_time($x['end_time']) . ')'; }, $c);
$e[] = ($room['name'] ?? 'This space') . ' is already booked at that time: ' . implode('; ', $list) . '.';
} else {
$e[] = ($room['name'] ?? 'This space') . ' is already booked for part of that time. Please pick another time or space.';
}
}
}
if (!$e) {
$t = compute_totals($d['custom_rate'], $d['rate_type'], $d['booking_date'], $d['end_date'], $d['start_time'], $d['end_time'], $d['discount']);
$d['discount'] = $t['discount'];
$d['total_amount'] = $t['total'];
$d['start_time'] .= ':00';
$d['end_time'] .= ':00';
}
return [$d, $e];
}
// ---------- Branded emails ----------
function mail_html(string $to, string $subject, string $html, string $cc = ''): bool
{
if (!filter_var($to, FILTER_VALIDATE_EMAIL)) return false;
$headers = "MIME-Version: 1.0\r\n";
$headers .= "Content-Type: text/html; charset=UTF-8\r\n";
$headers .= 'From: ' . COMPANY_SHORT . ' <' . MAIL_FROM . ">\r\n";
if (COMPANY_EMAIL && $to !== COMPANY_EMAIL) $headers .= 'Reply-To: ' . COMPANY_EMAIL . "\r\n";
if ($cc && filter_var($cc, FILTER_VALIDATE_EMAIL)) $headers .= 'Cc: ' . $cc . "\r\n";
try {
return (bool) @mail($to, $subject, $html, $headers);
} catch (Throwable $e) {
return false;
}
}
function email_layout(string $eyebrow, string $headline, string $body): string
{
$contact = trim(implode(' · ', array_filter([COMPANY_ADDRESS, COMPANY_PHONE, COMPANY_EMAIL])));
return "
" . h($eyebrow) . "
" . h($headline) . "
$body
" . h(COMPANY_NAME) . ($contact ? ' · ' . h($contact) : '') . "
";
}
function email_rows(array $pairs): string
{
$html = '';
foreach ($pairs as $k => $v) {
if ($v === null || $v === '') continue;
$html .= "" . h($k) . " $v ";
}
return "";
}
function email_note(string $title, string $text, string $tone = 'solar'): string
{
$c = ['solar' => ['#FCF9D2', '#D4B800', '#4a4300'], 'red' => ['#FBE7E7', '#C23434', '#8A2525'], 'lime' => ['#E6F6DB', '#5DAE3A', '#1F5A17']][$tone];
return "" . h($title) . "
" . h($text) . "
";
}
function email_button(string $label, string $url): string
{
return "";
}
function email_terms(bool $with_amenities, array $b): string
{
$html = '';
if ($with_amenities) {
$list = '';
foreach (amenity_list($b['ramenities'] ?? '') ?: ['Standard facilities'] as $a) $list .= "" . h($a) . " ";
$html .= "Included room facilities
";
}
$terms = '';
foreach (TERMS as $x) $terms .= "" . h($x) . " ";
$html .= "Terms & conditions
$terms
Important facility note: " . h(FACILITY_NOTE) . "
";
return $html;
}
function booking_email_rows(array $b, bool $with_money): string
{
$t = booking_totals($b);
$rows = [
'Booking ID' => h(booking_ref($b['id'])),
'Space' => h($b['rname']),
'Check-in' => h(fmt_date($b['booking_date'], 'D, d M Y')) . ' at ' . h(fmt_time($b['start_time'])),
'Check-out' => h(fmt_date($b['end_date'], 'D, d M Y')) . ' at ' . h(fmt_time($b['end_time'])),
'Duration' => $t['days'] . ' day(s) · ' . num_clean($t['hours']) . ' hrs/day',
'Purpose' => $b['purpose'] ? h($b['purpose']) : null,
'Expected guests' => $b['attendees'] ? (int) $b['attendees'] : null,
];
if ($with_money && $t['grand'] > 0) {
$rows['Amount (excl. VAT)'] = h(money($t['total'], 2));
$rows['VAT (' . VAT_RATE . '%)'] = h(money($t['vat'], 2));
$rows['Total payable'] = '' . h(money($t['grand'], 2)) . ' ';
if ($t['paid'] > 0) {
$rows['Received'] = h(money($t['paid'], 2));
$rows['Balance due'] = h(money(max(0, $t['balance']), 2));
}
}
return email_rows($rows);
}
/** $kind: approved | confirmed | tentative | modified | declined | received */
function email_guest(array $b, string $kind): bool
{
$name = h(trim($b['guest_name']));
$remarks = trim((string) ($b['admin_remarks'] ?? ''));
switch ($kind) {
case 'received':
$subject = 'We received your booking request ' . booking_ref($b['id']);
$body = "Namaste $name ,
Thank you! Your request is awaiting review . Our team will confirm the space and pricing and email you shortly.
"
. booking_email_rows($b, false) . email_terms(false, $b);
$html = email_layout(COMPANY_SHORT, 'Request received', $body);
break;
case 'declined':
$subject = 'Booking request declined ' . booking_ref($b['id']) . ' - ' . COMPANY_SHORT;
$body = "Namaste $name ,
Thank you for your interest in " . h(COMPANY_SHORT) . ". Unfortunately we are unable to accept this request.
"
. booking_email_rows($b, false)
. email_note('Reason', $remarks ?: 'No specific reason was provided. Please contact us for details.', 'red')
. "If you would like to try other dates or another space, please reply to this email or call " . h(COMPANY_PHONE) . ".
";
$html = email_layout(COMPANY_SHORT, 'Booking request declined', $body);
break;
default:
$heads = ['approved' => 'Your booking is approved', 'confirmed' => 'Your booking is confirmed', 'tentative' => 'Your booking is on hold', 'modified' => 'Your booking was updated'];
$intro = [
'approved' => 'Good news — your booking request has been reviewed and approved . Here are your reservation details:',
'confirmed' => 'Your booking has been created and confirmed by our team. Here are your reservation details:',
'tentative' => 'We have provisionally reserved the space for you. It will be confirmed once finalised with our team.',
'modified' => 'Your booking details have been updated by our team. Here are the latest details:',
];
$kind = isset($heads[$kind]) ? $kind : 'confirmed';
$subject = ['approved' => 'Booking Approved', 'confirmed' => 'Booking Confirmed', 'tentative' => 'Booking on hold', 'modified' => 'Booking Modified'][$kind] . ' ' . booking_ref($b['id']) . ' - ' . COMPANY_SHORT;
$body = "Namaste $name ,
{$intro[$kind]}
"
. booking_email_rows($b, true)
. email_note('Remarks from our team', $remarks ?: 'Your booking is confirmed. We look forward to hosting you!', 'solar')
. email_terms(true, $b)
. "Thank you for choosing " . h(COMPANY_SHORT) . ".
";
$html = email_layout(COMPANY_SHORT, $heads[$kind], $body);
}
return mail_html((string) $b['guest_email'], $subject, $html);
}
function email_admin_request(array $b): bool
{
$rows = email_rows([
'Request' => h(booking_ref($b['id'])),
'Guest' => h($b['guest_name']),
'Email' => h($b['guest_email']),
'Phone' => h($b['guest_phone']),
'Space' => h($b['rname']),
'Dates' => h(fmt_range($b['booking_date'], $b['end_date'])),
'Timings' => h(fmt_time($b['start_time']) . ' – ' . fmt_time($b['end_time'])),
'Purpose' => $b['purpose'] ? h($b['purpose']) : null,
'Guests' => $b['attendees'] ? (int) $b['attendees'] : null,
'Notes' => $b['notes'] ? nl2br(h($b['notes'])) : null,
]);
$body = "Hello team,
A new workspace booking request needs your review and pricing. Status: Pending approval .
"
. $rows . email_button('Review in the booking portal', PORTAL_URL . '?view=booking&id=' . (int) $b['id']);
return mail_html(ADMIN_NOTIFY_TO, 'New Booking Request (' . booking_ref($b['id']) . ') - Pending Approval', email_layout('Action required', 'New booking request', $body), ADMIN_NOTIFY_CC);
}
function email_admin_client_cancel(array $b): bool
{
$body = "The client withdrew this request:
" . email_rows(['Request' => h(booking_ref($b['id'])), 'Guest' => h($b['guest_name']), 'Space' => h($b['rname']), 'Dates' => h(fmt_range($b['booking_date'], $b['end_date']))]);
return mail_html(ADMIN_NOTIFY_TO, 'Request withdrawn (' . booking_ref($b['id']) . ')', email_layout('For your information', 'Request withdrawn', $body));
}
function record_payment(int $booking_id, float $amount, string $method, string $ref, string $paid_on, string $by): void
{
db_exec("INSERT INTO booking_payments (booking_id, amount, method, reference, paid_on, created_by, created_at) VALUES (?, ?, ?, ?, ?, ?, ?)",
[$booking_id, $amount, $method, $ref, $paid_on, $by, date('Y-m-d H:i:s')]);
db_exec("UPDATE bookings SET amount_paid = amount_paid + ?, updated_at = ? WHERE id = ?", [$amount, date('Y-m-d H:i:s'), $booking_id]);
}
/** Calendar file (.ics); multi-day bookings repeat daily in the booked time window. */
function ics_for_booking(array $b): string
{
$esc = function ($s) { return str_replace(["\r\n", "\n"], '\\n', preg_replace('/([,;\\\\])/', '\\\\$1', (string) $s)); };
$days = days_between($b['booking_date'], $b['end_date']);
$lines = [
'BEGIN:VCALENDAR', 'VERSION:2.0', 'PRODID:-//Leapfrog Connect//Booking//EN', 'CALSCALE:GREGORIAN', 'METHOD:PUBLISH',
'BEGIN:VTIMEZONE', 'TZID:Asia/Kathmandu', 'BEGIN:STANDARD', 'DTSTART:19860101T000000', 'TZOFFSETFROM:+0530', 'TZOFFSETTO:+0545', 'TZNAME:NPT', 'END:STANDARD', 'END:VTIMEZONE',
'BEGIN:VEVENT',
'UID:lfc-booking-' . (int) $b['id'] . '@leapfrogconnect',
'DTSTAMP:' . gmdate('Ymd\THis\Z'),
'DTSTART;TZID=Asia/Kathmandu:' . date('Ymd\THis', strtotime($b['booking_date'] . ' ' . $b['start_time'])),
'DTEND;TZID=Asia/Kathmandu:' . date('Ymd\THis', strtotime($b['booking_date'] . ' ' . $b['end_time'])),
];
if ($days > 1) $lines[] = 'RRULE:FREQ=DAILY;COUNT=' . $days;
$lines[] = 'SUMMARY:' . $esc('Workspace booking - ' . $b['rname']);
$lines[] = 'DESCRIPTION:' . $esc('Booking ' . booking_ref($b['id']) . ' at ' . COMPANY_NAME . ($b['purpose'] ? "\n" . $b['purpose'] : ''));
$lines[] = 'LOCATION:' . $esc(COMPANY_ADDRESS);
$lines[] = 'END:VEVENT';
$lines[] = 'END:VCALENDAR';
return implode("\r\n", $lines) . "\r\n";
}
/** Filters shared by the bookings list and CSV export. */
function booking_filters(array $src): array
{
$f = [
'search' => trim((string) ($src['search'] ?? '')),
'filter' => (string) ($src['filter'] ?? ''),
'from' => valid_date($src['from'] ?? '', ''),
'to' => valid_date($src['to'] ?? '', ''),
'room' => (int) ($src['room'] ?? 0),
'status' => in_array($src['status'] ?? '', STATUSES, true) ? $src['status'] : '',
'pay' => in_array($src['pay'] ?? '', ['unpaid', 'partial', 'paid', 'due'], true) ? $src['pay'] : '',
];
$today = date('Y-m-d');
$w = ['1=1'];
$p = [];
if ($f['search'] !== '') {
$like = '%' . $f['search'] . '%';
$idq = (int) preg_replace('/\D/', '', $f['search']);
$w[] = "(b.guest_name LIKE ? OR b.guest_email LIKE ? OR b.guest_pan LIKE ? OR b.guest_phone LIKE ? OR b.purpose LIKE ? OR b.id = ?)";
array_push($p, $like, $like, $like, $like, $like, $idq);
}
if ($f['filter'] === 'today') { $w[] = "? BETWEEN b.booking_date AND b.end_date"; $p[] = $today; }
elseif ($f['filter'] === 'month') { $w[] = "b.booking_date <= ? AND b.end_date >= ?"; array_push($p, date('Y-m-t'), date('Y-m-01')); }
elseif ($f['filter'] === 'upcoming') { $w[] = "b.booking_date > ? AND " . SQL_BLOCKING; $p[] = $today; }
elseif ($f['filter'] === 'pending') { $w[] = "b.status = 'Pending'"; }
if ($f['from']) { $w[] = "b.end_date >= ?"; $p[] = $f['from']; }
if ($f['to']) { $w[] = "b.booking_date <= ?"; $p[] = $f['to']; }
if ($f['room']) { $w[] = "b.room_id = ?"; $p[] = $f['room']; }
if ($f['status']) { $w[] = "b.status = ?"; $p[] = $f['status']; }
$grand = "(b.total_amount * " . (1 + VAT_RATE / 100) . ")";
if ($f['pay'] === 'unpaid') $w[] = "b.amount_paid <= 0 AND $grand > 0 AND " . SQL_BLOCKING;
if ($f['pay'] === 'partial') $w[] = "b.amount_paid > 0 AND b.amount_paid < $grand - 0.5 AND " . SQL_BLOCKING;
if ($f['pay'] === 'paid') $w[] = "b.amount_paid >= $grand - 0.5 AND $grand > 0";
if ($f['pay'] === 'due') $w[] = "b.amount_paid < $grand - 0.5 AND " . SQL_BLOCKING;
return [$f, implode(' AND ', $w), $p];
}
// =====================================================================
// 7. JSON API · CALENDAR FILE · CSV EXPORT
// =====================================================================
if (isset($_GET['api'])) {
if ($_GET['api'] === 'availability') {
$sd = valid_date($_GET['sd'] ?? '');
$ed = valid_date($_GET['ed'] ?? '');
$st = valid_time($_GET['st'] ?? '');
$et = valid_time($_GET['et'] ?? '');
if (!$sd || !$ed || !$st || !$et) json_out(['ok' => false, 'error' => 'Choose valid dates and times.']);
if ($ed < $sd) json_out(['ok' => false, 'error' => 'The end date is before the start date.']);
if ($et <= $st) json_out(['ok' => false, 'error' => 'The end time must be after the start time.']);
json_out(['ok' => true, 'days' => days_between($sd, $ed), 'hours' => hours_between($st, $et),
'rooms' => availability($sd, $ed, $st, $et, $is_staff ? (int) ($_GET['exclude'] ?? 0) : 0, $is_staff)]);
}
json_out(['ok' => false, 'error' => 'Unknown request'], 404);
}
if (isset($_GET['download_ics'])) {
$b = get_booking((int) $_GET['download_ics']);
$mine = $b && $client && strcasecmp((string) $b['guest_email'], $client['email']) === 0;
if (!$b || !($is_staff || $mine) || !is_blocking($b['status'])) {
flash('danger', 'Calendar files are available for approved bookings only.');
redirect('?view=dashboard');
}
header('Content-Type: text/calendar; charset=utf-8');
header('Content-Disposition: attachment; filename="Booking_LF_' . str_pad((string) $b['id'], 4, '0', STR_PAD_LEFT) . '.ics"');
echo ics_for_booking($b);
exit;
}
if ($is_staff && (($_GET['export'] ?? '') === 'bookings' || isset($_GET['export_csv']))) {
[$f, $where, $params] = booking_filters($_GET);
$rows = db_all("SELECT b.*, r.name AS rname FROM bookings b LEFT JOIN rooms r ON r.id = b.room_id WHERE $where ORDER BY b.booking_date DESC, b.start_time", $params);
header('Content-Type: text/csv; charset=utf-8');
header('Content-Disposition: attachment; filename="Leapfrog_Connect_Bookings_' . date('Y-m-d') . '.csv"');
$out = fopen('php://output', 'w');
fwrite($out, "\xEF\xBB\xBF");
fputcsv($out, ['Booking ID', 'Status', 'Guest', 'Email', 'Phone', 'PAN', 'Address', 'Space', 'From', 'To', 'Start', 'End', 'Days', 'Purpose', 'Attendees', 'Rate basis', 'Rate', 'Discount', 'Amount (excl. VAT)', 'VAT', 'Total incl. VAT', 'Received', 'Balance', 'Payment', 'Remarks', 'Created by', 'Created at']);
foreach ($rows as $b) {
$t = booking_totals($b);
fputcsv($out, [booking_ref($b['id']), $b['status'], $b['guest_name'], $b['guest_email'], $b['guest_phone'], $b['guest_pan'], $b['guest_address'], $b['rname'],
$b['booking_date'], $b['end_date'], substr($b['start_time'], 0, 5), substr($b['end_time'], 0, 5), $t['days'], $b['purpose'], $b['attendees'],
RATE_TYPES[$b['rate_type']] ?? $b['rate_type'], $b['custom_rate'], $b['discount'], $t['total'], $t['vat'], $t['grand'], $t['paid'], $t['balance'],
is_blocking($b['status']) ? pay_status($t) : '—', $b['admin_remarks'], $b['created_by'], $b['created_at']]);
}
fclose($out);
exit;
}
// =====================================================================
// 8. FORM ACTIONS (Post → Redirect → Get, so refresh never double-books)
// =====================================================================
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
$action = (string) ($_POST['action'] ?? '');
if (!csrf_ok()) {
flash('danger', 'Your session expired — please try that again.');
redirect(back_to('?'));
}
$now = date('Y-m-d H:i:s');
$staff_only = ['review_booking', 'update_booking', 'set_status', 'add_payment', 'resend_email', 'save_invoice', 'invoice_status', 'change_password'];
$admin_only = ['delete_booking', 'delete_payment', 'delete_invoice', 'save_room', 'delete_room', 'add_user', 'delete_user', 'reset_user_password'];
if ((in_array($action, $staff_only, true) && !$is_staff) || (in_array($action, $admin_only, true) && !$is_admin)) {
flash('danger', 'You do not have permission for that action.');
redirect('?');
}
// ---------- Create booking (staff: direct booking · client: request) ----------
if ($action === 'create_booking') {
$in = $_POST;
if (!$is_staff) $in['g_email'] = $client['email']; // a client's requests always use their sign-in email
[$d, $errors] = read_booking_input($in, 0, $is_staff);
if ($is_staff && !in_array($d['status'], ['Approved', 'Tentative'], true)) $d['status'] = 'Approved';
$advance = $is_staff ? max(0, round((float) ($_POST['advance'] ?? 0), 2)) : 0;
if ($errors) {
$_SESSION['old_booking'] = array_diff_key($_POST, ['csrf' => 1]);
foreach ($errors as $er) flash('danger', $er);
redirect('?view=new&' . http_build_query(['sd' => $_POST['start_date'] ?? '', 'ed' => $_POST['end_date'] ?? '', 'st' => $_POST['s_time'] ?? '', 'et' => $_POST['e_time'] ?? '']));
}
$id = db_exec(
"INSERT INTO bookings (room_id, guest_name, guest_email, guest_pan, guest_address, guest_phone, booking_date, end_date, start_time, end_time, custom_rate, total_amount, rate_type, discount, amount_paid, status, purpose, attendees, notes, admin_remarks, created_by, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 0, ?, ?, ?, ?, ?, ?, ?, ?)",
[$d['room_id'], $d['guest_name'], $d['guest_email'], $d['guest_pan'], $d['guest_address'], $d['guest_phone'], $d['booking_date'], $d['end_date'], $d['start_time'], $d['end_time'],
$d['custom_rate'], $d['total_amount'], $d['rate_type'], $d['discount'], $d['status'], $d['purpose'], $d['attendees'], $d['notes'], $d['admin_remarks'],
$is_staff ? $me : $me . ' (client)', $now, $now]
);
if ($advance > 0) {
$method = in_array($_POST['advance_method'] ?? '', PAY_METHODS, true) ? $_POST['advance_method'] : 'Cash';
record_payment($id, $advance, $method, 'Advance', date('Y-m-d'), $me);
}
$b = get_booking($id);
if (!$is_staff) {
email_admin_request($b);
email_guest($b, 'received');
$_SESSION['lfc_client']['phone'] = $d['guest_phone'];
flash('success', 'Request ' . booking_ref($id) . ' sent for ' . $b['rname'] . '. We will email you at ' . $client['email'] . ' once it is reviewed.');
redirect('?view=dashboard#my-requests');
}
$msg = 'Booking ' . booking_ref($id) . ' saved — ' . $b['rname'] . ', ' . fmt_range($b['booking_date'], $b['end_date']) . '.';
if (!empty($_POST['send_email'])) {
if (!$b['guest_email']) $msg .= ' No email sent (no guest email).';
elseif (email_guest($b, $d['status'] === 'Tentative' ? 'tentative' : 'confirmed')) $msg .= ' Confirmation email sent.';
else flash('warning', 'The booking is saved, but the confirmation email could not be sent. Check the server mail settings.');
}
flash('success', $msg);
redirect('?view=booking&id=' . $id);
}
// ---------- Client withdraws their own pending request ----------
if ($action === 'client_cancel' && !$is_staff) {
$b = get_booking((int) ($_POST['booking_id'] ?? 0));
if ($b && strcasecmp((string) $b['guest_email'], $client['email']) === 0 && $b['status'] === 'Pending') {
db_exec("UPDATE bookings SET status = 'Cancelled', updated_at = ? WHERE id = ?", [$now, (int) $b['id']]);
email_admin_client_cancel($b);
flash('success', 'Request ' . booking_ref($b['id']) . ' withdrawn.');
} else {
flash('danger', 'Only requests that are still awaiting review can be withdrawn. Please call ' . COMPANY_PHONE . ' for help.');
}
redirect('?view=dashboard#my-requests');
}
// ---------- Review a request: approve (with price) or decline ----------
if ($action === 'review_booking') {
$id = (int) ($_POST['booking_id'] ?? 0);
$b = get_booking($id);
$decision = ($_POST['decision'] ?? '') === 'Rejected' ? 'Rejected' : 'Approved';
$remarks = trim((string) ($_POST['admin_remarks'] ?? ''));
if (!$b) { flash('danger', 'Booking not found.'); redirect('?view=dashboard'); }
if ($decision === 'Approved') {
$c = find_conflicts((int) $b['room_id'], $b['booking_date'], $b['end_date'], $b['start_time'], $b['end_time'], $id);
if ($c) {
flash('danger', 'Cannot approve — ' . $b['rname'] . ' is already booked by ' . $c[0]['guest_name'] . ' (' . booking_ref($c[0]['id']) . ', ' . fmt_time($c[0]['start_time']) . '–' . fmt_time($c[0]['end_time']) . '). Edit the request to move it, or decline it.');
redirect(back_to('?view=booking&id=' . $id));
}
$type = array_key_exists($_POST['rate_type'] ?? '', RATE_TYPES) ? $_POST['rate_type'] : 'day';
$rate = max(0, round((float) ($_POST['allocated_rate'] ?? 0), 2));
$t = compute_totals($rate, $type, $b['booking_date'], $b['end_date'], $b['start_time'], $b['end_time'], round((float) ($_POST['discount'] ?? 0), 2));
db_exec("UPDATE bookings SET status = 'Approved', admin_remarks = ?, rate_type = ?, custom_rate = ?, discount = ?, total_amount = ?, updated_at = ? WHERE id = ?",
[$remarks, $type, $rate, $t['discount'], $t['total'], $now, $id]);
} else {
db_exec("UPDATE bookings SET status = 'Rejected', admin_remarks = ?, updated_at = ? WHERE id = ?", [$remarks, $now, $id]);
}
$b = get_booking($id);
$msg = booking_ref($id) . ' ' . ($decision === 'Approved' ? 'approved' : 'declined') . '.';
if (!empty($_POST['notify'])) {
$msg .= email_guest($b, $decision === 'Approved' ? 'approved' : 'declined') ? ' The guest has been emailed.' : ' (The email could not be sent.)';
}
flash('success', $msg);
redirect(back_to('?view=booking&id=' . $id));
}
// ---------- Update booking ----------
if ($action === 'update_booking') {
$id = (int) ($_POST['booking_id'] ?? 0);
if (!get_booking($id)) { flash('danger', 'Booking not found.'); redirect('?view=reports'); }
[$d, $errors] = read_booking_input($_POST, $id, true);
if ($errors) {
$_SESSION['old_edit'] = array_diff_key($_POST, ['csrf' => 1]);
foreach ($errors as $er) flash('danger', $er);
redirect('?view=edit&id=' . $id);
}
db_exec(
"UPDATE bookings SET room_id=?, guest_name=?, guest_email=?, guest_pan=?, guest_address=?, guest_phone=?, booking_date=?, end_date=?, start_time=?, end_time=?,
custom_rate=?, total_amount=?, rate_type=?, discount=?, status=?, purpose=?, attendees=?, notes=?, admin_remarks=?, updated_at=? WHERE id=?",
[$d['room_id'], $d['guest_name'], $d['guest_email'], $d['guest_pan'], $d['guest_address'], $d['guest_phone'], $d['booking_date'], $d['end_date'], $d['start_time'], $d['end_time'],
$d['custom_rate'], $d['total_amount'], $d['rate_type'], $d['discount'], $d['status'], $d['purpose'], $d['attendees'], $d['notes'], $d['admin_remarks'], $now, $id]
);
$msg = 'Changes to ' . booking_ref($id) . ' saved.';
if (!empty($_POST['send_email']) && $d['guest_email']) {
$msg .= email_guest(get_booking($id), 'modified') ? ' Update emailed to the guest.' : ' (Email could not be sent.)';
}
flash('success', $msg);
redirect('?view=booking&id=' . $id);
}
// ---------- Status change ----------
if ($action === 'set_status') {
$id = (int) ($_POST['booking_id'] ?? 0);
$status = in_array($_POST['status'] ?? '', STATUSES, true) ? $_POST['status'] : '';
$b = get_booking($id);
if ($b && $status) {
if (is_blocking($status) && !is_blocking($b['status'])) {
$c = find_conflicts((int) $b['room_id'], $b['booking_date'], $b['end_date'], $b['start_time'], $b['end_time'], $id);
if ($c) {
flash('danger', 'Cannot mark as ' . strtolower($status) . ' — the space is booked by ' . $c[0]['guest_name'] . ' (' . booking_ref($c[0]['id']) . '). Edit the booking to pick another time or space.');
redirect('?view=booking&id=' . $id);
}
}
db_exec("UPDATE bookings SET status = ?, updated_at = ? WHERE id = ?", [$status, $now, $id]);
flash('success', booking_ref($id) . ' is now ' . strtolower(status_label($status)) . '.' . (in_array($status, ['Cancelled', 'Rejected'], true) ? ' The time slot is free again.' : ''));
}
redirect(back_to('?view=booking&id=' . $id));
}
if ($action === 'delete_booking') {
$id = (int) ($_POST['booking_id'] ?? 0);
db_exec("DELETE FROM booking_payments WHERE booking_id = ?", [$id]);
db_exec("DELETE FROM bookings WHERE id = ?", [$id]);
flash('success', 'Booking ' . booking_ref($id) . ' deleted permanently.');
redirect('?view=reports');
}
// ---------- Payments ----------
if ($action === 'add_payment') {
$id = (int) ($_POST['booking_id'] ?? 0);
$amount = round((float) ($_POST['amount'] ?? 0), 2);
$method = in_array($_POST['method'] ?? '', PAY_METHODS, true) ? $_POST['method'] : 'Cash';
$paid_on = valid_date($_POST['paid_on'] ?? '', date('Y-m-d'));
if ($amount <= 0 || !get_booking($id)) {
flash('danger', 'Enter a payment amount greater than zero.');
} else {
record_payment($id, $amount, $method, trim((string) ($_POST['reference'] ?? '')), $paid_on, $me);
flash('success', money($amount, 2) . ' recorded (' . $method . ').');
}
redirect('?view=booking&id=' . $id . '#payments');
}
if ($action === 'delete_payment') {
$p = db_one("SELECT * FROM booking_payments WHERE id = ?", [(int) ($_POST['payment_id'] ?? 0)]);
if ($p) {
db_exec("DELETE FROM booking_payments WHERE id = ?", [(int) $p['id']]);
db_exec("UPDATE bookings SET amount_paid = GREATEST(0, amount_paid - ?), updated_at = ? WHERE id = ?", [(float) $p['amount'], $now, (int) $p['booking_id']]);
flash('success', 'Payment entry removed.');
redirect('?view=booking&id=' . (int) $p['booking_id'] . '#payments');
}
redirect('?view=reports');
}
if ($action === 'resend_email') {
$id = (int) ($_POST['booking_id'] ?? 0);
$b = get_booking($id);
$kind = $b ? ['Approved' => 'approved', 'Tentative' => 'tentative', 'Rejected' => 'declined', 'Pending' => 'received'][$b['status']] ?? '' : '';
if ($b && !$b['guest_email']) flash('warning', 'This booking has no guest email. Add one via Edit first.');
elseif ($b && $kind && email_guest($b, $kind)) flash('success', 'Email sent to ' . $b['guest_email'] . '.');
else flash('danger', 'The email could not be sent (or there is nothing to send for this status).');
redirect('?view=booking&id=' . $id);
}
// ---------- Invoices (totals recalculated on the server) ----------
if ($action === 'save_invoice') {
$descs = (array) ($_POST['item_desc'] ?? []);
$items = [];
$sub = 0.0;
foreach ($descs as $i => $desc) {
$desc = trim((string) $desc);
$qty = round((float) ($_POST['item_qty'][$i] ?? 0), 2);
$rate = round((float) ($_POST['item_rate'][$i] ?? 0), 2);
if ($desc === '' && $rate == 0) continue;
$line = round($qty * $rate, 2);
$sub += $line;
$items[] = ['desc' => $desc, 'date' => trim((string) ($_POST['item_date'][$i] ?? '')), 'qty' => $qty, 'rate' => $rate, 'total' => $line];
}
$guest = trim((string) ($_POST['inv_guest_name'] ?? ''));
if (!$items || $guest === '') {
flash('danger', 'An invoice needs a customer and at least one line item.');
redirect(back_to('?view=invoices'));
}
$discount = min(max(0, round((float) ($_POST['inv_discount'] ?? 0), 2)), $sub);
$vat_rate = !empty($_POST['inv_vat_on']) ? (float) VAT_RATE : 0.0;
$taxable = round($sub - $discount, 2);
$vat = round($taxable * $vat_rate / 100, 2);
$total = round($taxable + $vat, 2);
$iid = db_exec(
"INSERT INTO generated_invoices (invoice_number, invoice_date, guest_name, guest_email, guest_address, guest_pan, start_date, end_date, sub_total, discount, vat_rate, vat_amount, total_amount, items_json, status, notes, created_by, created_at)
VALUES ('', ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'Unpaid', ?, ?, ?)",
[valid_date($_POST['inv_date'] ?? '', date('Y-m-d')), $guest, trim((string) ($_POST['inv_guest_email'] ?? '')), trim((string) ($_POST['inv_guest_address'] ?? '')), trim((string) ($_POST['inv_guest_pan'] ?? '')),
valid_date($_POST['inv_start_date'] ?? ''), valid_date($_POST['inv_end_date'] ?? ''), round($sub, 2), $discount, $vat_rate, $vat, $total,
json_encode($items, JSON_UNESCAPED_UNICODE), trim((string) ($_POST['inv_notes'] ?? '')), $me, $now]
);
$num = INVOICE_PREFIX . str_pad((string) $iid, 4, '0', STR_PAD_LEFT);
db_exec("UPDATE generated_invoices SET invoice_number = ? WHERE id = ?", [$num, $iid]);
flash('success', 'Invoice ' . $num . ' saved — ' . money($total, 2) . '.');
redirect('?view=invoice_view&id=' . $iid);
}
if ($action === 'invoice_status') {
$iid = (int) ($_POST['invoice_id'] ?? 0);
$st = ($_POST['status'] ?? '') === 'Paid' ? 'Paid' : 'Unpaid';
db_exec("UPDATE generated_invoices SET status = ? WHERE id = ?", [$st, $iid]);
flash('success', 'Invoice marked as ' . strtolower($st) . '.');
redirect('?view=invoice_view&id=' . $iid);
}
if ($action === 'delete_invoice') {
db_exec("DELETE FROM generated_invoices WHERE id = ?", [(int) ($_POST['invoice_id'] ?? 0)]);
flash('success', 'Invoice deleted.');
redirect('?view=invoices');
}
// ---------- Rooms ----------
if ($action === 'save_room') {
$rid = (int) ($_POST['room_id'] ?? 0);
$name = trim((string) ($_POST['name'] ?? ''));
$color = preg_match('/^#[0-9a-fA-F]{6}$/', (string) ($_POST['color'] ?? '')) ? $_POST['color'] : ROOM_COLORS[0];
$amen = implode(', ', amenity_list($_POST['amenities'] ?? ''));
$vals = [$name, round((float) ($_POST['base_rate'] ?? 0), 2),
($_POST['hourly_rate'] ?? '') === '' ? null : round((float) $_POST['hourly_rate'], 2),
($_POST['capacity'] ?? '') === '' ? null : max(0, (int) $_POST['capacity']),
$color, trim((string) ($_POST['description'] ?? '')), $amen, !empty($_POST['is_active']) ? 1 : 0];
if ($name === '') {
flash('danger', 'Room name is required.');
} elseif ($rid) {
db_exec("UPDATE rooms SET name=?, base_rate=?, hourly_rate=?, capacity=?, color=?, description=?, amenities=?, is_active=? WHERE id=?", array_merge($vals, [$rid]));
flash('success', $name . ' updated.');
} else {
db_exec("INSERT INTO rooms (name, base_rate, hourly_rate, capacity, color, description, amenities, is_active) VALUES (?, ?, ?, ?, ?, ?, ?, ?)", $vals);
flash('success', $name . ' added. Add photos from Manage Properties.');
}
redirect('?view=settings&tab=rooms');
}
if ($action === 'delete_room') {
$rid = (int) ($_POST['room_id'] ?? 0);
if ((int) db_val("SELECT COUNT(*) FROM bookings WHERE room_id = ?", [$rid]) > 0) {
db_exec("UPDATE rooms SET is_active = 0 WHERE id = ?", [$rid]);
flash('warning', 'This room has booking history, so it was hidden (inactive) instead of deleted.');
} else {
db_exec("DELETE FROM rooms WHERE id = ?", [$rid]);
flash('success', 'Room deleted.');
}
redirect('?view=settings&tab=rooms');
}
// ---------- Users ----------
if ($action === 'add_user') {
$n = trim((string) ($_POST['name'] ?? ''));
$e = strtolower(trim((string) ($_POST['email'] ?? '')));
$p = (string) ($_POST['password'] ?? '');
$role = ($_POST['role'] ?? '') === 'admin' ? 'admin' : 'staff';
if ($n === '' || !filter_var($e, FILTER_VALIDATE_EMAIL) || strlen($p) < 8) {
flash('danger', 'Enter a name, a valid email and a password of at least 8 characters.');
} elseif (db_val("SELECT COUNT(*) FROM app_users WHERE email = ?", [$e])) {
flash('danger', 'A user with that email already exists.');
} else {
db_exec("INSERT INTO app_users (name, email, password_hash, role, created_at) VALUES (?, ?, ?, ?, ?)", [$n, $e, password_hash($p, PASSWORD_DEFAULT), $role, $now]);
flash('success', $n . ' can now sign in (Staff tab) with ' . $e . '.');
}
redirect('?view=settings&tab=users');
}
if ($action === 'delete_user') {
$uid = (int) ($_POST['user_id'] ?? 0);
if ($uid === (int) $current_user['id']) flash('danger', 'You cannot remove your own account.');
else { db_exec("DELETE FROM app_users WHERE id = ?", [$uid]); flash('success', 'User removed.'); }
redirect('?view=settings&tab=users');
}
if ($action === 'reset_user_password') {
$p = (string) ($_POST['password'] ?? '');
if (strlen($p) < 8) flash('danger', 'New password must be at least 8 characters.');
else { db_exec("UPDATE app_users SET password_hash = ? WHERE id = ?", [password_hash($p, PASSWORD_DEFAULT), (int) ($_POST['user_id'] ?? 0)]); flash('success', 'Password updated.'); }
redirect('?view=settings&tab=users');
}
if ($action === 'change_password') {
$u = db_one("SELECT * FROM app_users WHERE id = ?", [(int) $current_user['id']]);
$p = (string) ($_POST['new_password'] ?? '');
if (!$u || !password_verify((string) ($_POST['current_password'] ?? ''), $u['password_hash'])) flash('danger', 'Your current password is incorrect.');
elseif (strlen($p) < 8) flash('danger', 'New password must be at least 8 characters.');
elseif ($p !== ($_POST['new_password2'] ?? '')) flash('danger', 'New passwords do not match.');
else { db_exec("UPDATE app_users SET password_hash = ? WHERE id = ?", [password_hash($p, PASSWORD_DEFAULT), (int) $u['id']]); flash('success', 'Password changed.'); }
redirect('?view=settings&tab=account');
}
flash('danger', 'That action is not allowed.');
redirect('?');
}
// =====================================================================
// 9. PAGE STATE
// =====================================================================
$view = (string) ($_GET['view'] ?? 'dashboard');
$aliases = ['check' => 'new', 'view_invoice' => 'invoice_view']; // old links keep working
$view = $aliases[$view] ?? $view;
$staff_views = ['dashboard', 'new', 'daily_view', 'monthly_planner', 'reports', 'booking', 'edit', 'conf_slip', 'invoices', 'invoice_view', 'settings'];
$client_views = ['dashboard', 'new'];
if (!in_array($view, $is_staff ? $staff_views : $client_views, true)) $view = 'dashboard';
$today_date = date('Y-m-d');
$now_time = date('H:i:s');
$flashes = $_SESSION['flash'] ?? [];
unset($_SESSION['flash']);
$pending_count = $is_staff ? (int) db_val("SELECT COUNT(*) FROM bookings WHERE status = 'Pending'") : 0;
$page_titles = [
'dashboard' => $is_staff ? ['Front desk', 'overview', 'Requests to review, today\'s schedule and live space status.'] : ['Welcome to', 'Connect', 'Book a workspace and follow your requests.'],
'new' => $is_staff ? ['New', 'booking', 'Pick a date and time — available spaces update instantly.'] : ['Book a', 'space', 'Pick a date and time — available spaces update instantly.'],
'daily_view' => ['Daily', 'schedule', 'Hour-by-hour view of every space. Click an empty slot to book it.'],
'monthly_planner' => ['Monthly', 'planner', 'See the whole month at a glance, per space or all together.'],
'reports' => ['Bookings', '& reports', 'Search, filter, export and follow up on payments.'],
'booking' => ['Booking', 'details', 'Everything about this reservation in one place.'],
'edit' => ['Edit', 'booking', 'Changes are checked against other bookings before saving.'],
'conf_slip' => ['Confirmation', 'slip', 'Print or save as PDF for the client.'],
'invoices' => ['Bill', 'generator', 'Build a proforma invoice from a client\'s approved bookings.'],
'invoice_view' => ['Invoice', 'record', 'Saved invoice — print, share or mark as paid.'],
'settings' => ['Rooms', '& settings', 'Manage spaces, rates, users and your account.'],
];
[$pt_plain, $pt_em, $pt_sub] = $page_titles[$view];
$hour_now = (int) date('G');
$greeting = $hour_now < 12 ? 'Good morning' : ($hour_now < 17 ? 'Good afternoon' : 'Good evening');
?>
= h($title) ?> · = h(COMPANY_SHORT) ?>
' : h($val ?: '—');
};
?>
= h(COMPANY_NAME) ?>
= h(COMPANY_ADDRESS) ?>
= h(implode(' · ', array_filter([COMPANY_PHONE, COMPANY_EMAIL]))) ?>
PAN/VAT: = h(COMPANY_PAN) ?>
Billing period
= h(fmt_range($inv['start_date'], $inv['end_date'])) ?>
Venue & workspace usage as per confirmed bookings.
add Add line (equipment, extra hours…)
Sub total = money($sub, 2) ?>
Discount
− = money($discount, 2) ?>
Taxable amount = money($taxable, 2) ?>
VAT = $edit ? VAT_RATE : rtrim(rtrim(number_format($vat_rate, 2), '0'), '.') ?>% = money($vat, 2) ?>
Grand total = money($grand, 2) ?>
In words: = h(amount_in_words($grand)) ?>
account_balance Bank details for payment
$val): ?>= h($k) ?> = h($val) ?>
sticky_note_2 Notes
= h(($inv['notes'] ?? '') ?: INVOICE_NOTE) ?>
Received by
For = h(COMPANY_SHORT) ?>
'
. '' . h($fallback) . ' ';
}
function guest_directory(): array
{
return db_all("SELECT b.guest_name AS n, b.guest_email AS e, b.guest_phone AS p, b.guest_pan AS pan, b.guest_address AS a
FROM bookings b INNER JOIN (SELECT MAX(id) AS mid FROM bookings GROUP BY guest_name) x ON x.mid = b.id
ORDER BY b.guest_name LIMIT 2000");
}
function js_data($v): string { return json_encode($v, JSON_HEX_TAG | JSON_HEX_AMP | JSON_HEX_APOS | JSON_HEX_QUOT | JSON_UNESCAPED_UNICODE); }
/** Data for the review modal (price defaults + clash warnings). */
function review_payload(array $p): array
{
$clash = find_conflicts((int) $p['room_id'], $p['booking_date'], $p['end_date'], $p['start_time'], $p['end_time'], (int) $p['id']);
$rivals = find_pending_overlaps((int) $p['room_id'], $p['booking_date'], $p['end_date'], $p['start_time'], $p['end_time'], (int) $p['id']);
$base = (float) ($p['rbase'] ?? 0);
return ['id' => (int) $p['id'], 'ref' => booking_ref($p['id']), 'name' => $p['guest_name'], 'email' => $p['guest_email'], 'phone' => $p['guest_phone'],
'room' => $p['rname'], 'color' => $p['rcolor'] ?: '#9BEF6B', 'dates' => fmt_range($p['booking_date'], $p['end_date']), 'time' => fmt_time($p['start_time']) . ' – ' . fmt_time($p['end_time']),
'sd' => $p['booking_date'], 'ed' => $p['end_date'], 'st' => substr($p['start_time'], 0, 5), 'et' => substr($p['end_time'], 0, 5),
'purpose' => $p['purpose'], 'attendees' => $p['attendees'], 'capacity' => $p['rcapacity'] ?? null, 'notes' => $p['notes'], 'remarks' => $p['admin_remarks'] ?? '',
'rate' => (float) $p['custom_rate'] > 0 ? (float) $p['custom_rate'] : $base, 'base' => $base,
'hourly' => ($p['rhourly'] ?? null) !== null ? (float) $p['rhourly'] : round($base / 8), 'rate_type' => $p['rate_type'] ?: 'day', 'discount' => (float) $p['discount'],
'clash' => $clash ? $clash[0]['guest_name'] . ' (' . booking_ref($clash[0]['id']) . ', ' . fmt_time($clash[0]['start_time']) . '–' . fmt_time($clash[0]['end_time']) . ')' : '',
'clash_name' => $clash ? $clash[0]['guest_name'] : '', 'rivals' => count($rivals)];
}
function render_review_modal(string $return_to): void
{ ?>
= logo_img(LOGO_ROUND) ?>
Leapfrog Connect
Booking management
Your workspace,ready when you are.
Halls, training rooms and co-working desks in Dillibazar — check live availability, request a space and follow your booking in one place.
event_available Live availability for every space
mark_email_read Email updates when your request is reviewed
calendar_add_on Add approved bookings to your calendar
'reports', 'edit' => 'reports', 'conf_slip' => 'reports', 'invoice_view' => 'invoices'][$view] ?? $view;
if ($is_staff) {
$nav = [
'Overview' => [['dashboard', 'space_dashboard', 'Dashboard', '']],
'Bookings' => [['new', 'add_circle', 'New booking', 'N'], ['daily_view', 'view_day', 'Daily schedule', ''], ['monthly_planner', 'calendar_month', 'Monthly planner', ''], ['reports', 'list_alt', 'All bookings', '']],
'Billing' => [['invoices', 'receipt_long', 'Bills & invoices', '']],
'Tools' => [[LINK_BULK, 'library_add', 'Bulk booking', 'ext'], [LINK_PROPERTIES, 'apartment', 'Manage properties', 'ext'], [LINK_REVENUE, 'monitoring', 'Revenue analytics', 'ext']],
'Manage' => [['settings', 'tune', 'Rooms & settings', '']],
];
} else {
$nav = [
'My space' => [['dashboard', 'space_dashboard', 'My requests', ''], ['new', 'add_circle', 'Book a space', 'N'], [LINK_BULK, 'library_add', 'Bulk booking', 'ext']],
];
}
$role_label = $is_staff ? ($is_admin ? 'Admin' : 'Staff') : 'Client';
?>
= ? AND b.status = 'Approved'", [$month_end, $month_start]);
$vatf = 1 + VAT_RATE / 100;
$due = db_one("SELECT COUNT(*) AS c, COALESCE(SUM(b.total_amount * $vatf - b.amount_paid),0) AS amt FROM bookings b WHERE " . SQL_BLOCKING . " AND b.amount_paid < b.total_amount * $vatf - 0.5") ?: ['c' => 0, 'amt' => 0];
$upcoming = db_all("SELECT b.*, r.name AS rname, r.color AS rcolor FROM bookings b LEFT JOIN rooms r ON r.id = b.room_id
WHERE b.booking_date > ? AND b.booking_date <= ? AND " . SQL_BLOCKING . " ORDER BY b.booking_date, b.start_time LIMIT 8", [$today_date, date('Y-m-d', strtotime('+7 days'))]);
$pending = db_all("SELECT b.*, r.name AS rname, r.color AS rcolor, r.base_rate AS rbase, r.hourly_rate AS rhourly, r.capacity AS rcapacity
FROM bookings b LEFT JOIN rooms r ON r.id = b.room_id WHERE b.status = 'Pending' ORDER BY b.booking_date, b.id LIMIT 50");
$live = [];
$free_now = 0;
$next_up = null;
foreach ($rooms as $r) {
$cur = null; $nxt = null;
foreach ($today_list as $b) {
if ((int) $b['room_id'] !== (int) $r['id']) continue;
if ($b['start_time'] <= $now_time && $b['end_time'] > $now_time) $cur = $b;
elseif ($b['start_time'] > $now_time && !$nxt) $nxt = $b;
}
if (!$cur) $free_now++;
if ($nxt && (!$next_up || $nxt['start_time'] < $next_up['start_time'])) $next_up = $nxt;
$live[] = ['room' => $r, 'cur' => $cur, 'next' => $nxt];
}
$booked_days = [];
foreach (db_all("SELECT b.room_id, b.booking_date, b.end_date FROM bookings b WHERE b.booking_date <= ? AND b.end_date >= ? AND " . SQL_BLOCKING, [$month_end, $month_start]) as $x) {
$d = new DateTime(max($x['booking_date'], $month_start));
$e = new DateTime(min($x['end_date'], $month_end));
for (; $d <= $e; $d->modify('+1 day')) $booked_days[(int) $x['room_id']][$d->format('Y-m-d')] = 1;
}
$dim = (int) date('t');
$first = explode(' ', trim($me))[0];
?>
wb_sunny = date('l, d F Y') ?>
= h($greeting) ?>, = h($first) ?>
= count($today_list) ?> booking= count($today_list) === 1 ? '' : 's' ?> today · = $free_now ?> of = count($rooms) ?> spaces free right now= $next_up ? ' · next up ' . h(fmt_time($next_up['start_time'])) . ' in ' . h($next_up['rname']) : '' ?>.
Add your first room or hall to start taking bookings.
inbox
Pending approval Set the price, add remarks, then approve or decline — the client is emailed automatically.
hourglass_top = $pending_count ?> waiting
= h(initials($p['guest_name'])) ?>
= h($p['guest_name']) ?> = h(booking_ref($p['id'])) ?>
= h($p['guest_email']) ?> · = h($p['guest_phone']) ?>
Requested = h(fmt_date($p['created_at'], 'd M, g:i A')) ?>
= room_dot($p['rcolor']) ?>= h($p['rname']) ?>
= h(fmt_range($p['booking_date'], $p['end_date'])) ?> · = h(fmt_time($p['start_time'])) ?> – = h(fmt_time($p['end_time'])) ?>
= h($p['purpose'] ?: '') ?>= $p['attendees'] ? ($p['purpose'] ? ' · ' : '') . (int) $p['attendees'] . ' guests' : '' ?>
event_busy Clashes with = h($pj['clash_name']) ?>
group Also requested by = $pj['rivals'] ?> other= $pj['rivals'] > 1 ? 's' : '' ?>
fact_check Review
chat
arrow_forward
done_all Inbox zero! All caught up — no requests waiting for approval.
sensors
Spaces right now As of = date('g:i A') ?>
= h($r['name']) ?>
In use by = h($L['cur']['guest_name']) ?> until = h(fmt_time($L['cur']['end_time'])) ?>
Free · next booking at = h(fmt_time($L['next']['start_time'])) ?>
Free for the rest of today
View
Book
insights
Utilisation · = date('F') ?> Days with at least one approved booking
= room_dot($r['color']) ?>= h($r['name']) ?> = $n ?> / = $dim ?> days · = $pct ?>%
No rooms yet.
event_upcoming Nothing booked for the next 7 days A good time to follow up with regular clients.
= $today_date) $n_upcoming++;
}
$first = explode(' ', trim($me))[0];
$policy_icons = ['door_open', 'warning', 'groups', 'schedule'];
?>
wb_sunny = date('l, d F Y') ?>
= h($greeting) ?>, = h($first) ?>
Experience the productivity hub in Dillibazar — high-speed internet, ergonomic setups, networking opportunities and great coffee, waiting for you.
hourglass_top = $n_pending ?> awaiting review
event_available = $n_upcoming ?> upcoming
history
My requests Requests and bookings made with = h($client['email']) ?>
= $today_date; ?>
= date('d', strtotime($m['booking_date'])) ?> = date('M', strtotime($m['booking_date'])) ?>
= h($m['rname']) ?> = h(booking_ref($m['id'])) ?>
= h(fmt_range($m['booking_date'], $m['end_date'])) ?> · = h(fmt_time($m['start_time'])) ?> – = h(fmt_time($m['end_time'])) ?>= $m['purpose'] ? ' · ' . h($m['purpose']) : '' ?>
handshake Workspace etiquette Help us keep Connect a great place to work
verified_user General policies Applies to every booking
$x): ?>= $policy_icons[$i] ?? 'info' ?> = h($x) ?>
$sd, 'ed' => $ed, 'st' => $st, 'et' => $et, 'room' => (int) ($old['room_id'] ?? ($_GET['room_id'] ?? 0)), 'old' => $old,
'staff' => $is_staff, 'today' => $today_date, 'guests' => $is_staff ? guest_directory() : [],
'initial' => availability($sd, $ed, $st, $et, 0, $is_staff)];
?>
= h(COMPANY_SHORT) ?> — schedule for = h(fmt_date($sel, 'l, d F Y')) ?>
Time
= room_dot($r['color']) ?>
= h($r['name']) ?>
= $r['capacity'] ? (int) $r['capacity'] . ' seats · ' : '' ?>= h(money($r['base_rate'])) ?>/day
= date('g A', mktime($hh, 0)) ?>
= $H0 * 60 && $now_min < $H1 * 60): ?>
format_list_bulleted
Bookings on = h(date('d M', strtotime($sel))) ?> Quick list for the front desk
Time Guest Space Purpose Contact Status
= h(fmt_time($b['start_time'])) ?> – = h(fmt_time($b['end_time'])) ?>
= h($b['guest_name']) ?>
= h(booking_ref($b['id'])) ?>
= room_dot($b['rcolor']) ?>= h($b['rname']) ?>
= h($b['purpose'] ?: '—') ?>= $b['attendees'] ? '' . (int) $b['attendees'] . ' guests
' : '' ?>
= h($b['guest_phone']) ?>= h($b['guest_email']) ?>
= badge($b['status'] ?: 'Pending') ?>
event_available Nothing booked Every space is free on this day.
12) $month = (int) date('n');
if ($year < 2000 || $year > 2100) $year = (int) date('Y');
$room_filter = (int) ($_GET['room_id'] ?? 0);
$rooms = get_rooms();
$first = mktime(0, 0, 0, $month, 1, $year);
$dim = (int) date('t', $first);
$lead = (int) date('w', $first);
$ms = date('Y-m-01', $first);
$me_ = date('Y-m-t', $first);
$params = [$me_, $ms];
$sqlr = '';
if ($room_filter) { $sqlr = ' AND b.room_id = ?'; $params[] = $room_filter; }
$mrows = db_all("SELECT b.id, b.room_id, b.guest_name, b.booking_date, b.end_date, b.start_time, b.end_time, b.status, r.name AS rname, r.color AS rcolor
FROM bookings b LEFT JOIN rooms r ON r.id = b.room_id
WHERE b.booking_date <= ? AND b.end_date >= ? AND b.status IN ('Approved','Tentative','Pending')" . $sqlr . " ORDER BY b.start_time, b.room_id", $params);
$by_day = [];
foreach ($mrows as $b) {
$d = new DateTime(max($b['booking_date'], $ms));
$e = new DateTime(min($b['end_date'], $me_));
for (; $d <= $e; $d->modify('+1 day')) $by_day[$d->format('Y-m-d')][] = $b;
}
$pm = $month === 1 ? 12 : $month - 1; $py = $month === 1 ? $year - 1 : $year;
$nm = $month === 12 ? 1 : $month + 1; $ny = $month === 12 ? $year + 1 : $year;
$base = '?view=monthly_planner' . ($room_filter ? '&room_id=' . $room_filter : '');
$busy_days = 0;
foreach ($by_day as $lst) { foreach ($lst as $x) { if (is_blocking($x['status'])) { $busy_days++; break; } } }
?>
Legend:
= room_dot($r['color']) ?>= h($r['name']) ?>
striped on hold dashed request
0, 'val' => 0, 'paid' => 0, 'due' => 0];
$total_rows = (int) $agg['n'];
$pages = max(1, (int) ceil($total_rows / PER_PAGE));
$page = min($pages, max(1, (int) ($_GET['page'] ?? 1)));
$sort = in_array($_GET['sort'] ?? '', ['newest', 'date_asc', 'date_desc'], true) ? $_GET['sort'] : (in_array($f['filter'], ['today', 'upcoming'], true) ? 'date_asc' : 'newest');
$order = ['newest' => 'b.id DESC', 'date_asc' => 'b.booking_date ASC, b.start_time ASC', 'date_desc' => 'b.booking_date DESC, b.start_time DESC'][$sort];
$rows = db_all("SELECT b.*, r.name AS rname, r.color AS rcolor FROM bookings b LEFT JOIN rooms r ON r.id = b.room_id WHERE $where ORDER BY $order LIMIT " . PER_PAGE . " OFFSET " . (($page - 1) * PER_PAGE), $params);
$all_rooms = get_rooms(false);
$qs = function (array $o = []) use ($f, $sort) {
$q = array_filter(array_merge(['view' => 'reports', 'search' => $f['search'], 'filter' => $f['filter'], 'from' => $f['from'], 'to' => $f['to'],
'room' => $f['room'] ?: '', 'status' => $f['status'], 'pay' => $f['pay'], 'sort' => $sort], $o), function ($v) { return $v !== '' && $v !== null && $v !== 0; });
return '?' . http_build_query($q);
};
$quick = ['' => ['All', 'list'], 'pending' => ['Awaiting review', 'hourglass_top'], 'today' => ['Today', 'today'], 'month' => ['This month', 'calendar_month'], 'upcoming' => ['Upcoming', 'event_upcoming']];
$has_filters = $f['search'] !== '' || $f['from'] || $f['to'] || $f['room'] || $f['status'] || $f['pay'] || $f['filter'];
?>
Bookings found = nformat($total_rows) ?>
Booked value (excl. VAT) = money($agg['val']) ?>
Received = money($agg['paid']) ?>
Outstanding (incl. VAT) = money($agg['due']) ?>
list_alt
Booking log Click any row to open it= $pages > 1 ? ' · page ' . $page . ' of ' . $pages : '' ?>
>Newest first
>Event date ↑
>Event date ↓
Booking Guest Space Schedule Amount Status Actions
= h(booking_ref($b['id'])) ?> = h(fmt_date($b['created_at'], 'd M')) ?>
= h($b['guest_name']) ?>
chat
= h($b['guest_phone']) ?>= $b['guest_email'] ? ' · ' . h($b['guest_email']) : '' ?>
= room_dot($b['rcolor']) ?>= h($b['rname'] ?? '—') ?>
= h(fmt_range($b['booking_date'], $b['end_date'])) ?>
= h(fmt_time($b['start_time'])) ?> – = h(fmt_time($b['end_time'])) ?> · = $t['days'] ?> day= $t['days'] > 1 ? 's' : '' ?>
= $b['status'] === 'Pending' && (float) $b['total_amount'] <= 0 ? 'to be priced ' : money($b['total_amount']) ?>
0): $ps = pay_status($t); ?>= $ps === 'Paid' ? 'Paid in full' : 'Due ' . money(max(0, $t['balance'])) ?>
= badge($b['status'] ?: 'Pending') ?>= $blk ? badge(time_status($b)) : '' ?>
receipt
edit
arrow_forward
search_off No bookings match Try clearing some filters= $has_filters ? ' —
show all bookings ' : '' ?>.
1): ?>
Showing = ($page - 1) * PER_PAGE + 1 ?>–= min($total_rows, $page * PER_PAGE) ?> of = $total_rows ?>
'approved', 'Tentative' => 'on hold', 'Pending' => 'awaiting review', 'Rejected' => 'declined', 'Cancelled' => 'cancelled'][$b['status']] ?? strtolower((string) $b['status']);
$wa_text = "Namaste " . $b['guest_name'] . ",\nYour booking " . booking_ref($id) . " at " . COMPANY_SHORT . " is " . $state_word . ":\n"
. $b['rname'] . "\n" . fmt_range($b['booking_date'], $b['end_date']) . ", " . fmt_time($b['start_time']) . " – " . fmt_time($b['end_time'])
. ($blk && $t['grand'] > 0 ? "\nTotal: " . money($t['grand'], 2) . " (incl. VAT)" . ($t['paid'] > 0 ? "\nReceived: " . money($t['paid'], 2) . "\nBalance: " . money(max(0, $t['balance']), 2) : '') : '') . "\nThank you!";
$pct = $t['grand'] > 0 ? min(100, round($t['paid'] / $t['grand'] * 100)) : 100;
$email_label = ['Approved' => 'Resend approval email', 'Tentative' => 'Resend on-hold email', 'Rejected' => 'Resend decline email', 'Pending' => 'Resend “request received”'][$b['status']] ?? '';
$amen = amenity_list($b['ramenities']);
?>
= h(booking_ref($id)) ?>
= $b['created_at'] ? 'Created ' . h(fmt_date($b['created_at'], 'd M Y, g:i A')) . ($b['created_by'] ? ' by ' . h($b['created_by']) : '') : 'Created before tracking was added' ?>
= h($b['guest_name']) ?>
= badge($b['status'] ?: 'Pending') ?>
= badge($ts) ?>= badge($ps) ?>
= room_dot($b['rcolor']) ?>= h($b['rname'] ?? 'Room removed') ?>
cancel This booking is = strtolower(status_label($b['status'])) ?> and no longer blocks the space. Use ⋮ → Restore to bring it back.
event Schedule = $t['days'] ?> day= $t['days'] > 1 ? 's' : '' ?> · = num_clean($t['hours']) ?> hrs per day
Check-in = h(fmt_date($b['booking_date'], 'D, d M Y')) ?> = h(fmt_time($b['start_time'])) ?>
arrow_forward
Check-out = h(fmt_date($b['end_date'], 'D, d M Y')) ?> = h(fmt_time($b['end_time'])) ?>
Space = h($b['rname'] ?? '—') ?>= $b['rcapacity'] ? ' · seats ' . (int) $b['rcapacity'] . ' ' : '' ?>
= amenity_icon($a) ?> = h($a) ?>
Purpose = h($b['purpose'] ?: '—') ?>
Expected guests = $b['attendees'] ? (int) $b['attendees'] : '—' ?> $b['rcapacity']): ?> warning Over capacity
Client notes = h($b['notes'] ?: '—') ?>
Remarks to client = $b['admin_remarks'] ? '' : '—' ?>
person Guest Contact & billing details
Name = h($b['guest_name']) ?>
Phone = $b['guest_phone'] ? '' . h($b['guest_phone']) . ' ' : '—' ?>
Email = $b['guest_email'] ? '' . h($b['guest_email']) . ' ' : 'Not provided ' ?>
PAN / VAT no. = h($b['guest_pan'] ?: '—') ?>
Address = h($b['guest_address'] ?: '—') ?>
payments Charges = h(RATE_TYPES[$b['rate_type']] ?? 'Per day') ?> rate
= $blk ? badge($ps) : '' ?>
sell Not priced yet The price is set when you review and approve this request= $b['rbase'] ? ' (standard rate ' . h(money($b['rbase'])) . '/day)' : '' ?>.
= h(money($b['custom_rate'], 2)) ?> × = num_clean($t['qty']) ?> = ['day' => 'day(s)', 'hour' => 'hour(s)', 'flat' => 'package'][$b['rate_type']] ?? 'day(s)' ?> = money($t['gross'], 2) ?>
0): ?>
Discount − = money($t['discount'], 2) ?>
Amount (excl. VAT) = money($t['total'], 2) ?>
VAT = VAT_RATE ?>% = money($t['vat'], 2) ?>
Total = money($t['grand'], 2) ?>
0): ?>
Received = money($t['paid'], 2) ?> Balance = money(max(0, $t['balance']), 2) ?>
account_balance_wallet Payments = count($payments) ?> entr= count($payments) === 1 ? 'y' : 'ies' ?>
done
= money($p['amount'], 2) ?> · = h($p['method']) ?>= $p['reference'] ? ' · ' . h($p['reference']) : '' ?>
= h(fmt_date($p['paid_on'])) ?>= $p['created_by'] ? ' · by ' . h($p['created_by']) : '' ?>
= csrf_field() ?>
close
0): ?>
Received earlier: = money($b['amount_paid'], 2) ?>
No payments recorded yet.
0.5): ?>
= csrf_field() ?>
Amount (Rs.)
Method = h($m) ?>
Date
Reference
add_card Record payment
0): ?>
verified Fully paid
Last updated = h(fmt_date($b['updated_at'], 'd M Y, g:i A')) ?>
$r['name'], 'base' => (float) $r['base_rate'], 'hourly' => $r['hourly_rate'] !== null ? (float) $r['hourly_rate'] : round((float) $r['base_rate'] / 8), 'cap' => $r['capacity'] !== null ? (int) $r['capacity'] : null];
$edit_cfg = ['id' => $id, 'rooms' => $room_map, 'paid' => (float) $b['amount_paid']];
?>
= csrf_field() ?>
event Space & time Editing = h(booking_ref($id)) ?>
Updated price
Total payable Rs. 0
Already received Rs. 0
Balance due Rs. 0
= h(COMPANY_NAME) ?>
= h(COMPANY_ADDRESS) ?>
= h(implode(' · ', array_filter([COMPANY_PHONE, COMPANY_EMAIL]))) ?>
PAN/VAT: = h(COMPANY_PAN) ?>
Booking Confirmation
Booking ID = h(booking_ref($id)) ?> Issued = date('d M Y') ?> Status = h(status_label($b['status'])) ?>
= $bad ? 'This booking has been ' . h(strtolower(status_label($b['status']))) . ' ' : ($b['status'] === 'Pending' ? 'Request awaiting review — not yet confirmed' : 'Provisional — on hold until confirmed') ?>
person Client details
Name = h($b['guest_name']) ?>
Phone = h($b['guest_phone'] ?: '—') ?>
Email = h($b['guest_email'] ?: '—') ?>
PAN / VAT no. = h($b['guest_pan'] ?: '—') ?>
Address = h($b['guest_address']) ?>
event Reservation details
Space = h($b['rname']) ?> Up to = (int) $b['rcapacity'] ?> seats
Duration = $t['days'] ?> day= $t['days'] > 1 ? 's' : '' ?> = rtrim(rtrim(number_format($t['hours'], 2), '0'), '.') ?> hrs / day
From = h(fmt_date($b['booking_date'], 'D, d M Y')) ?> = h(fmt_time($b['start_time'])) ?>
To = h(fmt_date($b['end_date'], 'D, d M Y')) ?> = h(fmt_time($b['end_time'])) ?>
Purpose = h($b['purpose'] ?: '—') ?>
Expected guests = $b['attendees'] ? (int) $b['attendees'] : '—' ?>
Included facilities = h(implode(' · ', $slip_amen)) ?>
Remarks = h($b['admin_remarks']) ?>
payments Charges
Rate = h(money($b['custom_rate'], 2)) ?> = h(strtolower(RATE_TYPES[$b['rate_type']] ?? 'per day')) ?> × = rtrim(rtrim(number_format($t['qty'], 2), '0'), '.') ?>
Amount (excl. VAT) = h(money($t['total'], 2)) ?> = $t['discount'] > 0 ? 'after ' . h(money($t['discount'])) . ' discount' : 'no discount' ?>
VAT = VAT_RATE ?>% = h(money($t['vat'], 2)) ?>
Total payable = h(money($t['grand'], 2)) ?>
= $t['paid'] > 0 ? 'Received ' . h(money($t['paid'])) . ' · Balance ' . h(money(max(0, $t['balance']))) : 'Nothing received yet' ?>
gavel Terms & conditions
= h($x) ?>
info Notes
Client signature
Authorised signatory
= ? AND b.status = 'Approved' ORDER BY b.booking_date, b.start_time", [$inv_gname, $inv_ed, $inv_sd]);
$profile = db_one("SELECT guest_email, guest_address, guest_pan FROM bookings WHERE guest_name = ? ORDER BY id DESC LIMIT 1", [$inv_gname]);
$bk_count = count($bks);
foreach ($bks as $bk) {
$label = ($bk['rname'] ?: 'Venue') . ' — ' . ($bk['purpose'] ?: 'Venue booking') . ' (' . fmt_time($bk['start_time']) . '–' . fmt_time($bk['end_time']) . ')';
if ($bk['rate_type'] === 'flat') {
$items[] = ['desc' => $label . ' · package', 'date' => fmt_range($bk['booking_date'], $bk['end_date']), 'qty' => 1, 'rate' => (float) $bk['custom_rate']];
} else {
$d = new DateTime(max($bk['booking_date'], $inv_sd));
$e = new DateTime(min($bk['end_date'], $inv_ed));
$q = $bk['rate_type'] === 'hour' ? hours_between($bk['start_time'], $bk['end_time']) : 1;
for (; $d <= $e; $d->modify('+1 day')) {
$items[] = ['desc' => $label, 'date' => $d->format('d M Y'), 'qty' => $q, 'rate' => (float) $bk['custom_rate']];
}
}
if ($bk['booking_date'] >= $inv_sd && $bk['end_date'] <= $inv_ed) $disc_default += (float) $bk['discount'];
}
}
$history = db_all("SELECT id, invoice_number, invoice_date, guest_name, total_amount, status FROM generated_invoices ORDER BY id DESC LIMIT 200");
?>
history Saved invoices = count($history) ?> most recent
= csrf_field() ?>
= $bk_count ?> booking= $bk_count > 1 ? 's' : '' ?> · = count($items) ?> line= count($items) > 1 ? 's' : '' ?>. Edit anything below before saving.
'', 'invoice_date' => $today_date, 'guest_name' => $inv_gname,
'guest_email' => $profile['guest_email'] ?? '', 'guest_address' => $profile['guest_address'] ?? '', 'guest_pan' => $profile['guest_pan'] ?? '',
'start_date' => $inv_sd, 'end_date' => $inv_ed, 'discount' => $disc_default, 'vat_rate' => VAT_RATE, 'notes' => INVOICE_NOTE,
], $items, true); ?>
event_busy No bookings for “= h($inv_gname) ?>” in this period Check the spelling or widen the dates (= h(fmt_range($inv_sd, $inv_ed)) ?>). Only approved bookings are billed.
receipt_long Choose a client to start Their bookings in the selected period become invoice lines automatically — one line per day, with rates you can adjust. Tip: open any booking and press Invoice to jump straight here.
arrow_back All invoices
= badge($inv['status'] ?: 'Unpaid') ?>
= csrf_field() ?>
= $paid ? 'undo' : 'paid' ?> Mark as = $paid ? 'unpaid' : 'paid' ?>
= csrf_field() ?>
delete
print Print / Save PDF
= ? AND " . SQL_BLOCKING . ") AS upcoming
FROM rooms r ORDER BY r.id", [$today_date]); ?>
meeting_room Spaces & rates Rates pre-fill new bookings; each booking can still use its own rate.
add Add room
Space Seats Day rate Hourly rate Bookings Status Actions
(int) $r['id'], 'name' => $r['name'], 'base_rate' => (float) $r['base_rate'], 'hourly_rate' => $r['hourly_rate'], 'capacity' => $r['capacity'], 'color' => $r['color'] ?: ROOM_COLORS[0], 'description' => $r['description'], 'amenities' => implode(', ', amenity_list($r['amenities'])), 'is_active' => (int) $r['is_active']]; $imgs = room_images($r['images']); ?>
= room_dot($r['color']) ?>
= h($r['name']) ?>
= h(short_text($r['description'], 110)) ?>
= $r['capacity'] ? (int) $r['capacity'] : '—' ?>
= money($r['base_rate']) ?>
= $r['hourly_rate'] !== null ? money($r['hourly_rate']) : 'auto · ' . money(round($r['base_rate'] / 8)) . ' ' ?>
= (int) $r['bcount'] ?> total= (int) $r['upcoming'] ?> upcoming
= $r['is_active'] ? badge('Active', 'b-ok') : badge('Hidden', 'b-muted') ?>
edit Edit
= csrf_field() ?>
delete
meeting_room No rooms yet Add your halls, training rooms and desks to start booking.
group Staff users Admins can manage rooms, users and delete records.
settings System settings Edit these at the top of index.php
Company = h(COMPANY_NAME) ?>
Address = h(COMPANY_ADDRESS) ?>
Phone / email = h(implode(' · ', array_filter([COMPANY_PHONE, COMPANY_EMAIL])) ?: 'Not set') ?>
Company PAN = h(COMPANY_PAN ?: 'Not set') ?>
VAT rate = VAT_RATE ?>%
Invoice numbers = h(INVOICE_PREFIX) ?>0001, 0002…
Emails sent from = h(MAIL_FROM) ?>
New requests go to = h(ADMIN_NOTIFY_TO) ?> (copy: = h(ADMIN_NOTIFY_CC) ?>)
Portal link in emails = h(PORTAL_URL) ?>
Time zone Asia/Kathmandu (= date('g:i A') ?>)
Schedule hours = date('g A', mktime(DAY_START_HOUR, 0)) ?> – = date('g A', mktime(DAY_END_HOUR, 0)) ?>
add
'check_circle', 'danger' => 'error', 'warning' => 'warning'][$fl['type']] ?? 'info'; ?>
= $ic ?> = h($fl['msg']) ?>
close
help
Are you sure ?
Go back Yes, continue
verified_user Please verify your schedule