feat: add Room Status tab in admin panel for housekeeping management

- 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
This commit is contained in:
2026-07-03 00:09:38 -05:00
parent 1e66e918ac
commit 6c8d70d41c
3 changed files with 360 additions and 0 deletions
+224
View File
@@ -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<RoomStatus[]>([]);
const [loading, setLoading] = useState(true);
const [editingRoom, setEditingRoom] = useState<number | null>(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<string, { bg: string; color: string }> = {
pending: { bg: '#fef3c7', color: '#b45309' },
partial: { bg: '#dbeafe', color: '#1d4ed8' },
paid: { bg: '#d1fae5', color: '#047857' },
};
const c = colors[status] || colors.pending;
return (
<span style={{ padding: '2px 8px', borderRadius: 12, fontSize: '11px', fontWeight: 600, background: c.bg, color: c.color }}>
{status}
</span>
);
};
const formatDate = (d: string) => new Date(d).toLocaleDateString();
return (
<div>
<SecHead title="🧹 Room Status" count={rooms.length} />
{loading ? (
<Empty icon="⏳" title="Loading..." />
) : rooms.length === 0 ? (
<Empty icon="🏨" title="No rooms" desc="Add rooms in the Rooms section" />
) : (
<div style={{ display: 'grid', gap: '12px' }}>
{rooms.map(room => {
const statusColors = getStatusColor(room.clean_status);
const isEditing = editingRoom === room.id;
return (
<div key={room.id} style={{ background: '#fff', borderRadius: 12, border: `1px solid ${C.border}`, overflow: 'hidden' }}>
{/* Header Row */}
<div style={{ display: 'flex', alignItems: 'center', padding: '12px 16px', background: room.occupancy_status === 'occupied' ? '#f0fdf4' : '#fafafa', gap: '12px' }}>
{/* Room Name & Status */}
<div style={{ flex: 1, display: 'flex', alignItems: 'center', gap: '10px' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: '6px' }}>
<span style={{ fontSize: '20px' }}>{getStatusIcon(room.clean_status)}</span>
<span style={{ fontWeight: 600, fontSize: '16px', color: C.navy }}>{room.name}</span>
</div>
<span style={{
padding: '4px 10px',
borderRadius: 12,
fontSize: '12px',
fontWeight: 600,
background: statusColors.bg,
color: statusColors.color,
border: `1px solid ${statusColors.border}`
}}>
{room.clean_status}
</span>
<span style={{
padding: '4px 10px',
borderRadius: 12,
fontSize: '12px',
fontWeight: 600,
background: room.occupancy_status === 'occupied' ? '#d1fae5' : '#e0e7ff',
color: room.occupancy_status === 'occupied' ? '#047857' : '#4338ca'
}}>
{room.occupancy_status === 'occupied' ? '🛏️ Occupied' : '🚪 Available'}
</span>
</div>
{/* Price */}
<div style={{ fontWeight: 600, color: C.gold, fontSize: '15px' }}>
${parseFloat(room.price).toFixed(2)}/night
</div>
{/* Edit Button */}
<Btn size="sm" variant="secondary" onClick={() => {
setEditingRoom(isEditing ? null : room.id);
setEditStatus(room.clean_status);
setEditNotes(room.notes || '');
}}>
{isEditing ? 'Cancel' : 'Edit'}
</Btn>
</div>
{/* Details Row */}
<div style={{ padding: '12px 16px', borderTop: `1px solid ${C.border}` }}>
{/* Current Guest */}
{room.current_guest && (
<div style={{ marginBottom: '8px', display: 'flex', alignItems: 'center', gap: '8px' }}>
<span style={{ fontSize: '13px', color: '#666' }}>Current Guest:</span>
<span style={{ fontWeight: 600, color: C.navy }}>{room.current_guest}</span>
{getPaymentBadge(room.payment_status)}
</div>
)}
{/* Upcoming Reservations */}
{room.upcoming_reservations && room.upcoming_reservations.length > 0 && (
<div style={{ marginBottom: '8px' }}>
<div style={{ fontSize: '12px', color: '#888', marginBottom: '4px' }}>Upcoming:</div>
{room.upcoming_reservations.map((res, i) => (
<div key={res.id || i} style={{ display: 'flex', alignItems: 'center', gap: '8px', fontSize: '13px', marginBottom: '2px' }}>
<span>📅 {formatDate(res.check_in)} {formatDate(res.check_out)}</span>
<span style={{ fontWeight: 500 }}>{res.guest_name || res.first_name || 'Guest'}</span>
{getPaymentBadge(res.payment_status)}
</div>
))}
</div>
)}
{/* Notes */}
{room.notes && !isEditing && (
<div style={{ fontSize: '13px', color: '#666', fontStyle: 'italic' }}>
📝 {room.notes}
</div>
)}
{/* Edit Form */}
{isEditing && (
<div style={{ marginTop: '12px', display: 'flex', flexDirection: 'column', gap: '12px' }}>
<S
label="Clean Status"
value={editStatus}
onChange={(v: any) => setEditStatus(v)}
options={[
{ value: 'clean', label: '✨ Clean' },
{ value: 'dirty', label: '🧹 Dirty' },
{ value: 'maintenance', label: '🔧 Maintenance' },
]}
/>
<TA
label="Notes"
value={editNotes}
onChange={setEditNotes}
rows={2}
placeholder="e.g., Need extra towels, AC broken..."
/>
<div style={{ display: 'flex', gap: '8px' }}>
<Btn onClick={() => updateRoomStatus(room.id, editStatus, editNotes)} size="sm">Save</Btn>
<Btn variant="secondary" onClick={() => setEditingRoom(null)} size="sm">Cancel</Btn>
</div>
</div>
)}
</div>
</div>
);
})}
</div>
)}
</div>
);
}
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 <SocialSection events={socialEvents} setEvents={setSocialEvents} images={images} onToast={showToast} />;
case 'private-events': return <PrivateEventsSection onToast={showToast} />;
case 'rooms': return <RoomsSection rooms={rooms} setRooms={setRooms} images={images} onToast={showToast} />;
case 'room-status': return <RoomStatusSection onToast={showToast} />;
case 'room-reservations': return <RoomReservationsSection onToast={showToast} />;
case 'restaurant-reservations': return <RestaurantReservationsSection onToast={showToast} />;
case 'orders': return <OrdersSection onToast={showToast} />;