Files
mooresunnydaze/mockups/admin.html
T

989 lines
38 KiB
HTML
Raw Blame History

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