From 6c8d70d41c1ed426a55b050c8928a19b5e35f86d Mon Sep 17 00:00:00 2001 From: muken Date: Fri, 3 Jul 2026 00:09:38 -0500 Subject: [PATCH] feat: add Room Status tab in admin panel for housekeeping management MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add clean_status and notes columns to rooms table - Create /api/admin/room-status endpoint for status tracking - Add RoomStatusSection component with: - Visual status icons (โœจ clean, ๐Ÿงน dirty, ๐Ÿ”ง maintenance) - Occupancy status (occupied/available) - Current guest display with payment status - Upcoming reservations preview - Inline status and notes editing - Add 'Room Status' tab to admin navigation --- src/app/admin/page.tsx | 224 +++++++++++++++++++++++++ src/app/api/admin/room-status/route.ts | 129 ++++++++++++++ src/lib/schema.ts | 7 + 3 files changed, 360 insertions(+) create mode 100644 src/app/api/admin/room-status/route.ts diff --git a/src/app/admin/page.tsx b/src/app/admin/page.tsx index 92875c0..03bb6d7 100644 --- a/src/app/admin/page.tsx +++ b/src/app/admin/page.tsx @@ -33,6 +33,7 @@ const NAV = [ { id: 'social', label: 'Public Events', icon: '๐ŸŽช' }, { id: 'private-events', label: 'Private Events', icon: '๐Ÿ”’' }, { id: 'rooms', label: 'Rooms', icon: '๐Ÿจ' }, + { id: 'room-status', label: 'Room Status', icon: '๐Ÿงน' }, { id: 'room-reservations', label: 'Room Bookings', icon: '๐Ÿ›๏ธ' }, { id: 'restaurant-reservations', label: 'Restaurant', icon: '๐Ÿฝ๏ธ' }, { id: 'orders', label: 'Food Orders', icon: '๐Ÿ“ฆ' }, @@ -1372,6 +1373,228 @@ function RoomsSection({ rooms, setRooms, images, onToast }: any) { ); } +/* โ”€โ”€โ”€ Room Status Section โ”€โ”€โ”€ */ +type RoomStatus = { + id: number; + name: string; + price: string; + clean_status: 'clean' | 'dirty' | 'maintenance'; + notes: string | null; + active: boolean; + occupancy_status: 'available' | 'occupied'; + current_guest: string | null; + reservation_id: number | null; + payment_status: 'pending' | 'paid' | 'partial' | null; + upcoming_reservations: Array<{ + id: number; + guest_name: string | null; + check_in: string; + check_out: string; + status: string; + payment_status: string; + first_name: string | null; + last_name: string | null; + }>; +}; + +function RoomStatusSection({ onToast }: { onToast: (msg: string, type: 'success' | 'error') => void }) { + const [rooms, setRooms] = useState([]); + const [loading, setLoading] = useState(true); + const [editingRoom, setEditingRoom] = useState(null); + const [editNotes, setEditNotes] = useState(''); + const [editStatus, setEditStatus] = useState<'clean' | 'dirty' | 'maintenance'>('clean'); + + useEffect(() => { loadRooms(); }, []); + + const loadRooms = async () => { + setLoading(true); + try { + const res = await fetch('/api/admin/room-status'); + const data = await res.json(); + setRooms(data.rooms || []); + } catch { onToast('Error loading room status', 'error'); } + setLoading(false); + }; + + const updateRoomStatus = async (roomId: number, cleanStatus: 'clean' | 'dirty' | 'maintenance', notes: string) => { + try { + const res = await fetch('/api/admin/room-status', { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ id: roomId, clean_status: cleanStatus, notes }), + }); + if (res.ok) { + setRooms(prev => prev.map(r => r.id === roomId ? { ...r, clean_status: cleanStatus, notes } : r)); + onToast('Room status updated', 'success'); + } + } catch { onToast('Error updating status', 'error'); } + setEditingRoom(null); + }; + + const getStatusIcon = (status: string) => { + switch (status) { + case 'clean': return 'โœจ'; + case 'dirty': return '๐Ÿงน'; + case 'maintenance': return '๐Ÿ”ง'; + default: return 'โ“'; + } + }; + + const getStatusColor = (status: string) => { + switch (status) { + case 'clean': return { bg: '#d1fae5', color: '#047857', border: '#10b981' }; + case 'dirty': return { bg: '#fef3c7', color: '#b45309', border: '#f59e0b' }; + case 'maintenance': return { bg: '#fee2e2', color: '#b91c1c', border: '#ef4444' }; + default: return { bg: '#f3f4f6', color: '#6b7280', border: '#d1d5db' }; + } + }; + + const getPaymentBadge = (status: string | null) => { + if (!status) return null; + const colors: Record = { + pending: { bg: '#fef3c7', color: '#b45309' }, + partial: { bg: '#dbeafe', color: '#1d4ed8' }, + paid: { bg: '#d1fae5', color: '#047857' }, + }; + const c = colors[status] || colors.pending; + return ( + + {status} + + ); + }; + + const formatDate = (d: string) => new Date(d).toLocaleDateString(); + + return ( +
+ + {loading ? ( + + ) : rooms.length === 0 ? ( + + ) : ( +
+ {rooms.map(room => { + const statusColors = getStatusColor(room.clean_status); + const isEditing = editingRoom === room.id; + + return ( +
+ {/* Header Row */} +
+ {/* Room Name & Status */} +
+
+ {getStatusIcon(room.clean_status)} + {room.name} +
+ + {room.clean_status} + + + {room.occupancy_status === 'occupied' ? '๐Ÿ›๏ธ Occupied' : '๐Ÿšช Available'} + +
+ + {/* Price */} +
+ ${parseFloat(room.price).toFixed(2)}/night +
+ + {/* Edit Button */} + { + setEditingRoom(isEditing ? null : room.id); + setEditStatus(room.clean_status); + setEditNotes(room.notes || ''); + }}> + {isEditing ? 'Cancel' : 'Edit'} + +
+ + {/* Details Row */} +
+ {/* Current Guest */} + {room.current_guest && ( +
+ Current Guest: + {room.current_guest} + {getPaymentBadge(room.payment_status)} +
+ )} + + {/* Upcoming Reservations */} + {room.upcoming_reservations && room.upcoming_reservations.length > 0 && ( +
+
Upcoming:
+ {room.upcoming_reservations.map((res, i) => ( +
+ ๐Ÿ“… {formatDate(res.check_in)} โ†’ {formatDate(res.check_out)} + {res.guest_name || res.first_name || 'Guest'} + {getPaymentBadge(res.payment_status)} +
+ ))} +
+ )} + + {/* Notes */} + {room.notes && !isEditing && ( +
+ ๐Ÿ“ {room.notes} +
+ )} + + {/* Edit Form */} + {isEditing && ( +
+ setEditStatus(v)} + options={[ + { value: 'clean', label: 'โœจ Clean' }, + { value: 'dirty', label: '๐Ÿงน Dirty' }, + { value: 'maintenance', label: '๐Ÿ”ง Maintenance' }, + ]} + /> + +
+ updateRoomStatus(room.id, editStatus, editNotes)} size="sm">Save + setEditingRoom(null)} size="sm">Cancel +
+
+ )} +
+
+ ); + })} +
+ )} +
+ ); +} + function RoomEditor({ room, setRoom, images, onSave, onCancel }: any) { const [showPicker, setShowPicker] = useState(false); const [roomImages, setRoomImages] = useState<{id: number; data: string; thumbnail: string}[]>([]); @@ -3001,6 +3224,7 @@ export default function AdminPage() { case 'social': return ; case 'private-events': return ; case 'rooms': return ; + case 'room-status': return ; case 'room-reservations': return ; case 'restaurant-reservations': return ; case 'orders': return ; diff --git a/src/app/api/admin/room-status/route.ts b/src/app/api/admin/room-status/route.ts new file mode 100644 index 0000000..68c9b98 --- /dev/null +++ b/src/app/api/admin/room-status/route.ts @@ -0,0 +1,129 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { db } from '@/lib/db'; +import { requireRole } from '@/lib/auth'; +import { getErrorMessage } from '@/lib/errors'; + +// Get all rooms with their current status and reservation info +export async function GET() { + try { + await requireRole('admin'); + + // Get all rooms with current reservation info + const { rows } = await db.query(` + SELECT + r.id, + r.name, + r.price, + r.clean_status, + r.notes, + r.active, + rr.id as reservation_id, + rr.guest_name, + rr.check_in, + rr.check_out, + rr.status as reservation_status, + rr.payment_status, + u.first_name, + u.last_name, + u.username, + CASE + WHEN rr.id IS NOT NULL AND rr.status = 'confirmed' + AND CURRENT_DATE >= rr.check_in + AND CURRENT_DATE <= rr.check_out + THEN 'occupied' + ELSE 'available' + END as occupancy_status + FROM rooms r + LEFT JOIN room_reservations rr ON r.id = rr.room_id + AND rr.status = 'confirmed' + AND CURRENT_DATE >= rr.check_in + AND CURRENT_DATE <= rr.check_out + LEFT JOIN users u ON rr.user_id = u.id + ORDER BY r.name + `); + + // Get upcoming reservations for each room + const rooms = await Promise.all(rows.map(async (room: any) => { + const { rows: upcoming } = await db.query(` + SELECT + rr.id, + rr.guest_name, + rr.check_in, + rr.check_out, + rr.status, + rr.payment_status, + u.first_name, + u.last_name + FROM room_reservations rr + LEFT JOIN users u ON rr.user_id = u.id + WHERE rr.room_id = $1 + AND rr.status = 'confirmed' + AND rr.check_in > CURRENT_DATE + ORDER BY rr.check_in + LIMIT 3 + `, [room.id]); + + return { + ...room, + upcoming_reservations: upcoming, + current_guest: room.guest_name || (room.first_name ? `${room.first_name} ${room.last_name || ''}`.trim() : room.username) || null, + }; + })); + + return NextResponse.json({ rooms }); + } catch (error) { + console.error('Get room status error:', error); + return NextResponse.json( + { error: getErrorMessage(error, 'Failed to get room status') }, + { status: 500 } + ); + } +} + +// Update room status +export async function PATCH(request: NextRequest) { + try { + await requireRole('admin'); + + const { id, clean_status, notes } = await request.json(); + + if (!id) { + return NextResponse.json({ error: 'Room ID is required' }, { status: 400 }); + } + + const updates: string[] = []; + const values: any[] = []; + let paramIndex = 1; + + if (clean_status !== undefined) { + updates.push(`clean_status = $${paramIndex}`); + values.push(clean_status); + paramIndex++; + } + + if (notes !== undefined) { + updates.push(`notes = $${paramIndex}`); + values.push(notes); + paramIndex++; + } + + if (updates.length === 0) { + return NextResponse.json({ error: 'No updates provided' }, { status: 400 }); + } + + values.push(id); + + const { rows } = await db.query( + `UPDATE rooms SET ${updates.join(', ')}, updated_at = NOW() WHERE id = $${paramIndex} RETURNING *`, + values + ); + + return NextResponse.json({ room: rows[0] }); + } catch (error) { + console.error('Update room status error:', error); + return NextResponse.json( + { error: getErrorMessage(error, 'Failed to update room status') }, + { status: 500 } + ); + } +} \ No newline at end of file diff --git a/src/lib/schema.ts b/src/lib/schema.ts index 1302742..c09c6e9 100644 --- a/src/lib/schema.ts +++ b/src/lib/schema.ts @@ -534,6 +534,13 @@ export async function migrateFromLegacyPlaces() { await db.query(` ALTER TABLE rooms ADD COLUMN IF NOT EXISTS featured_photo VARCHAR(500); `); + + // Add room status columns for housekeeping management + await db.query(`ALTER TABLE rooms ADD COLUMN IF NOT EXISTS clean_status VARCHAR(20) DEFAULT 'clean'`); + await db.query(`ALTER TABLE rooms ADD COLUMN IF NOT EXISTS notes TEXT`); + + // Add payment_status to room_reservations + await db.query(`ALTER TABLE room_reservations ADD COLUMN IF NOT EXISTS payment_status VARCHAR(20) DEFAULT 'pending'`); } export async function seed() {