feat: add messaging system with admin bulk messages and user conversations
- Add conversations and direct_messages tables to schema - User messaging page at /messages (users can start conversations, see admin responses) - Admin messaging page at /admin/messages (view all conversations, reply to users) - Admin bulk messaging: send to all customers, specific users, or admins - Mail icon in navbar for logged-in users (links to appropriate messaging page) - Show first name of admin who responded in conversation view - Clean build cache after middleware changes
This commit is contained in:
+252
-1
@@ -14,6 +14,9 @@ type Review = { id: number; author_name: string; author_email: string | null; ra
|
||||
type FooterConfig = { logo: string; favicon: string; tagline: string; facebook_url: string; instagram_url: string; whatsapp_url: string; address: string; phone: string; email: string };
|
||||
type SocialRes = { id: number; event_id: number; username: string; event_name: string; guests: number; notes: string; status: string; created_at: string };
|
||||
type Amenity = { id: number; name: string; icon_svg: string; sort_order: number };
|
||||
type RestaurantReservation = { id: number; user_id: number | null; date: string; time: string; guests: number; table_name: string; created_at: string; username?: string; first_name?: string; last_name?: string };
|
||||
type RoomReservation = { id: number; room_id: number; user_id: number | null; check_in: string; check_out: string; status: string; total_price: string; guest_name: string | null; guest_contact_method: string | null; guest_contact: string | null; created_at: string; room_name?: string; username?: string; first_name?: string; last_name?: string };
|
||||
type Order = { id: number; user_id: number | null; order_type: string; room_id: number | null; subtotal: string; service_fee: string; total: string; status: string; notes: string | null; created_at: string; room_name?: string; username?: string; first_name?: string; last_name?: string; items: { menu_item_id: number; name: string; quantity: number; unit_price: string; total_price: string }[] };
|
||||
|
||||
const C = {
|
||||
gold: '#D4A853', goldLight: 'rgba(212,168,83,0.12)', navy: '#010D1E',
|
||||
@@ -30,10 +33,13 @@ const NAV = [
|
||||
{ id: 'social', label: 'Public Events', icon: '🎪' },
|
||||
{ id: 'private-events', label: 'Private Events', icon: '🔒' },
|
||||
{ id: 'rooms', label: 'Rooms', icon: '🏨' },
|
||||
{ id: 'room-reservations', label: 'Room Bookings', icon: '🛏️' },
|
||||
{ id: 'restaurant-reservations', label: 'Restaurant', icon: '🍽️' },
|
||||
{ id: 'orders', label: 'Food Orders', icon: '📦' },
|
||||
{ id: 'amenities', label: 'Amenities', icon: '✨' },
|
||||
{ id: 'trips', label: 'Trips', icon: '✈️' },
|
||||
{ id: 'messages', label: 'Messages', icon: '💬' },
|
||||
{ id: 'menu', label: 'Menu', icon: '🍽️' },
|
||||
{ id: 'menu', label: 'Menu', icon: '📋' },
|
||||
{ id: 'places', label: 'Places', icon: '🗺️' },
|
||||
{ id: 'reviews', label: 'Reviews', icon: '⭐' },
|
||||
{ id: 'users', label: 'Users', icon: '👥' },
|
||||
@@ -1071,6 +1077,248 @@ function PrivateEventsSection({ onToast }: { onToast: (msg: string, type: 'succe
|
||||
);
|
||||
}
|
||||
|
||||
/* ─── Room Reservations Section ─── */
|
||||
function RoomReservationsSection({ onToast }: { onToast: (msg: string, type: 'success' | 'error') => void }) {
|
||||
const [reservations, setReservations] = useState<RoomReservation[]>([]);
|
||||
const [statusFilter, setStatusFilter] = useState<'pending' | 'confirmed' | 'cancelled' | 'all'>('pending');
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [confirmDel, setConfirmDel] = useState<number | null>(null);
|
||||
|
||||
useEffect(() => { loadReservations(); }, [statusFilter]);
|
||||
|
||||
const loadReservations = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const url = statusFilter === 'all' ? '/api/admin/room-reservations?status=all' : `/api/admin/room-reservations?status=${statusFilter}`;
|
||||
const res = await fetch(url);
|
||||
const data = await res.json();
|
||||
setReservations(data.reservations || []);
|
||||
} catch { onToast('Error loading reservations', 'error'); }
|
||||
setLoading(false);
|
||||
};
|
||||
|
||||
const updateStatus = async (id: number, status: 'confirmed' | 'cancelled') => {
|
||||
try {
|
||||
const res = await fetch('/api/admin/room-reservations', {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ id, status }),
|
||||
});
|
||||
if (res.ok) {
|
||||
setReservations(r => r.map(x => x.id === id ? { ...x, status } : x));
|
||||
onToast(`Reservation ${status}`, 'success');
|
||||
}
|
||||
} catch { onToast('Error updating reservation', 'error'); }
|
||||
};
|
||||
|
||||
const del = async (id: number) => {
|
||||
try {
|
||||
await fetch(`/api/admin/room-reservations?id=${id}`, { method: 'DELETE' });
|
||||
setReservations(r => r.filter(x => x.id !== id));
|
||||
onToast('Reservation deleted', 'success');
|
||||
} catch { onToast('Error deleting reservation', 'error'); }
|
||||
setConfirmDel(null);
|
||||
};
|
||||
|
||||
const formatDate = (d: string) => new Date(d).toLocaleDateString();
|
||||
const formatPrice = (p: string) => `$${parseFloat(p).toFixed(2)}`;
|
||||
|
||||
return (
|
||||
<div>
|
||||
<SecHead title="🛏️ Room Bookings" count={reservations.length} />
|
||||
<div style={{ display: 'flex', gap: '8px', marginBottom: '16px' }}>
|
||||
{(['pending', 'confirmed', 'cancelled', 'all'] as const).map(s => (
|
||||
<Btn key={s} size="sm" variant={statusFilter === s ? 'primary' : 'secondary'} onClick={() => setStatusFilter(s)}>
|
||||
{s.charAt(0).toUpperCase() + s.slice(1)}
|
||||
</Btn>
|
||||
))}
|
||||
</div>
|
||||
{loading ? <Empty icon="⏳" title="Loading..." /> : reservations.length === 0 ? <Empty icon="🛏️" title="No bookings" desc={`No ${statusFilter === 'all' ? '' : statusFilter} bookings`} /> : (
|
||||
<div style={{ display: 'grid', gap: '12px' }}>
|
||||
{reservations.map((r) => (
|
||||
<div key={r.id} style={{ background: '#fff', borderRadius: 12, padding: '16px', border: `1px solid ${r.status === 'pending' ? '#f59e0b' : r.status === 'confirmed' ? '#10b981' : '#ef4444'}` }}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', marginBottom: '12px' }}>
|
||||
<div>
|
||||
<div style={{ fontWeight: 600, fontSize: '16px', color: C.navy }}>{r.room_name || `Room #${r.room_id}`}</div>
|
||||
<div style={{ fontSize: '13px', color: '#666', marginTop: '4px' }}>
|
||||
{formatDate(r.check_in)} → {formatDate(r.check_out)} • {formatPrice(r.total_price)}
|
||||
</div>
|
||||
</div>
|
||||
<span style={{
|
||||
padding: '4px 10px', borderRadius: 12, fontSize: '12px', fontWeight: 600,
|
||||
background: r.status === 'confirmed' ? '#d1fae5' : r.status === 'cancelled' ? '#fee2e2' : '#fef3c7',
|
||||
color: r.status === 'confirmed' ? '#047857' : r.status === 'cancelled' ? '#b91c1c' : '#b45309'
|
||||
}}>{r.status}</span>
|
||||
</div>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '8px', fontSize: '13px', marginBottom: '12px' }}>
|
||||
<div><strong>Guest:</strong> {r.guest_name || r.username || r.first_name || 'N/A'}</div>
|
||||
{r.guest_contact && <div><strong>Contact:</strong> {r.guest_contact}</div>}
|
||||
{r.guest_contact_method && <div><strong>Method:</strong> {r.guest_contact_method}</div>}
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: '8px' }}>
|
||||
{r.status === 'pending' && (
|
||||
<>
|
||||
<Btn size="sm" variant="secondary" onClick={() => updateStatus(r.id, 'confirmed')}>✓ Confirm</Btn>
|
||||
<Btn size="sm" variant="danger" onClick={() => updateStatus(r.id, 'cancelled')}>✗ Cancel</Btn>
|
||||
</>
|
||||
)}
|
||||
{r.status === 'confirmed' && <Btn size="sm" variant="danger" onClick={() => updateStatus(r.id, 'cancelled')}>✗ Cancel</Btn>}
|
||||
{r.status === 'cancelled' && <Btn size="sm" variant="secondary" onClick={() => updateStatus(r.id, 'confirmed')}>✓ Reactivate</Btn>}
|
||||
<Btn size="sm" variant="danger" onClick={() => setConfirmDel(r.id)}>🗑 Delete</Btn>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{confirmDel !== null && <Confirm message="Delete this booking?" onCancel={() => setConfirmDel(null)} onConfirm={() => del(confirmDel)} />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ─── Restaurant Reservations Section ─── */
|
||||
function RestaurantReservationsSection({ onToast }: { onToast: (msg: string, type: 'success' | 'error') => void }) {
|
||||
const [reservations, setReservations] = useState<RestaurantReservation[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
useEffect(() => { loadReservations(); }, []);
|
||||
|
||||
const loadReservations = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await fetch('/api/admin/restaurant-reservations');
|
||||
const data = await res.json();
|
||||
setReservations(data.reservations || []);
|
||||
} catch { onToast('Error loading reservations', 'error'); }
|
||||
setLoading(false);
|
||||
};
|
||||
|
||||
const del = async (id: number) => {
|
||||
try {
|
||||
await fetch(`/api/admin/restaurant-reservations?id=${id}`, { method: 'DELETE' });
|
||||
setReservations(r => r.filter(x => x.id !== id));
|
||||
onToast('Reservation deleted', 'success');
|
||||
} catch { onToast('Error deleting reservation', 'error'); }
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<SecHead title="🍽️ Restaurant Reservations" count={reservations.length} />
|
||||
{loading ? <Empty icon="⏳" title="Loading..." /> : reservations.length === 0 ? <Empty icon="🍽️" title="No reservations" desc="No restaurant reservations yet" /> : (
|
||||
<div style={{ display: 'grid', gap: '12px' }}>
|
||||
{reservations.map((r) => (
|
||||
<div key={r.id} style={{ background: '#fff', borderRadius: 12, padding: '16px', border: `1px solid ${C.border}` }}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', marginBottom: '8px' }}>
|
||||
<div>
|
||||
<div style={{ fontWeight: 600, fontSize: '16px', color: C.navy }}>{r.table_name}</div>
|
||||
<div style={{ fontSize: '13px', color: '#666', marginTop: '4px' }}>
|
||||
{formatDisplayDate(r.date)} at {r.time} • {r.guests} guests
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ fontSize: '12px', color: C.textLight }}>{formatDisplayDateTime(r.created_at)}</div>
|
||||
</div>
|
||||
<div style={{ fontSize: '13px', color: '#666', marginBottom: '8px' }}>
|
||||
<strong>Guest:</strong> {r.username || r.first_name || 'Guest'}
|
||||
</div>
|
||||
<Btn size="sm" variant="danger" onClick={() => del(r.id)}>🗑 Delete</Btn>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ─── Orders Section ─── */
|
||||
function OrdersSection({ onToast }: { onToast: (msg: string, type: 'success' | 'error') => void }) {
|
||||
const [orders, setOrders] = useState<Order[]>([]);
|
||||
const [statusFilter, setStatusFilter] = useState<'pending' | 'preparing' | 'ready' | 'delivered' | 'cancelled' | 'all'>('pending');
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
useEffect(() => { loadOrders(); }, [statusFilter]);
|
||||
|
||||
const loadOrders = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const url = statusFilter === 'all' ? '/api/orders?status=all' : `/api/orders?status=${statusFilter}`;
|
||||
const res = await fetch(url);
|
||||
const data = await res.json();
|
||||
setOrders(data.orders || []);
|
||||
} catch { onToast('Error loading orders', 'error'); }
|
||||
setLoading(false);
|
||||
};
|
||||
|
||||
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) {
|
||||
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' : s === 'ready' ? '#10b981' : s === 'delivered' ? '#6b7280' : '#ef4444';
|
||||
|
||||
return (
|
||||
<div>
|
||||
<SecHead title="📦 Food Orders" count={orders.length} />
|
||||
<div style={{ display: 'flex', gap: '8px', marginBottom: '16px', flexWrap: 'wrap' }}>
|
||||
{(['pending', 'preparing', 'ready', 'delivered', 'cancelled', 'all'] as const).map(s => (
|
||||
<Btn key={s} size="sm" variant={statusFilter === s ? 'primary' : 'secondary'} onClick={() => setStatusFilter(s)}>
|
||||
{s.charAt(0).toUpperCase() + s.slice(1)}
|
||||
</Btn>
|
||||
))}
|
||||
</div>
|
||||
{loading ? <Empty icon="⏳" title="Loading..." /> : orders.length === 0 ? <Empty icon="📦" title="No orders" desc={`No ${statusFilter === 'all' ? '' : statusFilter} orders`} /> : (
|
||||
<div style={{ display: 'grid', gap: '12px' }}>
|
||||
{orders.map((o) => (
|
||||
<div key={o.id} style={{ background: '#fff', borderRadius: 12, padding: '16px', border: `1px solid ${statusColor(o.status)}` }}>
|
||||
<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>
|
||||
</div>
|
||||
<span style={{
|
||||
padding: '4px 10px', borderRadius: 12, fontSize: '12px', fontWeight: 600,
|
||||
background: `${statusColor(o.status)}20`, color: statusColor(o.status)
|
||||
}}>{o.status}</span>
|
||||
</div>
|
||||
<div style={{ marginBottom: '12px' }}>
|
||||
{o.items?.map((item, i) => (
|
||||
<div key={i} style={{ fontSize: '13px', color: '#666', display: 'flex', justifyContent: 'space-between' }}>
|
||||
<span>{item.quantity}x {item.name}</span>
|
||||
<span>{formatPrice(item.total_price)}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
{o.notes && <div style={{ fontSize: '13px', color: '#666', fontStyle: 'italic', marginBottom: '12px' }}>"{o.notes}"</div>}
|
||||
<div style={{ display: 'flex', gap: '8px', flexWrap: 'wrap' }}>
|
||||
{o.status === 'pending' && (
|
||||
<>
|
||||
<Btn size="sm" variant="secondary" onClick={() => updateStatus(o.id, 'preparing')}>🍳 Start Preparing</Btn>
|
||||
<Btn size="sm" variant="danger" onClick={() => updateStatus(o.id, 'cancelled')}>✗ Cancel</Btn>
|
||||
</>
|
||||
)}
|
||||
{o.status === 'preparing' && <Btn size="sm" variant="secondary" onClick={() => updateStatus(o.id, 'ready')}>✓ Ready</Btn>}
|
||||
{o.status === 'ready' && <Btn size="sm" variant="secondary" onClick={() => updateStatus(o.id, 'delivered')}>✓ Delivered</Btn>}
|
||||
</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);
|
||||
@@ -2753,6 +3001,9 @@ 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-reservations': return <RoomReservationsSection onToast={showToast} />;
|
||||
case 'restaurant-reservations': return <RestaurantReservationsSection onToast={showToast} />;
|
||||
case 'orders': return <OrdersSection 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