```html Villa Antibes Rental Calendar - MVP

Villa Antibes - Rental Calendar

Booked: 0 days
Occupancy: 0%
Pending: 0
``` ```php exec("CREATE TABLE IF NOT EXISTS bookings ( id INTEGER PRIMARY KEY AUTOINCREMENT, renter_name TEXT NOT NULL, renter_email TEXT NOT NULL, renter_phone TEXT, start_date DATE NOT NULL, end_date DATE NOT NULL, status TEXT NOT NULL CHECK(status IN ('booked', 'pending', 'blocked')), notes TEXT, created_at DATETIME DEFAULT CURRENT_TIMESTAMP, updated_at DATETIME DEFAULT CURRENT_TIMESTAMP );"); // Trigger for updated_at $db->exec("CREATE TRIGGER IF NOT EXISTS update_timestamp AFTER UPDATE ON bookings FOR EACH ROW BEGIN UPDATE bookings SET updated_at = CURRENT_TIMESTAMP WHERE id = OLD.id; END;"); // Helper: Send JSON response function jsonResponse($data, $status = 200) { http_response_code($status); echo json_encode($data); exit; } // Helper: Validate date format function validateDate($date) { $d = DateTime::createFromFormat('Y-m-d', $date); return $d && $d->format('Y-m-d') === $date; } // Helper: Check date overlap function hasOverlap($db, $start, $end, $excludeId = null) { $sql = "SELECT id FROM bookings WHERE status != 'blocked' AND start_date <= :end AND end_date >= :start"; if ($excludeId) { $sql .= " AND id != :excludeId"; } $stmt = $db->prepare($sql); $stmt->bindValue(':start', $start, SQLITE3_TEXT); $stmt->bindValue(':end', $end, SQLITE3_TEXT); if ($excludeId) { $stmt->bindValue(':excludeId', $excludeId, SQLITE3_INTEGER); } $result = $stmt->execute(); return $result->fetchArray() !== false; } // Routing $method = $_SERVER['REQUEST_METHOD']; $action = $_GET['action'] ?? ''; try { switch ($method) { case 'GET': if ($action === 'list') { $stmt = $db->prepare("SELECT * FROM bookings ORDER BY start_date"); $result = $stmt->execute(); $bookings = []; while ($row = $result->fetchArray(SQLITE3_ASSOC)) { $bookings[] = $row; } jsonResponse($bookings); } if ($action === 'get' && isset($_GET['id'])) { $id = (int)$_GET['id']; $stmt = $db->prepare("SELECT * FROM bookings WHERE id = :id"); $stmt->bindValue(':id', $id, SQLITE3_INTEGER); $result = $stmt->execute(); $booking = $result->fetchArray(SQLITE3_ASSOC); jsonResponse($booking ?: ['error' => 'Not found'], $booking ? 200 : 404); } if ($action === 'check' && isset($_GET['date'])) { $date = $_GET['date']; if (!validateDate($date)) { jsonResponse(['error' => 'Invalid date'], 400); } $stmt = $db->prepare("SELECT id FROM bookings WHERE :date >= start_date AND :date < end_date"); $stmt->bindValue(':date', $date, SQLITE3_TEXT); $result = $stmt->execute(); jsonResponse(['available' => $result->fetchArray() === false]); } if ($action === 'summary' && isset($_GET['start'], $_GET['end'])) { $start = $_GET['start']; $end = $_GET['end']; if (!validateDate($start) || !validateDate($end)) { jsonResponse(['error' => 'Invalid dates'], 400); } // Count booked days $stmt = $db->prepare("SELECT SUM( JULIANDAY(MIN(:end, end_date)) - JULIANDAY(MAX(:start, start_date)) + 1 ) as bookedDays FROM bookings WHERE status != 'blocked' AND start_date < :end AND end_date > :start"); $stmt->bindValue(':start', $start, SQLITE3_TEXT); $stmt->bindValue(':end', $end, SQLITE3_TEXT); $result = $stmt->execute(); $row = $result->fetchArray(SQLITE3_NUM); $bookedDays = (int)($row[0] ?? 0); // Count pending $stmt = $db->prepare("SELECT COUNT(*) FROM bookings WHERE status = 'pending' AND start_date >= :start AND end_date <= :end"); $stmt->bindValue(':start', $start, SQLITE3_TEXT); $stmt->bindValue(':end', $end, SQLITE3_TEXT); $result = $stmt->execute(); $pending = $result->fetchArray(SQLITE3_NUM)[0]; jsonResponse([ 'bookedDays' => $bookedDays, 'pendingCount' => (int)$pending ]); } break; case 'POST': $input = json_decode(file_get_contents('php://input'), true); if (!$input || !validateDate($input['start_date']) || !validateDate($input['end_date'])) { jsonResponse(['error' => 'Invalid data'], 400); } if ($input['start_date'] >= $input['end_date']) { jsonResponse(['error' => 'Check-out must be after check-in'], 400); } if (hasOverlap($db, $input['start_date'], $input['end_date'])) { jsonResponse(['error' => 'Date range overlaps with existing booking'], 400); } $stmt = $db->prepare("INSERT INTO bookings (renter_name, renter_email, renter_phone, start_date, end_date, status, notes) VALUES (:name, :email, :phone, :start, :end, :status, :notes)"); $stmt->bindValue(':name', $input['renter_name'], SQLITE3_TEXT); $stmt->bindValue(':email', $input['renter_email'], SQLITE3_TEXT); $stmt->bindValue(':phone', $input['renter_phone'] ?? '', SQLITE3_TEXT); $stmt->bindValue(':start', $input['start_date'], SQLITE3_TEXT); $stmt->bindValue(':end', $input['end_date'], SQLITE3_TEXT); $stmt->bindValue(':status', $input['status'], SQLITE3_TEXT); $stmt->bindValue(':notes', $input['notes'] ?? '', SQLITE3_TEXT); $stmt->execute(); jsonResponse(['success' => true, 'id' => $db->lastInsertRowID()]); break; case 'PUT': $input = json_decode(file_get_contents('php://input'), true); if (!$input || !isset($input['id']) || !validateDate($input['start_date']) || !validateDate($input['end_date'])) { jsonResponse(['error' => 'Invalid data'], 400); } $id = (int)$input['id']; if ($input['start_date'] >= $input['end_date']) { jsonResponse(['error' => 'Check-out must be after check-in'], 400); } if (hasOverlap($db, $input['start_date'], $input['end_date'], $id)) { jsonResponse(['error' => 'Date range overlaps with existing booking'], 400); } $stmt = $db->prepare("UPDATE bookings SET renter_name = :name, renter_email = :email, renter_phone = :phone, start_date = :start, end_date = :end, status = :status, notes = :notes, updated_at = CURRENT_TIMESTAMP WHERE id = :id"); $stmt->bindValue(':id', $id, SQLITE3_INTEGER); $stmt->bindValue(':name', $input['renter_name'], SQLITE3_TEXT); $stmt->bindValue(':email', $input['renter_email'], SQLITE3_TEXT); $stmt->bindValue(':phone', $input['renter_phone'] ?? '', SQLITE3_TEXT); $stmt->bindValue(':start', $input['start_date'], SQLITE3_TEXT); $stmt->bindValue(':end', $input['end_date'], SQLITE3_TEXT); $stmt->bindValue(':status', $input['status'], SQLITE3_TEXT); $stmt->bindValue(':notes', $input['notes'] ?? '', SQLITE3_TEXT); $stmt->execute(); jsonResponse(['success' => true]); break; case 'DELETE': $input = json_decode(file_get_contents('php://input'), true); if (!$input || !isset($input['id'])) { jsonResponse(['error' => 'ID required'], 400); } $id = (int)$input['id']; $stmt = $db->prepare("DELETE FROM bookings WHERE id = :id"); $stmt->bindValue(':id', $id, SQLITE3_INTEGER); $stmt->execute(); jsonResponse(['success' => true]); break; default: jsonResponse(['error' => 'Method not allowed'], 405); } } catch (Exception $e) { jsonResponse(['error' => 'Server error: ' . $e->getMessage()], 500); } ?> ``` ```sqlite -- villa_calendar.db - Initialize with sample data (run once via sqlite3 CLI or PHP) -- sqlite3 villa_calendar.db < this_file.sql INSERT INTO bookings (renter_name, renter_email, renter_phone, start_date, end_date, status, notes) VALUES ('John Smith', 'john@example.com', '+33 6 12 34 56 78', '2025-07-01', '2025-07-08', 'booked', 'Paid in full. Arriving from UK.'), ('Marie Dubois', 'marie.dubois@email.fr', '', '2025-08-15', '2025-08-22', 'pending', 'Awaiting deposit.'), ('Maintenance', '', '', '2025-06-20', '2025-06-23', 'blocked', 'Annual AC service.'); ``` ### Deployment Instructions 1. Place `index.html` and `api.php` in your web root (e.g., `/var/www/html/villa-calendar/`). 2. Ensure PHP 8.2 is installed with SQLite3 extension enabled. 3. Create the database: ```bash sqlite3 /path/to/villa_calendar.db < schema_sample.sql ``` 4. Set permissions: ```bash chmod 664 villa_calendar.db chown www-data:www-data villa_calendar.db ``` 5. Access via browser: `http://your-server/villa-calendar/` ### MVP Features Delivered - Full calendar dashboard with color-coded status - Drag-select to create bookings - Click event to edit/delete - Real-time availability highlighting - Occupancy summary - Overlap conflict prevention - SQLite persistence - Responsive design - No external dependencies beyond FullCalendar CDN