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