Add per-customer booking flow checklist + fix admin login

Admin portal overhaul:
- Fix require_once path (was admin/db.php, should be ../db.php) — this was
  the root cause of the login always redirecting back to the login page
- Fix session save path to /home/parkerslingshotrentals.com/sessions so the
  web user (parke1909) can actually read sessions back (the system default
  /var/lib/php/sessions was write-only for non-root)
- Fix AJAX unauthenticated response: return 401 JSON instead of login HTML
- Fresh bcrypt hash for admin password (Parker2026!)
- Add 3 new DB columns: insurance_verified, deposit_received, license_verified
- Replace flat bookings table with expandable per-customer flow panel:
  click any row to open a 3-column detail drawer showing:
  (1) full contact info + admin notes
  (2) 6-step booking flow checklist with inline toggle buttons for steps
      that admin marks (insurance, deposit, license)
  (3) send-reminder email builder — pick which pending items to include,
      send customer a personalized nudge with waiver link + instructions
- Progress dots in table row update live when admin toggles a step
- Stats row now includes waiver, insurance, deposit counts

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-05-22 14:01:42 +00:00
parent 85448d18c5
commit b3b831e4a0
2 changed files with 583 additions and 80 deletions
+582 -79
View File
@@ -1,5 +1,8 @@
<?php
require_once __DIR__ . '/db.php';
require_once dirname(__DIR__) . '/db.php';
ini_set('session.save_path', '/home/parkerslingshotrentals.com/sessions');
ini_set('session.cookie_httponly', 1);
ini_set('session.cookie_samesite', 'Lax');
session_start();
$isAjax = !empty($_SERVER['HTTP_X_REQUESTED_WITH']) || (($_SERVER['HTTP_ACCEPT'] ?? '') === 'application/json');
@@ -16,8 +19,14 @@ if ($_GET['action'] ?? '' === 'logout') {
}
$authed = !empty($_SESSION[ADMIN_SESSION_KEY]);
// ── AJAX handlers (must be authenticated) ────────────────────────────────────
if ($isAjax && $authed) {
// ── AJAX handlers ─────────────────────────────────────────────────────────────
if ($isAjax && !$authed) {
http_response_code(401);
header('Content-Type: application/json');
echo json_encode(['error'=>'Session expired. Please reload and log in again.']);
exit;
}
if ($isAjax) {
header('Content-Type: application/json');
$action = $_POST['action'] ?? $_GET['action'] ?? '';
@@ -28,11 +37,10 @@ if ($isAjax && $authed) {
if ($id && in_array($status, $allowed)) {
db()->prepare("UPDATE bookings SET status=? WHERE id=?")->execute([$status, $id]);
echo json_encode(['ok'=>true]);
} else {
echo json_encode(['error'=>'Invalid']);
}
} else { echo json_encode(['error'=>'Invalid']); }
exit;
}
if ($action === 'save_admin_notes') {
$id = (int)($_POST['id'] ?? 0);
$notes = substr(trim($_POST['notes'] ?? ''), 0, 1000);
@@ -40,6 +48,103 @@ if ($isAjax && $authed) {
echo json_encode(['ok'=>true]);
exit;
}
if ($action === 'toggle_requirement') {
$id = (int)($_POST['id'] ?? 0);
$field = $_POST['field'] ?? '';
$allowed_fields = ['insurance_verified','deposit_received','license_verified'];
if ($id && in_array($field, $allowed_fields)) {
$stmt = db()->prepare("SELECT `{$field}` FROM bookings WHERE id=?");
$stmt->execute([$id]);
$current = (int)$stmt->fetchColumn();
$new = $current ? 0 : 1;
db()->prepare("UPDATE bookings SET `{$field}`=? WHERE id=?")->execute([$new, $id]);
echo json_encode(['ok'=>true,'value'=>$new]);
} else { echo json_encode(['error'=>'Invalid']); }
exit;
}
if ($action === 'send_reminder') {
$id = (int)($_POST['id'] ?? 0);
$keys = array_filter(explode(',', $_POST['items'] ?? ''));
$stmt = db()->prepare("SELECT * FROM bookings WHERE id=?");
$stmt->execute([$id]);
$b = $stmt->fetch();
if (!$b) { echo json_encode(['error'=>'Not found']); exit; }
$pkg = PACKAGES[$b['package']] ?? ['label' => $b['package']];
$dateLabel = date('F j, Y', strtotime($b['rental_date']));
$ref = $b['booking_ref'];
$itemDefs = [
'waiver' => [
'label' => 'Sign Your Rental Agreement',
'detail' => 'Your digital rental agreement still needs to be signed before your pickup. It only takes a minute and can be done on any device — no printer required.',
'cta' => "<div style='margin-top:10px'><a href='https://parkerslingshotrentals.com/waiver.php?ref={$ref}' style='display:inline-block;background:#f97316;color:#fff;text-decoration:none;padding:9px 20px;border-radius:6px;font-weight:700;font-size:13px'>Sign Agreement &rarr;</a></div>",
],
'insurance' => [
'label' => 'Proof of Personal Auto Insurance',
'detail' => 'You\'ll need to bring proof of valid personal auto insurance to pickup. A photo on your phone of your insurance card is fine. This is required before we can hand over the keys.',
'cta' => '',
],
'deposit' => [
'label' => 'Security Deposit',
'detail' => 'A refundable security deposit is required at the time of pickup. Please have it ready — cash or card accepted. It will be returned in full upon safe return of the vehicle.',
'cta' => '',
],
'license' => [
'label' => "Valid Driver's License",
'detail' => "Please bring your valid driver's license to pickup. We're required to verify it before you take the Slingshot out. Must match the name on the booking.",
'cta' => '',
],
];
$rowsHtml = '';
$n = 1;
foreach ($keys as $key) {
if (!isset($itemDefs[$key])) continue;
$d = $itemDefs[$key];
$rowsHtml .= "
<tr>
<td style='padding:14px 0;border-bottom:1px solid #f3f4f6;vertical-align:top;width:30px'>
<span style='display:inline-flex;align-items:center;justify-content:center;width:24px;height:24px;border-radius:50%;background:#fff7ed;border:2px solid #f97316;font-size:11px;font-weight:800;color:#f97316;flex-shrink:0'>{$n}</span>
</td>
<td style='padding:14px 0 14px 14px;border-bottom:1px solid #f3f4f6'>
<strong style='color:#111;font-size:14px'>" . htmlspecialchars($d['label']) . "</strong>
<p style='margin:5px 0 0;font-size:13px;color:#6b7280;line-height:1.5'>" . htmlspecialchars($d['detail']) . "</p>
{$d['cta']}
</td>
</tr>";
$n++;
}
if (!$rowsHtml) { echo json_encode(['error'=>'No items selected']); exit; }
$html = "
<div style='max-width:600px;margin:0 auto;font-family:Arial,sans-serif'>
<div style='background:#0d0d0d;padding:24px;text-align:center'>
<h1 style='color:#f97316;margin:0;font-size:20px'>Parker County Slingshot Rentals</h1>
</div>
<div style='padding:32px;background:#fff'>
<h2 style='margin-top:0;color:#111'>Almost Ready — A Few Things Before Pickup</h2>
<p style='color:#374151;margin-bottom:8px'>Hey " . htmlspecialchars($b['name']) . ", your <strong>" . htmlspecialchars($pkg['label']) . "</strong> rental on <strong>{$dateLabel}</strong> is coming up! (Ref: <strong>{$ref}</strong>)</p>
<p style='color:#374151;margin-bottom:20px'>To make sure pickup goes smoothly, here's what still needs to be taken care of:</p>
<table style='width:100%;border-collapse:collapse'>{$rowsHtml}</table>
<div style='margin-top:24px;padding:16px;background:#f9fafb;border-radius:8px'>
<p style='margin:0;font-size:13px;color:#6b7280'>Questions? Call or text <strong style='color:#111'>(817) 555-0199</strong> or reply to this email — we're happy to help.</p>
</div>
<p style='color:#374151;margin-top:24px'>Ride on,<br><strong>The Parker County Slingshot Team</strong></p>
</div>
<div style='background:#f3f4f6;padding:16px;text-align:center'>
<p style='margin:0;font-size:12px;color:#9ca3af'>&copy; " . date('Y') . " Parker County Slingshot Rentals &mdash; Weatherford, TX</p>
</div>
</div>";
$sent = sendEmail($b['email'], $b['name'], "Action Needed Before Your Rental — {$ref}", $html);
echo json_encode(['ok'=>true]);
exit;
}
if ($action === 'block_date') {
$date = $_POST['date'] ?? '';
$reason = substr($_POST['reason'] ?? '', 0, 200);
@@ -58,7 +163,7 @@ if ($isAjax && $authed) {
exit;
}
// ── Login page ────────────────────────────────────────────────────────────────
// ── Login page ────────────────────────────────────────────────────────────────
if (!$authed) { ?>
<!DOCTYPE html>
<html lang="en">
@@ -95,7 +200,7 @@ button:hover{background:#ea580c}
</html>
<?php exit; }
// ── Dashboard data ────────────────────────────────────────────────────────────
// ── Dashboard data ────────────────────────────────────────────────────────────
$statusFilter = $_GET['status'] ?? '';
$where = $statusFilter ? "WHERE status = " . db()->quote($statusFilter) : '';
$bookings = db()->query("SELECT * FROM bookings {$where} ORDER BY rental_date ASC, created_at DESC")->fetchAll();
@@ -108,11 +213,12 @@ $stats = db()->query("
SUM(status='confirmed') AS confirmed,
SUM(status='completed') AS completed,
SUM(status='cancelled') AS cancelled,
SUM(CASE WHEN status IN ('confirmed','completed') THEN amount ELSE 0 END) AS revenue
SUM(CASE WHEN status IN ('confirmed','completed') THEN amount ELSE 0 END) AS revenue,
SUM(waiver_signed) AS waivers_signed,
SUM(insurance_verified) AS insurance_done,
SUM(deposit_received) AS deposits_done
FROM bookings
")->fetch();
$statusColors = ['pending'=>'#d97706','confirmed'=>'#16a34a','completed'=>'#2563eb','cancelled'=>'#dc2626'];
?>
<!DOCTYPE html>
<html lang="en">
@@ -122,40 +228,108 @@ $statusColors = ['pending'=>'#d97706','confirmed'=>'#16a34a','completed'=>'#2563
<style>
*{box-sizing:border-box;margin:0;padding:0}
body{font-family:system-ui,sans-serif;background:#f3f4f6;color:#111;min-height:100vh}
header{background:#0d0d0d;color:#fff;padding:1rem 2rem;display:flex;align-items:center;justify-content:space-between}
header h1{color:#f97316;font-size:1.2rem;font-weight:800}
header a{color:rgba(255,255,255,.5);font-size:.85rem;text-decoration:none}
header{background:#0d0d0d;color:#fff;padding:1rem 2rem;display:flex;align-items:center;justify-content:space-between;gap:1rem}
header h1{color:#f97316;font-size:1.15rem;font-weight:800;white-space:nowrap}
header a{color:rgba(255,255,255,.5);font-size:.85rem;text-decoration:none;white-space:nowrap}
header a:hover{color:#fff}
.main{max-width:1200px;margin:0 auto;padding:2rem 1.5rem}
.stats{display:grid;grid-template-columns:repeat(auto-fit,minmax(150px,1fr));gap:1rem;margin-bottom:2rem}
.stat{background:#fff;border-radius:10px;padding:1.25rem;border:1px solid #e5e7eb;text-align:center}
.stat-val{font-size:2rem;font-weight:800;line-height:1}
.stat-lbl{font-size:.8rem;color:#6b7280;margin-top:.25rem;text-transform:uppercase;letter-spacing:.5px}
.card{background:#fff;border-radius:10px;border:1px solid #e5e7eb;margin-bottom:2rem}
.main{max-width:1300px;margin:0 auto;padding:2rem 1.5rem}
/* Stats */
.stats{display:grid;grid-template-columns:repeat(auto-fit,minmax(130px,1fr));gap:1rem;margin-bottom:2rem}
.stat{background:#fff;border-radius:10px;padding:1.1rem;border:1px solid #e5e7eb;text-align:center}
.stat-val{font-size:1.75rem;font-weight:800;line-height:1}
.stat-lbl{font-size:.72rem;color:#6b7280;margin-top:.3rem;text-transform:uppercase;letter-spacing:.5px}
/* Card */
.card{background:#fff;border-radius:10px;border:1px solid #e5e7eb;margin-bottom:2rem;overflow:hidden}
.card-header{padding:1rem 1.5rem;border-bottom:1px solid #f3f4f6;display:flex;align-items:center;justify-content:space-between;flex-wrap:wrap;gap:.75rem}
.card-header h2{font-size:1rem;font-weight:700}
.filters a{padding:.35rem .75rem;border-radius:6px;font-size:.8rem;text-decoration:none;color:#6b7280;border:1px solid #e5e7eb;margin-left:.35rem}
.filters a.active,.filters a:hover{background:#f97316;color:#fff;border-color:#f97316}
/* Table */
table{width:100%;border-collapse:collapse;font-size:.875rem}
th{background:#f9fafb;padding:.6rem 1rem;text-align:left;font-weight:600;color:#6b7280;font-size:.75rem;text-transform:uppercase;letter-spacing:.5px;border-bottom:1px solid #e5e7eb}
td{padding:.75rem 1rem;border-bottom:1px solid #f3f4f6;vertical-align:top}
tr:last-child td{border-bottom:none}
tr:hover td{background:#fafafa}
.badge{display:inline-block;padding:.2rem .6rem;border-radius:999px;font-size:.72rem;font-weight:700;text-transform:uppercase;letter-spacing:.5px}
.badge-pending{background:#fef3c7;color:#92400e}
.badge-confirmed{background:#dcfce7;color:#14532d}
.badge-completed{background:#dbeafe;color:#1e3a8a}
.badge-cancelled{background:#fee2e2;color:#7f1d1d}
select.status-sel{font-size:.8rem;border:1px solid #e5e7eb;border-radius:6px;padding:.25rem .5rem;cursor:pointer}
textarea.notes-ta{width:100%;font-size:.8rem;border:1px solid #e5e7eb;border-radius:6px;padding:.4rem .6rem;resize:vertical;min-height:50px;font-family:inherit}
.save-btn{font-size:.75rem;background:#f97316;color:#fff;border:none;border-radius:5px;padding:.25rem .6rem;cursor:pointer;margin-top:.25rem}
th{background:#f9fafb;padding:.6rem 1rem;text-align:left;font-weight:600;color:#6b7280;font-size:.72rem;text-transform:uppercase;letter-spacing:.5px;border-bottom:1px solid #e5e7eb;white-space:nowrap}
td{padding:.7rem 1rem;border-bottom:1px solid #f3f4f6;vertical-align:middle}
.booking-row td{cursor:pointer}
.booking-row:hover td{background:#fafafa}
.detail-row td{padding:0;background:#f9fafb}
.detail-row.open td{border-bottom:3px solid #f97316}
/* Expand button */
.expand-btn{background:none;border:none;cursor:pointer;color:#9ca3af;font-size:1rem;padding:2px 6px;border-radius:4px;transition:transform .2s,color .2s;line-height:1}
.expand-btn.open{transform:rotate(90deg);color:#f97316}
/* Progress dots */
.dots{display:flex;gap:4px;align-items:center}
.dot{width:11px;height:11px;border-radius:50%;flex-shrink:0;transition:background .2s}
.dot-done{background:#22c55e}
.dot-skip{background:#d1d5db}
.dot-pending{background:#fbbf24}
/* Status badge / select */
.badge{display:inline-block;padding:.2rem .6rem;border-radius:999px;font-size:.72rem;font-weight:700;text-transform:uppercase;letter-spacing:.4px}
select.status-sel{font-size:.8rem;border:1px solid #e5e7eb;border-radius:6px;padding:.25rem .5rem;cursor:pointer;background:#fff}
/* ── Detail panel ───────────────────────────────────────── */
.detail-panel{display:grid;grid-template-columns:240px 1fr 1fr;gap:0;border-top:1px solid #e5e7eb}
@media(max-width:900px){.detail-panel{grid-template-columns:1fr}}
.dp-col{padding:1.5rem;border-right:1px solid #f3f4f6}
.dp-col:last-child{border-right:none}
.dp-col h3{font-size:.72rem;font-weight:700;text-transform:uppercase;letter-spacing:1px;color:#9ca3af;margin-bottom:1rem}
/* Contact info */
.ci-name{font-size:1rem;font-weight:700;margin-bottom:.2rem}
.ci-row{display:flex;gap:.5rem;align-items:baseline;font-size:.85rem;color:#6b7280;margin-bottom:.35rem}
.ci-row a{color:#f97316;text-decoration:none}
.ci-row a:hover{text-decoration:underline}
.ci-ref{display:inline-block;font-size:.75rem;font-weight:700;color:#f97316;background:#fff7ed;padding:.15rem .5rem;border-radius:4px;margin-bottom:.75rem}
.ci-field{font-size:.78rem;color:#9ca3af;text-transform:uppercase;letter-spacing:.5px;margin-bottom:.15rem;margin-top:.6rem}
/* Flow checklist */
.flow-list{display:flex;flex-direction:column;gap:.15rem}
.flow-step{display:flex;align-items:flex-start;gap:.75rem;padding:.65rem .5rem;border-radius:8px;transition:background .15s}
.flow-step:hover{background:rgba(0,0,0,.02)}
.flow-icon{width:26px;height:26px;border-radius:50%;display:flex;align-items:center;justify-content:center;font-size:.75rem;font-weight:700;flex-shrink:0;margin-top:1px}
.flow-icon.done{background:#dcfce7;color:#16a34a}
.flow-icon.pending{background:#fef3c7;color:#d97706;font-size:.65rem}
.flow-icon.skip{background:#f3f4f6;color:#9ca3af}
.flow-body{flex:1;min-width:0}
.flow-label{font-size:.88rem;font-weight:600;color:#111;display:block}
.flow-label.muted{color:#9ca3af}
.flow-meta{font-size:.75rem;color:#9ca3af;margin-top:.1rem;display:block}
.flow-action{margin-top:.35rem}
.flow-toggle{background:none;border:1px solid #d1d5db;border-radius:5px;font-size:.73rem;padding:.2rem .6rem;cursor:pointer;color:#374151;transition:all .15s}
.flow-toggle:hover{border-color:#f97316;color:#f97316}
.flow-toggle.active{background:#dcfce7;border-color:#bbf7d0;color:#15803d}
.flow-link{font-size:.75rem;color:#f97316;text-decoration:none}
.flow-link:hover{text-decoration:underline}
/* Notes */
textarea.notes-ta{width:100%;font-size:.82rem;border:1px solid #e5e7eb;border-radius:6px;padding:.5rem .65rem;resize:vertical;min-height:72px;font-family:inherit;outline:none;transition:border-color .2s}
textarea.notes-ta:focus{border-color:#f97316}
.save-btn{margin-top:.5rem;font-size:.78rem;background:#f97316;color:#fff;border:none;border-radius:5px;padding:.3rem .75rem;cursor:pointer;transition:background .15s}
.save-btn:hover{background:#ea580c}
/* Reminder section */
.reminder-box{background:#fff7ed;border:1px solid #fed7aa;border-radius:8px;padding:1rem;margin-top:1.25rem}
.reminder-box h4{font-size:.78rem;font-weight:700;text-transform:uppercase;letter-spacing:.5px;color:#92400e;margin-bottom:.65rem}
.reminder-items{display:flex;flex-direction:column;gap:.4rem;margin-bottom:.85rem}
.reminder-items label{display:flex;align-items:flex-start;gap:.5rem;font-size:.83rem;color:#374151;cursor:pointer}
.reminder-items input[type=checkbox]{margin-top:2px;accent-color:#f97316;width:14px;height:14px}
.send-btn{width:100%;background:#f97316;color:#fff;border:none;border-radius:6px;padding:.55rem;font-size:.83rem;font-weight:700;cursor:pointer;transition:background .15s}
.send-btn:hover{background:#ea580c}
.send-btn:disabled{background:#d1d5db;cursor:not-allowed}
/* Block dates */
.block-form{display:flex;gap:.75rem;flex-wrap:wrap;align-items:flex-end;padding:1rem 1.5rem}
.block-form input{border:1px solid #e5e7eb;border-radius:6px;padding:.45rem .75rem;font-size:.875rem}
.block-form button{background:#dc2626;color:#fff;border:none;border-radius:6px;padding:.45rem 1rem;font-size:.875rem;font-weight:600;cursor:pointer}
.block-list{padding:0 1.5rem 1rem}
.block-item{display:flex;align-items:center;gap:.75rem;padding:.5rem 0;border-bottom:1px solid #f3f4f6;font-size:.875rem}
.block-item:last-child{border-bottom:none}
.del-btn{background:none;border:none;color:#dc2626;cursor:pointer;font-size:1rem;padding:0}
.del-btn{background:none;border:none;color:#dc2626;cursor:pointer;font-size:1rem;padding:0;line-height:1}
@media(max-width:700px){.main{padding:1rem}.stat-val{font-size:1.5rem}}
</style>
</head>
@@ -168,11 +342,14 @@ textarea.notes-ta{width:100%;font-size:.8rem;border:1px solid #e5e7eb;border-rad
<!-- Stats -->
<div class="stats">
<div class="stat"><div class="stat-val"><?= $stats['total'] ?></div><div class="stat-lbl">Total Bookings</div></div>
<div class="stat"><div class="stat-val" style="color:#d97706"><?= $stats['pending'] ?></div><div class="stat-lbl">Pending</div></div>
<div class="stat"><div class="stat-val" style="color:#16a34a"><?= $stats['confirmed'] ?></div><div class="stat-lbl">Confirmed</div></div>
<div class="stat"><div class="stat-val" style="color:#2563eb"><?= $stats['completed'] ?></div><div class="stat-lbl">Completed</div></div>
<div class="stat"><div class="stat-val" style="color:#f97316">$<?= number_format($stats['revenue'],0) ?></div><div class="stat-lbl">Revenue</div></div>
<div class="stat"><div class="stat-val"><?= (int)$stats['total'] ?></div><div class="stat-lbl">Total Bookings</div></div>
<div class="stat"><div class="stat-val" style="color:#d97706"><?= (int)$stats['pending'] ?></div><div class="stat-lbl">Pending</div></div>
<div class="stat"><div class="stat-val" style="color:#16a34a"><?= (int)$stats['confirmed'] ?></div><div class="stat-lbl">Confirmed</div></div>
<div class="stat"><div class="stat-val" style="color:#2563eb"><?= (int)$stats['completed'] ?></div><div class="stat-lbl">Completed</div></div>
<div class="stat"><div class="stat-val" style="color:#f97316">$<?= number_format((float)$stats['revenue'],0) ?></div><div class="stat-lbl">Revenue</div></div>
<div class="stat"><div class="stat-val" style="color:#16a34a"><?= (int)$stats['waivers_signed'] ?></div><div class="stat-lbl">Waivers Signed</div></div>
<div class="stat"><div class="stat-val" style="color:#16a34a"><?= (int)$stats['insurance_done'] ?></div><div class="stat-lbl">Insurance OK</div></div>
<div class="stat"><div class="stat-val" style="color:#16a34a"><?= (int)$stats['deposits_done'] ?></div><div class="stat-lbl">Deposits Rcvd</div></div>
</div>
<!-- Bookings -->
@@ -180,13 +357,14 @@ textarea.notes-ta{width:100%;font-size:.8rem;border:1px solid #e5e7eb;border-rad
<div class="card-header">
<h2>Bookings</h2>
<div class="filters">
<a href="/admin/" class="<?= !$statusFilter ? 'active':'' ?>">All</a>
<a href="/admin/?status=pending" class="<?= $statusFilter==='pending' ? 'active':'' ?>">Pending</a>
<a href="/admin/?status=confirmed" class="<?= $statusFilter==='confirmed' ? 'active':'' ?>">Confirmed</a>
<a href="/admin/?status=completed" class="<?= $statusFilter==='completed' ? 'active':'' ?>">Completed</a>
<a href="/admin/?status=cancelled" class="<?= $statusFilter==='cancelled' ? 'active':'' ?>">Cancelled</a>
<a href="/admin/" class="<?= !$statusFilter?'active':'' ?>">All</a>
<a href="/admin/?status=pending" class="<?= $statusFilter==='pending' ?'active':'' ?>">Pending</a>
<a href="/admin/?status=confirmed" class="<?= $statusFilter==='confirmed' ?'active':'' ?>">Confirmed</a>
<a href="/admin/?status=completed" class="<?= $statusFilter==='completed' ?'active':'' ?>">Completed</a>
<a href="/admin/?status=cancelled" class="<?= $statusFilter==='cancelled' ?'active':'' ?>">Cancelled</a>
</div>
</div>
<?php if (empty($bookings)): ?>
<div style="padding:3rem;text-align:center;color:#9ca3af">No bookings found.</div>
<?php else: ?>
@@ -194,52 +372,276 @@ textarea.notes-ta{width:100%;font-size:.8rem;border:1px solid #e5e7eb;border-rad
<table>
<thead>
<tr>
<th>Ref</th><th>Customer</th><th>Package</th><th>Date</th>
<th>Amount</th><th>Status</th><th>Waiver</th><th>Admin Notes</th><th>Received</th>
<th style="width:36px"></th>
<th>Customer</th>
<th>Rental Date</th>
<th>Package</th>
<th>Amount</th>
<th>Status</th>
<th>Progress</th>
<th>Submitted</th>
</tr>
</thead>
<tbody>
<?php foreach ($bookings as $b): ?>
<tr>
<td><strong><?= htmlspecialchars($b['booking_ref']) ?></strong></td>
<td>
<strong><?= htmlspecialchars($b['name']) ?></strong><br>
<a href="mailto:<?= htmlspecialchars($b['email']) ?>" style="color:#f97316;font-size:.8rem"><?= htmlspecialchars($b['email']) ?></a>
<?php if ($b['phone']): ?><br><span style="font-size:.8rem;color:#6b7280"><?= htmlspecialchars($b['phone']) ?></span><?php endif; ?>
<?php foreach ($bookings as $b):
$bid = $b['id'];
$pkg = PACKAGES[$b['package']] ?? ['label'=>$b['package']];
// Determine each step's state
$stepConfirmed = in_array($b['status'], ['confirmed','completed']);
$stepWaiver = (bool)$b['waiver_signed'];
$stepInsurance = (bool)$b['insurance_verified'];
$stepDeposit = (bool)$b['deposit_received'];
$stepLicense = (bool)$b['license_verified'];
// Dot colors: done=green, if cancelled skip all
$cancelled = $b['status'] === 'cancelled';
$dotClass = function($done) use ($cancelled) {
if ($cancelled) return 'dot-skip';
return $done ? 'dot-done' : 'dot-pending';
};
$allDone = $stepConfirmed && $stepWaiver && $stepInsurance && $stepDeposit && $stepLicense;
$pendingCount = ($cancelled ? 0 : (
(!$stepConfirmed?1:0)+(!$stepWaiver?1:0)+(!$stepInsurance?1:0)+(!$stepDeposit?1:0)+(!$stepLicense?1:0)
));
?>
<tr class="booking-row" onclick="toggleDetail(<?= $bid ?>)">
<td onclick="event.stopPropagation()">
<button class="expand-btn" id="expand-<?= $bid ?>" onclick="toggleDetail(<?= $bid ?>)">&#9658;</button>
</td>
<td>
<?php $pkg = PACKAGES[$b['package']] ?? ['label'=>$b['package']]; ?>
<?= htmlspecialchars($pkg['label']) ?>
<strong><?= htmlspecialchars($b['name']) ?></strong><br>
<span style="font-size:.78rem;color:#9ca3af"><?= htmlspecialchars($b['email']) ?></span>
</td>
<td>
<strong><?= date('M j, Y', strtotime($b['rental_date'])) ?></strong>
<?php if ($b['end_date'] !== $b['rental_date']): ?>
<br><span style="font-size:.8rem;color:#6b7280">→ <?= date('M j', strtotime($b['end_date'])) ?></span>
<br><span style="font-size:.75rem;color:#9ca3af">→ <?= date('M j', strtotime($b['end_date'])) ?></span>
<?php endif; ?>
</td>
<td>$<?= number_format($b['amount'],2) ?></td>
<td>
<select class="status-sel" data-id="<?= $b['id'] ?>" onchange="updateStatus(this)">
<td style="font-size:.83rem"><?= htmlspecialchars($pkg['label']) ?></td>
<td style="font-weight:600">$<?= number_format($b['amount'],2) ?></td>
<td onclick="event.stopPropagation()">
<select class="status-sel" data-id="<?= $bid ?>" onchange="updateStatus(this)">
<?php foreach (['pending','confirmed','completed','cancelled'] as $s): ?>
<option value="<?= $s ?>" <?= $b['status']===$s?'selected':'' ?>><?= ucfirst($s) ?></option>
<?php endforeach; ?>
</select>
</td>
<td>
<?php if ($b['waiver_signed']): ?>
<span class="badge" style="background:#dcfce7;color:#14532d">&#10003; Signed</span>
<br><span style="font-size:.72rem;color:#9ca3af"><?= date('M j g:ia', strtotime($b['waiver_signed_at'])) ?></span>
<?php else: ?>
<a href="https://parkerslingshotrentals.com/waiver.php?ref=<?= urlencode($b['booking_ref']) ?>" target="_blank"
style="font-size:.78rem;color:#f97316;text-decoration:none">Send Link &#8599;</a>
<br><span class="badge" style="background:#fef3c7;color:#92400e;margin-top:.25rem">Pending</span>
<div class="dots" title="Confirmed / Waiver / Insurance / Deposit / License">
<div class="dot <?= $dotClass($stepConfirmed) ?>" title="Confirmed"></div>
<div class="dot <?= $dotClass($stepWaiver) ?>" title="Waiver Signed"></div>
<div class="dot <?= $dotClass($stepInsurance) ?>" title="Insurance" id="dot-<?= $bid ?>-insurance_verified"></div>
<div class="dot <?= $dotClass($stepDeposit) ?>" title="Deposit" id="dot-<?= $bid ?>-deposit_received"></div>
<div class="dot <?= $dotClass($stepLicense) ?>" title="License" id="dot-<?= $bid ?>-license_verified"></div>
</div>
<?php if (!$cancelled && $pendingCount): ?>
<span style="font-size:.68rem;color:#d97706;display:block;margin-top:3px"><?= $pendingCount ?> pending</span>
<?php elseif ($allDone): ?>
<span style="font-size:.68rem;color:#16a34a;display:block;margin-top:3px">All done ✓</span>
<?php endif; ?>
</td>
<td>
<textarea class="notes-ta" data-id="<?= $b['id'] ?>"><?= htmlspecialchars($b['admin_notes'] ?? '') ?></textarea>
<button class="save-btn" onclick="saveNotes(this)">Save</button>
<td style="font-size:.78rem;color:#9ca3af;white-space:nowrap"><?= date('M j g:ia', strtotime($b['created_at'])) ?></td>
</tr>
<!-- ── Detail Panel ───────────────────────────────────────────── -->
<tr class="detail-row" id="detail-<?= $bid ?>">
<td colspan="8">
<div class="detail-panel">
<!-- Column 1: Contact info -->
<div class="dp-col">
<h3>Customer</h3>
<div class="ci-ref"><?= htmlspecialchars($b['booking_ref']) ?></div>
<div class="ci-name"><?= htmlspecialchars($b['name']) ?></div>
<div class="ci-row"><a href="mailto:<?= htmlspecialchars($b['email']) ?>"><?= htmlspecialchars($b['email']) ?></a></div>
<?php if ($b['phone']): ?>
<div class="ci-row"><a href="tel:<?= htmlspecialchars($b['phone']) ?>"><?= htmlspecialchars($b['phone']) ?></a></div>
<?php endif; ?>
<div class="ci-field">Package</div>
<div style="font-size:.85rem;font-weight:600"><?= htmlspecialchars($pkg['label']) ?></div>
<div style="font-size:.82rem;color:#6b7280">$<?= number_format($b['amount'],2) ?></div>
<div class="ci-field">Rental Date</div>
<div style="font-size:.85rem;font-weight:600"><?= date('F j, Y', strtotime($b['rental_date'])) ?></div>
<?php if ($b['notes']): ?>
<div class="ci-field">Customer Message</div>
<div style="font-size:.82rem;color:#6b7280;font-style:italic"><?= nl2br(htmlspecialchars($b['notes'])) ?></div>
<?php endif; ?>
<div style="margin-top:1.25rem">
<div class="ci-field">Admin Notes</div>
<textarea class="notes-ta" id="notes-<?= $bid ?>"><?= htmlspecialchars($b['admin_notes'] ?? '') ?></textarea>
<button class="save-btn" onclick="saveNotes(<?= $bid ?>)">Save Notes</button>
</div>
</div>
<!-- Column 2: Booking flow checklist -->
<div class="dp-col">
<h3>Booking Flow</h3>
<div class="flow-list">
<!-- Step 1: Submitted -->
<div class="flow-step">
<div class="flow-icon done">✓</div>
<div class="flow-body">
<span class="flow-label">Booking Submitted</span>
<span class="flow-meta"><?= date('M j, Y g:ia', strtotime($b['created_at'])) ?></span>
</div>
</div>
<!-- Step 2: Confirmed -->
<div class="flow-step">
<div class="flow-icon <?= $stepConfirmed?'done':($cancelled?'skip':'pending') ?>">
<?= $stepConfirmed?'✓':($cancelled?'—':'2') ?>
</div>
<div class="flow-body">
<span class="flow-label <?= (!$stepConfirmed&&!$cancelled)?'':''; ?>">Booking Confirmed</span>
<span class="flow-meta">
<?php if ($stepConfirmed): ?>Confirmed — status: <?= ucfirst($b['status']) ?>
<?php elseif ($cancelled): ?>Cancelled
<?php else: ?>Awaiting confirmation — change status above
<?php endif; ?>
</span>
</div>
</div>
<!-- Step 3: Waiver -->
<div class="flow-step">
<div class="flow-icon <?= $stepWaiver?'done':($cancelled?'skip':'pending') ?>">
<?= $stepWaiver?'✓':($cancelled?'—':'3') ?>
</div>
<div class="flow-body">
<span class="flow-label">Rental Waiver Signed</span>
<span class="flow-meta">
<?php if ($stepWaiver): ?>
Signed by <?= htmlspecialchars($b['waiver_name'] ?? $b['name']) ?>
<?php if ($b['waiver_signed_at']): ?> on <?= date('M j g:ia', strtotime($b['waiver_signed_at'])) ?><?php endif; ?>
<?php elseif ($cancelled): ?>N/A
<?php else: ?>Not yet signed
<?php endif; ?>
</span>
<?php if (!$stepWaiver && !$cancelled): ?>
<div class="flow-action">
<a class="flow-link" href="https://parkerslingshotrentals.com/waiver.php?ref=<?= urlencode($b['booking_ref']) ?>" target="_blank">Open Waiver Link ↗</a>
</div>
<?php endif; ?>
</div>
</div>
<!-- Step 4: Insurance -->
<div class="flow-step">
<div class="flow-icon <?= $stepInsurance?'done':($cancelled?'skip':'pending') ?>" id="icon-<?= $bid ?>-insurance_verified">
<?= $stepInsurance?'✓':($cancelled?'—':'4') ?>
</div>
<div class="flow-body">
<span class="flow-label">Proof of Insurance Received</span>
<span class="flow-meta" id="meta-<?= $bid ?>-insurance_verified">
<?= $stepInsurance?'Verified — on file':($cancelled?'N/A':'Pending — verify at pickup') ?>
</span>
<?php if (!$cancelled): ?>
<div class="flow-action">
<button class="flow-toggle <?= $stepInsurance?'active':'' ?>"
id="btn-<?= $bid ?>-insurance_verified"
onclick="toggleReq(<?= $bid ?>,'insurance_verified',this)">
<?= $stepInsurance?'✓ Marked Received':'Mark Received' ?>
</button>
</div>
<?php endif; ?>
</div>
</div>
<!-- Step 5: Deposit -->
<div class="flow-step">
<div class="flow-icon <?= $stepDeposit?'done':($cancelled?'skip':'pending') ?>" id="icon-<?= $bid ?>-deposit_received">
<?= $stepDeposit?'✓':($cancelled?'—':'5') ?>
</div>
<div class="flow-body">
<span class="flow-label">Security Deposit Received</span>
<span class="flow-meta" id="meta-<?= $bid ?>-deposit_received">
<?= $stepDeposit?'Deposit received':($cancelled?'N/A':'Pending — collect at pickup') ?>
</span>
<?php if (!$cancelled): ?>
<div class="flow-action">
<button class="flow-toggle <?= $stepDeposit?'active':'' ?>"
id="btn-<?= $bid ?>-deposit_received"
onclick="toggleReq(<?= $bid ?>,'deposit_received',this)">
<?= $stepDeposit?'✓ Deposit Received':'Mark Deposit Received' ?>
</button>
</div>
<?php endif; ?>
</div>
</div>
<!-- Step 6: License -->
<div class="flow-step">
<div class="flow-icon <?= $stepLicense?'done':($cancelled?'skip':'pending') ?>" id="icon-<?= $bid ?>-license_verified">
<?= $stepLicense?'✓':($cancelled?'—':'6') ?>
</div>
<div class="flow-body">
<span class="flow-label">Driver's License Verified</span>
<span class="flow-meta" id="meta-<?= $bid ?>-license_verified">
<?= $stepLicense?'License verified':($cancelled?'N/A':'Verify at pickup') ?>
</span>
<?php if (!$cancelled): ?>
<div class="flow-action">
<button class="flow-toggle <?= $stepLicense?'active':'' ?>"
id="btn-<?= $bid ?>-license_verified"
onclick="toggleReq(<?= $bid ?>,'license_verified',this)">
<?= $stepLicense?'✓ License Verified':'Mark License Verified' ?>
</button>
</div>
<?php endif; ?>
</div>
</div>
</div><!-- /.flow-list -->
</div><!-- /.dp-col flow -->
<!-- Column 3: Reminder -->
<div class="dp-col">
<h3>Send Reminder Email</h3>
<p style="font-size:.82rem;color:#6b7280;margin-bottom:1rem">Select what the customer still needs to do, then send them a nudge email with clear instructions.</p>
<?php if ($cancelled): ?>
<p style="font-size:.82rem;color:#9ca3af">Not applicable for cancelled bookings.</p>
<?php else: ?>
<div class="reminder-box">
<h4>Include in Reminder</h4>
<div class="reminder-items" id="reminder-<?= $bid ?>">
<label>
<input type="checkbox" value="waiver" <?= !$stepWaiver?'checked':'' ?>>
Sign rental agreement
</label>
<label>
<input type="checkbox" value="insurance" <?= !$stepInsurance?'checked':'' ?>>
Bring proof of insurance
</label>
<label>
<input type="checkbox" value="deposit" <?= !$stepDeposit?'checked':'' ?>>
Security deposit info
</label>
<label>
<input type="checkbox" value="license" <?= !$stepLicense?'checked':'' ?>>
Bring driver's license
</label>
</div>
<button class="send-btn" id="remind-btn-<?= $bid ?>" onclick="sendReminder(<?= $bid ?>)">
Send Reminder Email
</button>
<div id="remind-status-<?= $bid ?>" style="margin-top:.6rem;font-size:.78rem;display:none"></div>
</div>
<div style="margin-top:1.25rem;padding:1rem;background:#f9fafb;border-radius:8px;font-size:.8rem;color:#6b7280">
<strong style="color:#111;display:block;margin-bottom:.35rem">Waiver Link</strong>
<code style="word-break:break-all;font-size:.72rem">https://parkerslingshotrentals.com/waiver.php?ref=<?= htmlspecialchars($b['booking_ref']) ?></code>
</div>
<?php endif; ?>
</div>
</div><!-- /.detail-panel -->
</td>
<td style="font-size:.8rem;color:#9ca3af;white-space:nowrap"><?= date('M j g:ia', strtotime($b['created_at'])) ?></td>
</tr>
<?php endforeach; ?>
</tbody>
@@ -250,7 +652,7 @@ textarea.notes-ta{width:100%;font-size:.8rem;border:1px solid #e5e7eb;border-rad
<!-- Block Dates -->
<div class="card">
<div class="card-header"><h2>Block Dates (maintenance / personal use)</h2></div>
<div class="card-header"><h2>Block Dates</h2></div>
<form class="block-form" onsubmit="blockDate(event)">
<div>
<label style="font-size:.8rem;color:#6b7280;display:block;margin-bottom:.25rem">Date</label>
@@ -258,7 +660,7 @@ textarea.notes-ta{width:100%;font-size:.8rem;border:1px solid #e5e7eb;border-rad
</div>
<div style="flex:1;min-width:180px">
<label style="font-size:.8rem;color:#6b7280;display:block;margin-bottom:.25rem">Reason (optional)</label>
<input type="text" id="blockReason" placeholder="e.g. Maintenance">
<input type="text" id="blockReason" placeholder="e.g. Maintenance, personal use">
</div>
<button type="submit">Block Date</button>
</form>
@@ -276,9 +678,21 @@ textarea.notes-ta{width:100%;font-size:.8rem;border:1px solid #e5e7eb;border-rad
<?php endif; ?>
</div>
</div>
</div>
</div><!-- /.main -->
<script>
// ── Expand/collapse detail panel ──────────────────────────────────────────────
function toggleDetail(id) {
const row = document.getElementById('detail-' + id);
const btn = document.getElementById('expand-' + id);
const open = row.style.display !== 'table-row';
row.style.display = open ? 'table-row' : 'none';
row.classList.toggle('open', open);
btn.classList.toggle('open', open);
}
// ── Status dropdown ───────────────────────────────────────────────────────────
function updateStatus(sel) {
fetch('/admin/', {
method:'POST',
@@ -287,18 +701,108 @@ function updateStatus(sel) {
}).then(r=>r.json()).then(d=>{ if(!d.ok) alert('Error saving status'); });
}
function saveNotes(btn) {
var ta = btn.previousElementSibling;
// ── Toggle requirement (insurance / deposit / license) ────────────────────────
function toggleReq(id, field, btn) {
btn.disabled = true;
fetch('/admin/', {
method:'POST',
headers:{'Content-Type':'application/x-www-form-urlencoded','X-Requested-With':'XMLHttpRequest'},
body:'action=save_admin_notes&id='+ta.dataset.id+'&notes='+encodeURIComponent(ta.value)
}).then(r=>r.json()).then(d=>{
btn.textContent = d.ok ? 'Saved ✓' : 'Error';
setTimeout(()=>btn.textContent='Save', 1500);
body:'action=toggle_requirement&id='+id+'&field='+field
})
.then(r=>r.json())
.then(d=>{
if (!d.ok) { alert('Error saving'); btn.disabled=false; return; }
const on = d.value === 1;
// Update button
btn.classList.toggle('active', on);
const labels = {
insurance_verified: ['Mark Received','✓ Marked Received'],
deposit_received: ['Mark Deposit Received','✓ Deposit Received'],
license_verified: ['Mark License Verified','✓ License Verified'],
};
btn.textContent = on ? labels[field][1] : labels[field][0];
btn.disabled = false;
// Update icon
const icon = document.getElementById('icon-'+id+'-'+field);
if (icon) {
icon.className = 'flow-icon ' + (on ? 'done' : 'pending');
icon.textContent = on ? '✓' : icon.textContent.replace('✓','').trim() || '?';
// Restore step numbers for known fields
const stepNums = {insurance_verified:'4', deposit_received:'5', license_verified:'6'};
if (!on) icon.textContent = stepNums[field] || '?';
}
// Update meta text
const meta = document.getElementById('meta-'+id+'-'+field);
const metaTexts = {
insurance_verified: ['Pending — verify at pickup','Verified — on file'],
deposit_received: ['Pending — collect at pickup','Deposit received'],
license_verified: ['Verify at pickup','License verified'],
};
if (meta && metaTexts[field]) meta.textContent = on ? metaTexts[field][1] : metaTexts[field][0];
// Update progress dot in table row
const dot = document.getElementById('dot-'+id+'-'+field);
if (dot) {
dot.className = 'dot ' + (on ? 'dot-done' : 'dot-pending');
}
// Auto-uncheck reminder checkbox if item is now done
const reminderBox = document.getElementById('reminder-'+id);
if (reminderBox && on) {
const fieldToItem = {insurance_verified:'insurance', deposit_received:'deposit', license_verified:'license'};
const cb = reminderBox.querySelector('input[value="'+fieldToItem[field]+'"]');
if (cb) cb.checked = false;
}
});
}
// ── Admin notes ───────────────────────────────────────────────────────────────
function saveNotes(id) {
const ta = document.getElementById('notes-' + id);
const btn = ta.nextElementSibling;
fetch('/admin/', {
method:'POST',
headers:{'Content-Type':'application/x-www-form-urlencoded','X-Requested-With':'XMLHttpRequest'},
body:'action=save_admin_notes&id='+id+'&notes='+encodeURIComponent(ta.value)
}).then(r=>r.json()).then(d=>{
btn.textContent = d.ok ? 'Saved ✓' : 'Error';
setTimeout(()=>btn.textContent='Save Notes', 1800);
});
}
// ── Send reminder email ───────────────────────────────────────────────────────
function sendReminder(id) {
const box = document.getElementById('reminder-' + id);
const btn = document.getElementById('remind-btn-' + id);
const status= document.getElementById('remind-status-' + id);
const checks= Array.from(box.querySelectorAll('input[type=checkbox]:checked')).map(c=>c.value);
if (!checks.length) { alert('Please select at least one item to include in the reminder.'); return; }
if (!confirm('Send reminder email about: ' + checks.join(', ') + '?')) return;
btn.disabled = true;
btn.textContent = 'Sending…';
fetch('/admin/', {
method:'POST',
headers:{'Content-Type':'application/x-www-form-urlencoded','X-Requested-With':'XMLHttpRequest'},
body:'action=send_reminder&id='+id+'&items='+checks.join(',')
}).then(r=>r.json()).then(d=>{
btn.disabled = false;
if (d.ok) {
btn.textContent = 'Reminder Sent ✓';
btn.style.background = '#16a34a';
status.style.display = 'block';
status.style.color = '#16a34a';
status.textContent = 'Email sent successfully.';
setTimeout(()=>{
btn.textContent = 'Send Reminder Email';
btn.style.background = '';
status.style.display = 'none';
}, 4000);
} else {
btn.textContent = 'Send Reminder Email';
alert(d.error || 'Failed to send reminder.');
}
});
}
// ── Block / unblock dates ─────────────────────────────────────────────────────
function blockDate(e) {
e.preventDefault();
var date = document.getElementById('blockDate').value;
@@ -309,7 +813,6 @@ function blockDate(e) {
body:'action=block_date&date='+date+'&reason='+encodeURIComponent(reason)
}).then(r=>r.json()).then(d=>{ if(d.ok) location.reload(); else alert(d.error); });
}
function unblockDate(id) {
if (!confirm('Remove this blocked date?')) return;
fetch('/admin/', {
+1 -1
View File
@@ -5,7 +5,7 @@ define('PARKER_DB_USER', 'parker_user');
define('PARKER_DB_PASS', 'Pk4rk3r_2026!Tx');
define('ADMIN_USER', 'admin');
define('ADMIN_PASS', '$2y$12$Tz8QkXv9mNpL3wR1uE6OaOQbKjH5sYdF2cVnMxPt7lGAeWqZiBSHu'); // Parker2026!
define('ADMIN_PASS', '$2y$10$ynnk3RfarOD7VIJizC30kuXqu6tQ3gotNrlp5y33afh5fPOgnAMU6'); // Parker2026!
define('ADMIN_SESSION_KEY', 'parker_admin_auth');
define('SENDGRID_API_KEY', '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');