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) . '' . '' . '

' . htmlspecialchars($title) . '

' . htmlspecialchars($detail) . '

← Back to dashboard

'; 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 "$html
"; } 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 "
" . h($label) . "
"; } 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 ?: '—'); }; ?>

    PAN/VAT:

    Proforma Invoice

    No. · number given on save' : '' ?>
    Date
    Status
    Bill to
    Address
    Email
    PAN
    Billing period
    Venue & workspace usage as per confirmed bookings.
    $it): $amt = round((float) $it['qty'] * (float) $it['rate'], 2); ?>
    S.N.DescriptionDateQtyRateAmount
    Sub total
    Discount −
    Taxable amount
    VAT %
    Grand total
    In words:
    account_balanceBank details for payment
    $val): ?>
    sticky_note_2Notes
    Received by
    For
    This is a proforma invoice and not a tax invoice.
    ' . ''; } 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 { ?>
    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
    Need help? Call · ©

    Welcome in

    Tell us who you are — we use your email to send confirmations and show your requests.

    error
    person
    mail
    call

    Create the admin account

    First-time setup. Use one of the owner emails (). You can add other staff afterwards.

    error

    Staff sign in

    For the Leapfrog Connect booking team.

    error
    mail
    lock

    Forgot your password? An admin can reset it from Rooms & settings → Users.

    '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'; ?>

    schedule·
    add
    = ? 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

    ,

    booking today · of spaces free right now. 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 waiting
    ·
    Requested
    · –
    event_busyClashes with groupAlso requested by other 1 ? 's' : '' ?>
    chat arrow_forward
    done_allInbox zero!All caught up — no requests waiting for approval.
    event_note

    Today's schedule

    · booking

    $today_date, 'end_date' => $today_date, 'start_time' => $b['start_time'], 'end_time' => $b['end_time']]); ?>
    to
    event_availableNo bookings todayAll spaces are free. Create a booking.
    sensors

    Spaces right now

    As of

    meeting_roomNo rooms yetAdd rooms to start.
    In use by until Free · next booking at Free for the rest of today
    View Book
    insights

    Utilisation ·

    Days with at least one approved booking

    / days · %
    No rooms yet.
    upcoming

    Coming up

    Next 7 days

    event_upcomingNothing booked for the next 7 daysA 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

    ,

    Experience the productivity hub in Dillibazar — high-speed internet, ergonomic setups, networking opportunities and great coffee, waiting for you.

    hourglass_top awaiting review event_available upcoming
    history

    My requests

    Requests and bookings made with

    = $today_date; ?>
    · –
    :
    folder_openNo requests yetBook your first space — it only takes a minute.
    handshake

    Workspace etiquette

    Help us keep Connect a great place to work

    Do's
    • check_circle
    Don'ts
    • cancel
    verified_user

    General policies

    Applies to every booking

      $x): ?>
    Please note:
    $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)]; ?>
    event

    When?

    Dates & time window

    >
    >

    info

    Choose a space

    Checking availability…
    warning
    chevron_left
    chevron_right today Today
    ,
    booking · of spaces free all day' . $n_pending_day . ' request' . ($n_pending_day > 1 ? 's' : '') . ' to review (dashed)' : '' ?>
    add Book this day

    — schedule for

    meeting_roomNo rooms yetAdd your first room to see the schedule.
    Time
    /day
    format_list_bulleted

    Bookings on

    Quick list for the front desk

    TimeGuestSpacePurposeContactStatus
    –
    ' . (int) $b['attendees'] . ' guests' : '' ?>
    event_availableNothing bookedEvery 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; } } } ?>
    $dn): ?>
    add
    3): ?>+ more = $today_date): ?>
    Available
    Legend: striped on hold dashed request
    event_availableNo bookings this monthCreate one
    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']; ?>
    search
    filter_alt_off
    Bookings found
    Booked value (excl. VAT)
    Received
    Outstanding (incl. VAT)
    list_alt

    Booking log

    Click any row to open it 1 ? ' · page ' . $page . ' of ' . $pages : '' ?>

    BookingGuestSpaceScheduleAmountStatusActions
    – · day 1 ? 's' : '' ?>
    to be priced' : money($b['total_amount']) ?>
    0): $ps = pay_status($t); ?>
    receipt edit arrow_forward
    search_offNo bookings matchTry clearing some filtersshow all bookings' : '' ?>.
    1): ?>
    search_offBooking not foundIt may have been deleted. Back to all bookings
    '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']); ?>

    edit Edit receipt Slip request_quote Invoice
    hourglass_topThis is a client request awaiting review. Press Review request to set the price and approve or decline it — the client is emailed either way.
    cancelThis booking is and no longer blocks the space. Use ⋮ → Restore to bring it back.
    event

    Schedule

    day 1 ? 's' : '' ?> · hrs per day

    Check-in
    arrow_forward
    Check-out
    Space
    · seats ' . (int) $b['rcapacity'] . '' : '' ?>
    Purpose
    Expected guests
    $b['rcapacity']): ?> warningOver capacity
    Client notes
    Remarks to client
    ' . h($b['admin_remarks']) . '
    ' : '—' ?>
    person

    Guest

    Contact & billing details

    Name
    Phone
    ' . h($b['guest_phone']) . '' : '—' ?>
    Email
    ' . h($b['guest_email']) . '' : 'Not provided' ?>
    PAN / VAT no.
    Address
    payments

    Charges

    rate

    sellNot priced yetThe price is set when you review and approve this request.
    × 'day(s)', 'hour' => 'hour(s)', 'flat' => 'package'][$b['rate_type']] ?? 'day(s)' ?>
    0): ?>
    Discount−
    Amount (excl. VAT)
    VAT %
    Total
    0): ?>
    Received Balance
    account_balance_wallet

    Payments

    entr

    done
    ·
    0): ?>
    Received earlier:
    No payments recorded yet.
    0.5): ?>
    0): ?>
    verifiedFully paid
    Last updated
    search_offBooking not foundBack to all bookings
    $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']]; ?>
    event

    Space & time

    Editing

    hourglass_topChecking availability…
    person

    Guest

    sell

    Event & pricing

    Updated price
    Total payableRs. 0
    Already receivedRs. 0
    Balance dueRs. 0
    Discard
    search_offBooking not foundBack to all bookings
    arrow_back Back to booking

    PAN/VAT:

    Booking Confirmation

    Booking ID
    Issued
    Status

    ' . h(strtolower(status_label($b['status']))) . '' : ($b['status'] === 'Pending' ? 'Request awaiting review — not yet confirmed' : 'Provisional — on hold until confirmed') ?>

    personClient details
    Name
    Phone
    Email
    PAN / VAT no.
    Address
    eventReservation details
    SpaceUp to seats
    Duration day 1 ? 's' : '' ?> hrs / day
    From
    To
    Purpose
    Expected guests
    Included facilities
    Remarks
    paymentsCharges
    Rate ×
    Amount (excl. VAT) 0 ? 'after ' . h(money($t['discount'])) . ' discount' : 'no discount' ?>
    VAT %
    Total payable 0 ? 'Received ' . h(money($t['paid'])) . ' · Balance ' . h(money(max(0, $t['balance']))) : 'Nothing received yet' ?>
    gavelTerms & conditions
    infoNotes
    Client signature
    Authorised signatory
    · Thank you for choosing us!Generated by
    = ? 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"); ?>
    tune

    Build an invoice

    Pick the client and billing period

    person_search
    history

    Saved invoices

    most recent

    filter_list
    ·
    receipt_longNo invoices saved yet.
    booking 1 ? 's' : '' ?> · line 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_busyNo bookings for “” in this periodCheck the spelling or widen the dates (). Only approved bookings are billed.
    receipt_longChoose a client to startTheir 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.
    search_offInvoice not foundBack to bills
    arrow_back All invoices
    = ? 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.

    (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']); ?>
    SpaceSeatsDay rateHourly rateBookingsStatusActions
    auto · ' . money(round($r['base_rate'] / 8)) . '' ?> total
    upcoming
    meeting_roomNo rooms yetAdd your halls, training rooms and desks to start booking.
    group

    Staff users

    Admins can manage rooms, users and delete records.

    UserRoleLast sign-inActions
    (you)' : '' ?>
    Never' ?>
    Share it with securely.
    person_add

    Add user

    lock

    Change password

    Signed in as

    settings

    System settings

    Edit these at the top of index.php

    Company
    Address
    Phone / email
    Company PAN
    VAT rate
    %
    Invoice numbers
    0001, 0002…
    Emails sent from
    New requests go to
    (copy: )
    Portal link in emails
    Time zone
    Asia/Kathmandu ()
    Schedule hours
    –
    add
    'check_circle', 'danger' => 'error', 'warning' => 'warning'][$fl['type']] ?? 'info'; ?>