989 lines
38 KiB
HTML
989 lines
38 KiB
HTML
<!DOCTYPE html>
|
||
<html lang="en">
|
||
<head>
|
||
<meta charset="UTF-8">
|
||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||
<title>Moore Sunny Daze — Admin Dashboard</title>
|
||
<style>
|
||
:root {
|
||
--bg: #0f172a;
|
||
--surface: #1e293b;
|
||
--border: #334155;
|
||
--text: #e2e8f0;
|
||
--muted: #94a3b8;
|
||
--accent: #06b6d4;
|
||
--accent2: #f59e0b;
|
||
--green: #10b981;
|
||
--red: #ef4444;
|
||
--sidebar-w: 240px;
|
||
}
|
||
|
||
* { margin:0; padding:0; box-sizing:border-box; }
|
||
|
||
body {
|
||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', system-ui, sans-serif;
|
||
background: var(--bg);
|
||
color: var(--text);
|
||
display: flex;
|
||
min-height: 100vh;
|
||
}
|
||
|
||
/* ── SIDEBAR ── */
|
||
.sidebar {
|
||
width: var(--sidebar-w);
|
||
min-height: 100vh;
|
||
background: var(--surface);
|
||
border-right: 1px solid var(--border);
|
||
display: flex;
|
||
flex-direction: column;
|
||
position: fixed;
|
||
top: 0; left: 0; bottom: 0;
|
||
z-index: 100;
|
||
transition: transform .25s ease;
|
||
}
|
||
.sidebar-logo {
|
||
padding: 24px 20px;
|
||
border-bottom: 1px solid var(--border);
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 12px;
|
||
}
|
||
.sidebar-logo .icon { width:36px; height:36px; border-radius:8px; background:var(--accent); display:flex; align-items:center; justify-content:center; color:#fff; font-weight:700; font-size:18px; }
|
||
.sidebar-logo h1 { font-size:15px; font-weight:600; color:var(--text); line-height:1.3; }
|
||
.sidebar-logo span { font-size:11px; color:var(--muted); display:block; }
|
||
|
||
.sidebar-nav { flex:1; padding:12px 8px; }
|
||
.nav-item {
|
||
display: flex; align-items: center; gap: 12px;
|
||
padding: 10px 14px; border-radius: 8px;
|
||
color: var(--muted); cursor: pointer; font-size: 14px;
|
||
transition: all .15s; margin-bottom: 2px;
|
||
border: none; background: none; width: 100%; text-align: left;
|
||
}
|
||
.nav-item:hover { background: rgba(255,255,255,.04); color: var(--text); }
|
||
.nav-item.active { background: rgba(6,182,212,.12); color: var(--accent); }
|
||
.nav-item svg { width:20px; height:20px; flex-shrink:0; }
|
||
|
||
.sidebar-footer {
|
||
padding: 16px 20px;
|
||
border-top: 1px solid var(--border);
|
||
font-size: 12px;
|
||
color: var(--muted);
|
||
}
|
||
|
||
/* ── MOBILE TOGGLE ── */
|
||
.mobile-toggle {
|
||
display: none;
|
||
position: fixed; top: 12px; left: 12px; z-index: 200;
|
||
background: var(--surface); border: 1px solid var(--border);
|
||
color: var(--text); border-radius: 8px; padding: 8px 10px;
|
||
cursor: pointer;
|
||
}
|
||
.mobile-overlay {
|
||
display: none;
|
||
position: fixed; inset: 0; background: rgba(0,0,0,.5);
|
||
z-index: 99;
|
||
}
|
||
|
||
/* ── MAIN ── */
|
||
.main {
|
||
margin-left: var(--sidebar-w);
|
||
flex: 1;
|
||
padding: 32px;
|
||
min-width: 0;
|
||
}
|
||
|
||
.page { display:none; }
|
||
.page.active { display:block; }
|
||
|
||
.page-header { margin-bottom: 28px; }
|
||
.page-header h2 { font-size:22px; font-weight:700; margin-bottom:4px; }
|
||
.page-header p { color:var(--muted); font-size:14px; }
|
||
|
||
/* ── STATS BAR ── */
|
||
.stats-grid {
|
||
display: grid;
|
||
grid-template-columns: repeat(4, 1fr);
|
||
gap: 16px;
|
||
margin-bottom: 32px;
|
||
}
|
||
.stat-card {
|
||
background: var(--surface);
|
||
border: 1px solid var(--border);
|
||
border-radius: 12px;
|
||
padding: 20px;
|
||
}
|
||
.stat-card .label { font-size:12px; color:var(--muted); text-transform:uppercase; letter-spacing:.05em; margin-bottom:6px; }
|
||
.stat-card .value { font-size:28px; font-weight:700; }
|
||
.stat-card .value.accent { color:var(--accent); }
|
||
.stat-card .value.green { color:var(--green); }
|
||
.stat-card .value.amber { color:var(--accent2); }
|
||
.stat-card .sub { font-size:12px; color:var(--muted); margin-top:4px; }
|
||
|
||
/* ── FILTER BAR ── */
|
||
.filter-bar {
|
||
display: flex; gap: 8px; margin-bottom: 16px;
|
||
flex-wrap: wrap;
|
||
}
|
||
.filter-btn {
|
||
padding: 6px 16px; border-radius: 20px; font-size: 13px;
|
||
border: 1px solid var(--border); background: transparent;
|
||
color: var(--muted); cursor: pointer; transition: all .15s;
|
||
}
|
||
.filter-btn:hover { border-color: var(--accent); color: var(--text); }
|
||
.filter-btn.active { background: var(--accent); color: #fff; border-color: var(--accent); }
|
||
|
||
/* ── TABLE ── */
|
||
.table-wrap {
|
||
background: var(--surface);
|
||
border: 1px solid var(--border);
|
||
border-radius: 12px;
|
||
overflow-x: auto;
|
||
}
|
||
table { width: 100%; border-collapse: collapse; }
|
||
th { text-align: left; padding: 12px 16px; font-size: 12px; color: var(--muted); text-transform: uppercase; letter-spacing:.05em; border-bottom: 1px solid var(--border); font-weight: 600; }
|
||
td { padding: 12px 16px; font-size: 14px; border-bottom: 1px solid var(--border); }
|
||
tr:last-child td { border-bottom: none; }
|
||
tr:hover td { background: rgba(255,255,255,.02); }
|
||
|
||
.badge {
|
||
display: inline-block; padding: 3px 10px; border-radius: 20px;
|
||
font-size: 12px; font-weight: 600; text-transform: capitalize;
|
||
}
|
||
.badge.confirmed { background: rgba(16,185,129,.15); color: var(--green); }
|
||
.badge.pending { background: rgba(245,158,11,.15); color: var(--accent2); }
|
||
.badge.cancelled { background: rgba(148,163,184,.15); color: var(--muted); }
|
||
.badge.completed { background: rgba(148,163,184,.1); color: var(--muted); }
|
||
|
||
.btn {
|
||
padding: 6px 14px; border-radius: 6px; font-size: 12px; font-weight: 600;
|
||
border: none; cursor: pointer; transition: all .15s;
|
||
}
|
||
.btn-sm { padding:4px 10px; font-size:11px; }
|
||
.btn-outline { background:transparent; border:1px solid var(--border); color:var(--text); }
|
||
.btn-outline:hover { border-color: var(--accent); color: var(--accent); }
|
||
.btn-danger { background: rgba(239,68,68,.15); color: var(--red); }
|
||
.btn-danger:hover { background: rgba(239,68,68,.25); }
|
||
.btn-green { background: rgba(16,185,129,.15); color: var(--green); }
|
||
.btn-green:hover { background: rgba(16,185,129,.25); }
|
||
.btn-accent { background: var(--accent); color: #fff; }
|
||
.btn-accent:hover { opacity:.85; }
|
||
|
||
.actions { display:flex; gap:6px; flex-wrap:wrap; }
|
||
|
||
/* ── CALENDAR ── */
|
||
.calendar-container {
|
||
display: grid;
|
||
grid-template-columns: 1fr 280px;
|
||
gap: 24px;
|
||
}
|
||
.calendar-nav {
|
||
display: flex; align-items: center; justify-content: space-between;
|
||
margin-bottom: 16px;
|
||
}
|
||
.calendar-nav h3 { font-size: 18px; font-weight: 600; }
|
||
.calendar-nav button {
|
||
background: var(--surface); border: 1px solid var(--border);
|
||
color: var(--text); border-radius: 8px; padding: 6px 14px;
|
||
cursor: pointer; font-size: 14px;
|
||
}
|
||
.calendar-nav button:hover { border-color: var(--accent); }
|
||
.calendar-grid {
|
||
display: grid; grid-template-columns: repeat(7, 1fr);
|
||
border: 1px solid var(--border); border-radius: 12px;
|
||
overflow: hidden;
|
||
}
|
||
.calendar-day-header {
|
||
background: var(--surface); padding: 10px;
|
||
text-align: center; font-size: 12px; color: var(--muted);
|
||
font-weight: 600; text-transform: uppercase;
|
||
}
|
||
.calendar-cell {
|
||
background: var(--bg); border-top: 1px solid var(--border);
|
||
border-right: 1px solid var(--border);
|
||
min-height: 80px; padding: 6px; cursor: pointer;
|
||
transition: all .15s; position: relative;
|
||
}
|
||
.calendar-cell:nth-child(7n) { border-right: none; }
|
||
.calendar-cell:hover { background: rgba(6,182,212,.08); }
|
||
.calendar-cell.empty { cursor: default; background: var(--bg); }
|
||
.calendar-cell.empty:hover { background: var(--bg); }
|
||
.calendar-cell .day-num { font-size: 13px; font-weight: 600; color: var(--muted); }
|
||
.calendar-cell.today .day-num { color: var(--accent); background:rgba(6,182,212,.15); border-radius:50%; width:26px; height:26px; display:flex; align-items:center; justify-content:center; }
|
||
.calendar-cell.booked { background: rgba(239,68,68,.08); }
|
||
.calendar-cell.blocked { background: rgba(245,158,11,.08); }
|
||
.calendar-cell .tag { font-size:10px; font-weight:600; padding:1px 6px; border-radius:10px; margin-top:4px; display:inline-block; }
|
||
.calendar-cell .tag.booked { background: rgba(239,68,68,.2); color: var(--red); }
|
||
.calendar-cell .tag.blocked { background: rgba(245,158,11,.2); color: var(--accent2); }
|
||
.calendar-cell.selected { outline:2px solid var(--accent); outline-offset:-2px; }
|
||
|
||
/* ── SIDE PANEL ── */
|
||
.side-panel {
|
||
background: var(--surface);
|
||
border: 1px solid var(--border);
|
||
border-radius: 12px;
|
||
padding: 20px;
|
||
}
|
||
.side-panel h4 { font-size:15px; font-weight:600; margin-bottom:16px; }
|
||
.side-panel p { font-size:13px; color:var(--muted); margin-bottom:16px; line-height:1.6; }
|
||
.selected-dates { margin-bottom: 16px; }
|
||
.selected-dates .chip {
|
||
display: inline-flex; align-items: center; gap: 6px;
|
||
background: rgba(6,182,212,.12); color: var(--accent);
|
||
padding: 4px 10px; border-radius: 16px; font-size: 12px;
|
||
margin: 2px 4px 2px 0;
|
||
}
|
||
.selected-dates .chip .remove {
|
||
cursor: pointer; font-weight: 700; font-size: 14px; line-height:1;
|
||
}
|
||
.side-panel .legend { margin-top:20px; }
|
||
.side-panel .legend-item { display:flex; align-items:center; gap:8px; font-size:12px; color:var(--muted); margin-bottom:6px; }
|
||
.side-panel .legend-dot { width:8px; height:8px; border-radius:50%; }
|
||
.side-panel .legend-dot.booked { background:var(--red); }
|
||
.side-panel .legend-dot.blocked { background:var(--accent2); }
|
||
.side-panel .legend-dot.available { background:var(--border); }
|
||
|
||
/* ── GUEST LIST ── */
|
||
.guest-card {
|
||
background: var(--surface);
|
||
border: 1px solid var(--border);
|
||
border-radius: 12px;
|
||
padding: 20px;
|
||
margin-bottom: 12px;
|
||
display: flex; justify-content: space-between; align-items: flex-start;
|
||
gap: 16px;
|
||
}
|
||
.guest-card .guest-info h4 { font-size:15px; font-weight:600; margin-bottom:4px; }
|
||
.guest-card .guest-info .email { font-size:13px; color:var(--muted); }
|
||
.guest-card .guest-info .phone { font-size:13px; color:var(--muted); }
|
||
.guest-card .guest-info .notes { font-size:13px; color:var(--muted); margin-top:6px; font-style:italic; }
|
||
.guest-card .guest-meta { text-align:right; font-size:12px; color:var(--muted); }
|
||
|
||
/* ── SETTINGS ── */
|
||
.settings-section {
|
||
background: var(--surface);
|
||
border: 1px solid var(--border);
|
||
border-radius: 12px;
|
||
padding: 24px;
|
||
margin-bottom: 20px;
|
||
}
|
||
.settings-section h3 { font-size:16px; font-weight:600; margin-bottom:16px; }
|
||
.form-group { margin-bottom: 16px; }
|
||
.form-group label { display:block; font-size:13px; color:var(--muted); margin-bottom:6px; }
|
||
.form-group input, .form-group textarea {
|
||
width: 100%; padding: 10px 14px; border-radius: 8px;
|
||
background: var(--bg); border: 1px solid var(--border);
|
||
color: var(--text); font-size: 14px;
|
||
}
|
||
.form-group input:focus, .form-group textarea:focus {
|
||
outline: none; border-color: var(--accent);
|
||
}
|
||
|
||
/* ── LOADING & EMPTY ── */
|
||
.loading { text-align:center; padding:60px 20px; color:var(--muted); }
|
||
.loading .spinner { width:32px; height:32px; border:3px solid var(--border); border-top-color:var(--accent); border-radius:50%; animation:spin .8s linear infinite; margin:0 auto 12px; }
|
||
@keyframes spin { to { transform:rotate(360deg); } }
|
||
.empty { text-align:center; padding:60px 20px; color:var(--muted); }
|
||
.empty svg { width:48px; height:48px; margin-bottom:12px; opacity:.3; }
|
||
|
||
.toast {
|
||
position: fixed; bottom: 24px; right: 24px;
|
||
padding: 12px 24px; border-radius: 8px;
|
||
font-size: 14px; font-weight: 600; z-index: 300;
|
||
animation: slideUp .3s ease;
|
||
}
|
||
.toast.success { background: var(--green); color: #fff; }
|
||
.toast.error { background: var(--red); color: #fff; }
|
||
@keyframes slideUp { from { opacity:0; transform:translateY(12px); } to { opacity:1; transform:translateY(0); } }
|
||
|
||
/* ── RESPONSIVE ── */
|
||
@media (max-width: 768px) {
|
||
.sidebar { transform: translateX(-100%); }
|
||
.sidebar.open { transform: translateX(0); }
|
||
.main { margin-left: 0; padding: 16px; padding-top: 60px; }
|
||
.mobile-toggle { display: block; }
|
||
.mobile-overlay.open { display: block; }
|
||
.stats-grid { grid-template-columns: repeat(2, 1fr); }
|
||
.calendar-container { grid-template-columns: 1fr; }
|
||
.calendar-cell { min-height: 52px; }
|
||
.guest-card { flex-direction: column; }
|
||
.guest-card .guest-meta { text-align: left; }
|
||
}
|
||
</style>
|
||
</head>
|
||
<body>
|
||
|
||
<!-- MOBILE -->
|
||
<button class="mobile-toggle" onclick="toggleSidebar()" aria-label="Menu">
|
||
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" stroke="currentColor" stroke-width="2"><path d="M3 5h14M3 10h14M3 15h14"/></svg>
|
||
</button>
|
||
<div class="mobile-overlay" id="mobileOverlay" onclick="toggleSidebar()"></div>
|
||
|
||
<!-- SIDEBAR -->
|
||
<aside class="sidebar" id="sidebar">
|
||
<div class="sidebar-logo">
|
||
<div class="icon">MS</div>
|
||
<div>
|
||
<h1>Moore Sunny Daze</h1>
|
||
<span>Admin Dashboard</span>
|
||
</div>
|
||
</div>
|
||
<nav class="sidebar-nav">
|
||
<button class="nav-item active" data-page="dashboard" onclick="showPage('dashboard', this)">
|
||
<svg viewBox="0 0 20 20" fill="none" stroke="currentColor" stroke-width="1.5"><rect x="2" y="2" width="7" height="7" rx="1"/><rect x="11" y="2" width="7" height="7" rx="1"/><rect x="2" y="11" width="7" height="7" rx="1"/><rect x="11" y="11" width="7" height="7" rx="1"/></svg>
|
||
Dashboard
|
||
</button>
|
||
<button class="nav-item" data-page="bookings" onclick="showPage('bookings', this)">
|
||
<svg viewBox="0 0 20 20" fill="none" stroke="currentColor" stroke-width="1.5"><rect x="2.5" y="3.5" width="15" height="14" rx="2"/><path d="M2.5 7.5h15M6.5 1.5v3M13.5 1.5v3"/><circle cx="6.5" cy="11" r="1"/><circle cx="10" cy="11" r="1"/><circle cx="13.5" cy="11" r="1"/></svg>
|
||
Bookings
|
||
</button>
|
||
<button class="nav-item" data-page="calendar" onclick="showPage('calendar', this)">
|
||
<svg viewBox="0 0 20 20" fill="none" stroke="currentColor" stroke-width="1.5"><rect x="2.5" y="3.5" width="15" height="14" rx="2"/><path d="M2.5 7.5h15M6.5 1.5v3M13.5 1.5v3"/></svg>
|
||
Calendar
|
||
</button>
|
||
<button class="nav-item" data-page="guests" onclick="showPage('guests', this)">
|
||
<svg viewBox="0 0 20 20" fill="none" stroke="currentColor" stroke-width="1.5"><circle cx="7" cy="6" r="2.5"/><path d="M1.5 18c0-3 2.5-5.5 5.5-5.5 1.1 0 2.1.3 3 .9M13 8.5a3 3 0 100-6 3 3 0 000 6zM11 12h4c2.2 0 4 1.8 4 4v.5"/></svg>
|
||
Guests
|
||
</button>
|
||
<button class="nav-item" data-page="settings" onclick="showPage('settings', this)">
|
||
<svg viewBox="0 0 20 20" fill="none" stroke="currentColor" stroke-width="1.5"><circle cx="10" cy="10" r="2.5"/><path d="M10 1.5v2M10 16.5v2M1.5 10h2M16.5 10h2M3.4 3.4l1.4 1.4M15.2 15.2l1.4 1.4M3.4 16.6l1.4-1.4M15.2 4.8l1.4-1.4"/></svg>
|
||
Settings
|
||
</button>
|
||
</nav>
|
||
<div class="sidebar-footer">Moore Sunny Daze v1.0</div>
|
||
</aside>
|
||
|
||
<!-- MAIN CONTENT -->
|
||
<main class="main">
|
||
|
||
<!-- DASHBOARD PAGE -->
|
||
<section class="page active" id="page-dashboard">
|
||
<div class="page-header">
|
||
<h2>Dashboard Overview</h2>
|
||
<p>Key metrics at a glance for your vacation rental.</p>
|
||
</div>
|
||
<div class="stats-grid" id="statsGrid">
|
||
<div class="loading"><div class="spinner"></div>Loading stats…</div>
|
||
</div>
|
||
<div style="background:var(--surface);border:1px solid var(--border);border-radius:12px;padding:24px;">
|
||
<h3 style="font-size:14px;color:var(--muted);margin-bottom:12px;">Quick Actions</h3>
|
||
<div style="display:flex;gap:8px;flex-wrap:wrap;">
|
||
<button class="btn btn-accent" onclick="showPage('bookings', document.querySelector('[data-page=bookings]'))">View All Bookings</button>
|
||
<button class="btn btn-outline" onclick="showPage('calendar', document.querySelector('[data-page=calendar]'))">Check Availability</button>
|
||
<button class="btn btn-outline" onclick="showPage('guests', document.querySelector('[data-page=guests]'))">Guest Directory</button>
|
||
</div>
|
||
</div>
|
||
</section>
|
||
|
||
<!-- BOOKINGS PAGE -->
|
||
<section class="page" id="page-bookings">
|
||
<div class="page-header">
|
||
<h2>Bookings</h2>
|
||
<p>Manage all reservations for Moore Sunny Daze.</p>
|
||
</div>
|
||
<div class="filter-bar" id="bookingFilter">
|
||
<button class="filter-btn active" data-status="all" onclick="filterBookings('all', this)">All</button>
|
||
<button class="filter-btn" data-status="confirmed" onclick="filterBookings('confirmed', this)">Confirmed</button>
|
||
<button class="filter-btn" data-status="pending" onclick="filterBookings('pending', this)">Pending</button>
|
||
<button class="filter-btn" data-status="completed" onclick="filterBookings('completed', this)">Completed</button>
|
||
<button class="filter-btn" data-status="cancelled" onclick="filterBookings('cancelled', this)">Cancelled</button>
|
||
</div>
|
||
<div class="table-wrap" id="bookingsTable">
|
||
<div class="loading"><div class="spinner"></div>Loading bookings…</div>
|
||
</div>
|
||
</section>
|
||
|
||
<!-- CALENDAR PAGE -->
|
||
<section class="page" id="page-calendar">
|
||
<div class="page-header">
|
||
<h2>Availability Calendar</h2>
|
||
<p>View and manage blocked dates.</p>
|
||
</div>
|
||
<div class="calendar-container">
|
||
<div>
|
||
<div class="calendar-nav" id="calendarNav"></div>
|
||
<div class="calendar-grid" id="calendarGrid">
|
||
<div class="loading"><div class="spinner"></div>Loading calendar…</div>
|
||
</div>
|
||
</div>
|
||
<div class="side-panel">
|
||
<h4>Block Dates</h4>
|
||
<p>Click dates on the calendar to select them, then block them below. Blocked dates prevent bookings.</p>
|
||
<div class="selected-dates" id="selectedDates"><span style="font-size:12px;color:var(--muted);">No dates selected</span></div>
|
||
<button class="btn btn-accent" style="width:100%;margin-bottom:8px;" onclick="blockSelectedDates()" id="blockDatesBtn" disabled>
|
||
Block Selected Dates
|
||
</button>
|
||
<button class="btn btn-outline" style="width:100%;" onclick="clearDateSelection()" id="clearSelectionBtn" disabled>
|
||
Clear Selection
|
||
</button>
|
||
<div class="legend">
|
||
<div class="legend-item"><span class="legend-dot available"></span> Available</div>
|
||
<div class="legend-item"><span class="legend-dot booked"></span> Booked</div>
|
||
<div class="legend-item"><span class="legend-dot blocked"></span> Blocked</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</section>
|
||
|
||
<!-- GUESTS PAGE -->
|
||
<section class="page" id="page-guests">
|
||
<div class="page-header">
|
||
<h2>Guest Directory</h2>
|
||
<p>All guests who have stayed at Moore Sunny Daze.</p>
|
||
</div>
|
||
<div id="guestsList">
|
||
<div class="loading"><div class="spinner"></div>Loading guests…</div>
|
||
</div>
|
||
</section>
|
||
|
||
<!-- SETTINGS PAGE -->
|
||
<section class="page" id="page-settings">
|
||
<div class="page-header">
|
||
<h2>Settings</h2>
|
||
<p>Configure your rental property details.</p>
|
||
</div>
|
||
<div class="settings-section">
|
||
<h3>Property Information</h3>
|
||
<div class="form-group">
|
||
<label>Property Name</label>
|
||
<input type="text" value="Moore Sunny Daze" disabled>
|
||
</div>
|
||
<div class="form-group">
|
||
<label>Nightly Rate ($)</label>
|
||
<input type="number" value="199" id="settingNightlyRate">
|
||
</div>
|
||
<div class="form-group">
|
||
<label>Cleaning Fee ($)</label>
|
||
<input type="number" value="120" id="settingCleaningFee">
|
||
</div>
|
||
<div class="form-group">
|
||
<label>Pet Fee ($)</label>
|
||
<input type="number" value="50" id="settingPetFee">
|
||
</div>
|
||
<div class="form-group">
|
||
<label>Max Guests</label>
|
||
<input type="number" value="6" id="settingMaxGuests">
|
||
</div>
|
||
<button class="btn btn-accent" onclick="saveSettings()">Save Settings</button>
|
||
</div>
|
||
</section>
|
||
|
||
</main>
|
||
|
||
<script>
|
||
// ── CONFIG ──
|
||
const API_BASE = '/api/admin';
|
||
const AUTH_HEADER = { 'Authorization': 'Bearer msd-admin-LoveMyBoys.1520', 'Content-Type': 'application/json' };
|
||
|
||
// ── STATE ──
|
||
let allBookings = [];
|
||
let filteredBookings = [];
|
||
let currentBookingFilter = 'all';
|
||
let calendarYear, calendarMonth;
|
||
let bookedDates = {};
|
||
let selectedDates = new Set();
|
||
let allGuests = [];
|
||
|
||
// ── UTILS ──
|
||
function $(sel) { return document.querySelector(sel); }
|
||
function $$(sel) { return document.querySelectorAll(sel); }
|
||
|
||
function formatDate(iso) {
|
||
if (!iso) return '—';
|
||
const d = new Date(iso + 'T00:00:00');
|
||
return d.toLocaleDateString('en-US', { month:'short', day:'numeric', year:'numeric' });
|
||
}
|
||
|
||
function formatCurrency(n) {
|
||
return '$' + Number(n).toLocaleString('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 2 });
|
||
}
|
||
|
||
function toast(msg, type) {
|
||
const el = document.createElement('div');
|
||
el.className = 'toast ' + (type || 'success');
|
||
el.textContent = msg;
|
||
document.body.appendChild(el);
|
||
setTimeout(() => el.remove(), 3000);
|
||
}
|
||
|
||
// ── SIDEBAR ──
|
||
function toggleSidebar() {
|
||
const sidebar = $('#sidebar');
|
||
const overlay = $('#mobileOverlay');
|
||
sidebar.classList.toggle('open');
|
||
overlay.classList.toggle('open');
|
||
}
|
||
|
||
function showPage(name, navBtn) {
|
||
$$('.page').forEach(p => p.classList.remove('active'));
|
||
$$('.nav-item').forEach(n => n.classList.remove('active'));
|
||
const page = $('#page-' + name);
|
||
if (page) page.classList.add('active');
|
||
if (navBtn) navBtn.classList.add('active');
|
||
// close mobile sidebar
|
||
$('#sidebar').classList.remove('open');
|
||
$('#mobileOverlay').classList.remove('open');
|
||
// load data on demand
|
||
if (name === 'dashboard') loadDashboard();
|
||
if (name === 'bookings') loadBookings();
|
||
if (name === 'calendar') loadCalendar();
|
||
if (name === 'guests') loadGuests();
|
||
}
|
||
|
||
// ── API HELPERS ──
|
||
async function apiFetch(path) {
|
||
try {
|
||
const res = await fetch(API_BASE + path, { headers: AUTH_HEADER });
|
||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||
return await res.json();
|
||
} catch (e) {
|
||
console.warn('API call failed, using mock data:', path, e.message);
|
||
return null;
|
||
}
|
||
}
|
||
|
||
async function apiPatch(path, body) {
|
||
const res = await fetch(API_BASE + path, {
|
||
method: 'PATCH',
|
||
headers: AUTH_HEADER,
|
||
body: JSON.stringify(body)
|
||
});
|
||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||
return res.json();
|
||
}
|
||
|
||
async function apiPost(path, body) {
|
||
const res = await fetch(API_BASE + path, {
|
||
method: 'POST',
|
||
headers: AUTH_HEADER,
|
||
body: JSON.stringify(body)
|
||
});
|
||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||
return res.json();
|
||
}
|
||
|
||
async function apiDelete(path, body) {
|
||
const res = await fetch(API_BASE + path, {
|
||
method: 'DELETE',
|
||
headers: AUTH_HEADER,
|
||
body: JSON.stringify(body)
|
||
});
|
||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||
return res.json();
|
||
}
|
||
|
||
// ── MOCK DATA ──
|
||
function getMockStats() {
|
||
return {
|
||
confirmed_bookings: 12,
|
||
upcoming_bookings: 3,
|
||
total_revenue: 18450.00,
|
||
monthly_revenue: 2850.00,
|
||
total_guests: 38,
|
||
occupancy_30d: 0.67
|
||
};
|
||
}
|
||
|
||
function getMockBookings() {
|
||
const now = new Date();
|
||
const y = now.getFullYear();
|
||
const m = now.getMonth();
|
||
const d = now.getDate();
|
||
|
||
function dateStr(yy, mm, dd) {
|
||
return `${yy}-${String(mm).padStart(2,'0')}-${String(dd).padStart(2,'0')}`;
|
||
}
|
||
|
||
return [
|
||
{ id: 1, guest_name: 'Sarah Johnson', guest_email: 'sarah@example.com', guest_phone: '(512) 555-0101', check_in: dateStr(y, m+1, d+2), check_out: dateStr(y, m+1, d+6), guests_count: 4, has_pet: false, nightly_rate: 199, cleaning_fee: 120, pet_fee: 0, total: 916.00, status: 'confirmed', stripe_session_id: 'cs_test_a1b2', notes: 'Anniversary trip', created_at: dateStr(y, m, d-10) },
|
||
{ id: 2, guest_name: 'Michael Chen', guest_email: 'mchen@example.com', guest_phone: '(512) 555-0102', check_in: dateStr(y, m+1, d+10), check_out: dateStr(y, m+1, d+13), guests_count: 2, has_pet: true, nightly_rate: 199, cleaning_fee: 120, pet_fee: 50, total: 767.00, status: 'confirmed', stripe_session_id: 'cs_test_c3d4', notes: 'Bringing small dog', created_at: dateStr(y, m, d-8) },
|
||
{ id: 3, guest_name: 'Emily Rodriguez', guest_email: 'emily.r@example.com', guest_phone: '(512) 555-0103', check_in: dateStr(y, m+1, d+18), check_out: dateStr(y, m+1, d+22), guests_count: 6, has_pet: false, nightly_rate: 199, cleaning_fee: 120, pet_fee: 0, total: 916.00, status: 'pending', stripe_session_id: null, notes: 'Family reunion', created_at: dateStr(y, m, d-3) },
|
||
{ id: 4, guest_name: 'David Kim', guest_email: 'dkim@example.com', guest_phone: '(512) 555-0104', check_in: dateStr(y, m-1, 5), check_out: dateStr(y, m-1, 8), guests_count: 3, has_pet: false, nightly_rate: 199, cleaning_fee: 120, pet_fee: 0, total: 717.00, status: 'completed', stripe_session_id: 'cs_test_e5f6', notes: '', created_at: dateStr(y, m-2, 15) },
|
||
{ id: 5, guest_name: 'Jessica Williams', guest_email: 'jwill@example.com', guest_phone: '(512) 555-0105', check_in: dateStr(y, m-1, 20), check_out: dateStr(y, m-1, 23), guests_count: 2, has_pet: false, nightly_rate: 199, cleaning_fee: 120, pet_fee: 0, total: 717.00, status: 'completed', stripe_session_id: 'cs_test_g7h8', notes: 'Weekend getaway', created_at: dateStr(y, m-2, 20) },
|
||
{ id: 6, guest_name: 'Robert Taylor', guest_email: 'rtaylor@example.com', guest_phone: '(512) 555-0106', check_in: dateStr(y, m+1, d+25), check_out: dateStr(y, m+1, d+28), guests_count: 5, has_pet: false, nightly_rate: 199, cleaning_fee: 120, pet_fee: 0, total: 717.00, status: 'pending', stripe_session_id: null, notes: 'Requested early check-in', created_at: dateStr(y, m, d-1) },
|
||
{ id: 7, guest_name: 'Amanda Brown', guest_email: 'abrown@example.com', guest_phone: '(512) 555-0107', check_in: dateStr(y, m-2, 10), check_out: dateStr(y, m-2, 14), guests_count: 4, has_pet: true, nightly_rate: 199, cleaning_fee: 120, pet_fee: 50, total: 966.00, status: 'cancelled', stripe_session_id: 'cs_test_i9j0', notes: 'Cancelled due to weather', created_at: dateStr(y, m-3, 1) },
|
||
{ id: 8, guest_name: 'James Wilson', guest_email: 'jwilson@example.com', guest_phone: '(512) 555-0108', check_in: dateStr(y, m-1, 12), check_out: dateStr(y, m-1, 15), guests_count: 3, has_pet: false, nightly_rate: 199, cleaning_fee: 120, pet_fee: 0, total: 717.00, status: 'completed', stripe_session_id: 'cs_test_k1l2', notes: '', created_at: dateStr(y, m-2, 10) },
|
||
];
|
||
}
|
||
|
||
function getMockGuests() {
|
||
return [
|
||
{ id: 1, name: 'Sarah Johnson', email: 'sarah@example.com', phone: '(512) 555-0101', notes: 'Returning guest, loves the lake view', created_at: '2026-05-10' },
|
||
{ id: 2, name: 'Michael Chen', email: 'mchen@example.com', phone: '(512) 555-0102', notes: 'Has a small dog, pet-friendly', created_at: '2026-06-02' },
|
||
{ id: 3, name: 'Emily Rodriguez', email: 'emily.r@example.com', phone: '(512) 555-0103', notes: 'Family of 6, needs pack-n-play', created_at: '2026-06-15' },
|
||
{ id: 4, name: 'David Kim', email: 'dkim@example.com', phone: '(512) 555-0104', notes: '', created_at: '2026-05-22' },
|
||
{ id: 5, name: 'Jessica Williams', email: 'jwill@example.com', phone: '(512) 555-0105', notes: 'Requested late checkout last time', created_at: '2026-05-28' },
|
||
{ id: 6, name: 'Robert Taylor', email: 'rtaylor@example.com', phone: '(512) 555-0106', notes: 'First time booking', created_at: '2026-07-01' },
|
||
{ id: 7, name: 'Amanda Brown', email: 'abrown@example.com', phone: '(512) 555-0107', notes: 'Cancelled reservation, may rebook', created_at: '2026-04-15' },
|
||
{ id: 8, name: 'James Wilson', email: 'jwilson@example.com', phone: '(512) 555-0108', notes: 'Repeat guest, 3rd stay', created_at: '2026-01-10' },
|
||
{ id: 9, name: 'Lisa Martinez', email: 'lmartinez@example.com', phone: '(512) 555-0109', notes: 'Preferred guest, no issues', created_at: '2026-03-20' },
|
||
{ id: 10, name: 'Thomas Anderson', email: 'tanderson@example.com', phone: '(512) 555-0110', notes: 'Stayed for business trip', created_at: '2026-02-14' },
|
||
];
|
||
}
|
||
|
||
function getMockAvailability(monthStr) {
|
||
// build some booked/blocked dates for the given month
|
||
const [y, m] = monthStr.split('-').map(Number);
|
||
const daysIn = new Date(y, m, 0).getDate();
|
||
const booked = [];
|
||
const blocked = [];
|
||
const now = new Date();
|
||
const todayY = now.getFullYear();
|
||
const todayM = now.getMonth() + 1;
|
||
const todayD = now.getDate();
|
||
|
||
// If viewing current month, add realistic booked/blocked
|
||
if (y === todayY && m === todayM) {
|
||
// Block some past dates
|
||
for (let i = 1; i <= Math.min(todayD, 5); i++) {
|
||
if (i < todayD - 1) blocked.push(i);
|
||
}
|
||
// Book some upcoming dates
|
||
const ranges = [[todayD + 2, todayD + 6], [todayD + 12, todayD + 14], [todayD + 20, todayD + 23]];
|
||
ranges.forEach(([s, e]) => {
|
||
for (let i = s; i <= Math.min(e, daysIn); i++) booked.push(i);
|
||
});
|
||
}
|
||
|
||
return {
|
||
month: monthStr,
|
||
booked_dates: booked.map(d => `${monthStr}-${String(d).padStart(2,'0')}`),
|
||
blocked_dates: blocked.map(d => `${monthStr}-${String(d).padStart(2,'0')}`)
|
||
};
|
||
}
|
||
|
||
// ── DASHBOARD ──
|
||
async function loadDashboard() {
|
||
const grid = $('#statsGrid');
|
||
let stats = await apiFetch('/stats');
|
||
if (!stats) stats = getMockStats();
|
||
|
||
grid.innerHTML = `
|
||
<div class="stat-card">
|
||
<div class="label">Upcoming Bookings</div>
|
||
<div class="value accent">${stats.upcoming_bookings}</div>
|
||
<div class="sub">${stats.confirmed_bookings} confirmed total</div>
|
||
</div>
|
||
<div class="stat-card">
|
||
<div class="label">Monthly Revenue</div>
|
||
<div class="value green">${formatCurrency(stats.monthly_revenue)}</div>
|
||
<div class="sub">Total: ${formatCurrency(stats.total_revenue)}</div>
|
||
</div>
|
||
<div class="stat-card">
|
||
<div class="label">Occupancy Rate (30d)</div>
|
||
<div class="value amber">${Math.round(stats.occupancy_30d * 100)}%</div>
|
||
</div>
|
||
<div class="stat-card">
|
||
<div class="label">Total Guests</div>
|
||
<div class="value">${stats.total_guests}</div>
|
||
<div class="sub">All-time guests hosted</div>
|
||
</div>
|
||
`;
|
||
}
|
||
|
||
// ── BOOKINGS ──
|
||
async function loadBookings() {
|
||
allBookings = await apiFetch('/bookings');
|
||
if (!allBookings || !Array.isArray(allBookings) || allBookings.length === 0) {
|
||
allBookings = getMockBookings();
|
||
}
|
||
filterBookings(currentBookingFilter);
|
||
}
|
||
|
||
function filterBookings(status, btn) {
|
||
currentBookingFilter = status;
|
||
// update filter buttons
|
||
$$('#bookingFilter .filter-btn').forEach(b => b.classList.remove('active'));
|
||
if (btn) btn.classList.add('active');
|
||
else {
|
||
const fb = $(`#bookingFilter [data-status="${status}"]`);
|
||
if (fb) fb.classList.add('active');
|
||
}
|
||
|
||
filteredBookings = status === 'all'
|
||
? allBookings
|
||
: allBookings.filter(b => b.status === status);
|
||
|
||
renderBookingsTable(filteredBookings);
|
||
}
|
||
|
||
function renderBookingsTable(bookings) {
|
||
const container = $('#bookingsTable');
|
||
if (!bookings.length) {
|
||
container.innerHTML = `<div class="empty"><svg viewBox="0 0 20 20" fill="none" stroke="currentColor" stroke-width="1.5"><rect x="2.5" y="3.5" width="15" height="14" rx="2"/><path d="M2.5 7.5h15M6.5 1.5v3M13.5 1.5v3"/></svg><p>No bookings found.</p></div>`;
|
||
return;
|
||
}
|
||
|
||
const rows = bookings.map(b => `
|
||
<tr>
|
||
<td><strong>${escapeHtml(b.guest_name)}</strong></td>
|
||
<td>${formatDate(b.check_in)} → ${formatDate(b.check_out)}</td>
|
||
<td>${b.guests_count} guest${b.guests_count !== 1 ? 's' : ''}${b.has_pet ? ' 🐾' : ''}</td>
|
||
<td><strong>${formatCurrency(b.total)}</strong></td>
|
||
<td><span class="badge ${b.status}">${b.status}</span></td>
|
||
<td>
|
||
<div class="actions">
|
||
${b.status === 'pending' ? `<button class="btn btn-green btn-sm" onclick="updateBookingStatus(${b.id},'confirmed')">Confirm</button>` : ''}
|
||
${b.status === 'confirmed' ? `<button class="btn btn-outline btn-sm" onclick="updateBookingStatus(${b.id},'completed')">Mark Completed</button>` : ''}
|
||
${(b.status === 'confirmed' || b.status === 'pending') ? `<button class="btn btn-danger btn-sm" onclick="updateBookingStatus(${b.id},'cancelled')">Cancel</button>` : ''}
|
||
${(b.status === 'completed' || b.status === 'cancelled') ? `<span style="font-size:11px;color:var(--muted);">—</span>` : ''}
|
||
</div>
|
||
</td>
|
||
</tr>
|
||
`).join('');
|
||
|
||
container.innerHTML = `<table>
|
||
<thead><tr>
|
||
<th>Guest</th><th>Dates</th><th>Guests</th><th>Total</th><th>Status</th><th>Actions</th>
|
||
</tr></thead>
|
||
<tbody>${rows}</tbody>
|
||
</table>`;
|
||
}
|
||
|
||
async function updateBookingStatus(id, newStatus) {
|
||
const verb = newStatus === 'completed' ? 'completed' : newStatus === 'cancelled' ? 'cancelled' : 'confirmed';
|
||
try {
|
||
await apiPatch('/booking/' + id, { status: newStatus });
|
||
toast(`Booking ${verb} successfully.`);
|
||
await loadBookings();
|
||
await loadDashboard();
|
||
} catch (e) {
|
||
// fallback: update locally
|
||
const booking = allBookings.find(b => b.id === id);
|
||
if (booking) booking.status = newStatus;
|
||
filterBookings(currentBookingFilter);
|
||
toast(`Booking ${verb} (offline mode).`, 'error');
|
||
}
|
||
}
|
||
|
||
function escapeHtml(str) {
|
||
const div = document.createElement('div');
|
||
div.textContent = str;
|
||
return div.innerHTML;
|
||
}
|
||
|
||
// ── CALENDAR ──
|
||
async function loadCalendar() {
|
||
const now = new Date();
|
||
if (!calendarYear) { calendarYear = now.getFullYear(); calendarMonth = now.getMonth() + 1; }
|
||
|
||
const monthStr = `${calendarYear}-${String(calendarMonth).padStart(2, '0')}`;
|
||
|
||
let data = await apiFetch('/availability?month=' + monthStr);
|
||
if (!data) data = getMockAvailability(monthStr);
|
||
|
||
// build lookup
|
||
bookedDates = {};
|
||
(data.booked_dates || []).forEach(d => bookedDates[d] = 'booked');
|
||
(data.blocked_dates || []).forEach(d => bookedDates[d] = 'blocked');
|
||
|
||
renderCalendar();
|
||
}
|
||
|
||
function renderCalendar() {
|
||
const now = new Date();
|
||
const todayStr = `${now.getFullYear()}-${String(now.getMonth()+1).padStart(2,'0')}-${String(now.getDate()).padStart(2,'0')}`;
|
||
const monthStr = `${calendarYear}-${String(calendarMonth).padStart(2, '0')}`;
|
||
const monthNames = ['January','February','March','April','May','June','July','August','September','October','November','December'];
|
||
|
||
// nav
|
||
$('#calendarNav').innerHTML = `
|
||
<button onclick="calendarPrevMonth()">←</button>
|
||
<h3>${monthNames[calendarMonth-1]} ${calendarYear}</h3>
|
||
<button onclick="calendarNextMonth()">→</button>
|
||
`;
|
||
|
||
// grid
|
||
const firstDay = new Date(calendarYear, calendarMonth - 1, 1).getDay(); // 0=Sun
|
||
const daysInMonth = new Date(calendarYear, calendarMonth, 0).getDate();
|
||
const dayHeaders = ['Sun','Mon','Tue','Wed','Thu','Fri','Sat'];
|
||
|
||
let html = dayHeaders.map(h => `<div class="calendar-day-header">${h}</div>`).join('');
|
||
|
||
// empty cells before first day
|
||
for (let i = 0; i < firstDay; i++) {
|
||
html += `<div class="calendar-cell empty"></div>`;
|
||
}
|
||
|
||
for (let day = 1; day <= daysInMonth; day++) {
|
||
const dateStr = `${monthStr}-${String(day).padStart(2, '0')}`;
|
||
const isToday = dateStr === todayStr;
|
||
const status = bookedDates[dateStr] || '';
|
||
const isSelected = selectedDates.has(dateStr);
|
||
|
||
let classes = ['calendar-cell'];
|
||
if (isToday) classes.push('today');
|
||
if (status === 'booked') classes.push('booked');
|
||
if (status === 'blocked') classes.push('blocked');
|
||
if (isSelected) classes.push('selected');
|
||
|
||
let tagHtml = status
|
||
? `<div class="tag ${status}">${status}</div>`
|
||
: '';
|
||
|
||
html += `<div class="${classes.join(' ')}" onclick="toggleDate('${dateStr}', '${status}')" data-date="${dateStr}">
|
||
<div class="day-num">${day}</div>${tagHtml}
|
||
</div>`;
|
||
}
|
||
|
||
$('#calendarGrid').innerHTML = html;
|
||
updateSidePanel();
|
||
}
|
||
|
||
function toggleDate(dateStr, status) {
|
||
// Don't allow selecting already booked dates
|
||
if (status === 'booked') return;
|
||
|
||
if (selectedDates.has(dateStr)) {
|
||
selectedDates.delete(dateStr);
|
||
} else {
|
||
selectedDates.add(dateStr);
|
||
}
|
||
|
||
// re-render just the cells
|
||
document.querySelectorAll('.calendar-cell[data-date]').forEach(cell => {
|
||
const d = cell.dataset.date;
|
||
if (selectedDates.has(d)) {
|
||
cell.classList.add('selected');
|
||
} else {
|
||
cell.classList.remove('selected');
|
||
}
|
||
});
|
||
updateSidePanel();
|
||
}
|
||
|
||
function updateSidePanel() {
|
||
const container = $('#selectedDates');
|
||
const blockBtn = $('#blockDatesBtn');
|
||
const clearBtn = $('#clearSelectionBtn');
|
||
|
||
if (selectedDates.size === 0) {
|
||
container.innerHTML = '<span style="font-size:12px;color:var(--muted);">No dates selected</span>';
|
||
blockBtn.disabled = true;
|
||
clearBtn.disabled = true;
|
||
} else {
|
||
const sorted = [...selectedDates].sort();
|
||
container.innerHTML = sorted.map(d =>
|
||
`<span class="chip">${d} <span class="remove" onclick="event.stopPropagation(); removeSelectedDate('${d}')">×</span></span>`
|
||
).join('');
|
||
blockBtn.disabled = false;
|
||
clearBtn.disabled = false;
|
||
}
|
||
}
|
||
|
||
function removeSelectedDate(dateStr) {
|
||
selectedDates.delete(dateStr);
|
||
renderCalendar();
|
||
}
|
||
|
||
function clearDateSelection() {
|
||
selectedDates.clear();
|
||
renderCalendar();
|
||
}
|
||
|
||
async function blockSelectedDates() {
|
||
if (selectedDates.size === 0) return;
|
||
|
||
const dates = [...selectedDates];
|
||
let success = 0;
|
||
let failed = 0;
|
||
|
||
for (const date of dates) {
|
||
try {
|
||
await apiPost('/block-date', { date, reason: 'admin_block' });
|
||
bookedDates[date] = 'blocked';
|
||
success++;
|
||
} catch (e) {
|
||
failed++;
|
||
// still mark locally as fallback
|
||
bookedDates[date] = 'blocked';
|
||
}
|
||
}
|
||
|
||
selectedDates.clear();
|
||
renderCalendar();
|
||
|
||
if (failed === 0) {
|
||
toast(`${success} date(s) blocked successfully.`);
|
||
} else {
|
||
toast(`${success} blocked, ${failed} failed (offline mode).`, 'error');
|
||
}
|
||
}
|
||
|
||
function calendarPrevMonth() {
|
||
calendarMonth--;
|
||
if (calendarMonth < 1) { calendarMonth = 12; calendarYear--; }
|
||
selectedDates.clear();
|
||
loadCalendar();
|
||
}
|
||
|
||
function calendarNextMonth() {
|
||
calendarMonth++;
|
||
if (calendarMonth > 12) { calendarMonth = 1; calendarYear++; }
|
||
selectedDates.clear();
|
||
loadCalendar();
|
||
}
|
||
|
||
// ── GUESTS ──
|
||
async function loadGuests() {
|
||
let guests = await apiFetch('/guests');
|
||
if (!guests || !Array.isArray(guests) || guests.length === 0) {
|
||
guests = getMockGuests();
|
||
}
|
||
allGuests = guests;
|
||
|
||
const container = $('#guestsList');
|
||
if (!guests.length) {
|
||
container.innerHTML = `<div class="empty"><svg viewBox="0 0 20 20" fill="none" stroke="currentColor" stroke-width="1.5"><circle cx="7" cy="6" r="2.5"/><path d="M1.5 18c0-3 2.5-5.5 5.5-5.5"/></svg><p>No guests yet.</p></div>`;
|
||
return;
|
||
}
|
||
|
||
container.innerHTML = guests.map(g => `
|
||
<div class="guest-card">
|
||
<div class="guest-info">
|
||
<h4>${escapeHtml(g.name)}</h4>
|
||
<div class="email">${escapeHtml(g.email)}</div>
|
||
${g.phone ? `<div class="phone">${escapeHtml(g.phone)}</div>` : ''}
|
||
${g.notes ? `<div class="notes">"${escapeHtml(g.notes)}"</div>` : ''}
|
||
</div>
|
||
<div class="guest-meta">
|
||
Guest since ${g.created_at ? formatDate(g.created_at) : '—'}
|
||
</div>
|
||
</div>
|
||
`).join('');
|
||
}
|
||
|
||
// ── SETTINGS ──
|
||
function saveSettings() {
|
||
const data = {
|
||
nightly_rate: parseInt($('#settingNightlyRate').value) || 199,
|
||
cleaning_fee: parseInt($('#settingCleaningFee').value) || 120,
|
||
pet_fee: parseInt($('#settingPetFee').value) || 50,
|
||
max_guests: parseInt($('#settingMaxGuests').value) || 6,
|
||
};
|
||
localStorage.setItem('msd_settings', JSON.stringify(data));
|
||
toast('Settings saved locally.');
|
||
}
|
||
|
||
function loadSettings() {
|
||
try {
|
||
const saved = JSON.parse(localStorage.getItem('msd_settings'));
|
||
if (saved) {
|
||
if (saved.nightly_rate) $('#settingNightlyRate').value = saved.nightly_rate;
|
||
if (saved.cleaning_fee) $('#settingCleaningFee').value = saved.cleaning_fee;
|
||
if (saved.pet_fee) $('#settingPetFee').value = saved.pet_fee;
|
||
if (saved.max_guests) $('#settingMaxGuests').value = saved.max_guests;
|
||
}
|
||
} catch (e) { /* ignore */ }
|
||
}
|
||
|
||
// ── INIT ──
|
||
document.addEventListener('DOMContentLoaded', () => {
|
||
loadDashboard();
|
||
loadSettings();
|
||
});
|
||
</script>
|
||
|
||
</body>
|
||
</html>
|