feat: room assignments, kitchen dashboard, and order user tracking

- Add room_assignments table for assigning users to rooms with dates
- Auto-detect logged-in user's assigned room when ordering food
- Kitchen tab in admin: dedicated view for active orders with auto-refresh
- Room Assignments tab: assign registered users to rooms by date range
- Orders now show user info (username, name) for tracking
- Kitchen view groups orders by status (pending/preparing/ready)
- Messages section shows order notifications
This commit is contained in:
2026-07-03 00:22:00 -05:00
parent 9557fa7d07
commit 60afc2349e
5 changed files with 443 additions and 16 deletions
+266
View File
@@ -34,9 +34,11 @@ const NAV = [
{ id: 'private-events', label: 'Private Events', icon: '🔒' },
{ id: 'rooms', label: 'Rooms', icon: '🏨' },
{ id: 'room-status', label: 'Room Status', icon: '🧹' },
{ id: 'room-assignments', label: 'Assignments', icon: '🔑' },
{ id: 'room-reservations', label: 'Room Bookings', icon: '🛏️' },
{ id: 'restaurant-reservations', label: 'Restaurant', icon: '🍽️' },
{ id: 'orders', label: 'Food Orders', icon: '📦' },
{ id: 'kitchen', label: 'Kitchen', icon: '👨‍🍳' },
{ id: 'amenities', label: 'Amenities', icon: '✨' },
{ id: 'trips', label: 'Trips', icon: '✈️' },
{ id: 'messages', label: 'Messages', icon: '💬' },
@@ -1287,6 +1289,7 @@ function OrdersSection({ onToast }: { onToast: (msg: string, type: 'success' | '
<div style={{ fontSize: '13px', color: '#666', marginTop: '4px' }}>
{formatPrice(o.total)} {formatDisplayDateTime(o.created_at)}
</div>
{o.username && <div style={{ fontSize: '12px', color: '#888', marginTop: '2px' }}>👤 {o.first_name || o.username}</div>}
</div>
<span style={{
padding: '4px 10px', borderRadius: 12, fontSize: '12px', fontWeight: 600,
@@ -1320,6 +1323,267 @@ function OrdersSection({ onToast }: { onToast: (msg: string, type: 'success' | '
);
}
/* Kitchen Section */
function KitchenSection({ onToast }: { onToast: (msg: string, type: 'success' | 'error') => void }) {
const [orders, setOrders] = useState<Order[]>([]);
const [loading, setLoading] = useState(true);
const [autoRefresh, setAutoRefresh] = useState(true);
const loadOrders = async () => {
try {
const res = await fetch('/api/orders?status=all');
const data = await res.json();
const active = (data.orders || []).filter((o: Order) => ['pending', 'preparing', 'ready'].includes(o.status));
setOrders(active);
} catch { onToast('Error loading orders', 'error'); }
setLoading(false);
};
useEffect(() => {
loadOrders();
if (autoRefresh) {
const interval = setInterval(loadOrders, 30000);
return () => clearInterval(interval);
}
}, [autoRefresh]);
const updateStatus = async (id: number, status: string) => {
try {
const res = await fetch('/api/orders', {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ order_id: id, status }),
});
if (res.ok) {
if (status === 'delivered' || status === 'cancelled') {
setOrders(o => o.filter(x => x.id !== id));
} else {
setOrders(o => o.map(x => x.id === id ? { ...x, status } : x));
}
onToast(`Order ${status}`, 'success');
}
} catch { onToast('Error updating order', 'error'); }
};
const formatPrice = (p: string) => `$${parseFloat(p).toFixed(2)}`;
const statusColor = (s: string) => s === 'pending' ? '#f59e0b' : s === 'preparing' ? '#3b82f6' : '#10b981';
const pending = orders.filter(o => o.status === 'pending');
const preparing = orders.filter(o => o.status === 'preparing');
const ready = orders.filter(o => o.status === 'ready');
const OrderCard = ({ o }: { o: Order }) => (
<div style={{ background: '#fff', borderRadius: 12, padding: '16px', border: `2px solid ${statusColor(o.status)}`, boxShadow: C.shadow }}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', marginBottom: '12px' }}>
<div>
<div style={{ fontWeight: 600, fontSize: '16px', color: C.navy }}>
{o.order_type === 'room_delivery' ? `Room ${o.room_name || o.room_id}` : o.order_type === 'dine_in' ? 'Dine-in' : 'Pickup'}
</div>
<div style={{ fontSize: '13px', color: '#666', marginTop: '4px' }}>
{formatPrice(o.total)} - {formatDisplayDateTime(o.created_at)}
</div>
{o.username && <div style={{ fontSize: '12px', color: '#888', marginTop: '2px' }}>User: {o.first_name || o.username}</div>}
</div>
<span style={{
padding: '4px 12px', borderRadius: 12, fontSize: '12px', fontWeight: 600,
background: statusColor(o.status), color: '#fff'
}}>{o.status.toUpperCase()}</span>
</div>
<div style={{ marginBottom: '12px', maxHeight: '120px', overflowY: 'auto' }}>
{o.items?.map((item, i) => (
<div key={i} style={{ fontSize: '14px', color: '#333', display: 'flex', justifyContent: 'space-between', padding: '4px 0' }}>
<span style={{ fontWeight: 500 }}>{item.quantity}x {item.name}</span>
<span style={{ color: '#666' }}>{formatPrice(item.total_price)}</span>
</div>
))}
</div>
{o.notes && <div style={{ fontSize: '13px', color: '#b45309', background: '#fef3c7', padding: '8px 12px', borderRadius: 6, marginBottom: '12px', fontStyle: 'italic' }}>Note: "{o.notes}"</div>}
<div style={{ display: 'flex', gap: '8px', flexWrap: 'wrap' }}>
{o.status === 'pending' && (
<>
<Btn onClick={() => updateStatus(o.id, 'preparing')}>Start Preparing</Btn>
<Btn variant="danger" onClick={() => updateStatus(o.id, 'cancelled')}>Cancel</Btn>
</>
)}
{o.status === 'preparing' && <Btn onClick={() => updateStatus(o.id, 'ready')}>Mark Ready</Btn>}
{o.status === 'ready' && <Btn onClick={() => updateStatus(o.id, 'delivered')}>Delivered</Btn>}
</div>
</div>
);
return (
<div>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '20px' }}>
<h2 style={{ margin: 0, fontSize: '24px', fontWeight: 700, color: C.navy }}>Kitchen Dashboard</h2>
<label style={{ display: 'flex', alignItems: 'center', gap: '8px', fontSize: '14px', cursor: 'pointer' }}>
<input type="checkbox" checked={autoRefresh} onChange={e => setAutoRefresh(e.target.checked)} style={{ width: 16, height: 16, accentColor: C.gold }} />
Auto-refresh (30s)
</label>
</div>
{loading ? <Empty icon="Loading..." title="Loading..." /> : orders.length === 0 ? (
<Empty icon="Done" title="All caught up!" desc="No active orders" />
) : (
<div style={{ display: 'grid', gap: '24px' }}>
{pending.length > 0 && (
<div>
<h3 style={{ margin: '0 0 12px', fontSize: '18px', fontWeight: 600, color: '#f59e0b' }}>Pending ({pending.length})</h3>
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(320px, 1fr))', gap: '12px' }}>
{pending.map(o => <OrderCard key={o.id} o={o} />)}
</div>
</div>
)}
{preparing.length > 0 && (
<div>
<h3 style={{ margin: '0 0 12px', fontSize: '18px', fontWeight: 600, color: '#3b82f6' }}>Preparing ({preparing.length})</h3>
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(320px, 1fr))', gap: '12px' }}>
{preparing.map(o => <OrderCard key={o.id} o={o} />)}
</div>
</div>
)}
{ready.length > 0 && (
<div>
<h3 style={{ margin: '0 0 12px', fontSize: '18px', fontWeight: 600, color: '#10b981' }}>Ready for Delivery ({ready.length})</h3>
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(320px, 1fr))', gap: '12px' }}>
{ready.map(o => <OrderCard key={o.id} o={o} />)}
</div>
</div>
)}
</div>
)}
</div>
);
}
/* Room Assignments Section */
type RoomAssignment = { id: number; room_id: number; room_name: string; user_id: number; username: string; first_name: string | null; last_name: string | null; email: string; check_in: string; check_out: string; notes: string | null };
function RoomAssignmentsSection({ onToast }: { onToast: (msg: string, type: 'success' | 'error') => void }) {
const [assignments, setAssignments] = useState<RoomAssignment[]>([]);
const [rooms, setRooms] = useState<Room[]>([]);
const [users, setUsers] = useState<{ id: number; username: string; first_name: string | null; last_name: string | null }[]>([]);
const [loading, setLoading] = useState(true);
const [showForm, setShowForm] = useState(false);
const [form, setForm] = useState({ room_id: '', user_id: '', check_in: '', check_out: '', notes: '' });
useEffect(() => {
Promise.all([
fetch('/api/room-assignments').then(r => r.json()),
fetch('/api/rooms').then(r => r.json()),
fetch('/api/admin/users').then(r => r.json()),
]).then(([assignData, roomData, userData]) => {
setAssignments(assignData.assignments || []);
setRooms(roomData.rooms || []);
setUsers(userData.users || []);
}).catch(() => onToast('Error loading data', 'error'))
.finally(() => setLoading(false));
}, []);
const today = new Date().toISOString().split('T')[0];
const active = assignments.filter(a => a.check_in <= today && a.check_out >= today);
const upcoming = assignments.filter(a => a.check_in > today);
const saveAssignment = async () => {
if (!form.room_id || !form.user_id || !form.check_in || !form.check_out) {
onToast('All fields required', 'error');
return;
}
try {
const res = await fetch('/api/room-assignments', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(form),
});
if (res.ok) {
const data = await res.json();
setAssignments(a => [...a, data.assignment]);
setForm({ room_id: '', user_id: '', check_in: '', check_out: '', notes: '' });
setShowForm(false);
onToast('Assignment created', 'success');
} else {
const err = await res.json();
onToast(err.error || 'Failed to create assignment', 'error');
}
} catch { onToast('Error saving assignment', 'error'); }
};
const deleteAssignment = async (id: number) => {
if (!confirm('Delete this assignment?')) return;
try {
await fetch(`/api/room-assignments?id=${id}`, { method: 'DELETE' });
setAssignments(a => a.filter(x => x.id !== id));
onToast('Assignment deleted', 'success');
} catch { onToast('Error deleting assignment', 'error'); }
};
return (
<div>
<SecHead title="Room Assignments" count={assignments.length} action={<Btn onClick={() => setShowForm(true)}>+ Assign Room</Btn>} />
{showForm && (
<div style={{ background: '#fff', borderRadius: 12, padding: '20px', marginBottom: '20px', boxShadow: C.shadow }}>
<h3 style={{ margin: '0 0 16px', fontSize: '16px', fontWeight: 600, color: C.navy }}>New Assignment</h3>
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(200px, 1fr))', gap: '12px', marginBottom: '16px' }}>
<S label="Room" value={form.room_id} onChange={(v: string) => setForm(f => ({ ...f, room_id: v }))} options={rooms.map(r => ({ value: r.id.toString(), label: r.name }))} />
<S label="User" value={form.user_id} onChange={(v: string) => setForm(f => ({ ...f, user_id: v }))} options={users.map(u => ({ value: u.id.toString(), label: `${u.username} (${u.first_name || ''} ${u.last_name || ''})`.trim() }))} />
<I label="Check-in" type="date" value={form.check_in} onChange={(v: string) => setForm(f => ({ ...f, check_in: v }))} />
<I label="Check-out" type="date" value={form.check_out} onChange={(v: string) => setForm(f => ({ ...f, check_out: v }))} />
</div>
<TA label="Notes (optional)" value={form.notes} onChange={(v: string) => setForm(f => ({ ...f, notes: v }))} rows={2} />
<div style={{ display: 'flex', gap: '8px', marginTop: '12px' }}>
<Btn onClick={saveAssignment}>Save Assignment</Btn>
<Btn variant="secondary" onClick={() => { setShowForm(false); setForm({ room_id: '', user_id: '', check_in: '', check_out: '', notes: '' }); }}>Cancel</Btn>
</div>
</div>
)}
{loading ? <Empty icon="Loading..." title="Loading..." /> : (
<div>
<div style={{ marginBottom: '24px' }}>
<h3 style={{ margin: '0 0 12px', fontSize: '16px', fontWeight: 600, color: C.navy }}>Currently Active ({active.length})</h3>
{active.length === 0 ? (
<div style={{ background: '#fff', borderRadius: 12, padding: '20px', textAlign: 'center', color: C.textLight }}>No active assignments</div>
) : (
<div style={{ display: 'grid', gap: '8px' }}>
{active.map(a => (
<div key={a.id} style={{ background: '#fff', borderRadius: 12, padding: '16px', boxShadow: C.shadow, display: 'flex', justifyContent: 'space-between', alignItems: 'center', flexWrap: 'wrap', gap: '12px' }}>
<div>
<div style={{ fontWeight: 600, color: C.navy }}>{a.room_name}</div>
<div style={{ fontSize: '13px', color: '#666' }}>User: {a.first_name || a.username} {a.last_name || ''} ({a.email})</div>
<div style={{ fontSize: '12px', color: '#888' }}>Dates: {a.check_in} to {a.check_out}</div>
{a.notes && <div style={{ fontSize: '12px', color: '#666', fontStyle: 'italic', marginTop: '4px' }}>Note: {a.notes}</div>}
</div>
<Btn variant="danger" size="sm" onClick={() => deleteAssignment(a.id)}>Remove</Btn>
</div>
))}
</div>
)}
</div>
{upcoming.length > 0 && (
<div>
<h3 style={{ margin: '0 0 12px', fontSize: '16px', fontWeight: 600, color: C.navy }}>Upcoming ({upcoming.length})</h3>
<div style={{ display: 'grid', gap: '8px' }}>
{upcoming.map(a => (
<div key={a.id} style={{ background: '#fff', borderRadius: 12, padding: '16px', boxShadow: C.shadow, display: 'flex', justifyContent: 'space-between', alignItems: 'center', flexWrap: 'wrap', gap: '12px' }}>
<div>
<div style={{ fontWeight: 600, color: C.navy }}>{a.room_name}</div>
<div style={{ fontSize: '13px', color: '#666' }}>User: {a.first_name || a.username} {a.last_name || ''}</div>
<div style={{ fontSize: '12px', color: '#888' }}>Dates: {a.check_in} to {a.check_out}</div>
</div>
<Btn variant="danger" size="sm" onClick={() => deleteAssignment(a.id)}>Remove</Btn>
</div>
))}
</div>
</div>
)}
</div>
)}
</div>
);
}
function RoomsSection({ rooms, setRooms, images, onToast }: any) {
const [editing, setEditing] = useState<Room | null>(null);
const [confirmDel, setConfirmDel] = useState<number | null>(null);
@@ -3388,6 +3652,8 @@ export default function AdminPage() {
case 'room-reservations': return <RoomReservationsSection onToast={showToast} />;
case 'restaurant-reservations': return <RestaurantReservationsSection onToast={showToast} />;
case 'orders': return <OrdersSection onToast={showToast} />;
case 'kitchen': return <KitchenSection onToast={showToast} />;
case 'room-assignments': return <RoomAssignmentsSection onToast={showToast} />;
case 'amenities': return <AmenitiesSection onToast={showToast} />;
case 'menu': return <MenuSection items={menuItems} setItems={setMenuItems} images={images} onToast={showToast} />;
case 'places': return <PlacesSection places={places} setPlaces={setPlaces} images={images} onToast={showToast} />;