feat: add housekeeping fields to room status

- Add checkout time/date for current guest
- Add last_cleaned timestamp
- Add assigned_staff_id with staff dropdown
- Add priority (urgent/normal/low) for cleaning order
- Add issues_count for tracking problems
- Add is_vip flag for VIP guests
- Update RoomStatusSection UI to display and edit all new fields
- Auto-set last_cleaned when status changes to 'clean'
This commit is contained in:
2026-07-03 00:13:19 -05:00
parent 0785facd9e
commit 200784fa49
3 changed files with 292 additions and 37 deletions
+179 -19
View File
@@ -1374,6 +1374,7 @@ function RoomsSection({ rooms, setRooms, images, onToast }: any) {
}
/* ─── Room Status Section ─── */
type StaffMember = { id: number; first_name: string | null; last_name: string | null; username: string };
type RoomStatus = {
id: number;
name: string;
@@ -1381,6 +1382,14 @@ type RoomStatus = {
clean_status: 'clean' | 'dirty' | 'maintenance';
notes: string | null;
active: boolean;
last_cleaned: string | null;
assigned_staff_id: number | null;
assigned_staff: { id: number; first_name: string | null; last_name: string | null } | null;
priority: 'urgent' | 'normal' | 'low';
issues_count: number;
is_vip: boolean;
checkout_time: string | null;
checkout_date: string | null;
occupancy_status: 'available' | 'occupied';
current_guest: string | null;
reservation_id: number | null;
@@ -1399,10 +1408,28 @@ type RoomStatus = {
function RoomStatusSection({ onToast }: { onToast: (msg: string, type: 'success' | 'error') => void }) {
const [rooms, setRooms] = useState<RoomStatus[]>([]);
const [staff, setStaff] = useState<StaffMember[]>([]);
const [loading, setLoading] = useState(true);
const [editingRoom, setEditingRoom] = useState<number | null>(null);
const [editNotes, setEditNotes] = useState('');
const [editStatus, setEditStatus] = useState<'clean' | 'dirty' | 'maintenance'>('clean');
const [editData, setEditData] = useState<{
clean_status: 'clean' | 'dirty' | 'maintenance';
notes: string;
priority: 'urgent' | 'normal' | 'low';
assigned_staff_id: number | null;
issues_count: number;
is_vip: boolean;
checkout_time: string;
checkout_date: string;
}>({
clean_status: 'clean',
notes: '',
priority: 'normal',
assigned_staff_id: null,
issues_count: 0,
is_vip: false,
checkout_time: '',
checkout_date: '',
});
useEffect(() => { loadRooms(); }, []);
@@ -1412,25 +1439,51 @@ function RoomStatusSection({ onToast }: { onToast: (msg: string, type: 'success'
const res = await fetch('/api/admin/room-status');
const data = await res.json();
setRooms(data.rooms || []);
setStaff(data.staff || []);
} catch { onToast('Error loading room status', 'error'); }
setLoading(false);
};
const updateRoomStatus = async (roomId: number, cleanStatus: 'clean' | 'dirty' | 'maintenance', notes: string) => {
const updateRoomStatus = async () => {
if (editingRoom === null) return;
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 }),
body: JSON.stringify({
id: editingRoom,
clean_status: editData.clean_status,
notes: editData.notes || null,
priority: editData.priority,
assigned_staff_id: editData.assigned_staff_id,
issues_count: editData.issues_count,
is_vip: editData.is_vip,
checkout_time: editData.checkout_time || null,
checkout_date: editData.checkout_date || null,
}),
});
if (res.ok) {
setRooms(prev => prev.map(r => r.id === roomId ? { ...r, clean_status: cleanStatus, notes } : r));
await loadRooms();
onToast('Room status updated', 'success');
}
} catch { onToast('Error updating status', 'error'); }
setEditingRoom(null);
};
const startEdit = (room: RoomStatus) => {
setEditingRoom(room.id);
setEditData({
clean_status: room.clean_status,
notes: room.notes || '',
priority: room.priority,
assigned_staff_id: room.assigned_staff_id,
issues_count: room.issues_count,
is_vip: room.is_vip,
checkout_time: room.checkout_time || '',
checkout_date: room.checkout_date || '',
});
};
const getStatusIcon = (status: string) => {
switch (status) {
case 'clean': return '✨';
@@ -1449,6 +1502,15 @@ function RoomStatusSection({ onToast }: { onToast: (msg: string, type: 'success'
}
};
const getPriorityStyle = (priority: string) => {
switch (priority) {
case 'urgent': return { bg: '#fee2e2', color: '#b91c1c', icon: '🔴' };
case 'normal': return { bg: '#dbeafe', color: '#1d4ed8', icon: '🔵' };
case 'low': return { bg: '#f3f4f6', color: '#6b7280', icon: '⚪' };
default: return { bg: '#f3f4f6', color: '#6b7280', icon: '⚪' };
}
};
const getPaymentBadge = (status: string | null) => {
if (!status) return null;
const colors: Record<string, { bg: string; color: string }> = {
@@ -1465,6 +1527,8 @@ function RoomStatusSection({ onToast }: { onToast: (msg: string, type: 'success'
};
const formatDate = (d: string) => new Date(d).toLocaleDateString();
const formatDateTime = (d: string) => new Date(d).toLocaleString();
const formatTime = (t: string) => t ? t.substring(0, 5) : '';
return (
<div>
@@ -1477,18 +1541,24 @@ function RoomStatusSection({ onToast }: { onToast: (msg: string, type: 'success'
<div style={{ display: 'grid', gap: '12px' }}>
{rooms.map(room => {
const statusColors = getStatusColor(room.clean_status);
const priorityStyle = getPriorityStyle(room.priority);
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' }}>
<div style={{ display: 'flex', alignItems: 'center', padding: '12px 16px', background: room.is_vip ? '#fef3c7' : room.occupancy_status === 'occupied' ? '#f0fdf4' : '#fafafa', gap: '12px' }}>
{/* Room Name & Status */}
<div style={{ flex: 1, display: 'flex', alignItems: 'center', gap: '10px' }}>
<div style={{ flex: 1, display: 'flex', alignItems: 'center', gap: '10px', flexWrap: 'wrap' }}>
<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>
{room.is_vip && (
<span style={{ padding: '2px 8px', borderRadius: 12, fontSize: '11px', fontWeight: 700, background: '#fbbf24', color: '#78350f' }}>
VIP
</span>
)}
<span style={{
padding: '4px 10px',
borderRadius: 12,
@@ -1510,6 +1580,21 @@ function RoomStatusSection({ onToast }: { onToast: (msg: string, type: 'success'
}}>
{room.occupancy_status === 'occupied' ? '🛏️ Occupied' : '🚪 Available'}
</span>
<span style={{
padding: '4px 10px',
borderRadius: 12,
fontSize: '12px',
fontWeight: 600,
background: priorityStyle.bg,
color: priorityStyle.color
}}>
{priorityStyle.icon} {room.priority}
</span>
{room.issues_count > 0 && (
<span style={{ padding: '2px 8px', borderRadius: 12, fontSize: '11px', fontWeight: 600, background: '#fee2e2', color: '#b91c1c' }}>
{room.issues_count} issue{room.issues_count > 1 ? 's' : ''}
</span>
)}
</div>
{/* Price */}
@@ -1518,26 +1603,50 @@ function RoomStatusSection({ onToast }: { onToast: (msg: string, type: 'success'
</div>
{/* Edit Button */}
<Btn size="sm" variant="secondary" onClick={() => {
setEditingRoom(isEditing ? null : room.id);
setEditStatus(room.clean_status);
setEditNotes(room.notes || '');
}}>
<Btn size="sm" variant="secondary" onClick={() => isEditing ? setEditingRoom(null) : startEdit(room)}>
{isEditing ? 'Cancel' : 'Edit'}
</Btn>
</div>
{/* Details Row */}
<div style={{ padding: '12px 16px', borderTop: `1px solid ${C.border}` }}>
{/* Quick Info Grid */}
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(200px, 1fr))', gap: '8px', marginBottom: '12px' }}>
{/* Current Guest */}
{room.current_guest && (
<div style={{ marginBottom: '8px', display: 'flex', alignItems: 'center', gap: '8px' }}>
<span style={{ fontSize: '13px', color: '#666' }}>Current Guest:</span>
<div style={{ fontSize: '13px' }}>
<span style={{ color: '#666' }}>Guest: </span>
<span style={{ fontWeight: 600, color: C.navy }}>{room.current_guest}</span>
{getPaymentBadge(room.payment_status)}
</div>
)}
{/* Checkout */}
{room.checkout_date && (
<div style={{ fontSize: '13px' }}>
<span style={{ color: '#666' }}>Checkout: </span>
<span style={{ fontWeight: 500 }}>{formatDate(room.checkout_date)}</span>
{room.checkout_time && <span> at {formatTime(room.checkout_time)}</span>}
</div>
)}
{/* Last Cleaned */}
{room.last_cleaned && (
<div style={{ fontSize: '13px' }}>
<span style={{ color: '#666' }}>Last cleaned: </span>
<span style={{ fontWeight: 500 }}>{formatDateTime(room.last_cleaned)}</span>
</div>
)}
{/* Assigned Staff */}
{room.assigned_staff && (
<div style={{ fontSize: '13px' }}>
<span style={{ color: '#666' }}>Staff: </span>
<span style={{ fontWeight: 500 }}>{room.assigned_staff.first_name || ''} {room.assigned_staff.last_name || ''}</span>
</div>
)}
</div>
{/* Upcoming Reservations */}
{room.upcoming_reservations && room.upcoming_reservations.length > 0 && (
<div style={{ marginBottom: '8px' }}>
@@ -1562,25 +1671,76 @@ function RoomStatusSection({ onToast }: { onToast: (msg: string, type: 'success'
{/* Edit Form */}
{isEditing && (
<div style={{ marginTop: '12px', display: 'flex', flexDirection: 'column', gap: '12px' }}>
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(200px, 1fr))', gap: '12px' }}>
<S
label="Clean Status"
value={editStatus}
onChange={(v: any) => setEditStatus(v)}
value={editData.clean_status}
onChange={(v: any) => setEditData(prev => ({ ...prev, clean_status: v }))}
options={[
{ value: 'clean', label: '✨ Clean' },
{ value: 'dirty', label: '🧹 Dirty' },
{ value: 'maintenance', label: '🔧 Maintenance' },
]}
/>
<S
label="Priority"
value={editData.priority}
onChange={(v: any) => setEditData(prev => ({ ...prev, priority: v }))}
options={[
{ value: 'urgent', label: '🔴 Urgent' },
{ value: 'normal', label: '🔵 Normal' },
{ value: 'low', label: '⚪ Low' },
]}
/>
<S
label="Assigned Staff"
value={editData.assigned_staff_id?.toString() || ''}
onChange={(v: any) => setEditData(prev => ({ ...prev, assigned_staff_id: v ? parseInt(v) : null }))}
options={[
{ value: '', label: '— Unassigned —' },
...staff.map(s => ({ value: s.id.toString(), label: `${s.first_name || ''} ${s.last_name || ''}`.trim() || s.username }))
]}
/>
</div>
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(150px, 1fr))', gap: '12px' }}>
<I
label="Issues Count"
type="number"
value={editData.issues_count.toString()}
onChange={(v: string) => setEditData(prev => ({ ...prev, issues_count: parseInt(v) || 0 }))}
placeholder="0"
/>
<I
label="Checkout Date"
type="date"
value={editData.checkout_date}
onChange={(v: string) => setEditData(prev => ({ ...prev, checkout_date: v }))}
/>
<I
label="Checkout Time"
type="time"
value={editData.checkout_time}
onChange={(v: string) => setEditData(prev => ({ ...prev, checkout_time: v }))}
/>
</div>
<label style={{ display: 'flex', alignItems: 'center', gap: '8px', cursor: 'pointer' }}>
<input
type="checkbox"
checked={editData.is_vip}
onChange={(e) => setEditData(prev => ({ ...prev, is_vip: e.target.checked }))}
style={{ width: 18, height: 18 }}
/>
<span style={{ fontWeight: 500 }}> VIP Guest</span>
</label>
<TA
label="Notes"
value={editNotes}
onChange={setEditNotes}
value={editData.notes}
onChange={(v: string) => setEditData(prev => ({ ...prev, notes: v }))}
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 onClick={updateRoomStatus} size="sm">Save</Btn>
<Btn variant="secondary" onClick={() => setEditingRoom(null)} size="sm">Cancel</Btn>
</div>
</div>
+92 -4
View File
@@ -8,7 +8,7 @@ export async function GET() {
try {
await requireRole('admin');
// Get all rooms with current reservation info
// Get all rooms with current reservation info and staff assignment
const { rows } = await db.query(`
SELECT
r.id,
@@ -17,6 +17,13 @@ export async function GET() {
r.clean_status,
r.notes,
r.active,
r.last_cleaned,
r.assigned_staff_id,
r.priority,
r.issues_count,
r.is_vip,
r.checkout_time,
r.checkout_date,
rr.id as reservation_id,
rr.guest_name,
rr.check_in,
@@ -26,6 +33,8 @@ export async function GET() {
u.first_name,
u.last_name,
u.username,
s.first_name as staff_first_name,
s.last_name as staff_last_name,
CASE
WHEN rr.id IS NOT NULL AND rr.status = 'confirmed'
AND CURRENT_DATE >= rr.check_in
@@ -39,7 +48,14 @@ export async function GET() {
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
LEFT JOIN users s ON r.assigned_staff_id = s.id
ORDER BY
CASE r.priority
WHEN 'urgent' THEN 1
WHEN 'normal' THEN 2
WHEN 'low' THEN 3
END,
r.name
`);
// Get upcoming reservations for each room
@@ -67,10 +83,23 @@ export async function GET() {
...room,
upcoming_reservations: upcoming,
current_guest: room.guest_name || (room.first_name ? `${room.first_name} ${room.last_name || ''}`.trim() : room.username) || null,
assigned_staff: room.assigned_staff_id ? {
id: room.assigned_staff_id,
first_name: room.staff_first_name,
last_name: room.staff_last_name,
} : null,
};
}));
return NextResponse.json({ rooms });
// Get all staff members (users who can be assigned to rooms)
const { rows: staff } = await db.query(`
SELECT id, first_name, last_name, username
FROM users
WHERE role IN ('admin', 'staff') OR role = 'user'
ORDER BY first_name, last_name
`);
return NextResponse.json({ rooms, staff });
} catch (error) {
console.error('Get room status error:', error);
return NextResponse.json(
@@ -85,7 +114,19 @@ export async function PATCH(request: NextRequest) {
try {
await requireRole('admin');
const { id, clean_status, notes } = await request.json();
const body = await request.json();
const {
id,
clean_status,
notes,
last_cleaned,
assigned_staff_id,
priority,
issues_count,
is_vip,
checkout_time,
checkout_date,
} = body;
if (!id) {
return NextResponse.json({ error: 'Room ID is required' }, { status: 400 });
@@ -107,6 +148,53 @@ export async function PATCH(request: NextRequest) {
paramIndex++;
}
if (last_cleaned !== undefined) {
updates.push(`last_cleaned = $${paramIndex}`);
values.push(last_cleaned);
paramIndex++;
}
if (assigned_staff_id !== undefined) {
updates.push(`assigned_staff_id = $${paramIndex}`);
values.push(assigned_staff_id || null);
paramIndex++;
}
if (priority !== undefined) {
updates.push(`priority = $${paramIndex}`);
values.push(priority);
paramIndex++;
}
if (issues_count !== undefined) {
updates.push(`issues_count = $${paramIndex}`);
values.push(issues_count);
paramIndex++;
}
if (is_vip !== undefined) {
updates.push(`is_vip = $${paramIndex}`);
values.push(is_vip);
paramIndex++;
}
if (checkout_time !== undefined) {
updates.push(`checkout_time = $${paramIndex}`);
values.push(checkout_time || null);
paramIndex++;
}
if (checkout_date !== undefined) {
updates.push(`checkout_date = $${paramIndex}`);
values.push(checkout_date || null);
paramIndex++;
}
// Auto-set last_cleaned when status changes to 'clean'
if (clean_status === 'clean') {
updates.push(`last_cleaned = NOW()`);
}
if (updates.length === 0) {
return NextResponse.json({ error: 'No updates provided' }, { status: 400 });
}
+7
View File
@@ -538,6 +538,13 @@ export async function migrateFromLegacyPlaces() {
// 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`);
await db.query(`ALTER TABLE rooms ADD COLUMN IF NOT EXISTS last_cleaned TIMESTAMP`);
await db.query(`ALTER TABLE rooms ADD COLUMN IF NOT EXISTS assigned_staff_id INTEGER REFERENCES users(id)`);
await db.query(`ALTER TABLE rooms ADD COLUMN IF NOT EXISTS priority VARCHAR(10) DEFAULT 'normal'`);
await db.query(`ALTER TABLE rooms ADD COLUMN IF NOT EXISTS issues_count INTEGER DEFAULT 0`);
await db.query(`ALTER TABLE rooms ADD COLUMN IF NOT EXISTS is_vip BOOLEAN DEFAULT FALSE`);
await db.query(`ALTER TABLE rooms ADD COLUMN IF NOT EXISTS checkout_time TIME`);
await db.query(`ALTER TABLE rooms ADD COLUMN IF NOT EXISTS checkout_date DATE`);
// Add payment_status to room_reservations
await db.query(`ALTER TABLE room_reservations ADD COLUMN IF NOT EXISTS payment_status VARCHAR(20) DEFAULT 'pending'`);