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: 'private-events', label: 'Private Events', icon: '🔒' },
|
||||||
{ id: 'rooms', label: 'Rooms', icon: '🏨' },
|
{ id: 'rooms', label: 'Rooms', icon: '🏨' },
|
||||||
{ id: 'room-status', label: 'Room Status', icon: '🧹' },
|
{ id: 'room-status', label: 'Room Status', icon: '🧹' },
|
||||||
|
{ id: 'room-assignments', label: 'Assignments', icon: '🔑' },
|
||||||
{ id: 'room-reservations', label: 'Room Bookings', icon: '🛏️' },
|
{ id: 'room-reservations', label: 'Room Bookings', icon: '🛏️' },
|
||||||
{ id: 'restaurant-reservations', label: 'Restaurant', icon: '🍽️' },
|
{ id: 'restaurant-reservations', label: 'Restaurant', icon: '🍽️' },
|
||||||
{ id: 'orders', label: 'Food Orders', icon: '📦' },
|
{ id: 'orders', label: 'Food Orders', icon: '📦' },
|
||||||
|
{ id: 'kitchen', label: 'Kitchen', icon: '👨🍳' },
|
||||||
{ id: 'amenities', label: 'Amenities', icon: '✨' },
|
{ id: 'amenities', label: 'Amenities', icon: '✨' },
|
||||||
{ id: 'trips', label: 'Trips', icon: '✈️' },
|
{ id: 'trips', label: 'Trips', icon: '✈️' },
|
||||||
{ id: 'messages', label: 'Messages', 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' }}>
|
<div style={{ fontSize: '13px', color: '#666', marginTop: '4px' }}>
|
||||||
{formatPrice(o.total)} • {formatDisplayDateTime(o.created_at)}
|
{formatPrice(o.total)} • {formatDisplayDateTime(o.created_at)}
|
||||||
</div>
|
</div>
|
||||||
|
{o.username && <div style={{ fontSize: '12px', color: '#888', marginTop: '2px' }}>👤 {o.first_name || o.username}</div>}
|
||||||
</div>
|
</div>
|
||||||
<span style={{
|
<span style={{
|
||||||
padding: '4px 10px', borderRadius: 12, fontSize: '12px', fontWeight: 600,
|
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) {
|
function RoomsSection({ rooms, setRooms, images, onToast }: any) {
|
||||||
const [editing, setEditing] = useState<Room | null>(null);
|
const [editing, setEditing] = useState<Room | null>(null);
|
||||||
const [confirmDel, setConfirmDel] = useState<number | 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 'room-reservations': return <RoomReservationsSection onToast={showToast} />;
|
||||||
case 'restaurant-reservations': return <RestaurantReservationsSection onToast={showToast} />;
|
case 'restaurant-reservations': return <RestaurantReservationsSection onToast={showToast} />;
|
||||||
case 'orders': return <OrdersSection 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 'amenities': return <AmenitiesSection onToast={showToast} />;
|
||||||
case 'menu': return <MenuSection items={menuItems} setItems={setMenuItems} images={images} 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} />;
|
case 'places': return <PlacesSection places={places} setPlaces={setPlaces} images={images} onToast={showToast} />;
|
||||||
|
|||||||
@@ -122,6 +122,7 @@ export async function GET(request: Request) {
|
|||||||
const { searchParams } = new URL(request.url);
|
const { searchParams } = new URL(request.url);
|
||||||
const status = searchParams.get('status') || 'all';
|
const status = searchParams.get('status') || 'all';
|
||||||
const limit = parseInt(searchParams.get('limit') || '50');
|
const limit = parseInt(searchParams.get('limit') || '50');
|
||||||
|
const kitchen = searchParams.get('kitchen') === 'true';
|
||||||
|
|
||||||
let query = `
|
let query = `
|
||||||
SELECT o.*, r.name as room_name, u.username, u.first_name, u.last_name
|
SELECT o.*, r.name as room_name, u.username, u.first_name, u.last_name
|
||||||
|
|||||||
@@ -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 });
|
||||||
|
}
|
||||||
|
}
|
||||||
+47
-16
@@ -35,6 +35,7 @@ export default function CoffeePage() {
|
|||||||
const [items, setItems] = useState<MenuItem[]>([]);
|
const [items, setItems] = useState<MenuItem[]>([]);
|
||||||
const [rooms, setRooms] = useState<Room[]>([]);
|
const [rooms, setRooms] = useState<Room[]>([]);
|
||||||
const [user, setUser] = useState<User | null>(null);
|
const [user, setUser] = useState<User | null>(null);
|
||||||
|
const [assignedRoom, setAssignedRoom] = useState<Room | null>(null);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [error, setError] = useState('');
|
const [error, setError] = useState('');
|
||||||
|
|
||||||
@@ -62,10 +63,25 @@ export default function CoffeePage() {
|
|||||||
return res.json();
|
return res.json();
|
||||||
}),
|
}),
|
||||||
])
|
])
|
||||||
.then(([menuData, sessionData, roomsData]) => {
|
.then(async ([menuData, sessionData, roomsData]) => {
|
||||||
setItems(menuData.data || []);
|
setItems(menuData.data || []);
|
||||||
setUser(sessionData.user || null);
|
setUser(sessionData.user || null);
|
||||||
setRooms(roomsData.rooms || []);
|
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))
|
.catch((err) => setError(err.message))
|
||||||
.finally(() => setLoading(false));
|
.finally(() => setLoading(false));
|
||||||
@@ -359,23 +375,38 @@ export default function CoffeePage() {
|
|||||||
{/* Room Selection for Delivery */}
|
{/* Room Selection for Delivery */}
|
||||||
{orderType === 'room_delivery' && user && (
|
{orderType === 'room_delivery' && user && (
|
||||||
<div style={{ marginBottom: '1.5rem' }}>
|
<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 }}>
|
||||||
<select
|
{assignedRoom ? 'Your Room' : 'Select Room'}
|
||||||
value={selectedRoom || ''}
|
</label>
|
||||||
onChange={(e) => setSelectedRoom(Number(e.target.value) || null)}
|
{assignedRoom ? (
|
||||||
style={{
|
<div style={{
|
||||||
width: '100%',
|
|
||||||
padding: '0.75rem',
|
padding: '0.75rem',
|
||||||
border: '2px solid #ddd',
|
border: '2px solid gold',
|
||||||
borderRadius: '8px',
|
borderRadius: '8px',
|
||||||
fontSize: '16px',
|
background: '#fff9e6',
|
||||||
}}
|
color: navy,
|
||||||
>
|
fontWeight: 500,
|
||||||
<option value="">Select a room...</option>
|
}}>
|
||||||
{rooms.map((room) => (
|
🏨 {assignedRoom.name}
|
||||||
<option key={room.id} value={room.id}>{room.name}</option>
|
</div>
|
||||||
))}
|
) : (
|
||||||
</select>
|
<select
|
||||||
|
value={selectedRoom || ''}
|
||||||
|
onChange={(e) => setSelectedRoom(Number(e.target.value) || null)}
|
||||||
|
style={{
|
||||||
|
width: '100%',
|
||||||
|
padding: '0.75rem',
|
||||||
|
border: '2px solid #ddd',
|
||||||
|
borderRadius: '8px',
|
||||||
|
fontSize: '16px',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<option value="">Select a room...</option>
|
||||||
|
{rooms.map((room) => (
|
||||||
|
<option key={room.id} value={room.id}>{room.name}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
@@ -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_orders_status ON orders(status)`);
|
||||||
await db.query(`CREATE INDEX IF NOT EXISTS idx_order_items_order ON order_items(order_id)`);
|
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
|
// Audit log for admin actions
|
||||||
await db.query(`
|
await db.query(`
|
||||||
CREATE TABLE IF NOT EXISTS audit_log (
|
CREATE TABLE IF NOT EXISTS audit_log (
|
||||||
|
|||||||
Reference in New Issue
Block a user