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:
+193
-33
@@ -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,25 +1603,49 @@ 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}` }}>
|
||||
{/* 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>
|
||||
)}
|
||||
{/* 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={{ 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 && (
|
||||
@@ -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' }}>
|
||||
<S
|
||||
label="Clean Status"
|
||||
value={editStatus}
|
||||
onChange={(v: any) => setEditStatus(v)}
|
||||
options={[
|
||||
{ value: 'clean', label: '✨ Clean' },
|
||||
{ value: 'dirty', label: '🧹 Dirty' },
|
||||
{ value: 'maintenance', label: '🔧 Maintenance' },
|
||||
]}
|
||||
/>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(200px, 1fr))', gap: '12px' }}>
|
||||
<S
|
||||
label="Clean Status"
|
||||
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>
|
||||
|
||||
Reference in New Issue
Block a user