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:
@@ -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} />;
|
||||
|
||||
@@ -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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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() {
|
||||
|
||||
Reference in New Issue
Block a user