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: 'social', label: 'Public Events', icon: '🎪' },
{ id: 'private-events', label: 'Private Events', icon: '🔒' }, { id: 'private-events', label: 'Private Events', icon: '🔒' },
{ id: 'rooms', label: 'Rooms', icon: '🏨' }, { id: 'rooms', label: 'Rooms', icon: '🏨' },
{ id: 'room-status', label: 'Room Status', icon: '🧹' },
{ id: 'room-reservations', label: 'Room Bookings', icon: '🛏️' }, { id: 'room-reservations', label: 'Room Bookings', icon: '🛏️' },
{ id: 'restaurant-reservations', label: 'Restaurant', icon: '🍽️' }, { id: 'restaurant-reservations', label: 'Restaurant', icon: '🍽️' },
{ id: 'orders', label: 'Food Orders', 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) { function RoomEditor({ room, setRoom, images, onSave, onCancel }: any) {
const [showPicker, setShowPicker] = useState(false); const [showPicker, setShowPicker] = useState(false);
const [roomImages, setRoomImages] = useState<{id: number; data: string; thumbnail: string}[]>([]); 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 'social': return <SocialSection events={socialEvents} setEvents={setSocialEvents} images={images} onToast={showToast} />;
case 'private-events': return <PrivateEventsSection onToast={showToast} />; case 'private-events': return <PrivateEventsSection onToast={showToast} />;
case 'rooms': return <RoomsSection rooms={rooms} setRooms={setRooms} images={images} 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 'room-reservations': return <RoomReservationsSection onToast={showToast} />;
case 'restaurant-reservations': return <RestaurantReservationsSection onToast={showToast} />; case 'restaurant-reservations': return <RestaurantReservationsSection onToast={showToast} />;
case 'orders': return <OrdersSection onToast={showToast} />; case 'orders': return <OrdersSection onToast={showToast} />;
+129
View File
@@ -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 }
);
}
}
+7
View File
@@ -534,6 +534,13 @@ export async function migrateFromLegacyPlaces() {
await db.query(` await db.query(`
ALTER TABLE rooms ADD COLUMN IF NOT EXISTS featured_photo VARCHAR(500); 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() { export async function seed() {