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} />;
+1
View File
@@ -122,6 +122,7 @@ export async function GET(request: Request) {
const { searchParams } = new URL(request.url);
const status = searchParams.get('status') || 'all';
const limit = parseInt(searchParams.get('limit') || '50');
const kitchen = searchParams.get('kitchen') === 'true';
let query = `
SELECT o.*, r.name as room_name, u.username, u.first_name, u.last_name
+111
View File
@@ -0,0 +1,111 @@
import { NextResponse } from 'next/server';
import { db } from '@/lib/db';
import { getSession } from '@/lib/auth';
// GET - list all room assignments (admin) or current user's assignment
export async function GET(request: Request) {
try {
const user = await getSession();
if (!user) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
const { searchParams } = new URL(request.url);
const onlyActive = searchParams.get('active') === 'true';
if (user.role !== 'admin') {
// Regular user - return their current active assignment
const today = new Date().toISOString().split('T')[0];
const { rows } = await db.query(`
SELECT ra.*, r.name as room_name, r.price
FROM room_assignments ra
JOIN rooms r ON ra.room_id = r.id
WHERE ra.user_id = $1 AND ra.check_in <= $2 AND ra.check_out >= $2
`, [user.id, today]);
return NextResponse.json({ assignments: rows });
}
// Admin - return all assignments
let query = `
SELECT ra.*, r.name as room_name, u.username, u.first_name, u.last_name, u.email
FROM room_assignments ra
JOIN rooms r ON ra.room_id = r.id
JOIN users u ON ra.user_id = u.id
`;
const params: any[] = [];
if (onlyActive) {
const today = new Date().toISOString().split('T')[0];
query += ' WHERE ra.check_in <= $1 AND ra.check_out >= $1';
params.push(today);
}
query += ' ORDER BY ra.check_in DESC';
const { rows } = await db.query(query, params);
return NextResponse.json({ assignments: rows });
} catch (error) {
console.error('Get room assignments error:', error);
return NextResponse.json({ error: 'Failed to get room assignments' }, { status: 500 });
}
}
// POST - create new room assignment (admin only)
export async function POST(request: Request) {
try {
const user = await getSession();
if (!user || user.role !== 'admin') {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
const { room_id, user_id, check_in, check_out, notes } = await request.json();
if (!room_id || !user_id || !check_in || !check_out) {
return NextResponse.json({ error: 'Room, user, check-in, and check-out are required' }, { status: 400 });
}
// Check for conflicting assignments
const { rows: conflicts } = await db.query(`
SELECT id FROM room_assignments
WHERE room_id = $1 AND check_in < $3 AND check_out > $2
`, [room_id, check_in, check_out]);
if (conflicts.length > 0) {
return NextResponse.json({ error: 'Room has conflicting assignment for these dates' }, { status: 400 });
}
const { rows } = await db.query(`
INSERT INTO room_assignments (room_id, user_id, check_in, check_out, notes, created_by)
VALUES ($1, $2, $3, $4, $5, $6)
RETURNING *
`, [room_id, user_id, check_in, check_out, notes || null, user.id]);
return NextResponse.json({ success: true, assignment: rows[0] });
} catch (error) {
console.error('Create room assignment error:', error);
return NextResponse.json({ error: 'Failed to create room assignment' }, { status: 500 });
}
}
// DELETE - remove room assignment (admin only)
export async function DELETE(request: Request) {
try {
const user = await getSession();
if (!user || user.role !== 'admin') {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
const { searchParams } = new URL(request.url);
const id = searchParams.get('id');
if (!id) {
return NextResponse.json({ error: 'Assignment ID required' }, { status: 400 });
}
await db.query('DELETE FROM room_assignments WHERE id = $1', [id]);
return NextResponse.json({ success: true });
} catch (error) {
console.error('Delete room assignment error:', error);
return NextResponse.json({ error: 'Failed to delete room assignment' }, { status: 500 });
}
}
+33 -2
View File
@@ -35,6 +35,7 @@ export default function CoffeePage() {
const [items, setItems] = useState<MenuItem[]>([]);
const [rooms, setRooms] = useState<Room[]>([]);
const [user, setUser] = useState<User | null>(null);
const [assignedRoom, setAssignedRoom] = useState<Room | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState('');
@@ -62,10 +63,25 @@ export default function CoffeePage() {
return res.json();
}),
])
.then(([menuData, sessionData, roomsData]) => {
.then(async ([menuData, sessionData, roomsData]) => {
setItems(menuData.data || []);
setUser(sessionData.user || null);
setRooms(roomsData.rooms || []);
// Fetch user's active room assignment if logged in
if (sessionData.user) {
try {
const assignRes = await fetch('/api/room-assignments');
if (assignRes.ok) {
const assignData = await assignRes.json();
if (assignData.assignments?.length > 0) {
const assignment = assignData.assignments[0];
setAssignedRoom({ id: assignment.room_id, name: assignment.room_name });
setSelectedRoom(assignment.room_id);
}
}
} catch { /* ignore */ }
}
})
.catch((err) => setError(err.message))
.finally(() => setLoading(false));
@@ -359,7 +375,21 @@ export default function CoffeePage() {
{/* Room Selection for Delivery */}
{orderType === 'room_delivery' && user && (
<div style={{ marginBottom: '1.5rem' }}>
<label style={{ display: 'block', fontWeight: 600, marginBottom: '0.5rem', color: navy }}>Select Room</label>
<label style={{ display: 'block', fontWeight: 600, marginBottom: '0.5rem', color: navy }}>
{assignedRoom ? 'Your Room' : 'Select Room'}
</label>
{assignedRoom ? (
<div style={{
padding: '0.75rem',
border: '2px solid gold',
borderRadius: '8px',
background: '#fff9e6',
color: navy,
fontWeight: 500,
}}>
🏨 {assignedRoom.name}
</div>
) : (
<select
value={selectedRoom || ''}
onChange={(e) => setSelectedRoom(Number(e.target.value) || null)}
@@ -376,6 +406,7 @@ export default function CoffeePage() {
<option key={room.id} value={room.id}>{room.name}</option>
))}
</select>
)}
</div>
)}
+18
View File
@@ -498,6 +498,24 @@ export async function initSchema() {
await db.query(`CREATE INDEX IF NOT EXISTS idx_orders_status ON orders(status)`);
await db.query(`CREATE INDEX IF NOT EXISTS idx_order_items_order ON order_items(order_id)`);
// Room assignments - admins assign registered users to rooms
await db.query(`
CREATE TABLE IF NOT EXISTS room_assignments (
id SERIAL PRIMARY KEY,
room_id INTEGER NOT NULL REFERENCES rooms(id) ON DELETE CASCADE,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
check_in DATE NOT NULL,
check_out DATE NOT NULL,
notes TEXT,
created_by INTEGER REFERENCES users(id),
created_at TIMESTAMP DEFAULT NOW(),
UNIQUE(room_id, check_in, check_out)
);
`);
await db.query(`CREATE INDEX IF NOT EXISTS idx_room_assignments_room ON room_assignments(room_id)`);
await db.query(`CREATE INDEX IF NOT EXISTS idx_room_assignments_user ON room_assignments(user_id)`);
await db.query(`CREATE INDEX IF NOT EXISTS idx_room_assignments_dates ON room_assignments(check_in, check_out)`);
// Audit log for admin actions
await db.query(`
CREATE TABLE IF NOT EXISTS audit_log (