4e514c2fbb
- Added is_private and max_guests columns to social_events table - Updated SocialEvent type with new fields - Modified /api/social_events to include private events for admins - Merged Public/Private Events into single 'Events' section with tabs - SocialEditor now supports private event creation with max_guests field - Public frontend still only sees public events
3761 lines
192 KiB
TypeScript
3761 lines
192 KiB
TypeScript
'use client';
|
||
|
||
import { useState, useEffect, useRef, useCallback } from 'react';
|
||
import { formatDisplayDate, formatDisplayDateTime } from '@/lib/date';
|
||
|
||
type Image = { id: number; filename: string; data: string; category: string; room_id: number | null; location_target: string; uploaded_at: string };
|
||
type Slide = { id: number; title: string; subtitle: string; image_url: string | null; image_id: number | null; active: boolean; sort_order: number };
|
||
type Room = { id: number; name: string; description: string; price: string; photos: string[]; featured_photo: string | null; active: boolean; sort_order: number; amenity_ids?: number[] };
|
||
type CalendarEvent = { id: number; date: string; title: string; type: string; color: string; description: string | null; active: boolean };
|
||
type SocialEvent = { id: number; name: string; description: string; price: string | null; date: string | null; photos: string[]; featured_photo: string | null; active: boolean; is_private: boolean; max_guests: number | null; sort_order: number };
|
||
type MenuItem = { id: number; category: string; name: string; description: string; price: string; is_daily_special: boolean; active: boolean; sort_order: number; photos: string[]; featured_photo: string | null };
|
||
type Place = { id: number; name: string; description: string; photos: string[]; featured_photo: string | null; distance_km: string; visit_time: string; suggestion: string; active: boolean; sort_order: number };
|
||
type Review = { id: number; author_name: string; author_email: string | null; rating: number; text: string; approved: boolean };
|
||
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',
|
||
warm: '#7A5A3A', bg: '#F7F4EF', card: '#FFFFFF', border: 'rgba(1,13,30,0.08)',
|
||
text: '#444444', textLight: '#888888', danger: '#D32F2F', success: '#388E3C',
|
||
shadow: '0 1px 4px rgba(1,13,30,0.06)', shadowHover: '0 6px 20px rgba(1,13,30,0.12)',
|
||
};
|
||
|
||
const NAV = [
|
||
{ id: 'brand', label: 'Brand', icon: '🎨' },
|
||
{ id: 'gallery', label: 'Images', icon: '🖼️' },
|
||
{ id: 'slides', label: 'Slideshow', icon: '🎞️' },
|
||
{ id: 'calendar', label: 'Calendar', icon: '📅' },
|
||
{ id: 'social', label: '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: '💬' },
|
||
{ id: 'menu', label: 'Menu', icon: '📋' },
|
||
{ id: 'places', label: 'Places', icon: '🗺️' },
|
||
{ id: 'reviews', label: 'Reviews', icon: '⭐' },
|
||
{ id: 'users', label: 'Users', icon: '👥' },
|
||
{ id: 'terms', label: 'Terms', icon: '📜' },
|
||
{ id: 'weather', label: 'Weather', icon: '🌤️' },
|
||
];
|
||
|
||
function I({ label, value, onChange, type = 'text', placeholder, style }: any) {
|
||
return (
|
||
<div style={{ display: 'flex', flexDirection: 'column', gap: '4px' }}>
|
||
{label && <label style={{ fontSize: '13px', fontWeight: 600, color: C.text }}>{label}</label>}
|
||
<input type={type} value={value ?? ''} onChange={e => onChange(e.target.value)} placeholder={placeholder}
|
||
style={{ padding: '8px 12px', borderRadius: 8, border: `1px solid ${C.border}`, fontSize: '14px', outline: 'none', background: '#FAFAFA', ...style }}
|
||
onFocus={e => { (e.target.style as any).borderColor = C.gold; (e.target.style as any).background = '#FFFFFF'; }}
|
||
onBlur={e => { (e.target.style as any).borderColor = C.border; (e.target.style as any).background = '#FAFAFA'; }} />
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function TA({ label, value, onChange, rows = 3 }: any) {
|
||
return (
|
||
<div style={{ display: 'flex', flexDirection: 'column', gap: '4px' }}>
|
||
{label && <label style={{ fontSize: '13px', fontWeight: 600, color: C.text }}>{label}</label>}
|
||
<textarea value={value ?? ''} onChange={e => onChange(e.target.value)} rows={rows}
|
||
style={{ padding: '8px 12px', borderRadius: 8, border: `1px solid ${C.border}`, fontSize: '14px', outline: 'none', background: '#FAFAFA', resize: 'vertical', fontFamily: 'inherit' }}
|
||
onFocus={e => { (e.target.style as any).borderColor = C.gold; (e.target.style as any).background = '#FFFFFF'; }}
|
||
onBlur={e => { (e.target.style as any).borderColor = C.border; (e.target.style as any).background = '#FAFAFA'; }} />
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function S({ label, value, onChange, options, style }: any) {
|
||
return (
|
||
<div style={{ display: 'flex', flexDirection: 'column', gap: '4px' }}>
|
||
{label && <label style={{ fontSize: '13px', fontWeight: 600, color: C.text }}>{label}</label>}
|
||
<select value={value ?? ''} onChange={e => onChange(e.target.value)}
|
||
style={{ padding: '8px 12px', borderRadius: 8, border: `1px solid ${C.border}`, fontSize: '14px', outline: 'none', background: '#FAFAFA' }}>
|
||
{options.map((o: any) => <option key={o.value} value={o.value}>{o.label}</option>)}
|
||
</select>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function Tgl({ label, checked, onChange }: any) {
|
||
return (
|
||
<label style={{ display: 'flex', alignItems: 'center', gap: '10px', cursor: 'pointer', fontSize: '14px', color: C.text }}>
|
||
<input type="checkbox" checked={checked} onChange={e => onChange(e.target.checked)}
|
||
style={{ width: 18, height: 18, accentColor: C.gold, cursor: 'pointer' }} />
|
||
{label}
|
||
</label>
|
||
);
|
||
}
|
||
|
||
function Btn({ children, onClick, variant = 'primary', size = 'md', disabled }: any) {
|
||
const sz = size === 'sm' ? { padding: '5px 10px', fontSize: '12px' } : { padding: '8px 16px', fontSize: '14px' };
|
||
const bg = variant === 'danger' ? C.danger : variant === 'secondary' ? '#E8E4DF' : C.gold;
|
||
const color = variant === 'secondary' ? C.text : '#fff';
|
||
return (
|
||
<button onClick={onClick} disabled={disabled} style={{ ...sz, borderRadius: 8, border: 'none', cursor: disabled ? 'default' : 'pointer',
|
||
background: bg, color, fontWeight: 600, opacity: disabled ? 0.5 : 1, transition: 'all 200ms' }}
|
||
onMouseEnter={e => { if (!disabled) { e.currentTarget.style.opacity = '0.85'; e.currentTarget.style.transform = 'translateY(-1px)'; } }}
|
||
onMouseLeave={e => { if (!disabled) { e.currentTarget.style.opacity = '1'; e.currentTarget.style.transform = 'translateY(0)'; } }}>
|
||
{children}
|
||
</button>
|
||
);
|
||
}
|
||
|
||
function Badge({ children, color: c }: any) {
|
||
return (
|
||
<span style={{ padding: '2px 10px', borderRadius: 20, fontSize: '12px', fontWeight: 600,
|
||
background: c || C.goldLight, color: c ? '#fff' : C.gold }}>
|
||
{children}
|
||
</span>
|
||
);
|
||
}
|
||
|
||
function Toast({ message, type, onClose }: any) {
|
||
useEffect(() => { const t = setTimeout(onClose, 3500); return () => clearTimeout(t); }, [onClose]);
|
||
return (
|
||
<div style={{ position: 'fixed', top: '20px', right: '20px', padding: '12px 20px', borderRadius: 12,
|
||
background: type === 'success' ? C.success : type === 'error' ? C.danger : C.gold,
|
||
color: '#fff', boxShadow: C.shadowHover, zIndex: 9999, fontSize: '14px', fontWeight: 500,
|
||
display: 'flex', alignItems: 'center', gap: '10px' }}>
|
||
{message}
|
||
<button onClick={onClose} style={{ background: 'transparent', border: 'none', color: '#fff', cursor: 'pointer', fontSize: '18px', lineHeight: 1 }}>×</button>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function StatCard({ icon, value, label }: any) {
|
||
return (
|
||
<div style={{ background: C.card, borderRadius: 14, padding: '16px 20px', boxShadow: C.shadow,
|
||
display: 'flex', alignItems: 'center', gap: '14px', minWidth: '160px' }}>
|
||
<span style={{ fontSize: '28px' }}>{icon}</span>
|
||
<div>
|
||
<div style={{ fontSize: '22px', fontWeight: 700, color: C.navy }}>{value}</div>
|
||
<div style={{ fontSize: '12px', color: C.textLight }}>{label}</div>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function Empty({ icon, title, desc }: any) {
|
||
return (
|
||
<div style={{ textAlign: 'center', padding: '40px 20px', color: C.textLight }}>
|
||
<div style={{ fontSize: '48px', marginBottom: '8px' }}>{icon}</div>
|
||
<div style={{ fontSize: '16px', fontWeight: 600, color: C.text, marginBottom: '4px' }}>{title}</div>
|
||
{desc && <div style={{ fontSize: '13px' }}>{desc}</div>}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
/* ─── WebP Migration ─── */
|
||
function WebPMigration({ onToast, onRefresh }: { onToast: (msg: string, type: string) => void; onRefresh: () => void }) {
|
||
const [migrating, setMigrating] = useState(false);
|
||
const [stats, setStats] = useState<{ totalImages: number; totalSizeMB: number } | null>(null);
|
||
|
||
useEffect(() => {
|
||
fetch('/api/admin/migrate-images')
|
||
.then(res => res.ok ? res.json() : null)
|
||
.then(data => data && setStats({ totalImages: data.totalImages, totalSizeMB: data.totalSizeMB }))
|
||
.catch(() => {});
|
||
}, []);
|
||
|
||
const runMigration = async () => {
|
||
setMigrating(true);
|
||
try {
|
||
const res = await fetch('/api/admin/migrate-images', { method: 'POST' });
|
||
const data = await res.json();
|
||
if (res.ok) {
|
||
onToast(`Converted ${data.converted} images, saved ${data.totalSavedMB} MB`, 'success');
|
||
onRefresh();
|
||
} else {
|
||
onToast(data.error || 'Migration failed', 'error');
|
||
}
|
||
} catch {
|
||
onToast('Migration failed', 'error');
|
||
}
|
||
setMigrating(false);
|
||
};
|
||
|
||
return (
|
||
<div style={{
|
||
marginBottom: '16px',
|
||
padding: '12px 16px',
|
||
background: '#fff',
|
||
borderRadius: '12px',
|
||
border: `1px solid ${C.border}`,
|
||
display: 'flex',
|
||
alignItems: 'center',
|
||
justifyContent: 'space-between',
|
||
flexWrap: 'wrap',
|
||
gap: '12px',
|
||
}}>
|
||
<div>
|
||
<div style={{ fontWeight: 600, color: C.navy, fontSize: '14px' }}>🔄 WebP Migration</div>
|
||
<div style={{ fontSize: '12px', color: C.textLight }}>
|
||
{stats ? `${stats.totalImages} images · ${stats.totalSizeMB} MB total` : 'Checking...'}
|
||
</div>
|
||
</div>
|
||
<Btn onClick={runMigration} disabled={migrating} size="sm">
|
||
{migrating ? 'Migrating...' : 'Convert to WebP'}
|
||
</Btn>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function SecHead({ title, count, action }: any) {
|
||
return (
|
||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '16px', flexWrap: 'wrap', gap: '10px' }}>
|
||
<h2 style={{ margin: 0, fontSize: '20px', fontWeight: 700, color: C.navy }}>
|
||
{title} <span style={{ fontSize: '14px', fontWeight: 400, color: C.textLight }}>({count})</span>
|
||
</h2>
|
||
{action}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
/* ─── Image helpers ─── */
|
||
function readFileBase64(file: File): Promise<string> {
|
||
return new Promise((res, rej) => {
|
||
const r = new FileReader();
|
||
r.onload = () => res(r.result as string);
|
||
r.onerror = rej;
|
||
r.readAsDataURL(file);
|
||
});
|
||
}
|
||
|
||
/* ─── Entity thumbnail ─── */
|
||
function Thumb({ urls, w = 200, h = 140 }: { urls: string[]; w?: number; h?: number }) {
|
||
if (!urls?.length) return <div style={{ width: w, height: h, background: '#EEE', borderRadius: 12, display: 'flex', alignItems: 'center', justifyContent: 'center', color: '#BBB' }}>No image</div>;
|
||
return <img src={urls[0]} alt="" style={{ width: w, height: h, objectFit: 'cover', borderRadius: 12 }} />;
|
||
}
|
||
|
||
/* ─── Thumbnail grid (inline, small) ─── */
|
||
function MiniThumbs({ urls, max = 3 }: { urls: string[]; max?: number }) {
|
||
if (!urls?.length) return null;
|
||
return (
|
||
<div style={{ display: 'flex', gap: '6px', marginTop: '8px' }}>
|
||
{urls.slice(0, max).map((u, i) => (
|
||
<img key={i} src={u} alt="" style={{ width: 70, height: 50, objectFit: 'cover', borderRadius: 8, cursor: 'pointer', border: '1px solid #eee' }} />
|
||
))}
|
||
{urls.length > max && <span style={{ padding: '8px', background: '#EEE', borderRadius: 8, fontSize: '12px', color: '#999' }}>+{urls.length - max}</span>}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
/* ─── Section uploader (per-section image upload) ─── */
|
||
function SectionUploader({ category, room_id, label, onUploaded }: { category: string; room_id: number | null; label: string; onUploaded?: () => void }) {
|
||
const ref = useRef<HTMLInputElement>(null);
|
||
return (
|
||
<>
|
||
<Btn onClick={() => ref.current?.click()} size="sm">{label}</Btn>
|
||
<input ref={ref} type="file" accept="image/*" multiple style={{ display: 'none' }}
|
||
onChange={async (e) => {
|
||
for (const file of Array.from(e.target.files || [])) {
|
||
const b64 = await readFileBase64(file);
|
||
try {
|
||
const res = await fetch('/api/admin/images', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ filename: file.name, data: b64, category, room_id, location_target: category }),
|
||
});
|
||
if (res.ok) onUploaded?.();
|
||
} catch (err) { console.error(err); }
|
||
}
|
||
e.target.value = '';
|
||
}} />
|
||
</>
|
||
);
|
||
}
|
||
|
||
/* ─── Organized image gallery with sections and borders ─── */
|
||
function ImageGallery({ images, onDelete }: { images: Image[]; onDelete: (id: number) => void }) {
|
||
const [selectedCategory, setSelectedCategory] = useState<string>('all');
|
||
const [hoveredCard, setHoveredCard] = useState<number | null>(null);
|
||
|
||
const sectionCategories = [
|
||
{ key: 'slides', label: '🎞️ Slideshow Gallery', filter: (i: Image) => i.category === 'slides' || i.location_target === 'slides' },
|
||
{ key: 'food', label: '🍽️ Food Gallery', filter: (i: Image) => i.category === 'food' || i.location_target === 'food' },
|
||
{ key: 'spa', label: '🧖 Spa Gallery', filter: (i: Image) => i.category === 'spa' || i.location_target === 'spa' },
|
||
{ key: 'pool', label: '🏊 Pool Gallery', filter: (i: Image) => i.category === 'pool' || i.location_target === 'pool' },
|
||
{ key: 'calendar', label: '📅 Calendar Events Gallery', filter: (i: Image) => i.category === 'calendar' || i.location_target === 'calendar' },
|
||
{ key: 'public', label: '🎪 Public Events Gallery', filter: (i: Image) => i.category === 'public' || i.location_target === 'public' || i.category === 'social' || i.location_target === 'social' },
|
||
{ key: 'private', label: '🔒 Private Events Gallery', filter: (i: Image) => i.category === 'private' || i.location_target === 'private' },
|
||
];
|
||
|
||
// Group room images by room location_target (e.g. "room-1", "room-2")
|
||
const roomImages = images.filter(
|
||
(i) => i.category === 'rooms' || i.location_target?.startsWith('room-')
|
||
);
|
||
const roomsByNumber: Record<string, Image[]> = roomImages.reduce((acc, img) => {
|
||
const key = img.location_target?.replace('room-', '') || 'uncategorized';
|
||
if (!acc[key]) acc[key] = [];
|
||
acc[key].push(img);
|
||
return acc;
|
||
}, {} as Record<string, Image[]>);
|
||
|
||
const filteredImages = images.filter((img) => {
|
||
if (selectedCategory === 'all') return true;
|
||
if (selectedCategory === 'rooms') return img.category === 'rooms' || img.location_target?.startsWith('room-');
|
||
if (selectedCategory === 'food') return img.category === 'food' || img.location_target === 'food';
|
||
if (selectedCategory === 'spa') return img.category === 'spa' || img.location_target === 'spa';
|
||
if (selectedCategory === 'pool') return img.category === 'pool' || img.location_target === 'pool';
|
||
if (selectedCategory === 'public') return img.category === 'public' || img.location_target === 'public' || img.category === 'social' || img.location_target === 'social';
|
||
if (selectedCategory === 'private') return img.category === 'private' || img.location_target === 'private';
|
||
return img.category === selectedCategory || img.location_target === selectedCategory;
|
||
});
|
||
|
||
const ImageCard = ({ img }: { img: Image }) => {
|
||
const isHov = hoveredCard === img.id;
|
||
return (
|
||
<div
|
||
style={{
|
||
position: 'relative', borderRadius: '10px', overflow: 'hidden',
|
||
boxShadow: isHov ? C.shadowHover : C.shadow,
|
||
border: `1px solid ${isHov ? C.gold : C.border}`,
|
||
transform: isHov ? 'translateY(-2px)' : 'none',
|
||
transition: 'all 200ms ease',
|
||
cursor: 'default',
|
||
}}
|
||
onMouseEnter={() => setHoveredCard(img.id)}
|
||
onMouseLeave={() => setHoveredCard(null)}
|
||
>
|
||
<img
|
||
src={img.data}
|
||
alt={img.filename}
|
||
style={{
|
||
width: '100%', height: '110px', objectFit: 'cover', display: 'block',
|
||
}}
|
||
/>
|
||
<div style={{
|
||
padding: '6px 8px', fontSize: '11px', color: C.text,
|
||
whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis',
|
||
background: C.card,
|
||
}}>
|
||
{img.filename}
|
||
</div>
|
||
<div style={{ display: 'flex', justifyContent: 'flex-end', padding: '4px' }}>
|
||
<button
|
||
onClick={(e) => { e.stopPropagation(); onDelete(img.id); }}
|
||
style={{
|
||
width: '24px', height: '24px', borderRadius: '6px',
|
||
border: '1px solid rgba(211,47,47,0.2)',
|
||
background: isHov ? 'rgba(211,47,47,0.15)' : 'transparent',
|
||
color: C.danger, fontSize: '14px', cursor: 'pointer',
|
||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||
transition: 'all 200ms',
|
||
}}
|
||
>
|
||
×
|
||
</button>
|
||
</div>
|
||
{/* Category label overlay on hover */}
|
||
{isHov && img.location_target && (
|
||
<div style={{
|
||
position: 'absolute', bottom: '30px', left: '4px', right: '4px',
|
||
padding: '3px 6px', fontSize: '10px', color: '#fff',
|
||
background: 'rgba(1,13,30,0.75)', borderRadius: '4px',
|
||
whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis',
|
||
textAlign: 'center',
|
||
}}>
|
||
{img.location_target}
|
||
</div>
|
||
)}
|
||
</div>
|
||
);
|
||
};
|
||
|
||
const SectionBox = ({ title, count, children }: { title: string; count: number; children: React.ReactNode }) => (
|
||
<div style={{
|
||
borderRadius: '12px', overflow: 'hidden',
|
||
border: `1px solid ${C.border}`, background: '#fff',
|
||
boxShadow: C.shadow,
|
||
}}>
|
||
<div style={{
|
||
padding: '10px 16px',
|
||
background: C.goldLight,
|
||
borderBottom: `1px solid ${C.border}`,
|
||
fontWeight: 700, color: C.navy, fontSize: '14px',
|
||
display: 'flex', alignItems: 'center', justifyContent: 'space-between',
|
||
}}>
|
||
<span>{title}</span>
|
||
<span style={{
|
||
fontSize: '11px', padding: '2px 10px', borderRadius: '999px',
|
||
background: C.success, color: '#fff', fontWeight: 600,
|
||
}}>{count}</span>
|
||
</div>
|
||
<div style={{ padding: '16px' }}>
|
||
{children}
|
||
</div>
|
||
</div>
|
||
);
|
||
|
||
return (
|
||
<div>
|
||
{/* Category filter bar with borders */}
|
||
<div style={{
|
||
display: 'flex', gap: '8px', marginBottom: '20px', flexWrap: 'wrap',
|
||
padding: '12px 16px', borderRadius: '12px',
|
||
border: `1px solid ${C.border}`, background: '#fff',
|
||
boxShadow: C.shadow,
|
||
}}>
|
||
{['all', 'slides', 'food', 'spa', 'pool', 'calendar', 'public', 'private', 'rooms', 'other'].map((cat) => (
|
||
<Btn
|
||
key={cat}
|
||
onClick={() => setSelectedCategory(cat)}
|
||
variant={selectedCategory === cat ? 'primary' : 'secondary'}
|
||
size="sm"
|
||
>
|
||
{cat === 'all' ? 'All Images' : cat.charAt(0).toUpperCase() + cat.slice(1)}
|
||
</Btn>
|
||
))}
|
||
</div>
|
||
|
||
{selectedCategory === 'all' ? (
|
||
<div style={{ display: 'flex', flexDirection: 'column', gap: '20px' }}>
|
||
{/* Static sections */}
|
||
{sectionCategories.map((section) => {
|
||
const imgs = images.filter(section.filter);
|
||
if (imgs.length === 0) return null;
|
||
return (
|
||
<SectionBox key={section.key} title={section.label} count={imgs.length}>
|
||
<div style={{
|
||
display: 'grid',
|
||
gridTemplateColumns: 'repeat(auto-fill, minmax(140px, 1fr))',
|
||
gap: '12px',
|
||
}}>
|
||
{imgs.map((img) => <ImageCard key={img.id} img={img} />)}
|
||
</div>
|
||
</SectionBox>
|
||
);
|
||
})}
|
||
|
||
{/* Rooms section with room number subdivision */}
|
||
{Object.keys(roomsByNumber).length > 0 && (
|
||
<SectionBox title="🏨 Rooms Gallery" count={roomImages.length}>
|
||
<div style={{ display: 'flex', flexDirection: 'column', gap: '16px' }}>
|
||
{Object.entries(roomsByNumber).map(([roomKey, imgs]) => (
|
||
<div key={roomKey}>
|
||
<div style={{
|
||
display: 'flex', alignItems: 'center', gap: '8px',
|
||
marginBottom: '10px', padding: '8px 12px',
|
||
background: '#FAFAFA', borderRadius: '8px',
|
||
border: `1px solid ${C.border}`,
|
||
}}>
|
||
<span style={{ fontSize: '13px', fontWeight: 600, color: C.navy }}>
|
||
🛏️ Room {roomKey}
|
||
</span>
|
||
<span style={{
|
||
fontSize: '11px', padding: '2px 8px', borderRadius: '999px',
|
||
background: C.goldLight, color: C.gold, fontWeight: 600,
|
||
}}>{imgs.length} photo{imgs.length !== 1 ? 's' : ''}</span>
|
||
</div>
|
||
<div style={{
|
||
display: 'grid',
|
||
gridTemplateColumns: 'repeat(auto-fill, minmax(140px, 1fr))',
|
||
gap: '12px',
|
||
}}>
|
||
{imgs.map((img) => <ImageCard key={img.id} img={img} />)}
|
||
</div>
|
||
</div>
|
||
))}
|
||
</div>
|
||
</SectionBox>
|
||
)}
|
||
|
||
{/* Uncategorized/other */}
|
||
{(() => {
|
||
const otherImages = images.filter(
|
||
(i) => !sectionCategories.some(s => s.filter(i)) &&
|
||
!(i.category === 'rooms' || i.location_target?.startsWith('room-'))
|
||
);
|
||
if (otherImages.length === 0) return null;
|
||
return (
|
||
<SectionBox title="📁 Uncategorized" count={otherImages.length}>
|
||
<div style={{
|
||
display: 'grid',
|
||
gridTemplateColumns: 'repeat(auto-fill, minmax(140px, 1fr))',
|
||
gap: '12px',
|
||
}}>
|
||
{otherImages.map((img) => <ImageCard key={img.id} img={img} />)}
|
||
</div>
|
||
</SectionBox>
|
||
);
|
||
})()}
|
||
</div>
|
||
) : (
|
||
<SectionBox title={
|
||
selectedCategory === 'rooms' ? '🏨 Rooms' :
|
||
selectedCategory === 'other' ? '📁 Other' :
|
||
selectedCategory.charAt(0).toUpperCase() + selectedCategory.slice(1)
|
||
} count={filteredImages.length}>
|
||
{filteredImages.length === 0 ? (
|
||
<div style={{ padding: '40px 20px', textAlign: 'center', color: C.textLight }}>
|
||
No images in this category
|
||
</div>
|
||
) : (
|
||
<div style={{
|
||
display: 'grid',
|
||
gridTemplateColumns: 'repeat(auto-fill, minmax(140px, 1fr))',
|
||
gap: '12px',
|
||
}}>
|
||
{filteredImages.map((img) => <ImageCard key={img.id} img={img} />)}
|
||
</div>
|
||
)}
|
||
</SectionBox>
|
||
)}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
/* ─── Entity card ─── */
|
||
|
||
/* ─── Entity card ─── */
|
||
function EntityCard({ title, desc, meta, thumb, actions, photoUrls }: any) {
|
||
return (
|
||
<div style={{ background: C.card, borderRadius: 16, overflow: 'hidden', boxShadow: C.shadow, transition: 'all 200ms', border: `1px solid ${C.border}` }}
|
||
onMouseEnter={e => { e.currentTarget.style.transform = 'translateY(-2px)'; e.currentTarget.style.boxShadow = C.shadowHover; }}
|
||
onMouseLeave={e => { e.currentTarget.style.transform = 'translateY(0)'; e.currentTarget.style.boxShadow = C.shadow; }}>
|
||
{thumb ? (
|
||
<img src={thumb} alt="" style={{ width: '100%', height: 140, objectFit: 'cover' }} />
|
||
) : photoUrls?.length ? <Thumb urls={photoUrls} /> : <div style={{ height: 100, background: '#EEE', display: 'flex', alignItems: 'center', justifyContent: 'center', color: '#CCC' }}>📷</div>}
|
||
<div style={{ padding: '14px 16px' }}>
|
||
<div style={{ fontWeight: 700, color: C.navy, fontSize: '15px', marginBottom: '4px' }}>{title}</div>
|
||
{desc && <div style={{ fontSize: '13px', color: C.text, lineHeight: 1.4, marginBottom: '6px', display: '-webkit-box', WebkitLineClamp: 2, WebkitBoxOrient: 'vertical', overflow: 'hidden' }}>{desc}</div>}
|
||
{meta && <div style={{ fontSize: '12px', color: C.textLight, marginBottom: '10px' }}>{meta}</div>}
|
||
{photoUrls?.length ? <MiniThumbs urls={photoUrls} /> : null}
|
||
{actions && <div style={{ display: 'flex', gap: '8px', marginTop: '12px' }}>{actions}</div>}
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
/* ─── Confirm dialog ─── */
|
||
function Confirm({ message, onConfirm, onCancel }: any) {
|
||
return (
|
||
<div style={{ position: 'fixed', top: 0, left: 0, right: 0, bottom: 0, background: 'rgba(0,0,0,0.4)', display: 'flex', alignItems: 'center', justifyContent: 'center', zIndex: 9998 }}>
|
||
<div style={{ background: '#FFFFFF', borderRadius: 16, padding: '24px 28px', maxWidth: 360, width: '90%', boxShadow: '0 20px 60px rgba(0,0,0,0.2)' }}>
|
||
<p style={{ margin: '0 0 20px', fontSize: '15px', color: C.text }}>{message}</p>
|
||
<div style={{ display: 'flex', gap: '10px', justifyContent: 'flex-end' }}>
|
||
<Btn onClick={onCancel} variant="secondary">Cancel</Btn>
|
||
<Btn onClick={onConfirm} variant="danger">Delete</Btn>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
/* ─── Slides Section ─── */
|
||
function SlidesSection({ slides, setSlides, images, onToast, onRefreshImages }: any) {
|
||
const [editing, setEditing] = useState<Slide | null>(null);
|
||
const [confirmDel, setConfirmDel] = useState<number | null>(null);
|
||
|
||
const saveSlide = async (slide: Slide) => {
|
||
try {
|
||
// If image_id is set, clear image_url (image is in images table)
|
||
const payload = {
|
||
...slide,
|
||
image_url: slide.image_id ? null : slide.image_url
|
||
};
|
||
const url = payload.id ? `/api/slides/${payload.id}` : '/api/slides';
|
||
const res = await fetch(url, {
|
||
method: payload.id ? 'PUT' : 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify(payload),
|
||
});
|
||
if (res.ok) {
|
||
const data = await res.json();
|
||
setEditing(null);
|
||
setSlides((s: any) => payload.id ? s.map((x: Slide) => x.id === payload.id ? data.data : x) : [...s, data.data]);
|
||
onToast('Slide saved!', 'success');
|
||
} else {
|
||
const err = await res.json();
|
||
console.error('Slide save error:', err);
|
||
onToast(err.error || 'Error saving slide', 'error');
|
||
}
|
||
} catch (err) {
|
||
console.error('Slide save exception:', err);
|
||
onToast('Error saving slide', 'error');
|
||
}
|
||
};
|
||
|
||
const delSlide = async (id: number) => {
|
||
try {
|
||
await fetch(`/api/slides/${id}`, { method: 'DELETE' });
|
||
setSlides((s: any) => s.filter((x: Slide) => x.id !== id));
|
||
onToast('Slide deleted', 'success');
|
||
} catch { onToast('Error deleting slide', 'error'); }
|
||
setConfirmDel(null);
|
||
};
|
||
|
||
return (
|
||
<div>
|
||
<SecHead title="🎞️ Slideshow" count={slides.length}
|
||
action={<Btn onClick={() => setEditing({ id: 0, title: '', subtitle: '', image_url: null, image_id: null, active: true, sort_order: slides.length })} size="sm">+ New Slide</Btn>} />
|
||
{slides.length === 0 ? <Empty icon="🎞️" title="No slides yet" /> : (
|
||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(280px, 1fr))', gap: '16px' }}>
|
||
{slides.map((s: Slide) => (
|
||
<EntityCard key={s.id} title={s.title} desc={s.subtitle} meta={s.active ? 'Active' : 'Inactive'}
|
||
thumb={s.image_url || s.image_id ? (images.find((i: Image) => i.id === s.image_id)?.data) : null}
|
||
actions={
|
||
<>
|
||
<Btn size="sm" variant="secondary" onClick={() => setEditing(s)}>Edit</Btn>
|
||
<Btn size="sm" variant="danger" onClick={() => setConfirmDel(s.id)}>Del</Btn>
|
||
</>
|
||
} />
|
||
))}
|
||
</div>
|
||
)}
|
||
{editing && (
|
||
<SlideEditor slide={editing} setSlide={setEditing} images={images} onRefreshImages={onRefreshImages} onSave={saveSlide} onCancel={() => setEditing(null)} />
|
||
)}
|
||
{confirmDel !== null && <Confirm message="Delete this slide?" onCancel={() => setConfirmDel(null)} onConfirm={() => delSlide(confirmDel)} />}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function SlideEditor({ slide, setSlide, images, onRefreshImages, onSave, onCancel }: any) {
|
||
const [showPicker, setShowPicker] = useState(false);
|
||
const [uploading, setUploading] = useState(false);
|
||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||
|
||
const handleUpload = async (files: FileList) => {
|
||
setUploading(true);
|
||
try {
|
||
for (const file of Array.from(files)) {
|
||
const b64 = await readFileBase64(file);
|
||
const res = await fetch('/api/admin/images', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ filename: file.name, data: b64, category: 'slides', room_id: null, location_target: 'slides' }),
|
||
});
|
||
if (res.ok) {
|
||
const data = await res.json();
|
||
await onRefreshImages?.();
|
||
setSlide((prev: any) => ({ ...prev, image_id: data.data.id, image_url: data.data.data }));
|
||
} else {
|
||
const err = await res.json();
|
||
console.error('Image upload error:', err);
|
||
alert('Upload failed: ' + (err.error || 'Unknown error'));
|
||
}
|
||
}
|
||
} catch (err) {
|
||
console.error('Upload failed:', err);
|
||
alert('Upload failed');
|
||
} finally {
|
||
setUploading(false);
|
||
}
|
||
};
|
||
|
||
const s = slide;
|
||
return (
|
||
<div style={{ position: 'fixed', top: 0, left: 0, right: 0, bottom: 0, background: 'rgba(0,0,0,0.4)', display: 'flex', alignItems: 'center', justifyContent: 'center', zIndex: 9998 }}>
|
||
<div style={{ background: '#FFFFFF', borderRadius: 16, padding: '24px 28px', maxWidth: 500, width: '90%', maxHeight: '90vh', overflowY: 'auto' }}>
|
||
<h3 style={{ margin: '0 0 20px', fontSize: '18px', fontWeight: 700, color: C.navy }}>{s.id ? 'Edit Slide' : 'New Slide'}</h3>
|
||
<div style={{ display: 'grid', gap: '14px' }}>
|
||
<I label="Title" value={s.title} onChange={(v: string) => setSlide({ ...s, title: v })} />
|
||
<I label="Subtitle" value={s.subtitle} onChange={(v: string) => setSlide({ ...s, subtitle: v })} />
|
||
<div style={{ display: 'flex', flexDirection: 'column', gap: '8px' }}>
|
||
<label style={{ fontSize: '13px', fontWeight: 600, color: C.text }}>Image</label>
|
||
<div style={{ display: 'flex', gap: '8px', flexWrap: 'wrap' }}>
|
||
<Btn onClick={() => setShowPicker(!showPicker)} size="sm" variant="secondary">{showPicker ? 'Hide picker' : 'Pick from gallery'}</Btn>
|
||
<input ref={fileInputRef} type="file" accept="image/*" style={{ display: 'none' }} onChange={(e) => { if (e.target.files?.length) handleUpload(e.target.files); e.target.value = ''; }} />
|
||
<Btn size="sm" variant="secondary" disabled={uploading} onClick={() => fileInputRef.current?.click()}>{uploading ? 'Uploading...' : 'Upload from computer'}</Btn>
|
||
{s.image_id && images.find((i: Image) => i.id === s.image_id) && (
|
||
<img src={images.find((i: Image) => i.id === s.image_id)!.data} style={{ width: 80, height: 56, objectFit: 'cover', borderRadius: 8, border: `2px solid ${C.gold}` }} />
|
||
)}
|
||
</div>
|
||
{showPicker && (
|
||
<div style={{ display: 'flex', gap: '6px', flexWrap: 'wrap', padding: '8px', background: '#FAFAFA', borderRadius: 8 }}>
|
||
{images.filter((i: Image) => i.category === 'slides').map((img: Image) => (
|
||
<img key={img.id} src={img.data} alt="" onClick={() => { setSlide({ ...s, image_id: img.id, image_url: img.data }); setShowPicker(false); }}
|
||
style={{ width: 64, height: 48, objectFit: 'cover', borderRadius: 6, cursor: 'pointer', border: s.image_id === img.id ? `2px solid ${C.gold}` : '1px solid #eee' }} />
|
||
))}
|
||
{images.filter((i: Image) => i.category === 'slides').length === 0 && <span style={{ fontSize: '12px', color: C.textLight, padding: '8px' }}>No images uploaded. Use "Upload from computer" above.</span>}
|
||
</div>
|
||
)}
|
||
</div>
|
||
<div style={{ display: 'flex', gap: '12px', alignItems: 'center' }}>
|
||
<Tgl label="Active" checked={s.active} onChange={(v: boolean) => setSlide({ ...s, active: v })} />
|
||
<I label="Sort Order" type="number" value={s.sort_order} onChange={(v: string) => setSlide({ ...s, sort_order: Number(v) })} style={{ width: 80 }} />
|
||
</div>
|
||
</div>
|
||
<div style={{ display: 'flex', gap: '10px', justifyContent: 'flex-end', marginTop: '24px' }}>
|
||
<Btn onClick={onCancel} variant="secondary">Cancel</Btn>
|
||
<Btn onClick={() => onSave(s)}>Save</Btn>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
/* ─── Calendar Section ─── */
|
||
function CalendarSection({ events, setEvents, images, onToast }: any) {
|
||
const [editing, setEditing] = useState<CalendarEvent | null>(null);
|
||
const [confirmDel, setConfirmDel] = useState<number | null>(null);
|
||
|
||
const save = async (ev: CalendarEvent) => {
|
||
try {
|
||
const url = ev.id ? `/api/calendar_events/${ev.id}` : '/api/calendar_events';
|
||
const res = await fetch(url, {
|
||
method: ev.id ? 'PUT' : 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify(ev),
|
||
});
|
||
if (res.ok) {
|
||
const data = await res.json();
|
||
setEditing(null);
|
||
setEvents((e: any) => ev.id ? e.map((x: CalendarEvent) => x.id === ev.id ? data.data : x) : [...e, data.data]);
|
||
onToast('Event saved!', 'success');
|
||
}
|
||
} catch { onToast('Error', 'error'); }
|
||
};
|
||
|
||
const del = async (id: number) => {
|
||
await fetch(`/api/calendar_events/${id}`, { method: 'DELETE' });
|
||
setEvents((e: any) => e.filter((x: CalendarEvent) => x.id !== id));
|
||
onToast('Event deleted', 'success');
|
||
setConfirmDel(null);
|
||
};
|
||
|
||
return (
|
||
<div>
|
||
<SecHead title="📅 Calendar Events" count={events.length}
|
||
action={<Btn onClick={() => setEditing({ id: 0, date: '', title: '', type: 'default', color: '#D4A853', description: null, active: true })} size="sm">+ New Event</Btn>} />
|
||
{events.length === 0 ? <Empty icon="📅" title="No events" /> : (
|
||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(300px, 1fr))', gap: '16px' }}>
|
||
{events.map((ev: CalendarEvent) => (
|
||
<EntityCard key={ev.id} title={ev.title} desc={ev.description || ev.type} meta={`${ev.date} • ${ev.type}`}
|
||
actions={
|
||
<>
|
||
<Btn size="sm" variant="secondary" onClick={() => setEditing(ev)}>Edit</Btn>
|
||
<Btn size="sm" variant="danger" onClick={() => setConfirmDel(ev.id)}>Del</Btn>
|
||
</>
|
||
} />
|
||
))}
|
||
</div>
|
||
)}
|
||
{editing && <CalendarEditor event={editing} setEvent={setEditing} onSave={save} onCancel={() => setEditing(null)} />}
|
||
{confirmDel !== null && <Confirm message="Delete this event?" onCancel={() => setConfirmDel(null)} onConfirm={() => del(confirmDel)} />}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function CalendarEditor({ event, setEvent, onSave, onCancel }: any) {
|
||
const e = { ...event };
|
||
return (
|
||
<div style={{ position: 'fixed', top: 0, left: 0, right: 0, bottom: 0, background: 'rgba(0,0,0,0.4)', display: 'flex', alignItems: 'center', justifyContent: 'center', zIndex: 9998 }}>
|
||
<div style={{ background: '#FFFFFF', borderRadius: 16, padding: '24px 28px', maxWidth: 460, width: '90%' }}>
|
||
<h3 style={{ margin: '0 0 20px', fontSize: '18px', fontWeight: 700, color: C.navy }}>{e.id ? 'Edit Event' : 'New Event'}</h3>
|
||
<div style={{ display: 'grid', gap: '14px' }}>
|
||
<I label="Title" value={e.title} onChange={(v: string) => setEvent({ ...e, title: v })} />
|
||
<I label="Date" type="date" value={e.date} onChange={(v: string) => setEvent({ ...e, date: v })} />
|
||
<S label="Type" value={e.type} onChange={(v: string) => setEvent({ ...e, type: v })}
|
||
options={[{ value: 'default', label: 'Default' }, { value: 'special', label: 'Special' }, { value: 'holiday', label: 'Holiday' }]} />
|
||
<I label="Color" value={e.color} onChange={(v: string) => setEvent({ ...e, color: v })} />
|
||
<TA label="Description" value={e.description || ''} onChange={(v: string) => setEvent({ ...e, description: v || null })} />
|
||
<Tgl label="Active" checked={e.active} onChange={(v: boolean) => setEvent({ ...e, active: v })} />
|
||
</div>
|
||
<div style={{ display: 'flex', gap: '10px', justifyContent: 'flex-end', marginTop: '24px' }}>
|
||
<Btn onClick={onCancel} variant="secondary">Cancel</Btn>
|
||
<Btn onClick={() => onSave(e)}>Save</Btn>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
/* ─── Social Events Section ─── */
|
||
function SocialSection({ events, setEvents, images, onToast }: any) {
|
||
const [editing, setEditing] = useState<SocialEvent | null>(null);
|
||
const [confirmDelEvent, setConfirmDelEvent] = useState<number | null>(null);
|
||
const [confirmDelReservation, setConfirmDelReservation] = useState<number | null>(null);
|
||
const [tab, setTab] = useState<'public' | 'private' | 'reservations'>('public');
|
||
const [reservations, setReservations] = useState<any[]>([]);
|
||
const [statusFilter, setStatusFilter] = useState<'pending' | 'confirmed' | 'cancelled' | 'all'>('pending');
|
||
const [loading, setLoading] = useState(false);
|
||
|
||
const publicEvents = events.filter((e: SocialEvent) => !e.is_private);
|
||
const privateEvents = events.filter((e: SocialEvent) => e.is_private);
|
||
|
||
useEffect(() => {
|
||
if (tab === 'reservations') loadReservations();
|
||
}, [tab, statusFilter]);
|
||
|
||
const loadReservations = async () => {
|
||
setLoading(true);
|
||
try {
|
||
const url = statusFilter === 'all' ? '/api/social_event_reservations' : `/api/social_event_reservations?status=${statusFilter}`;
|
||
const res = await fetch(url);
|
||
const data = await res.json();
|
||
setReservations(data.data || []);
|
||
} catch { onToast('Error loading reservations', 'error'); }
|
||
setLoading(false);
|
||
};
|
||
|
||
const updateStatus = async (id: number, status: 'confirmed' | 'cancelled') => {
|
||
try {
|
||
await fetch(`/api/social_event_reservations/${id}`, {
|
||
method: 'PUT',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ status }),
|
||
});
|
||
setReservations(r => r.map(x => x.id === id ? { ...x, status } : x));
|
||
onToast(`Reservation ${status}`, 'success');
|
||
} catch { onToast('Error updating reservation', 'error'); }
|
||
};
|
||
|
||
const delReservation = async (id: number) => {
|
||
await fetch(`/api/social_event_reservations/${id}`, { method: 'DELETE' });
|
||
setReservations(r => r.filter(x => x.id !== id));
|
||
onToast('Reservation deleted', 'success');
|
||
setConfirmDelReservation(null);
|
||
};
|
||
|
||
const save = async (ev: SocialEvent) => {
|
||
try {
|
||
const url = ev.id ? `/api/social_events/${ev.id}` : '/api/social_events';
|
||
const res = await fetch(url, {
|
||
method: ev.id ? 'PUT' : 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify(ev),
|
||
});
|
||
if (res.ok) {
|
||
const data = await res.json();
|
||
setEditing(null);
|
||
setEvents((e: any) => ev.id ? e.map((x: SocialEvent) => x.id === ev.id ? data.data : x) : [...e, data.data]);
|
||
onToast('Event saved!', 'success');
|
||
}
|
||
} catch { onToast('Error', 'error'); }
|
||
};
|
||
|
||
const del = async (id: number) => {
|
||
await fetch(`/api/social_events/${id}`, { method: 'DELETE' });
|
||
setEvents((e: any) => e.filter((x: SocialEvent) => x.id !== id));
|
||
onToast('Event deleted', 'success');
|
||
setConfirmDelEvent(null);
|
||
};
|
||
|
||
const formatDate = (d: string | null) => d ? new Date(d).toLocaleDateString() : '-';
|
||
const contactMethodIcon = (m: string) => m === 'email' ? '✉️' : m === 'whatsapp' ? '📱' : '📞';
|
||
|
||
const renderEventList = (eventList: SocialEvent[], title: string, icon: string) => (
|
||
<>
|
||
<SecHead title={`${icon} ${title}`} count={eventList.length}
|
||
action={<Btn onClick={() => setEditing({ id: 0, name: '', description: '', price: null, date: null, photos: [], featured_photo: null, active: true, is_private: icon === '🔒', max_guests: null, sort_order: events.length })} size="sm">+ New Event</Btn>} />
|
||
{eventList.length === 0 ? <Empty icon={icon} title={`No ${title.toLowerCase()}`} /> : (
|
||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(280px, 1fr))', gap: '16px' }}>
|
||
{eventList.map((ev: SocialEvent) => (
|
||
<EntityCard key={ev.id} title={ev.name} desc={ev.description || ''} meta={`${ev.price || 'Free'}${ev.date ? ' • ' + ev.date : ''}${ev.max_guests ? ' • Max ' + ev.max_guests + ' guests' : ''}`}
|
||
thumb={ev.featured_photo || ev.photos?.[0]}
|
||
photoUrls={ev.photos}
|
||
actions={
|
||
<>
|
||
<Btn size="sm" variant="secondary" onClick={() => setEditing(ev)}>Edit</Btn>
|
||
<Btn size="sm" variant="danger" onClick={() => setConfirmDelEvent(ev.id)}>Del</Btn>
|
||
</>
|
||
} />
|
||
))}
|
||
</div>
|
||
)}
|
||
</>
|
||
);
|
||
|
||
return (
|
||
<div>
|
||
<div style={{ display: 'flex', gap: '12px', marginBottom: '16px', borderBottom: '1px solid #e5e7eb', paddingBottom: '12px' }}>
|
||
<Btn size="sm" variant={tab === 'public' ? 'primary' : 'secondary'} onClick={() => setTab('public')}>🎪 Public</Btn>
|
||
<Btn size="sm" variant={tab === 'private' ? 'primary' : 'secondary'} onClick={() => setTab('private')}>🔒 Private</Btn>
|
||
<Btn size="sm" variant={tab === 'reservations' ? 'primary' : 'secondary'} onClick={() => setTab('reservations')}>📅 Reservations</Btn>
|
||
</div>
|
||
|
||
{tab === 'public' && renderEventList(publicEvents, 'Public Events', '🎪')}
|
||
{tab === 'private' && renderEventList(privateEvents, 'Private Events', '🔒')}
|
||
{tab === 'reservations' && (
|
||
<>
|
||
<SecHead title="📅 Public Event Reservations" 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 reservations" desc={`No ${statusFilter === 'all' ? '' : statusFilter} reservations`} /> : (
|
||
<div style={{ display: 'grid', gap: '12px' }}>
|
||
{reservations.map((r: any) => (
|
||
<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.event_name}</div>
|
||
<div style={{ fontSize: '13px', color: '#666', marginTop: '4px' }}>{r.guests} guests</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>Contact:</strong> {r.contact_name || r.first_name || r.username || '-'}</div>
|
||
<div><strong>Method:</strong> {r.contact_method ? contactMethodIcon(r.contact_method) : '-'} {r.contact_method || '-'}</div>
|
||
{r.contact_email && <div><strong>Email:</strong> {r.contact_email}</div>}
|
||
{r.contact_phone && <div><strong>Phone:</strong> {r.contact_phone}</div>}
|
||
</div>
|
||
{r.notes && <div style={{ fontSize: '13px', color: '#666', marginBottom: '12px', fontStyle: 'italic' }}>"{r.notes}"</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={() => setConfirmDelReservation(r.id)}>🗑 Delete</Btn>
|
||
</div>
|
||
</div>
|
||
))}
|
||
</div>
|
||
)}
|
||
{confirmDelReservation !== null && <Confirm message="Delete this reservation?" onCancel={() => setConfirmDelReservation(null)} onConfirm={() => delReservation(confirmDelReservation)} />}
|
||
</>
|
||
)}
|
||
{editing && <SocialEditor event={editing} setEvent={setEditing} images={images} onSave={save} onCancel={() => setEditing(null)} />}
|
||
{confirmDelEvent !== null && <Confirm message="Delete this event?" onCancel={() => setConfirmDelEvent(null)} onConfirm={() => del(confirmDelEvent)} />}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function SocialEditor({ event, setEvent, images, onSave, onCancel }: any) {
|
||
const [showPicker, setShowPicker] = useState(false);
|
||
const [localPhotos, setLocalPhotos] = useState<string[]>(event.photos || []);
|
||
const ev = { ...event, photos: localPhotos };
|
||
|
||
const addPhotos = async (files: FileList) => {
|
||
const newPhotos: string[] = [];
|
||
for (const f of Array.from(files)) {
|
||
const b64 = await readFileBase64(f);
|
||
try {
|
||
const r = await fetch('/api/admin/images', {
|
||
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ filename: f.name, data: b64, category: event.is_private ? 'private' : 'social', room_id: null, location_target: event.is_private ? 'private' : 'social' }),
|
||
});
|
||
if (r.ok) { const d = await r.json(); newPhotos.push(d.data.data); }
|
||
} catch { const b = await readFileBase64(f); newPhotos.push(b); }
|
||
}
|
||
setLocalPhotos(prev => [...prev, ...newPhotos]);
|
||
};
|
||
|
||
return (
|
||
<div style={{ position: 'fixed', top: 0, left: 0, right: 0, bottom: 0, background: 'rgba(0,0,0,0.4)', display: 'flex', alignItems: 'center', justifyContent: 'center', zIndex: 9998 }}>
|
||
<div style={{ background: '#FFFFFF', borderRadius: 16, padding: '24px 28px', maxWidth: 500, width: '90%', maxHeight: '90vh', overflowY: 'auto' }}>
|
||
<h3 style={{ margin: '0 0 20px', fontSize: '18px', fontWeight: 700, color: C.navy }}>{ev.id ? 'Edit Event' : 'New Event'}</h3>
|
||
<div style={{ display: 'grid', gap: '14px' }}>
|
||
<I label="Name" value={ev.name} onChange={(v: string) => setEvent({ ...ev, name: v })} />
|
||
<TA label="Description" value={ev.description || ''} onChange={(v: string) => setEvent({ ...ev, description: v })} />
|
||
<I label="Price" value={ev.price || ''} onChange={(v: string) => setEvent({ ...ev, price: v || null })} />
|
||
<I label="Date" type="date" value={ev.date || ''} onChange={(v: string) => setEvent({ ...ev, date: v || null })} />
|
||
<div style={{ display: 'flex', flexDirection: 'column', gap: '8px' }}>
|
||
<label style={{ fontSize: '13px', fontWeight: 600, color: C.text }}>Photos</label>
|
||
<div style={{ display: 'flex', gap: '8px', flexWrap: 'wrap' }}>
|
||
<Btn onClick={() => document.getElementById('social-photos')?.click()} size="sm" variant="secondary">+ Add Photos</Btn>
|
||
<input id="social-photos" type="file" accept="image/*" multiple style={{ display: 'none' }}
|
||
onChange={e => { addPhotos(e.target.files!); e.target.value = ''; }} />
|
||
</div>
|
||
<div style={{ display: 'flex', gap: '6px', flexWrap: 'wrap' }}>
|
||
{localPhotos.map((p, i) => (
|
||
<div key={i} style={{ position: 'relative' }}>
|
||
<img src={p} alt="" style={{ width: 64, height: 48, objectFit: 'cover', borderRadius: 6, border: '1px solid #eee' }} />
|
||
<button onClick={() => setLocalPhotos(prev => prev.filter((_, j) => j !== i))}
|
||
style={{ position: 'absolute', top: -4, right: -4, width: 18, height: 18, borderRadius: '50%', background: C.danger, color: '#fff', border: 'none', cursor: 'pointer', fontSize: '11px', lineHeight: 18, padding: 0 }}
|
||
>×</button>
|
||
</div>
|
||
))}
|
||
</div>
|
||
</div>
|
||
<Tgl label="Private Event" checked={ev.is_private || false} onChange={(v: boolean) => setEvent({ ...ev, is_private: v })} />
|
||
{ev.is_private && (
|
||
<I label="Max Guests" type="number" value={ev.max_guests || ''} onChange={(v: string) => setEvent({ ...ev, max_guests: v ? parseInt(v) : null })} placeholder="Leave empty for unlimited" />
|
||
)}
|
||
<Tgl label="Active" checked={ev.active} onChange={(v: boolean) => setEvent({ ...ev, active: v })} />
|
||
</div>
|
||
<div style={{ display: 'flex', gap: '10px', justifyContent: 'flex-end', marginTop: '24px' }}>
|
||
<Btn onClick={onCancel} variant="secondary">Cancel</Btn>
|
||
<Btn onClick={() => onSave(ev)}>Save</Btn>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
/* ─── Rooms Section ─── */
|
||
function PrivateEventsSection({ onToast }: { onToast: (msg: string, type: 'success' | 'error') => void }) {
|
||
const [reservations, setReservations] = useState<any[]>([]);
|
||
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/private-event-reservations' : `/api/private-event-reservations?status=${statusFilter}`;
|
||
const res = await fetch(url);
|
||
const data = await res.json();
|
||
setReservations(data.data || []);
|
||
} catch { onToast('Error loading reservations', 'error'); }
|
||
setLoading(false);
|
||
};
|
||
|
||
const updateStatus = async (id: number, status: 'confirmed' | 'cancelled') => {
|
||
try {
|
||
await fetch(`/api/private-event-reservations/${id}`, {
|
||
method: 'PUT',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ status }),
|
||
});
|
||
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) => {
|
||
await fetch(`/api/private-event-reservations/${id}`, { method: 'DELETE' });
|
||
setReservations(r => r.filter(x => x.id !== id));
|
||
onToast('Reservation deleted', 'success');
|
||
setConfirmDel(null);
|
||
};
|
||
|
||
const formatDate = (d: string | null) => d ? new Date(d).toLocaleDateString() : '-';
|
||
const formatTime = (t: string | null) => t || '-';
|
||
const contactMethodIcon = (m: string) => m === 'email' ? '✉️' : m === 'whatsapp' ? '📱' : '📞';
|
||
|
||
return (
|
||
<div>
|
||
<SecHead title="🔒 Private Event Reservations" 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 reservations" desc={`No ${statusFilter === 'all' ? '' : statusFilter} reservations`} /> : (
|
||
<div style={{ display: 'grid', gap: '12px' }}>
|
||
{reservations.map((r: any) => (
|
||
<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.event_name}</div>
|
||
<div style={{ fontSize: '13px', color: '#666', marginTop: '4px' }}>{r.guests} guests • {formatDate(r.event_date)} • {formatTime(r.event_time)}</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>Contact:</strong> {r.contact_name}</div>
|
||
<div><strong>Method:</strong> {contactMethodIcon(r.contact_method)} {r.contact_method}</div>
|
||
{r.contact_email && <div><strong>Email:</strong> {r.contact_email}</div>}
|
||
{r.contact_phone && <div><strong>Phone:</strong> {r.contact_phone}</div>}
|
||
</div>
|
||
{r.notes && <div style={{ fontSize: '13px', color: '#666', marginBottom: '12px', fontStyle: 'italic' }}>"{r.notes}"</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 reservation?" onCancel={() => setConfirmDel(null)} onConfirm={() => del(confirmDel)} />}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
/* ─── 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>
|
||
{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,
|
||
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>
|
||
);
|
||
}
|
||
|
||
|
||
/* 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);
|
||
|
||
const save = async (room: Room) => {
|
||
try {
|
||
const url = room.id ? `/api/rooms/${room.id}` : '/api/rooms';
|
||
const res = await fetch(url, {
|
||
method: room.id ? 'PUT' : 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify(room),
|
||
});
|
||
if (res.ok) {
|
||
const data = await res.json();
|
||
setEditing(null);
|
||
setRooms((r: any) => room.id ? r.map((x: Room) => x.id === room.id ? data.data : x) : [...r, data.data]);
|
||
onToast('Room saved!', 'success');
|
||
}
|
||
} catch { onToast('Error', 'error'); }
|
||
};
|
||
|
||
const del = async (id: number) => {
|
||
await fetch(`/api/rooms/${id}`, { method: 'DELETE' });
|
||
setRooms((r: any) => r.filter((x: Room) => x.id !== id));
|
||
onToast('Room deleted', 'success');
|
||
setConfirmDel(null);
|
||
};
|
||
|
||
return (
|
||
<div>
|
||
<SecHead title="🏨 Rooms" count={rooms.length}
|
||
action={<Btn onClick={() => setEditing({ id: 0, name: '', description: '', price: '', photos: [], featured_photo: null, active: true, sort_order: rooms.length })} size="sm">+ New Room</Btn>} />
|
||
{rooms.length === 0 ? <Empty icon="🏨" title="No rooms" /> : (
|
||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(280px, 1fr))', gap: '16px' }}>
|
||
{rooms.map((r: Room) => (
|
||
<EntityCard key={r.id} title={r.name} desc={r.description || ''} meta={r.price}
|
||
thumb={r.featured_photo || r.photos?.[0]}
|
||
photoUrls={r.photos}
|
||
actions={
|
||
<>
|
||
<Btn size="sm" variant="secondary" onClick={() => setEditing(r)}>Edit</Btn>
|
||
<Btn size="sm" variant="danger" onClick={() => setConfirmDel(r.id)}>Del</Btn>
|
||
</>
|
||
} />
|
||
))}
|
||
</div>
|
||
)}
|
||
{editing && <RoomEditor room={editing} setRoom={setEditing} images={images} onSave={save} onCancel={() => setEditing(null)} />}
|
||
{confirmDel !== null && <Confirm message="Delete this room?" onCancel={() => setConfirmDel(null)} onConfirm={() => del(confirmDel)} />}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
/* ─── Room Status Section ─── */
|
||
type StaffMember = { id: number; first_name: string | null; last_name: string | null; username: string };
|
||
type RoomStatus = {
|
||
id: number;
|
||
name: string;
|
||
price: string;
|
||
clean_status: 'clean' | 'dirty' | 'maintenance';
|
||
notes: string | null;
|
||
active: boolean;
|
||
last_cleaned: string | null;
|
||
assigned_staff_id: number | null;
|
||
assigned_staff: { id: number; first_name: string | null; last_name: string | null } | null;
|
||
priority: 'urgent' | 'normal' | 'low';
|
||
issues_count: number;
|
||
is_vip: boolean;
|
||
checkout_time: string | null;
|
||
checkout_date: string | null;
|
||
occupancy_status: 'available' | 'occupied';
|
||
current_guest: string | null;
|
||
reservation_id: number | null;
|
||
payment_status: 'pending' | 'paid' | 'partial' | null;
|
||
upcoming_reservations: Array<{
|
||
id: number;
|
||
guest_name: string | null;
|
||
check_in: string;
|
||
check_out: string;
|
||
status: string;
|
||
payment_status: string;
|
||
first_name: string | null;
|
||
last_name: string | null;
|
||
}>;
|
||
};
|
||
|
||
function RoomStatusSection({ onToast }: { onToast: (msg: string, type: 'success' | 'error') => void }) {
|
||
const [rooms, setRooms] = useState<RoomStatus[]>([]);
|
||
const [staff, setStaff] = useState<StaffMember[]>([]);
|
||
const [loading, setLoading] = useState(true);
|
||
const [editingRoom, setEditingRoom] = useState<number | null>(null);
|
||
const [editData, setEditData] = useState<{
|
||
clean_status: 'clean' | 'dirty' | 'maintenance';
|
||
notes: string;
|
||
priority: 'urgent' | 'normal' | 'low';
|
||
assigned_staff_id: number | null;
|
||
issues_count: number;
|
||
is_vip: boolean;
|
||
checkout_time: string;
|
||
checkout_date: string;
|
||
}>({
|
||
clean_status: 'clean',
|
||
notes: '',
|
||
priority: 'normal',
|
||
assigned_staff_id: null,
|
||
issues_count: 0,
|
||
is_vip: false,
|
||
checkout_time: '',
|
||
checkout_date: '',
|
||
});
|
||
|
||
useEffect(() => { loadRooms(); }, []);
|
||
|
||
const loadRooms = async () => {
|
||
setLoading(true);
|
||
try {
|
||
const res = await fetch('/api/admin/room-status');
|
||
const data = await res.json();
|
||
setRooms(data.rooms || []);
|
||
setStaff(data.staff || []);
|
||
} catch { onToast('Error loading room status', 'error'); }
|
||
setLoading(false);
|
||
};
|
||
|
||
const updateRoomStatus = async () => {
|
||
if (editingRoom === null) return;
|
||
try {
|
||
const res = await fetch('/api/admin/room-status', {
|
||
method: 'PATCH',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({
|
||
id: editingRoom,
|
||
clean_status: editData.clean_status,
|
||
notes: editData.notes || null,
|
||
priority: editData.priority,
|
||
assigned_staff_id: editData.assigned_staff_id,
|
||
issues_count: editData.issues_count,
|
||
is_vip: editData.is_vip,
|
||
checkout_time: editData.checkout_time || null,
|
||
checkout_date: editData.checkout_date || null,
|
||
}),
|
||
});
|
||
if (res.ok) {
|
||
await loadRooms();
|
||
onToast('Room status updated', 'success');
|
||
}
|
||
} catch { onToast('Error updating status', 'error'); }
|
||
setEditingRoom(null);
|
||
};
|
||
|
||
const startEdit = (room: RoomStatus) => {
|
||
setEditingRoom(room.id);
|
||
setEditData({
|
||
clean_status: room.clean_status,
|
||
notes: room.notes || '',
|
||
priority: room.priority,
|
||
assigned_staff_id: room.assigned_staff_id,
|
||
issues_count: room.issues_count,
|
||
is_vip: room.is_vip,
|
||
checkout_time: room.checkout_time || '',
|
||
checkout_date: room.checkout_date || '',
|
||
});
|
||
};
|
||
|
||
const getStatusIcon = (status: string) => {
|
||
switch (status) {
|
||
case 'clean': return '✨';
|
||
case 'dirty': return '🧹';
|
||
case 'maintenance': return '🔧';
|
||
default: return '❓';
|
||
}
|
||
};
|
||
|
||
const getStatusColor = (status: string) => {
|
||
switch (status) {
|
||
case 'clean': return { bg: '#d1fae5', color: '#047857', border: '#10b981' };
|
||
case 'dirty': return { bg: '#fef3c7', color: '#b45309', border: '#f59e0b' };
|
||
case 'maintenance': return { bg: '#fee2e2', color: '#b91c1c', border: '#ef4444' };
|
||
default: return { bg: '#f3f4f6', color: '#6b7280', border: '#d1d5db' };
|
||
}
|
||
};
|
||
|
||
const getPriorityStyle = (priority: string) => {
|
||
switch (priority) {
|
||
case 'urgent': return { bg: '#fee2e2', color: '#b91c1c', icon: '🔴' };
|
||
case 'normal': return { bg: '#dbeafe', color: '#1d4ed8', icon: '🔵' };
|
||
case 'low': return { bg: '#f3f4f6', color: '#6b7280', icon: '⚪' };
|
||
default: return { bg: '#f3f4f6', color: '#6b7280', icon: '⚪' };
|
||
}
|
||
};
|
||
|
||
const getPaymentBadge = (status: string | null) => {
|
||
if (!status) return null;
|
||
const colors: Record<string, { bg: string; color: string }> = {
|
||
pending: { bg: '#fef3c7', color: '#b45309' },
|
||
partial: { bg: '#dbeafe', color: '#1d4ed8' },
|
||
paid: { bg: '#d1fae5', color: '#047857' },
|
||
};
|
||
const c = colors[status] || colors.pending;
|
||
return (
|
||
<span style={{ padding: '2px 8px', borderRadius: 12, fontSize: '11px', fontWeight: 600, background: c.bg, color: c.color }}>
|
||
{status}
|
||
</span>
|
||
);
|
||
};
|
||
|
||
const formatDate = (d: string) => new Date(d).toLocaleDateString();
|
||
const formatDateTime = (d: string) => new Date(d).toLocaleString();
|
||
const formatTime = (t: string) => t ? t.substring(0, 5) : '';
|
||
|
||
return (
|
||
<div>
|
||
<SecHead title="🧹 Room Status" count={rooms.length} />
|
||
{loading ? (
|
||
<Empty icon="⏳" title="Loading..." />
|
||
) : rooms.length === 0 ? (
|
||
<Empty icon="🏨" title="No rooms" desc="Add rooms in the Rooms section" />
|
||
) : (
|
||
<div style={{ display: 'grid', gap: '12px' }}>
|
||
{rooms.map(room => {
|
||
const statusColors = getStatusColor(room.clean_status);
|
||
const priorityStyle = getPriorityStyle(room.priority);
|
||
const isEditing = editingRoom === room.id;
|
||
|
||
return (
|
||
<div key={room.id} style={{ background: '#fff', borderRadius: 12, border: `1px solid ${C.border}`, overflow: 'hidden' }}>
|
||
{/* Header Row */}
|
||
<div style={{ display: 'flex', alignItems: 'center', padding: '12px 16px', background: room.is_vip ? '#fef3c7' : room.occupancy_status === 'occupied' ? '#f0fdf4' : '#fafafa', gap: '12px' }}>
|
||
{/* Room Name & Status */}
|
||
<div style={{ flex: 1, display: 'flex', alignItems: 'center', gap: '10px', flexWrap: 'wrap' }}>
|
||
<div style={{ display: 'flex', alignItems: 'center', gap: '6px' }}>
|
||
<span style={{ fontSize: '20px' }}>{getStatusIcon(room.clean_status)}</span>
|
||
<span style={{ fontWeight: 600, fontSize: '16px', color: C.navy }}>{room.name}</span>
|
||
</div>
|
||
{room.is_vip && (
|
||
<span style={{ padding: '2px 8px', borderRadius: 12, fontSize: '11px', fontWeight: 700, background: '#fbbf24', color: '#78350f' }}>
|
||
⭐ VIP
|
||
</span>
|
||
)}
|
||
<span style={{
|
||
padding: '4px 10px',
|
||
borderRadius: 12,
|
||
fontSize: '12px',
|
||
fontWeight: 600,
|
||
background: statusColors.bg,
|
||
color: statusColors.color,
|
||
border: `1px solid ${statusColors.border}`
|
||
}}>
|
||
{room.clean_status}
|
||
</span>
|
||
<span style={{
|
||
padding: '4px 10px',
|
||
borderRadius: 12,
|
||
fontSize: '12px',
|
||
fontWeight: 600,
|
||
background: room.occupancy_status === 'occupied' ? '#d1fae5' : '#e0e7ff',
|
||
color: room.occupancy_status === 'occupied' ? '#047857' : '#4338ca'
|
||
}}>
|
||
{room.occupancy_status === 'occupied' ? '🛏️ Occupied' : '🚪 Available'}
|
||
</span>
|
||
<span style={{
|
||
padding: '4px 10px',
|
||
borderRadius: 12,
|
||
fontSize: '12px',
|
||
fontWeight: 600,
|
||
background: priorityStyle.bg,
|
||
color: priorityStyle.color
|
||
}}>
|
||
{priorityStyle.icon} {room.priority}
|
||
</span>
|
||
{room.issues_count > 0 && (
|
||
<span style={{ padding: '2px 8px', borderRadius: 12, fontSize: '11px', fontWeight: 600, background: '#fee2e2', color: '#b91c1c' }}>
|
||
⚠️ {room.issues_count} issue{room.issues_count > 1 ? 's' : ''}
|
||
</span>
|
||
)}
|
||
</div>
|
||
|
||
{/* Price */}
|
||
<div style={{ fontWeight: 600, color: C.gold, fontSize: '15px' }}>
|
||
${parseFloat(room.price).toFixed(2)}/night
|
||
</div>
|
||
|
||
{/* Edit Button */}
|
||
<Btn size="sm" variant="secondary" onClick={() => isEditing ? setEditingRoom(null) : startEdit(room)}>
|
||
{isEditing ? 'Cancel' : 'Edit'}
|
||
</Btn>
|
||
</div>
|
||
|
||
{/* Details Row */}
|
||
<div style={{ padding: '12px 16px', borderTop: `1px solid ${C.border}` }}>
|
||
{/* Quick Info Grid */}
|
||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(200px, 1fr))', gap: '8px', marginBottom: '12px' }}>
|
||
{/* Current Guest */}
|
||
{room.current_guest && (
|
||
<div style={{ fontSize: '13px' }}>
|
||
<span style={{ color: '#666' }}>Guest: </span>
|
||
<span style={{ fontWeight: 600, color: C.navy }}>{room.current_guest}</span>
|
||
{getPaymentBadge(room.payment_status)}
|
||
</div>
|
||
)}
|
||
|
||
{/* Checkout */}
|
||
{room.checkout_date && (
|
||
<div style={{ fontSize: '13px' }}>
|
||
<span style={{ color: '#666' }}>Checkout: </span>
|
||
<span style={{ fontWeight: 500 }}>{formatDate(room.checkout_date)}</span>
|
||
{room.checkout_time && <span> at {formatTime(room.checkout_time)}</span>}
|
||
</div>
|
||
)}
|
||
|
||
{/* Last Cleaned */}
|
||
{room.last_cleaned && (
|
||
<div style={{ fontSize: '13px' }}>
|
||
<span style={{ color: '#666' }}>Last cleaned: </span>
|
||
<span style={{ fontWeight: 500 }}>{formatDateTime(room.last_cleaned)}</span>
|
||
</div>
|
||
)}
|
||
|
||
{/* Assigned Staff */}
|
||
{room.assigned_staff && (
|
||
<div style={{ fontSize: '13px' }}>
|
||
<span style={{ color: '#666' }}>Staff: </span>
|
||
<span style={{ fontWeight: 500 }}>{room.assigned_staff.first_name || ''} {room.assigned_staff.last_name || ''}</span>
|
||
</div>
|
||
)}
|
||
</div>
|
||
|
||
{/* Upcoming Reservations */}
|
||
{room.upcoming_reservations && room.upcoming_reservations.length > 0 && (
|
||
<div style={{ marginBottom: '8px' }}>
|
||
<div style={{ fontSize: '12px', color: '#888', marginBottom: '4px' }}>Upcoming:</div>
|
||
{room.upcoming_reservations.map((res, i) => (
|
||
<div key={res.id || i} style={{ display: 'flex', alignItems: 'center', gap: '8px', fontSize: '13px', marginBottom: '2px' }}>
|
||
<span>📅 {formatDate(res.check_in)} → {formatDate(res.check_out)}</span>
|
||
<span style={{ fontWeight: 500 }}>{res.guest_name || res.first_name || 'Guest'}</span>
|
||
{getPaymentBadge(res.payment_status)}
|
||
</div>
|
||
))}
|
||
</div>
|
||
)}
|
||
|
||
{/* Notes */}
|
||
{room.notes && !isEditing && (
|
||
<div style={{ fontSize: '13px', color: '#666', fontStyle: 'italic' }}>
|
||
📝 {room.notes}
|
||
</div>
|
||
)}
|
||
|
||
{/* Edit Form */}
|
||
{isEditing && (
|
||
<div style={{ marginTop: '12px', display: 'flex', flexDirection: 'column', gap: '12px' }}>
|
||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(200px, 1fr))', gap: '12px' }}>
|
||
<S
|
||
label="Clean Status"
|
||
value={editData.clean_status}
|
||
onChange={(v: any) => setEditData(prev => ({ ...prev, clean_status: v }))}
|
||
options={[
|
||
{ value: 'clean', label: '✨ Clean' },
|
||
{ value: 'dirty', label: '🧹 Dirty' },
|
||
{ value: 'maintenance', label: '🔧 Maintenance' },
|
||
]}
|
||
/>
|
||
<S
|
||
label="Priority"
|
||
value={editData.priority}
|
||
onChange={(v: any) => setEditData(prev => ({ ...prev, priority: v }))}
|
||
options={[
|
||
{ value: 'urgent', label: '🔴 Urgent' },
|
||
{ value: 'normal', label: '🔵 Normal' },
|
||
{ value: 'low', label: '⚪ Low' },
|
||
]}
|
||
/>
|
||
<S
|
||
label="Assigned Staff"
|
||
value={editData.assigned_staff_id?.toString() || ''}
|
||
onChange={(v: any) => setEditData(prev => ({ ...prev, assigned_staff_id: v ? parseInt(v) : null }))}
|
||
options={[
|
||
{ value: '', label: '— Unassigned —' },
|
||
...staff.map(s => ({ value: s.id.toString(), label: `${s.first_name || ''} ${s.last_name || ''}`.trim() || s.username }))
|
||
]}
|
||
/>
|
||
</div>
|
||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(150px, 1fr))', gap: '12px' }}>
|
||
<I
|
||
label="Issues Count"
|
||
type="number"
|
||
value={editData.issues_count.toString()}
|
||
onChange={(v: string) => setEditData(prev => ({ ...prev, issues_count: parseInt(v) || 0 }))}
|
||
placeholder="0"
|
||
/>
|
||
<I
|
||
label="Checkout Date"
|
||
type="date"
|
||
value={editData.checkout_date}
|
||
onChange={(v: string) => setEditData(prev => ({ ...prev, checkout_date: v }))}
|
||
/>
|
||
<I
|
||
label="Checkout Time"
|
||
type="time"
|
||
value={editData.checkout_time}
|
||
onChange={(v: string) => setEditData(prev => ({ ...prev, checkout_time: v }))}
|
||
/>
|
||
</div>
|
||
<label style={{ display: 'flex', alignItems: 'center', gap: '8px', cursor: 'pointer' }}>
|
||
<input
|
||
type="checkbox"
|
||
checked={editData.is_vip}
|
||
onChange={(e) => setEditData(prev => ({ ...prev, is_vip: e.target.checked }))}
|
||
style={{ width: 18, height: 18 }}
|
||
/>
|
||
<span style={{ fontWeight: 500 }}>⭐ VIP Guest</span>
|
||
</label>
|
||
<TA
|
||
label="Notes"
|
||
value={editData.notes}
|
||
onChange={(v: string) => setEditData(prev => ({ ...prev, notes: v }))}
|
||
rows={2}
|
||
placeholder="e.g., Need extra towels, AC broken..."
|
||
/>
|
||
<div style={{ display: 'flex', gap: '8px' }}>
|
||
<Btn onClick={updateRoomStatus} size="sm">Save</Btn>
|
||
<Btn variant="secondary" onClick={() => setEditingRoom(null)} size="sm">Cancel</Btn>
|
||
</div>
|
||
</div>
|
||
)}
|
||
</div>
|
||
</div>
|
||
);
|
||
})}
|
||
</div>
|
||
)}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function RoomEditor({ room, setRoom, images, onSave, onCancel }: any) {
|
||
const [showPicker, setShowPicker] = useState(false);
|
||
const [roomImages, setRoomImages] = useState<{id: number; data: string; thumbnail: string}[]>([]);
|
||
const [allAmenities, setAllAmenities] = useState<Amenity[]>([]);
|
||
const [selectedAmenities, setSelectedAmenities] = useState<number[]>(room.amenity_ids || []);
|
||
const r = { ...room, photos: roomImages.map(img => img.thumbnail || img.data), amenity_ids: selectedAmenities };
|
||
|
||
useEffect(() => {
|
||
fetch('/api/amenities')
|
||
.then(res => res.json())
|
||
.then(d => setAllAmenities(d.data || []))
|
||
.catch(() => {});
|
||
|
||
// Load existing images for this room from images table
|
||
if (room.id) {
|
||
fetch(`/api/admin/images?room_id=${room.id}`)
|
||
.then(res => res.json())
|
||
.then(d => setRoomImages(d.data || []))
|
||
.catch(() => {});
|
||
}
|
||
}, [room.id]);
|
||
|
||
const toggleAmenity = (id: number) => {
|
||
setSelectedAmenities(prev =>
|
||
prev.includes(id) ? prev.filter(a => a !== id) : [...prev, id]
|
||
);
|
||
};
|
||
|
||
const addPhotos = async (files: FileList) => {
|
||
const newImages: {id: number; data: string; thumbnail: string}[] = [];
|
||
for (const f of Array.from(files)) {
|
||
const b64 = await readFileBase64(f);
|
||
try {
|
||
const res = await fetch('/api/admin/images', {
|
||
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ filename: f.name, data: b64, category: 'rooms', room_id: r.id || null, location_target: `room-${r.id || 'new'}` }),
|
||
});
|
||
if (res.ok) {
|
||
const d = await res.json();
|
||
newImages.push({ id: d.data.id, data: d.data.data, thumbnail: d.data.thumbnail || d.data.data });
|
||
}
|
||
} catch { /* ignore */ }
|
||
}
|
||
setRoomImages(prev => [...prev, ...newImages]);
|
||
};
|
||
|
||
const removeImage = async (imageId: number) => {
|
||
try {
|
||
await fetch(`/api/admin/images?id=${imageId}`, { method: 'DELETE' });
|
||
setRoomImages(prev => prev.filter(img => img.id !== imageId));
|
||
} catch { /* ignore */ }
|
||
};
|
||
|
||
return (
|
||
<div style={{ position: 'fixed', top: 0, left: 0, right: 0, bottom: 0, background: 'rgba(0,0,0,0.4)', display: 'flex', alignItems: 'center', justifyContent: 'center', zIndex: 9998 }}>
|
||
<div style={{ background: '#FFFFFF', borderRadius: 16, padding: '24px 28px', maxWidth: 500, width: '90%', maxHeight: '90vh', overflowY: 'auto' }}>
|
||
<h3 style={{ margin: '0 0 20px', fontSize: '18px', fontWeight: 700, color: C.navy }}>{r.id ? 'Edit Room' : 'New Room'}</h3>
|
||
<div style={{ display: 'grid', gap: '14px' }}>
|
||
<I label="Name" value={r.name} onChange={(v: string) => setRoom({ ...r, name: v })} />
|
||
<TA label="Description" value={r.description || ''} onChange={(v: string) => setRoom({ ...r, description: v })} />
|
||
<I label="Price" value={r.price} onChange={(v: string) => setRoom({ ...r, price: v })} />
|
||
<div style={{ display: 'flex', flexDirection: 'column', gap: '8px' }}>
|
||
<label style={{ fontSize: '13px', fontWeight: 600, color: C.text }}>Photos</label>
|
||
<div style={{ display: 'flex', gap: '8px', flexWrap: 'wrap' }}>
|
||
<Btn onClick={() => document.getElementById(`room-photos-${r.id || 'new'}`)?.click()} size="sm" variant="secondary">+ Add Photos</Btn>
|
||
<input id={`room-photos-${r.id || 'new'}`} type="file" accept="image/*" multiple style={{ display: 'none' }}
|
||
onChange={e => { addPhotos(e.target.files!); e.target.value = ''; }} />
|
||
</div>
|
||
<div style={{ display: 'flex', gap: '6px', flexWrap: 'wrap' }}>
|
||
{roomImages.map((img, i) => (
|
||
<div key={img.id || i} style={{ position: 'relative' }}>
|
||
<img src={img.thumbnail || img.data} alt="" style={{ width: 64, height: 48, objectFit: 'cover', borderRadius: 6, border: '1px solid #eee' }} />
|
||
<button onClick={() => removeImage(img.id)}
|
||
style={{ position: 'absolute', top: -4, right: -4, width: 18, height: 18, borderRadius: '50%', background: C.danger, color: '#fff', border: 'none', cursor: 'pointer', fontSize: '11px', lineHeight: 18, padding: 0 }}>×</button>
|
||
</div>
|
||
))}
|
||
</div>
|
||
</div>
|
||
<Tgl label="Active" checked={r.active} onChange={(v: boolean) => setRoom({ ...r, active: v })} />
|
||
<div style={{ display: 'flex', flexDirection: 'column', gap: '8px' }}>
|
||
<label style={{ fontSize: '13px', fontWeight: 600, color: C.text }}>Amenities</label>
|
||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '6px' }}>
|
||
{allAmenities.map(a => (
|
||
<button key={a.id} onClick={() => toggleAmenity(a.id)}
|
||
style={{
|
||
display: 'flex', alignItems: 'center', gap: '4px',
|
||
padding: '6px 10px', borderRadius: 6, border: `1px solid ${selectedAmenities.includes(a.id) ? C.gold : C.border}`,
|
||
background: selectedAmenities.includes(a.id) ? C.goldLight : '#fff',
|
||
cursor: 'pointer', fontSize: '12px', transition: 'all 150ms',
|
||
color: selectedAmenities.includes(a.id) ? C.gold : C.text
|
||
}}>
|
||
<span style={{ width: 16, height: 16, display: 'inline-flex', alignItems: 'center', justifyContent: 'center' }} dangerouslySetInnerHTML={{ __html: a.icon_svg.replace(/width="[^"]*"/g, '').replace(/height="[^"]*"/g, '').replace(/stroke="currentColor"/g, `stroke="${C.navy}"`).replace(/<svg/, '<svg style="width:100%;height:100%"') }} />
|
||
{a.name}
|
||
</button>
|
||
))}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<div style={{ display: 'flex', gap: '10px', justifyContent: 'flex-end', marginTop: '24px' }}>
|
||
<Btn onClick={onCancel} variant="secondary">Cancel</Btn>
|
||
<Btn onClick={() => onSave(r)}>Save</Btn>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
/* ─── Amenities Section ─── */
|
||
function AmenitiesSection({ onToast }: { onToast: (msg: string, type: string) => void }) {
|
||
const [amenities, setAmenities] = useState<Amenity[]>([]);
|
||
const [editing, setEditing] = useState<Amenity | null>(null);
|
||
const [confirmDel, setConfirmDel] = useState<number | null>(null);
|
||
|
||
useEffect(() => {
|
||
fetch('/api/amenities')
|
||
.then(r => r.json())
|
||
.then(d => setAmenities(d.data || []))
|
||
.catch(() => onToast('Failed to load amenities', 'error'));
|
||
}, []);
|
||
|
||
const save = async (amenity: Amenity) => {
|
||
try {
|
||
const url = amenity.id ? `/api/amenities?id=${amenity.id}` : '/api/amenities';
|
||
const res = await fetch(url, {
|
||
method: amenity.id ? 'PUT' : 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify(amenity),
|
||
});
|
||
if (res.ok) {
|
||
const data = await res.json();
|
||
if (amenity.id) {
|
||
setAmenities(prev => prev.map(a => a.id === amenity.id ? data.data : a));
|
||
} else {
|
||
setAmenities(prev => [...prev, data.data]);
|
||
}
|
||
setEditing(null);
|
||
onToast('Amenity saved!', 'success');
|
||
} else {
|
||
const err = await res.json();
|
||
onToast(err.error || 'Failed to save', 'error');
|
||
}
|
||
} catch { onToast('Error', 'error'); }
|
||
};
|
||
|
||
const del = async (id: number) => {
|
||
await fetch(`/api/amenities?id=${id}`, { method: 'DELETE' });
|
||
setAmenities(prev => prev.filter(a => a.id !== id));
|
||
onToast('Amenity deleted', 'success');
|
||
setConfirmDel(null);
|
||
};
|
||
|
||
return (
|
||
<div>
|
||
<SecHead title="✨ Room Amenities" count={amenities.length}
|
||
action={<Btn onClick={() => setEditing({ id: 0, name: '', icon_svg: '', sort_order: amenities.length })} size="sm">+ New Amenity</Btn>} />
|
||
{amenities.length === 0 ? <Empty icon="✨" title="No amenities" subtitle="Add icons for room features like Wi-Fi, TV, etc." /> : (
|
||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(200px, 1fr))', gap: '12px' }}>
|
||
{amenities.sort((a, b) => a.sort_order - b.sort_order).map((a: Amenity) => (
|
||
<div key={a.id} style={{ background: '#fff', borderRadius: 12, padding: '16px', boxShadow: C.shadow, display: 'flex', flexDirection: 'column', alignItems: 'center', gap: '10px' }}>
|
||
<div style={{ width: 32, height: 32, display: 'flex', alignItems: 'center', justifyContent: 'center', color: C.navy }}
|
||
dangerouslySetInnerHTML={{ __html: a.icon_svg
|
||
.replace(/width="[^"]*"/g, '')
|
||
.replace(/height="[^"]*"/g, '')
|
||
.replace(/stroke="currentColor"/g, `stroke="${C.navy}"`)
|
||
.replace(/<svg/, '<svg style="width:100%;height:100%"') }} />
|
||
<div style={{ fontWeight: 600, color: C.navy, textAlign: 'center' }}>{a.name}</div>
|
||
<div style={{ display: 'flex', gap: '8px' }}>
|
||
<Btn size="sm" variant="secondary" onClick={() => setEditing(a)}>Edit</Btn>
|
||
<Btn size="sm" variant="danger" onClick={() => setConfirmDel(a.id)}>Del</Btn>
|
||
</div>
|
||
</div>
|
||
))}
|
||
</div>
|
||
)}
|
||
{editing && <AmenityEditor amenity={editing} setAmenity={setEditing} onSave={save} onCancel={() => setEditing(null)} />}
|
||
{confirmDel !== null && <Confirm message="Delete this amenity?" onCancel={() => setConfirmDel(null)} onConfirm={() => del(confirmDel)} />}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function AmenityEditor({ amenity, setAmenity, onSave, onCancel }: any) {
|
||
const defaultIcons = [
|
||
{ name: 'Wi-Fi', svg: '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M5 12.55a11 11 0 0 1 14.08 0"/><path d="M1.42 9a16 16 0 0 1 21.16 0"/><path d="M8.53 16.11a6 6 0 0 1 6.95 0"/><line x1="12" y1="20" x2="12.01" y2="20"/></svg>' },
|
||
{ name: 'TV', svg: '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="2" y="7" width="20" height="15" rx="2" ry="2"/><polyline points="17 2 12 7 7 2"/></svg>' },
|
||
{ name: 'Shower', svg: '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M4 4v5a.5.5 0 0 0 .5.5h3a.5.5 0 0 0 .5-.5V4"/><path d="M4 9h4"/><path d="M6 9v12"/><path d="M10 4h10a2 2 0 0 1 2 2v4a2 2 0 0 1-2 2H10"/><circle cx="6" cy="14" r="1"/><circle cx="6" cy="18" r="1"/><circle cx="10" cy="14" r="1"/><circle cx="10" cy="18" r="1"/></svg>' },
|
||
{ name: 'Bathtub', svg: '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M4 12h16a1 1 0 0 1 1 1v3a4 4 0 0 1-4 4H7a4 4 0 0 1-4-4v-3a1 1 0 0 1 1-1z"/><path d="M6 12V5a2 2 0 0 1 2-2h3v2.25"/><circle cx="12" cy="5" r=".5"/></svg>' },
|
||
{ name: 'Fireplace', svg: '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M8.5 14.5A2.5 2.5 0 0 0 11 12c0-1.38-.5-2-1-3-1.072-2.143-.14-4.052 1.5-5 .5 2 2 4 2 6a2.5 2.5 0 0 0 5 0c0-1.38-.5-2-1-3-.5-1-1-2-1-3.5 1.5 1 3 2.5 3 5a4 4 0 1 1-8 0"/><path d="M4 19h16a2 2 0 0 0 2-2v-2H2v2a2 2 0 0 0 2 2z"/></svg>' },
|
||
{ name: 'Minibar', svg: '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 3h18v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V3z"/><path d="M5 9v10a2 2 0 0 0 2 2h10a2 2 0 0 0 2-2V9"/><line x1="10" y1="13" x2="14" y2="13"/></svg>' },
|
||
{ name: 'Coffee', svg: '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M17 8h1a4 4 0 1 1 0 8h-1"/><path d="M3 8h14v9a4 4 0 0 1-4 4H7a4 4 0 0 1-4-4Z"/><line x1="6" y1="2" x2="6" y2="4"/><line x1="10" y1="2" x2="10" y2="4"/><line x1="14" y1="2" x2="14" y2="4"/></svg>' },
|
||
{ name: 'Safe', svg: '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="11" width="18" height="11" rx="2" ry="2"/><path d="M7 11V7a5 5 0 0 1 10 0v4"/><circle cx="12" cy="16" r="1"/></svg>' },
|
||
{ name: 'Heating', svg: '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M14 4v10.54a4 4 0 1 1-4 0V4a2 2 0 0 1 4 0Z"/></svg>' },
|
||
{ name: 'Balcony', svg: '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="11" width="18" height="10" rx="2"/><line x1="3" y1="17" x2="21" y2="17"/><line x1="7" y1="11" x2="7" y2="21"/><line x1="12" y1="11" x2="12" y2="21"/><line x1="17" y1="11" x2="17" y2="21"/></svg>' },
|
||
{ name: 'King Bed', svg: '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M2 18v-4a2 2 0 0 1 2-2h16a2 2 0 0 1 2 2v4"/><path d="M2 14v-2a2 2 0 0 1 2-2h1"/><path d="M22 14v-2a2 2 0 0 0-2-2h-1"/><path d="M5 4h14a2 2 0 0 1 2 2v2H3V6a2 2 0 0 1 2-2z"/><rect x="3" y="10" width="18" height="4" rx="1"/></svg>' },
|
||
{ name: 'Mountain View', svg: '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="m8 3 4 8 5-5 5 15H2L8 3z"/></svg>' },
|
||
{ name: 'Parking', svg: '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="3" width="18" height="18" rx="2" ry="2"/><path d="M9 17V7h4a3 3 0 0 1 0 6H9"/></svg>' },
|
||
{ name: 'Restaurant', svg: '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 2v7c0 1.1.9 2 2 2h4a2 2 0 0 0 2-2V2"/><path d="M7 2v20"/><path d="M21 15V2v0a5 5 0 0 0-5 5v6c0 1.1.9 2 2 2h3Zm0 0v7"/></svg>' },
|
||
];
|
||
|
||
return (
|
||
<div style={{ position: 'fixed', top: 0, left: 0, right: 0, bottom: 0, background: 'rgba(0,0,0,0.4)', display: 'flex', alignItems: 'center', justifyContent: 'center', zIndex: 9998 }}>
|
||
<div style={{ background: '#FFFFFF', borderRadius: 16, padding: '24px 28px', maxWidth: 600, width: '90%', maxHeight: '90vh', overflowY: 'auto' }}>
|
||
<h3 style={{ margin: '0 0 20px', fontSize: '18px', fontWeight: 700, color: C.navy }}>{amenity.id ? 'Edit Amenity' : 'New Amenity'}</h3>
|
||
<div style={{ display: 'grid', gap: '14px' }}>
|
||
<I label="Name" value={amenity.name} onChange={(v: string) => setAmenity({ ...amenity, name: v })} placeholder="e.g., Wi-Fi, TV, Shower" />
|
||
<div style={{ display: 'flex', flexDirection: 'column', gap: '8px' }}>
|
||
<label style={{ fontSize: '13px', fontWeight: 600, color: C.text }}>Icon (SVG)</label>
|
||
<textarea value={amenity.icon_svg} onChange={e => setAmenity({ ...amenity, icon_svg: e.target.value })}
|
||
rows={4} placeholder="<svg>...</svg>"
|
||
style={{ padding: '8px 12px', borderRadius: 8, border: `1px solid ${C.border}`, fontSize: '13px', fontFamily: 'monospace', outline: 'none', background: '#FAFAFA', resize: 'vertical' }} />
|
||
{amenity.icon_svg && (
|
||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', width: 64, height: 64, background: C.bg, borderRadius: 8, marginTop: '8px' }}
|
||
dangerouslySetInnerHTML={{ __html: amenity.icon_svg.replace(/width="[^"]*"/g, '').replace(/height="[^"]*"/g, '').replace(/stroke="currentColor"/g, `stroke="${C.navy}"`).replace(/<svg/, '<svg style="width:100%;height:100%"') }} />
|
||
)}
|
||
</div>
|
||
<div style={{ display: 'flex', flexDirection: 'column', gap: '8px' }}>
|
||
<label style={{ fontSize: '13px', fontWeight: 600, color: C.text }}>Quick Icons</label>
|
||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '6px' }}>
|
||
{defaultIcons.map(icon => (
|
||
<button key={icon.name} onClick={() => setAmenity({ ...amenity, name: icon.name, icon_svg: icon.svg })}
|
||
style={{ padding: '6px 10px', borderRadius: 6, border: `1px solid ${C.border}`, background: '#fff', cursor: 'pointer', fontSize: '12px', display: 'flex', alignItems: 'center', gap: '4px', transition: 'all 150ms' }}
|
||
onMouseEnter={e => { e.currentTarget.style.borderColor = C.gold; e.currentTarget.style.background = C.goldLight; }}
|
||
onMouseLeave={e => { e.currentTarget.style.borderColor = C.border; e.currentTarget.style.background = '#fff'; }}>
|
||
<span style={{ width: 18, height: 18, display: 'flex', alignItems: 'center', justifyContent: 'center' }} dangerouslySetInnerHTML={{ __html: icon.svg.replace(/width="[^"]*"/g, '').replace(/height="[^"]*"/g, '').replace(/stroke="currentColor"/g, `stroke="${C.navy}"`).replace(/<svg/, '<svg style="width:100%;height:100%"') }} />
|
||
{icon.name}
|
||
</button>
|
||
))}
|
||
</div>
|
||
</div>
|
||
<I label="Sort Order" type="number" value={amenity.sort_order} onChange={(v: string) => setAmenity({ ...amenity, sort_order: parseInt(v) || 0 })} />
|
||
</div>
|
||
<div style={{ display: 'flex', gap: '10px', justifyContent: 'flex-end', marginTop: '24px' }}>
|
||
<Btn onClick={onCancel} variant="secondary">Cancel</Btn>
|
||
<Btn onClick={() => onSave(amenity)}>Save</Btn>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
/* ─── Trips Section ─── */
|
||
type Trip = { id: number; user_id: number; username: string; room_id: number | null; room_name: string | null; check_in: string; check_out: string; notes: string | null; status: string };
|
||
|
||
function TripsSection({ onToast }: { onToast: (msg: string, type: string) => void }) {
|
||
const [trips, setTrips] = useState<Trip[]>([]);
|
||
const [users, setUsers] = useState<{id: number; username: string}[]>([]);
|
||
const [rooms, setRooms] = useState<{id: number; name: string}[]>([]);
|
||
const [editing, setEditing] = useState<Trip | null>(null);
|
||
const [creating, setCreating] = useState(false);
|
||
|
||
useEffect(() => {
|
||
const load = async () => {
|
||
const [tripsRes, usersRes, roomsRes] = await Promise.all([
|
||
fetch('/api/admin/trips'),
|
||
fetch('/api/admin/users'),
|
||
fetch('/api/rooms'),
|
||
]);
|
||
if (tripsRes.ok) { const d = await tripsRes.json(); setTrips(d.trips || []); }
|
||
if (usersRes.ok) { const d = await usersRes.json(); setUsers(d.users || []); }
|
||
if (roomsRes.ok) { const d = await roomsRes.json(); setRooms(Array.isArray(d) ? d : (d.data || [])); }
|
||
};
|
||
load();
|
||
}, []);
|
||
|
||
const save = async (trip: Trip) => {
|
||
try {
|
||
const method = trip.id ? 'PUT' : 'POST';
|
||
const res = await fetch('/api/admin/trips', {
|
||
method,
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify(trip),
|
||
});
|
||
if (res.ok) {
|
||
const d = await res.json();
|
||
if (trip.id) {
|
||
setTrips(prev => prev.map(t => t.id === trip.id ? d.trip : t));
|
||
} else {
|
||
setTrips(prev => [...prev, d.trip]);
|
||
}
|
||
setEditing(null);
|
||
setCreating(false);
|
||
onToast('Trip saved', 'success');
|
||
} else {
|
||
const err = await res.json();
|
||
onToast(err.error || 'Failed to save trip', 'error');
|
||
}
|
||
} catch { onToast('Error saving trip', 'error'); }
|
||
};
|
||
|
||
const del = async (id: number) => {
|
||
if (!confirm('Delete this trip?')) return;
|
||
try {
|
||
await fetch('/api/admin/trips', { method: 'DELETE', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ id }) });
|
||
setTrips(prev => prev.filter(t => t.id !== id));
|
||
onToast('Trip deleted', 'success');
|
||
} catch { onToast('Error deleting trip', 'error'); }
|
||
};
|
||
|
||
const emptyTrip: Trip = { id: 0, user_id: 0, username: '', room_id: null, room_name: null, check_in: '', check_out: '', notes: '', status: 'confirmed' };
|
||
|
||
return (
|
||
<div>
|
||
<SecHead title="✈️ Trips" count={trips.length} onAdd={() => { setCreating(true); setEditing(emptyTrip); }} />
|
||
{creating && editing && !editing.id && (
|
||
<div style={{ background: '#fff', borderRadius: 12, padding: '20px', marginBottom: '20px', boxShadow: C.shadow }}>
|
||
<h3 style={{ margin: '0 0 16px', fontSize: '16px' }}>New Trip</h3>
|
||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(200px, 1fr))', gap: '12px' }}>
|
||
<S label="User" value={editing.user_id} onChange={(v: string) => setEditing({ ...editing, user_id: Number(v) })} options={[{ value: 0, label: 'Select user' }, ...users.map(u => ({ value: u.id, label: u.username }))]} />
|
||
<S label="Room" value={editing.room_id || ''} onChange={(v: string) => setEditing({ ...editing, room_id: v ? Number(v) : null })} options={[{ value: '', label: 'No room' }, ...rooms.map(r => ({ value: r.id, label: r.name }))]} />
|
||
<I label="Check-in" type="date" value={editing.check_in} onChange={(v: string) => setEditing({ ...editing, check_in: v })} />
|
||
<I label="Check-out" type="date" value={editing.check_out} onChange={(v: string) => setEditing({ ...editing, check_out: v })} />
|
||
<S label="Status" value={editing.status} onChange={(v: string) => setEditing({ ...editing, status: v })} options={['pending', 'confirmed', 'checked_in', 'checked_out', 'cancelled'].map(s => ({ value: s, label: s.replace('_', ' ') }))} />
|
||
</div>
|
||
<TA label="Notes" value={editing.notes || ''} onChange={(v: string) => setEditing({ ...editing, notes: v })} rows={2} />
|
||
<div style={{ display: 'flex', gap: '8px', marginTop: '16px' }}>
|
||
<Btn onClick={() => save(editing)}>Save Trip</Btn>
|
||
<Btn variant="secondary" onClick={() => { setCreating(false); setEditing(null); }}>Cancel</Btn>
|
||
</div>
|
||
</div>
|
||
)}
|
||
<div style={{ display: 'flex', flexDirection: 'column', gap: '12px' }}>
|
||
{trips.map(t => (
|
||
<div key={t.id} style={{ background: '#fff', borderRadius: 12, padding: '16px', boxShadow: C.shadow, display: 'flex', alignItems: 'center', gap: '16px', flexWrap: 'wrap' }}>
|
||
<div style={{ flex: '1 1 200px' }}>
|
||
<div style={{ fontWeight: 600, color: C.navy }}>{t.username || `User ${t.user_id}`}</div>
|
||
<div style={{ fontSize: '13px', color: C.textLight }}>{t.room_name || 'No room assigned'}</div>
|
||
</div>
|
||
<div style={{ fontSize: '13px', color: C.text }}>
|
||
<strong>In:</strong> {formatDisplayDate(t.check_in)} · <strong>Out:</strong> {formatDisplayDate(t.check_out)}
|
||
</div>
|
||
<Badge color={t.status === 'confirmed' ? C.success : t.status === 'checked_in' ? '#1976d2' : t.status === 'cancelled' ? C.danger : '#666'}>{t.status.replace('_', ' ')}</Badge>
|
||
<div style={{ display: 'flex', gap: '8px' }}>
|
||
<Btn size="sm" onClick={() => { setCreating(false); setEditing(t); }}>Edit</Btn>
|
||
<Btn size="sm" variant="danger" onClick={() => del(t.id)}>Delete</Btn>
|
||
</div>
|
||
</div>
|
||
))}
|
||
</div>
|
||
{!creating && editing && editing.id && (
|
||
<div style={{ position: 'fixed', inset: 0, background: 'rgba(0,0,0,0.5)', display: 'flex', alignItems: 'center', justifyContent: 'center', zIndex: 1000 }} onClick={() => setEditing(null)}>
|
||
<div style={{ background: '#fff', borderRadius: 12, padding: '24px', maxWidth: '500px', width: '90%' }} onClick={e => e.stopPropagation()}>
|
||
<h3 style={{ margin: '0 0 16px' }}>Edit Trip</h3>
|
||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '12px' }}>
|
||
<S label="User" value={editing.user_id} onChange={(v: string) => setEditing({ ...editing, user_id: Number(v) })} options={[{ value: 0, label: 'Select user' }, ...users.map(u => ({ value: u.id, label: u.username }))]} />
|
||
<S label="Room" value={editing.room_id || ''} onChange={(v: string) => setEditing({ ...editing, room_id: v ? Number(v) : null })} options={[{ value: '', label: 'No room' }, ...rooms.map(r => ({ value: r.id, label: r.name }))]} />
|
||
<I label="Check-in" type="date" value={editing.check_in} onChange={(v: string) => setEditing({ ...editing, check_in: v })} />
|
||
<I label="Check-out" type="date" value={editing.check_out} onChange={(v: string) => setEditing({ ...editing, check_out: v })} />
|
||
<S label="Status" value={editing.status} onChange={(v: string) => setEditing({ ...editing, status: v })} options={['pending', 'confirmed', 'checked_in', 'checked_out', 'cancelled'].map(s => ({ value: s, label: s.replace('_', ' ') }))} />
|
||
</div>
|
||
<TA label="Notes" value={editing.notes || ''} onChange={(v: string) => setEditing({ ...editing, notes: v })} rows={2} />
|
||
<div style={{ display: 'flex', gap: '8px', marginTop: '16px' }}>
|
||
<Btn onClick={() => save(editing)}>Save</Btn>
|
||
<Btn variant="secondary" onClick={() => setEditing(null)}>Cancel</Btn>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
)}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
/* ─── Messages Section ─── */
|
||
type MsgThread = { user_id: number; username: string; message: string; created_at: string };
|
||
type Msg = { id: number; sender_role: string; message: string; created_at: string };
|
||
|
||
function MessagesSection({ onToast }: { onToast: (msg: string, type: string) => void }) {
|
||
const [threads, setThreads] = useState<MsgThread[]>([]);
|
||
const [selectedUser, setSelectedUser] = useState<number | null>(null);
|
||
const [messages, setMessages] = useState<Msg[]>([]);
|
||
const [reply, setReply] = useState('');
|
||
const messagesEndRef = useRef<HTMLDivElement>(null);
|
||
|
||
useEffect(() => {
|
||
const load = async () => {
|
||
const res = await fetch('/api/admin/messages');
|
||
if (res.ok) { const d = await res.json(); setThreads(d.threads || []); }
|
||
};
|
||
load();
|
||
}, []);
|
||
|
||
useEffect(() => {
|
||
if (selectedUser) {
|
||
const load = async () => {
|
||
const res = await fetch(`/api/admin/messages?user_id=${selectedUser}`);
|
||
if (res.ok) { const d = await res.json(); setMessages(d.messages || []); }
|
||
};
|
||
load();
|
||
}
|
||
}, [selectedUser]);
|
||
|
||
useEffect(() => {
|
||
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' });
|
||
}, [messages]);
|
||
|
||
const sendReply = async () => {
|
||
if (!reply.trim() || !selectedUser) return;
|
||
try {
|
||
const res = await fetch('/api/admin/messages', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ user_id: selectedUser, message: reply.trim() }),
|
||
});
|
||
if (res.ok) {
|
||
const d = await res.json();
|
||
setMessages(prev => [...prev, d.message]);
|
||
setReply('');
|
||
onToast('Reply sent', 'success');
|
||
}
|
||
} catch { onToast('Error sending reply', 'error'); }
|
||
};
|
||
|
||
return (
|
||
<div style={{ display: 'grid', gridTemplateColumns: '300px 1fr', gap: '20px', minHeight: '400px' }}>
|
||
<div style={{ background: '#fff', borderRadius: 12, overflow: 'hidden', boxShadow: C.shadow }}>
|
||
<div style={{ padding: '16px', borderBottom: `1px solid ${C.border}`, fontWeight: 600, color: C.navy }}>Conversations</div>
|
||
<div style={{ maxHeight: '400px', overflowY: 'auto' }}>
|
||
{threads.length === 0 ? (
|
||
<div style={{ padding: '20px', textAlign: 'center', color: C.textLight }}>No conversations yet</div>
|
||
) : (
|
||
threads.map(t => (
|
||
<div key={t.user_id} onClick={() => setSelectedUser(t.user_id)} style={{ padding: '12px 16px', cursor: 'pointer', background: selectedUser === t.user_id ? C.goldLight : 'transparent', borderBottom: `1px solid ${C.border}` }}
|
||
onMouseEnter={e => { if (selectedUser !== t.user_id) (e.currentTarget.style as any).background = '#f5f5f5'; }}
|
||
onMouseLeave={e => { if (selectedUser !== t.user_id) (e.currentTarget.style as any).background = 'transparent'; }}>
|
||
<div style={{ fontWeight: 500, color: C.navy }}>{t.username}</div>
|
||
<div style={{ fontSize: '12px', color: C.textLight, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{t.message}</div>
|
||
</div>
|
||
))
|
||
)}
|
||
</div>
|
||
</div>
|
||
<div style={{ background: '#fff', borderRadius: 12, overflow: 'hidden', boxShadow: C.shadow, display: 'flex', flexDirection: 'column' }}>
|
||
{selectedUser ? (
|
||
<>
|
||
<div style={{ padding: '16px', borderBottom: `1px solid ${C.border}`, fontWeight: 600, color: C.navy }}>
|
||
{threads.find(t => t.user_id === selectedUser)?.username || `User ${selectedUser}`}
|
||
</div>
|
||
<div style={{ flex: 1, overflowY: 'auto', padding: '16px', maxHeight: '300px' }}>
|
||
{messages.map(m => (
|
||
<div key={m.id} style={{ display: 'flex', justifyContent: m.sender_role === 'admin' ? 'flex-start' : 'flex-end', marginBottom: '12px' }}>
|
||
<div style={{ maxWidth: '70%', padding: '10px 14px', borderRadius: 12, background: m.sender_role === 'admin' ? C.gold : '#f0f0f0', color: m.sender_role === 'admin' ? '#fff' : C.navy }}>
|
||
<div style={{ fontSize: '12px', fontWeight: 500, marginBottom: '4px' }}>{m.sender_role === 'admin' ? 'Staff' : 'Guest'}</div>
|
||
<div style={{ fontSize: '14px' }}>{m.message}</div>
|
||
<div style={{ fontSize: '11px', opacity: 0.7, marginTop: '4px' }}>{formatDisplayDateTime(m.created_at)}</div>
|
||
</div>
|
||
</div>
|
||
))}
|
||
<div ref={messagesEndRef} />
|
||
</div>
|
||
<div style={{ padding: '12px', borderTop: `1px solid ${C.border}`, display: 'flex', gap: '8px' }}>
|
||
<input value={reply} onChange={e => setReply(e.target.value)} onKeyDown={e => { if (e.key === 'Enter') sendReply(); }} placeholder="Type a reply..." style={{ flex: 1, padding: '10px 14px', borderRadius: 8, border: `1px solid ${C.border}`, fontSize: '14px', outline: 'none' }} />
|
||
<Btn onClick={sendReply} disabled={!reply.trim()}>Send</Btn>
|
||
</div>
|
||
</>
|
||
) : (
|
||
<div style={{ flex: 1, display: 'flex', alignItems: 'center', justifyContent: 'center', color: C.textLight }}>Select a conversation to view messages</div>
|
||
)}
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
/* ─── Menu Section ─── */
|
||
function MenuSection({ items, setItems, images, onToast }: any) {
|
||
const [editing, setEditing] = useState<MenuItem | null>(null);
|
||
const [confirmDel, setConfirmDel] = useState<number | null>(null);
|
||
const [dragIdx, setDragIdx] = useState<number | null>(null);
|
||
const [overIdx, setOverIdx] = useState<number | null>(null);
|
||
|
||
const save = async (item: MenuItem) => {
|
||
if (!item.category || !item.name || !item.price) {
|
||
onToast('Category, name, and price are required', 'error');
|
||
return;
|
||
}
|
||
try {
|
||
const url = item.id ? `/api/menu/${item.id}` : '/api/menu/';
|
||
const res = await fetch(url, {
|
||
method: item.id ? 'PUT' : 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify(item),
|
||
});
|
||
if (res.ok) {
|
||
const data = await res.json();
|
||
setEditing(null);
|
||
if (item.id) {
|
||
setItems((i: any) => i.map((x: any) => x.id === item.id ? data.data : x));
|
||
} else {
|
||
setItems((i: any) => [...i, data.data]);
|
||
}
|
||
onToast('Menu item saved!', 'success');
|
||
} else {
|
||
const err = await res.json().catch(() => ({}));
|
||
onToast(err.error || 'Failed to save menu item', 'error');
|
||
}
|
||
} catch { onToast('Error', 'error'); }
|
||
};
|
||
|
||
const del = async (id: number) => {
|
||
await fetch(`/api/menu/${id}`, { method: 'DELETE' });
|
||
setItems((i: any) => i.filter((x: any) => x.id !== id));
|
||
onToast('Item deleted', 'success');
|
||
setConfirmDel(null);
|
||
};
|
||
|
||
const toggleVisible = async (item: MenuItem) => {
|
||
const updated = { ...item, active: !item.active };
|
||
try {
|
||
await fetch(`/api/menu/${item.id}`, {
|
||
method: 'PUT',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ ...item, active: updated.active }),
|
||
});
|
||
setItems((i: any) => i.map((x: any) => x.id === item.id ? updated : x));
|
||
} catch { onToast('Error', 'error'); }
|
||
};
|
||
|
||
// ─── Drag-and-drop reordering ───
|
||
const handleDragStart = (idx: number) => { setDragIdx(idx); };
|
||
|
||
const handleDragOver = (e: any, idx: number) => {
|
||
e.preventDefault();
|
||
if (dragIdx !== null && dragIdx !== idx) setOverIdx(idx);
|
||
};
|
||
|
||
const handleDragEnd = () => {
|
||
if (dragIdx !== null && overIdx !== null && dragIdx !== overIdx) {
|
||
// Use sorted array for correct index mapping
|
||
const reordered = [...sorted];
|
||
const [moved] = reordered.splice(dragIdx, 1);
|
||
reordered.splice(overIdx, 0, moved);
|
||
// Update sort_order based on new position
|
||
const updated = reordered.map((it: MenuItem, i: number) => ({ ...it, sort_order: i }));
|
||
console.log('Drag end:', { dragIdx, overIdx, oldOrder: sorted.map((it: MenuItem) => it.name), newOrder: updated.map((it: MenuItem) => it.name) });
|
||
setItems(updated);
|
||
}
|
||
setDragIdx(null);
|
||
setOverIdx(null);
|
||
};
|
||
|
||
const saveOrder = async () => {
|
||
try {
|
||
// Use items state (which has updated sort_order from drag)
|
||
const currentItems = [...items].sort((a, b) => (a.sort_order || 0) - (b.sort_order || 0));
|
||
const order = currentItems.map((it: MenuItem, i: number) => ({ id: it.id, sort_order: i }));
|
||
console.log('Saving order:', order);
|
||
const res = await fetch('/api/menu/reorder', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ order }),
|
||
});
|
||
if (res.ok) {
|
||
onToast('Order saved!', 'success');
|
||
} else {
|
||
const err = await res.json().catch(() => ({}));
|
||
console.error('Save order error:', err);
|
||
onToast(err.error || 'Error saving order', 'error');
|
||
}
|
||
} catch (e) {
|
||
console.error('Save order exception:', e);
|
||
onToast('Error reordering', 'error');
|
||
}
|
||
};
|
||
|
||
const sorted = [...items].sort((a, b) => (a.sort_order || 0) - (b.sort_order || 0));
|
||
|
||
return (
|
||
<div>
|
||
<SecHead title="🍽️ Menu" count={items.length}
|
||
action={<Btn onClick={() => setEditing({ id: 0, category: '', name: '', description: '', price: '', is_daily_special: false, active: true, sort_order: items.length, photos: [], featured_photo: null })} size="sm">+ New Item</Btn>} />
|
||
{sorted.length === 0 ? <Empty icon="🍽️" title="No menu items" /> : (
|
||
<div style={{ background: C.card, borderRadius: 16, overflow: 'hidden', boxShadow: C.shadow }}>
|
||
{/* Drag handle instruction */}
|
||
<div style={{ padding: '8px 16px', background: C.goldLight, fontSize: '12px', color: C.textLight, display: 'flex', alignItems: 'center', gap: '8px' }}>
|
||
<span>⠿</span> Drag items to reorder · Toggle visibility to hide from the public site
|
||
</div>
|
||
|
||
{sorted.map((item: MenuItem, idx: number) => {
|
||
const isDragging = dragIdx === idx;
|
||
const isOver = overIdx === idx && dragIdx !== idx;
|
||
return (
|
||
<div
|
||
key={item.id}
|
||
draggable
|
||
onDragStart={(e: any) => { e.dataTransfer!.effectAllowed = 'move'; handleDragStart(idx); }}
|
||
onDragOver={(e) => handleDragOver(e, idx)}
|
||
onDragEnd={handleDragEnd}
|
||
onDragEnter={() => setOverIdx(idx)}
|
||
style={{
|
||
display: 'flex',
|
||
alignItems: 'center',
|
||
gap: '12px',
|
||
padding: '12px 16px',
|
||
border: isOver ? `2px dashed ${C.gold}` : idx === 0 ? 'none' : `1px solid ${C.border}`,
|
||
borderTop: isDragging ? `2px dashed ${C.gold}` : 'none',
|
||
background: isDragging ? 'rgba(212,168,83,0.06)' : isOver ? C.goldLight : idx % 2 === 0 ? '#fff' : '#FAFAFA',
|
||
opacity: isDragging ? 0.5 : 1,
|
||
cursor: 'grab',
|
||
transition: 'all 150ms ease',
|
||
borderRadius: idx === 0 ? '0' : '0',
|
||
}}
|
||
onMouseEnter={(e) => {
|
||
if (!isDragging) e.currentTarget.style.background = C.goldLight;
|
||
}}
|
||
onMouseLeave={(e) => {
|
||
if (!isDragging && !isOver) e.currentTarget.style.background = idx % 2 === 0 ? '#fff' : '#FAFAFA';
|
||
}}
|
||
>
|
||
{/* Drag handle */}
|
||
<span style={{ fontSize: '18px', color: C.textLight, userSelect: 'none', flexShrink: 0, cursor: 'grab' }}
|
||
onMouseDown={() => {}}>⠿</span>
|
||
|
||
{/* Position number */}
|
||
<span style={{ fontSize: '13px', fontWeight: 700, color: C.textLight, width: '22px', textAlign: 'center', flexShrink: 0 }}>
|
||
{idx + 1}
|
||
</span>
|
||
|
||
{/* Visible/invisible toggle */}
|
||
<label style={{ display: 'flex', alignItems: 'center', gap: '6px', cursor: 'pointer', flexShrink: 0 }}
|
||
onClick={(e) => { e.stopPropagation(); toggleVisible(item); }}>
|
||
<div style={{
|
||
width: '42px', height: '24px', borderRadius: '12px', position: 'relative',
|
||
background: item.active ? C.success : '#ccc',
|
||
transition: 'background 200ms',
|
||
flexShrink: 0,
|
||
}}>
|
||
<div style={{
|
||
width: '20px', height: '20px', borderRadius: '50%', background: '#fff',
|
||
position: 'absolute', top: '2px',
|
||
left: item.active ? '20px' : '2px',
|
||
transition: 'left 200ms',
|
||
boxShadow: '0 1px 3px rgba(0,0,0,0.2)',
|
||
}} />
|
||
</div>
|
||
<span style={{ fontSize: '12px', color: item.active ? C.success : C.textLight, fontWeight: 600, minWidth: '24px' }}>
|
||
{item.active ? 'On' : 'Off'}
|
||
</span>
|
||
</label>
|
||
|
||
{/* Category badge */}
|
||
<span style={{
|
||
fontSize: '11px', fontWeight: 700, padding: '3px 8px', borderRadius: 6,
|
||
background: C.navy, color: '#fff', flexShrink: 0,
|
||
}}>{item.category}</span>
|
||
|
||
{/* Photo thumbnail */}
|
||
{(item.photos && item.photos.length > 0) && (
|
||
<div style={{ width: 40, height: 30, borderRadius: 6, overflow: 'hidden', flexShrink: 0, border: `1px solid ${C.border}` }}>
|
||
<img
|
||
src={item.featured_photo || item.photos[0]}
|
||
alt=""
|
||
style={{ width: '100%', height: '100%', objectFit: 'cover' }}
|
||
/>
|
||
</div>
|
||
)}
|
||
|
||
{/* Name & Description */}
|
||
<div style={{ flex: 1, minWidth: 0 }}>
|
||
<div style={{ fontWeight: 600, color: C.navy, fontSize: '14px', whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>
|
||
{item.name}
|
||
</div>
|
||
{item.description && (
|
||
<div style={{ fontSize: '12px', color: C.textLight, whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>
|
||
{item.description}
|
||
</div>
|
||
)}
|
||
</div>
|
||
|
||
{/* Price */}
|
||
<span style={{ fontWeight: 700, color: C.warm, fontSize: '14px', flexShrink: 0 }}>
|
||
{item.price}
|
||
</span>
|
||
|
||
{/* Actions */}
|
||
<div style={{ display: 'flex', gap: '6px', flexShrink: 0 }}>
|
||
<Btn size="sm" variant="secondary" onClick={() => setEditing(item)} style={{ fontSize: '11px', padding: '3px 8px' }}>✏️</Btn>
|
||
<Btn size="sm" variant="danger" onClick={() => setConfirmDel(item.id)} style={{ fontSize: '11px', padding: '3px 8px' }}>🗑️</Btn>
|
||
</div>
|
||
</div>
|
||
);
|
||
})}
|
||
|
||
{/* Save order button */}
|
||
<div style={{ padding: '12px 16px', borderTop: `1px solid ${C.border}`, display: 'flex', justifyContent: 'flex-end' }}>
|
||
<Btn onClick={saveOrder} size="sm">💾 Save Order</Btn>
|
||
</div>
|
||
</div>
|
||
)}
|
||
{editing && <MenuEditor item={editing} setItem={setEditing} onSave={save} onCancel={() => setEditing(null)} />}
|
||
{confirmDel !== null && <Confirm message="Delete this menu item?" onCancel={() => setConfirmDel(null)} onConfirm={() => del(confirmDel)} />}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function MenuEditor({ item, setItem, onSave, onCancel }: any) {
|
||
const [localPhotos, setLocalPhotos] = useState<string[]>(item.photos || []);
|
||
const [featuredPhoto, setFeaturedPhoto] = useState<string | null>(item.featured_photo || null);
|
||
const i = { ...item, photos: localPhotos, featured_photo: featuredPhoto };
|
||
|
||
const addPhotos = async (files: FileList) => {
|
||
const newPhotos: string[] = [];
|
||
for (const f of Array.from(files)) {
|
||
const b64 = await readFileBase64(f);
|
||
try {
|
||
const res = await fetch('/api/admin/images', {
|
||
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ filename: f.name, data: b64, category: 'menu', room_id: null, location_target: `menu-${i.id || 'new'}` }),
|
||
});
|
||
if (res.ok) { const d = await res.json(); newPhotos.push(d.data.data); }
|
||
} catch { const b = await readFileBase64(f); newPhotos.push(b); }
|
||
}
|
||
setLocalPhotos(prev => [...prev, ...newPhotos]);
|
||
};
|
||
|
||
return (
|
||
<div style={{ position: 'fixed', top: 0, left: 0, right: 0, bottom: 0, background: 'rgba(0,0,0,0.4)', display: 'flex', alignItems: 'center', justifyContent: 'center', zIndex: 9998 }}>
|
||
<div style={{ background: '#FFFFFF', borderRadius: 16, padding: '24px 28px', maxWidth: 500, width: '90%', maxHeight: '90vh', overflowY: 'auto' }}>
|
||
<h3 style={{ margin: '0 0 20px', fontSize: '18px', fontWeight: 700, color: C.navy }}>{i.id ? 'Edit Item' : 'New Item'}</h3>
|
||
<div style={{ display: 'grid', gap: '14px' }}>
|
||
<I label="Category" value={i.category} onChange={(v: string) => setItem({ ...i, category: v })} />
|
||
<I label="Name" value={i.name} onChange={(v: string) => setItem({ ...i, name: v })} />
|
||
<TA label="Description" value={i.description || ''} onChange={(v: string) => setItem({ ...i, description: v })} />
|
||
<I label="Price" value={i.price} onChange={(v: string) => setItem({ ...i, price: v })} />
|
||
|
||
{/* Photo upload */}
|
||
<div style={{ display: 'flex', flexDirection: 'column', gap: '8px' }}>
|
||
<label style={{ fontSize: '13px', fontWeight: 600, color: C.text }}>Photos</label>
|
||
<div style={{ display: 'flex', gap: '8px', flexWrap: 'wrap' }}>
|
||
<Btn onClick={() => document.getElementById(`menu-photos-${i.id}`)?.click()} size="sm" variant="secondary">+ Add Photos</Btn>
|
||
<input id={`menu-photos-${i.id}`} type="file" accept="image/*" multiple style={{ display: 'none' }}
|
||
onChange={e => { addPhotos(e.target.files!); e.target.value = ''; }} />
|
||
</div>
|
||
<div style={{ display: 'flex', gap: '6px', flexWrap: 'wrap' }}>
|
||
{localPhotos.map((ph, idx) => (
|
||
<div key={idx} style={{ position: 'relative' }}>
|
||
<img
|
||
src={ph}
|
||
alt=""
|
||
onClick={() => setFeaturedPhoto(ph)}
|
||
style={{
|
||
width: 64, height: 48, objectFit: 'cover', borderRadius: 6, border: featuredPhoto === ph ? `2px solid ${C.gold}` : '1px solid #eee',
|
||
cursor: 'pointer',
|
||
}}
|
||
/>
|
||
<button onClick={() => { setLocalPhotos(prev => prev.filter((_, j) => j !== idx)); if (featuredPhoto === ph) setFeaturedPhoto(null); }}
|
||
style={{ position: 'absolute', top: -4, right: -4, width: 18, height: 18, borderRadius: '50%', background: C.danger, color: '#fff', border: 'none', cursor: 'pointer', fontSize: '11px', lineHeight: 18, padding: 0 }}>×</button>
|
||
</div>
|
||
))}
|
||
</div>
|
||
{localPhotos.length > 0 && <div style={{ fontSize: '11px', color: C.textLight }}>Click a photo to set as featured</div>}
|
||
</div>
|
||
|
||
<Tgl label="Daily Special" checked={i.is_daily_special} onChange={(v: boolean) => setItem({ ...i, is_daily_special: v })} />
|
||
<Tgl label="Active" checked={i.active} onChange={(v: boolean) => setItem({ ...i, active: v })} />
|
||
</div>
|
||
<div style={{ display: 'flex', gap: '10px', justifyContent: 'flex-end', marginTop: '24px' }}>
|
||
<Btn onClick={onCancel} variant="secondary">Cancel</Btn>
|
||
<Btn onClick={() => onSave(i)}>Save</Btn>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
/* ─── Places Section ─── */
|
||
function PlacesSection({ places, setPlaces, images, onToast }: any) {
|
||
const [editing, setEditing] = useState<Place | null>(null);
|
||
const [confirmDel, setConfirmDel] = useState<number | null>(null);
|
||
|
||
const save = async (place: Place) => {
|
||
try {
|
||
const url = place.id ? `/api/places/${place.id}` : '/api/places';
|
||
const res = await fetch(url, {
|
||
method: place.id ? 'PUT' : 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify(place),
|
||
});
|
||
if (res.ok) {
|
||
const data = await res.json();
|
||
setEditing(null);
|
||
setPlaces((p: any) => place.id ? p.map((x: Place) => x.id === place.id ? data.data : x) : [...p, data.data]);
|
||
onToast('Place saved!', 'success');
|
||
}
|
||
} catch { onToast('Error', 'error'); }
|
||
};
|
||
|
||
const del = async (id: number) => {
|
||
await fetch(`/api/places/${id}`, { method: 'DELETE' });
|
||
setPlaces((p: any) => p.filter((x: Place) => x.id !== id));
|
||
onToast('Place deleted', 'success');
|
||
setConfirmDel(null);
|
||
};
|
||
|
||
return (
|
||
<div>
|
||
<SecHead title="🗺️ Landscape Places" count={places.length}
|
||
action={<Btn onClick={() => setEditing({ id: 0, name: '', description: '', photos: [], featured_photo: null, distance_km: '', visit_time: '', suggestion: '', active: true, sort_order: places.length })} size="sm">+ New Place</Btn>} />
|
||
{places.length === 0 ? <Empty icon="🗺️" title="No places" /> : (
|
||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(280px, 1fr))', gap: '16px' }}>
|
||
{places.map((p: Place) => (
|
||
<EntityCard key={p.id} title={p.name} desc={p.description || ''} meta={`${p.distance_km || ''} ${p.visit_time ? '• ' + p.visit_time : ''}`}
|
||
thumb={p.featured_photo || p.photos?.[0]}
|
||
photoUrls={p.photos}
|
||
actions={
|
||
<>
|
||
<Btn size="sm" variant="secondary" onClick={() => setEditing(p)}>Edit</Btn>
|
||
<Btn size="sm" variant="danger" onClick={() => setConfirmDel(p.id)}>Del</Btn>
|
||
</>
|
||
} />
|
||
))}
|
||
</div>
|
||
)}
|
||
{editing && <PlaceEditor place={editing} setPlace={setEditing} images={images} onSave={save} onCancel={() => setEditing(null)} />}
|
||
{confirmDel !== null && <Confirm message="Delete this place?" onCancel={() => setConfirmDel(null)} onConfirm={() => del(confirmDel)} />}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function PlaceEditor({ place, setPlace, images, onSave, onCancel }: any) {
|
||
const [localPhotos, setLocalPhotos] = useState<string[]>(place.photos || []);
|
||
const p = { ...place, photos: localPhotos };
|
||
|
||
const addPhotos = async (files: FileList) => {
|
||
const newPhotos: string[] = [];
|
||
for (const f of Array.from(files)) {
|
||
const b64 = await readFileBase64(f);
|
||
try {
|
||
const res = await fetch('/api/admin/images', {
|
||
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ filename: f.name, data: b64, category: 'landscape', room_id: null, location_target: `place-${p.id || 'new'}` }),
|
||
});
|
||
if (res.ok) { const d = await res.json(); newPhotos.push(d.data.data); }
|
||
} catch { const b = await readFileBase64(f); newPhotos.push(b); }
|
||
}
|
||
setLocalPhotos(prev => [...prev, ...newPhotos]);
|
||
};
|
||
|
||
return (
|
||
<div style={{ position: 'fixed', top: 0, left: 0, right: 0, bottom: 0, background: 'rgba(0,0,0,0.4)', display: 'flex', alignItems: 'center', justifyContent: 'center', zIndex: 9998 }}>
|
||
<div style={{ background: '#FFFFFF', borderRadius: 16, padding: '24px 28px', maxWidth: 500, width: '90%', maxHeight: '90vh', overflowY: 'auto' }}>
|
||
<h3 style={{ margin: '0 0 20px', fontSize: '18px', fontWeight: 700, color: C.navy }}>{p.id ? 'Edit Place' : 'New Place'}</h3>
|
||
<div style={{ display: 'grid', gap: '14px' }}>
|
||
<I label="Name" value={p.name} onChange={(v: string) => setPlace({ ...p, name: v })} />
|
||
<TA label="Description" value={p.description || ''} onChange={(v: string) => setPlace({ ...p, description: v })} />
|
||
<I label="Distance (km)" value={p.distance_km} onChange={(v: string) => setPlace({ ...p, distance_km: v })} />
|
||
<I label="Visit Time" value={p.visit_time} onChange={(v: string) => setPlace({ ...p, visit_time: v })} />
|
||
<TA label="Suggestion" value={p.suggestion || ''} onChange={(v: string) => setPlace({ ...p, suggestion: v })} />
|
||
<div style={{ display: 'flex', flexDirection: 'column', gap: '8px' }}>
|
||
<label style={{ fontSize: '13px', fontWeight: 600, color: C.text }}>Photos</label>
|
||
<div style={{ display: 'flex', gap: '8px', flexWrap: 'wrap' }}>
|
||
<Btn onClick={() => document.getElementById(`place-photos-${p.id}`)?.click()} size="sm" variant="secondary">+ Add Photos</Btn>
|
||
<input id={`place-photos-${p.id}`} type="file" accept="image/*" multiple style={{ display: 'none' }}
|
||
onChange={e => { addPhotos(e.target.files!); e.target.value = ''; }} />
|
||
</div>
|
||
<div style={{ display: 'flex', gap: '6px', flexWrap: 'wrap' }}>
|
||
{localPhotos.map((ph, i) => (
|
||
<div key={i} style={{ position: 'relative' }}>
|
||
<img src={ph} alt="" style={{ width: 64, height: 48, objectFit: 'cover', borderRadius: 6, border: '1px solid #eee' }} />
|
||
<button onClick={() => setLocalPhotos(prev => prev.filter((_, j) => j !== i))}
|
||
style={{ position: 'absolute', top: -4, right: -4, width: 18, height: 18, borderRadius: '50%', background: C.danger, color: '#fff', border: 'none', cursor: 'pointer', fontSize: '11px', lineHeight: 18, padding: 0 }}>×</button>
|
||
</div>
|
||
))}
|
||
</div>
|
||
</div>
|
||
<Tgl label="Active" checked={p.active} onChange={(v: boolean) => setPlace({ ...p, active: v })} />
|
||
</div>
|
||
<div style={{ display: 'flex', gap: '10px', justifyContent: 'flex-end', marginTop: '24px' }}>
|
||
<Btn onClick={onCancel} variant="secondary">Cancel</Btn>
|
||
<Btn onClick={() => onSave(p)}>Save</Btn>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
/* ─── Reviews Section ─── */
|
||
function ReviewsSection({ reviews, setReviews, onToast }: any) {
|
||
const approve = async (id: number) => {
|
||
try {
|
||
await fetch(`/api/reviews/${id}`, {
|
||
method: 'PUT', headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ approved: true }),
|
||
});
|
||
setReviews((r: any) => r.map((x: Review) => x.id === id ? { ...x, approved: true } : x));
|
||
onToast('Review approved!', 'success');
|
||
} catch { onToast('Error', 'error'); }
|
||
};
|
||
|
||
const reject = async (id: number) => {
|
||
try {
|
||
await fetch(`/api/reviews/${id}`, {
|
||
method: 'PUT', headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ approved: false }),
|
||
});
|
||
setReviews((r: any) => r.map((x: Review) => x.id === id ? { ...x, approved: false } : x));
|
||
onToast('Review hidden', 'success');
|
||
} catch { onToast('Error', 'error'); }
|
||
};
|
||
|
||
return (
|
||
<div>
|
||
<SecHead title="⭐ Reviews" count={reviews.length} />
|
||
{reviews.length === 0 ? <Empty icon="⭐" title="No reviews" /> : (
|
||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(300px, 1fr))', gap: '16px' }}>
|
||
{reviews.map((r: Review) => (
|
||
<div key={r.id} style={{ background: C.card, borderRadius: 16, padding: '16px 20px', boxShadow: C.shadow, border: `1px solid ${C.border}` }}>
|
||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '8px' }}>
|
||
<span style={{ fontWeight: 700, color: C.navy }}>{r.author_name}</span>
|
||
<Badge color={r.approved ? C.success : '#FF9800'}>{r.approved ? 'Approved' : 'Pending'}</Badge>
|
||
</div>
|
||
<div style={{ fontSize: '14px', color: '#FFB400', marginBottom: '8px', letterSpacing: 2 }}>
|
||
{'★'.repeat(r.rating)}{'☆'.repeat(5 - r.rating)}
|
||
</div>
|
||
<p style={{ margin: '0 0 12px', fontSize: '13px', color: C.text, lineHeight: 1.5 }}>{r.text}</p>
|
||
<div style={{ display: 'flex', gap: '8px' }}>
|
||
{!r.approved && <Btn size="sm" variant="secondary" onClick={() => approve(r.id)}>Approve</Btn>}
|
||
{r.approved && <Btn size="sm" variant="secondary" onClick={() => reject(r.id)}>Hide</Btn>}
|
||
</div>
|
||
</div>
|
||
))}
|
||
</div>
|
||
)}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
/* ─── Brand Section ─── */
|
||
function BrandSection({ images, onToast }: { images: Image[]; onToast: (m: string, t: string) => void }) {
|
||
const [config, setConfig] = useState<FooterConfig>({
|
||
logo: '', favicon: '', tagline: 'Tu refugio en la naturaleza',
|
||
facebook_url: '', instagram_url: '', whatsapp_url: '',
|
||
address: '', phone: '', email: '',
|
||
});
|
||
const [loading, setLoading] = useState(true);
|
||
const [saving, setSaving] = useState(false);
|
||
|
||
useEffect(() => {
|
||
loadConfig();
|
||
}, []);
|
||
|
||
const loadConfig = async () => {
|
||
try {
|
||
const res = await fetch('/api/site-assets');
|
||
if (res.ok) {
|
||
const data = await res.json();
|
||
if (data.data) {
|
||
setConfig(prev => ({ ...prev, ...data.data }));
|
||
}
|
||
}
|
||
} catch (err) {
|
||
console.error('Failed to load config:', err);
|
||
} finally {
|
||
setLoading(false);
|
||
}
|
||
};
|
||
|
||
const handleUpload = async (key: keyof FooterConfig) => {
|
||
const input = document.createElement('input');
|
||
input.type = 'file';
|
||
input.accept = 'image/*';
|
||
input.onchange = async (e) => {
|
||
const file = (e.target as HTMLInputElement).files?.[0];
|
||
if (!file) return;
|
||
const b64 = await readFileBase64(file);
|
||
try {
|
||
const res = await fetch('/api/admin/images', {
|
||
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ filename: file.name, data: b64, category: 'other', room_id: null, location_target: key }),
|
||
});
|
||
if (res.ok) {
|
||
const d = await res.json();
|
||
setConfig(prev => ({ ...prev, [key]: d.data.data }));
|
||
onToast('Image uploaded!', 'success');
|
||
}
|
||
} catch { onToast('Upload failed', 'error'); }
|
||
};
|
||
input.click();
|
||
};
|
||
|
||
const saveConfig = async () => {
|
||
setSaving(true);
|
||
try {
|
||
const res = await fetch('/api/site-assets', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ values: config }),
|
||
});
|
||
if (res.ok) {
|
||
onToast('Configuration saved!', 'success');
|
||
} else {
|
||
onToast('Failed to save', 'error');
|
||
}
|
||
} catch {
|
||
onToast('Failed to save', 'error');
|
||
} finally {
|
||
setSaving(false);
|
||
}
|
||
};
|
||
|
||
if (loading) return <div style={{ padding: 20, color: C.textLight }}>Loading...</div>;
|
||
|
||
return (
|
||
<div>
|
||
<SecHead title="🎨 Brand & Identity" />
|
||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(240px, 1fr))', gap: '20px' }}>
|
||
{(['logo', 'favicon'] as (keyof FooterConfig)[]).map(key => (
|
||
<div key={key} style={{ background: C.card, borderRadius: 16, padding: '20px', boxShadow: C.shadow, textAlign: 'center', border: `1px solid ${C.border}` }}>
|
||
<div style={{ fontSize: '13px', fontWeight: 600, color: C.text, marginBottom: '12px', textTransform: 'capitalize' }}>{key}</div>
|
||
{config[key] ? (
|
||
<img src={config[key] as string} alt={key} style={{ width: 120, height: 80, objectFit: 'contain', borderRadius: 8, marginBottom: '12px' }} />
|
||
) : (
|
||
<div style={{ width: 120, height: 80, background: '#EEE', borderRadius: 8, display: 'flex', alignItems: 'center', justifyContent: 'center', color: '#BBB', margin: '0 auto 12px' }}>
|
||
No {key}
|
||
</div>
|
||
)}
|
||
<Btn onClick={() => handleUpload(key)} size="sm" variant="secondary">Upload</Btn>
|
||
</div>
|
||
))}
|
||
<div style={{ background: C.card, borderRadius: 16, padding: '20px', boxShadow: C.shadow, border: `1px solid ${C.border}`, gridColumn: 'span 2' }}>
|
||
<div style={{ fontSize: '13px', fontWeight: 600, color: C.text, marginBottom: '12px' }}>Tagline</div>
|
||
<I value={config.tagline} onChange={(v: string) => setConfig(prev => ({ ...prev, tagline: v }))} placeholder="Your hotel tagline" />
|
||
</div>
|
||
</div>
|
||
|
||
<SecHead title="📞 Contact & Social" />
|
||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(280px, 1fr))', gap: '16px', marginBottom: 20 }}>
|
||
<div style={{ background: C.card, borderRadius: 16, padding: '16px', boxShadow: C.shadow, border: `1px solid ${C.border}` }}>
|
||
<div style={{ fontSize: '13px', fontWeight: 600, color: C.text, marginBottom: '8px' }}>📍 Address</div>
|
||
<I value={config.address} onChange={(v: string) => setConfig(prev => ({ ...prev, address: v }))} placeholder="Full address" />
|
||
</div>
|
||
<div style={{ background: C.card, borderRadius: 16, padding: '16px', boxShadow: C.shadow, border: `1px solid ${C.border}` }}>
|
||
<div style={{ fontSize: '13px', fontWeight: 600, color: C.text, marginBottom: '8px' }}>📞 Phone</div>
|
||
<I value={config.phone} onChange={(v: string) => setConfig(prev => ({ ...prev, phone: v }))} placeholder="+593 99 999 9999" />
|
||
</div>
|
||
<div style={{ background: C.card, borderRadius: 16, padding: '16px', boxShadow: C.shadow, border: `1px solid ${C.border}` }}>
|
||
<div style={{ fontSize: '13px', fontWeight: 600, color: C.text, marginBottom: '8px' }}>✉️ Email</div>
|
||
<I value={config.email} onChange={(v: string) => setConfig(prev => ({ ...prev, email: v }))} placeholder="contact@lahuasca.com" />
|
||
</div>
|
||
<div style={{ background: C.card, borderRadius: 16, padding: '16px', boxShadow: C.shadow, border: `1px solid ${C.border}` }}>
|
||
<div style={{ fontSize: '13px', fontWeight: 600, color: C.text, marginBottom: '8px' }}>📘 Facebook URL</div>
|
||
<I value={config.facebook_url} onChange={(v: string) => setConfig(prev => ({ ...prev, facebook_url: v }))} placeholder="https://facebook.com/..." />
|
||
</div>
|
||
<div style={{ background: C.card, borderRadius: 16, padding: '16px', boxShadow: C.shadow, border: `1px solid ${C.border}` }}>
|
||
<div style={{ fontSize: '13px', fontWeight: 600, color: C.text, marginBottom: '8px' }}>📷 Instagram URL</div>
|
||
<I value={config.instagram_url} onChange={(v: string) => setConfig(prev => ({ ...prev, instagram_url: v }))} placeholder="https://instagram.com/..." />
|
||
</div>
|
||
<div style={{ background: C.card, borderRadius: 16, padding: '16px', boxShadow: C.shadow, border: `1px solid ${C.border}` }}>
|
||
<div style={{ fontSize: '13px', fontWeight: 600, color: C.text, marginBottom: '8px' }}>💬 WhatsApp URL</div>
|
||
<I value={config.whatsapp_url} onChange={(v: string) => setConfig(prev => ({ ...prev, whatsapp_url: v }))} placeholder="https://wa.me/..." />
|
||
</div>
|
||
</div>
|
||
|
||
<div style={{ display: 'flex', justifyContent: 'flex-end', marginTop: '16px' }}>
|
||
<Btn onClick={saveConfig} disabled={saving}>{saving ? 'Saving...' : '💾 Save Configuration'}</Btn>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
/* ─── Dashboard (stats cards) ─── */
|
||
function Dashboard({ rooms, slides, events, reviews, places }: any) {
|
||
return (
|
||
<div>
|
||
<h2 style={{ margin: '0 0 20px', fontSize: '22px', fontWeight: 700, color: C.navy }}>Dashboard</h2>
|
||
<div style={{ display: 'flex', gap: '14px', flexWrap: 'wrap', marginBottom: '24px' }}>
|
||
<StatCard icon="🏨" value={rooms?.length || 0} label="Rooms" />
|
||
<StatCard icon="🎞️" value={slides?.length || 0} label="Slides" />
|
||
<StatCard icon="📅" value={events?.length || 0} label="Events" />
|
||
<StatCard icon="🗺️" value={places?.length || 0} label="Places" />
|
||
<StatCard icon="⭐" value={reviews?.filter((r: Review) => r.approved)?.length || 0} label="Approved Reviews" />
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
/* ─── Users Section ─── */
|
||
function UsersSection({ onToast }: { onToast: (msg: string, type: string) => void }) {
|
||
const [users, setUsers] = useState<any[]>([]);
|
||
const [loading, setLoading] = useState(true);
|
||
const [editing, setEditing] = useState<any | null>(null);
|
||
const [newUser, setNewUser] = useState({ username: '', password: '', role: 'user', first_name: '', last_name: '', email: '', phone: '', country: '', preferred_language: 'es' });
|
||
const [showAdd, setShowAdd] = useState(false);
|
||
|
||
useEffect(() => {
|
||
loadUsers();
|
||
}, []);
|
||
|
||
const loadUsers = async () => {
|
||
try {
|
||
const res = await fetch('/api/admin/users');
|
||
if (res.ok) {
|
||
const data = await res.json();
|
||
setUsers(data.data || []);
|
||
}
|
||
} catch (err) {
|
||
console.error('Failed to load users:', err);
|
||
} finally {
|
||
setLoading(false);
|
||
}
|
||
};
|
||
|
||
const createUser = async () => {
|
||
if (!newUser.username || !newUser.password) {
|
||
onToast('Username and password required', 'error');
|
||
return;
|
||
}
|
||
try {
|
||
const res = await fetch('/api/admin/users', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify(newUser),
|
||
});
|
||
if (res.ok) {
|
||
onToast('User created!', 'success');
|
||
setNewUser({ username: '', password: '', role: 'user', first_name: '', last_name: '', email: '', phone: '', country: '', preferred_language: 'es' });
|
||
setShowAdd(false);
|
||
loadUsers();
|
||
} else {
|
||
const err = await res.json();
|
||
onToast(err.error || 'Failed to create user', 'error');
|
||
}
|
||
} catch {
|
||
onToast('Error creating user', 'error');
|
||
}
|
||
};
|
||
|
||
const updateUser = async (user: any) => {
|
||
try {
|
||
const res = await fetch('/api/admin/users', {
|
||
method: 'PUT',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify(user),
|
||
});
|
||
if (res.ok) {
|
||
onToast('User updated!', 'success');
|
||
setEditing(null);
|
||
loadUsers();
|
||
} else {
|
||
const err = await res.json();
|
||
onToast(err.error || 'Failed to update user', 'error');
|
||
}
|
||
} catch {
|
||
onToast('Error updating user', 'error');
|
||
}
|
||
};
|
||
|
||
const deleteUser = async (id: number) => {
|
||
if (!confirm('Delete this user?')) return;
|
||
try {
|
||
const res = await fetch(`/api/admin/users?id=${id}`, { method: 'DELETE' });
|
||
if (res.ok) {
|
||
onToast('User deleted', 'success');
|
||
loadUsers();
|
||
}
|
||
} catch {
|
||
onToast('Error deleting user', 'error');
|
||
}
|
||
};
|
||
|
||
if (loading) {
|
||
return <div style={{ textAlign: 'center', padding: '40px' }}>Loading...</div>;
|
||
}
|
||
|
||
return (
|
||
<div>
|
||
<SecHead title="👥 Users" count={users.length} action={<Btn onClick={() => setShowAdd(!showAdd)} size="sm">+ Add User</Btn>} />
|
||
{showAdd && (
|
||
<div style={{ background: C.card, borderRadius: 12, padding: 16, marginBottom: 16 }}>
|
||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(200px, 1fr))', gap: 12 }}>
|
||
<I label="Username" value={newUser.username} onChange={(v: string) => setNewUser({ ...newUser, username: v })} />
|
||
<I label="Password" type="password" value={newUser.password} onChange={(v: string) => setNewUser({ ...newUser, password: v })} />
|
||
<S label="Role" value={newUser.role} onChange={(v: string) => setNewUser({ ...newUser, role: v })} options={[{ value: 'user', label: 'User' }, { value: 'admin', label: 'Admin' }]} />
|
||
<I label="First Name" value={newUser.first_name} onChange={(v: string) => setNewUser({ ...newUser, first_name: v })} />
|
||
<I label="Last Name" value={newUser.last_name} onChange={(v: string) => setNewUser({ ...newUser, last_name: v })} />
|
||
<I label="Email" type="email" value={newUser.email} onChange={(v: string) => setNewUser({ ...newUser, email: v })} />
|
||
<I label="Phone" value={newUser.phone} onChange={(v: string) => setNewUser({ ...newUser, phone: v })} />
|
||
<I label="Country" value={newUser.country} onChange={(v: string) => setNewUser({ ...newUser, country: v })} />
|
||
<S label="Language" value={newUser.preferred_language} onChange={(v: string) => setNewUser({ ...newUser, preferred_language: v })} options={[{ value: 'es', label: '🇪🇨 Español' }, { value: 'en', label: '🇬🇧 English' }]} />
|
||
</div>
|
||
<div style={{ marginTop: 12, display: 'flex', gap: 8 }}>
|
||
<Btn onClick={createUser} size="sm">Create User</Btn>
|
||
<Btn variant="secondary" onClick={() => setShowAdd(false)} size="sm">Cancel</Btn>
|
||
</div>
|
||
</div>
|
||
)}
|
||
<div style={{ display: 'grid', gap: '12px' }}>
|
||
{users.map((u: any) => (
|
||
<div key={u.id} style={{ background: C.card, borderRadius: 12, padding: 16, display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||
<div>
|
||
<div style={{ fontWeight: 600, color: C.text }}>
|
||
{u.username} <span style={{ fontSize: 12, background: u.role === 'admin' ? '#dc2626' : '#059669', color: '#fff', padding: '2px 8px', borderRadius: 4, marginLeft: 8 }}>{u.role}</span>
|
||
{u.preferred_language && <span style={{ fontSize: 11, background: '#6366f1', color: '#fff', padding: '2px 6px', borderRadius: 4, marginLeft: 8 }}>{u.preferred_language === 'es' ? '🇪🇨 ES' : '🇬🇧 EN'}</span>}
|
||
</div>
|
||
<div style={{ fontSize: 12, color: C.textLight }}>
|
||
{u.first_name || ''} {u.last_name || ''} {u.email ? `• ${u.email}` : ''} {u.country ? `• ${u.country}` : ''}
|
||
{u.terms_accepted_at ? `• Terms: ${formatDisplayDate(u.terms_accepted_at)}` : '• Terms not accepted'}
|
||
{u.comments_disabled ? '• Comments disabled' : ''}
|
||
</div>
|
||
</div>
|
||
<div style={{ display: 'flex', gap: 8 }}>
|
||
<Btn size="sm" variant="secondary" onClick={() => setEditing(u)}>Edit</Btn>
|
||
{u.role !== 'admin' && <Btn size="sm" variant="danger" onClick={() => deleteUser(u.id)}>Delete</Btn>}
|
||
</div>
|
||
</div>
|
||
))}
|
||
</div>
|
||
{editing && (
|
||
<div style={{ position: 'fixed', top: 0, left: 0, right: 0, bottom: 0, background: 'rgba(0,0,0,0.4)', display: 'flex', alignItems: 'center', justifyContent: 'center', zIndex: 9998 }}>
|
||
<div style={{ background: '#fff', borderRadius: 16, padding: 24, maxWidth: 500, width: '90%', maxHeight: '90vh', overflow: 'auto' }}>
|
||
<h3 style={{ margin: '0 0 20px', fontSize: 18, fontWeight: 700 }}>Edit User</h3>
|
||
<div style={{ display: 'grid', gap: 12 }}>
|
||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 12 }}>
|
||
<I label="First Name" value={editing.first_name || ''} onChange={(v: string) => setEditing({ ...editing, first_name: v })} />
|
||
<I label="Last Name" value={editing.last_name || ''} onChange={(v: string) => setEditing({ ...editing, last_name: v })} />
|
||
</div>
|
||
<I label="Email" type="email" value={editing.email || ''} onChange={(v: string) => setEditing({ ...editing, email: v })} />
|
||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 12 }}>
|
||
<I label="Phone" value={editing.phone || ''} onChange={(v: string) => setEditing({ ...editing, phone: v })} />
|
||
<I label="Country" value={editing.country || ''} onChange={(v: string) => setEditing({ ...editing, country: v })} />
|
||
</div>
|
||
<S label="Language" value={editing.preferred_language || 'es'} onChange={(v: string) => setEditing({ ...editing, preferred_language: v })} options={[{ value: 'es', label: '🇪🇨 Español' }, { value: 'en', label: '🇬🇧 English' }]} />
|
||
<I label="New Password (leave blank to keep)" type="password" value={editing.password || ''} onChange={(v: string) => setEditing({ ...editing, password: v })} />
|
||
<Tgl label="Disable comments" checked={editing.comments_disabled || false} onChange={(v: boolean) => setEditing({ ...editing, comments_disabled: v })} />
|
||
{editing.terms_accepted_at && (
|
||
<div style={{ fontSize: 12, color: C.textLight }}>
|
||
Terms accepted: {formatDisplayDateTime(editing.terms_accepted_at)}
|
||
</div>
|
||
)}
|
||
</div>
|
||
<div style={{ marginTop: 16, display: 'flex', gap: 8 }}>
|
||
<Btn onClick={() => updateUser(editing)} size="sm">Save</Btn>
|
||
<Btn variant="secondary" onClick={() => setEditing(null)} size="sm">Cancel</Btn>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
)}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
/* ─── Terms Section ─── */
|
||
function TermsSection({ onToast }: { onToast: (msg: string, type: string) => void }) {
|
||
const [terms, setTerms] = useState<{ es: any; en: any }>({ es: null, en: null });
|
||
const [loading, setLoading] = useState(true);
|
||
const [editing, setEditing] = useState<any | null>(null);
|
||
|
||
useEffect(() => {
|
||
loadTerms();
|
||
}, []);
|
||
|
||
const loadTerms = async () => {
|
||
try {
|
||
const [esRes, enRes] = await Promise.all([
|
||
fetch('/api/terms?language=es'),
|
||
fetch('/api/terms?language=en'),
|
||
]);
|
||
const esData = await esRes.json();
|
||
const enData = await enRes.json();
|
||
setTerms({
|
||
es: esData.data || null,
|
||
en: enData.data || null,
|
||
});
|
||
} catch (err) {
|
||
console.error('Failed to load terms:', err);
|
||
} finally {
|
||
setLoading(false);
|
||
}
|
||
};
|
||
|
||
const saveTerm = async (term: any) => {
|
||
try {
|
||
const res = await fetch('/api/admin/terms', {
|
||
method: term.id ? 'PUT' : 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify(term),
|
||
});
|
||
if (res.ok) {
|
||
onToast('Terms saved!', 'success');
|
||
setEditing(null);
|
||
loadTerms();
|
||
} else {
|
||
const err = await res.json();
|
||
onToast(err.error || 'Failed to save terms', 'error');
|
||
}
|
||
} catch {
|
||
onToast('Error saving terms', 'error');
|
||
}
|
||
};
|
||
|
||
if (loading) {
|
||
return <div style={{ textAlign: 'center', padding: '40px' }}>Loading...</div>;
|
||
}
|
||
|
||
return (
|
||
<div>
|
||
<SecHead title="📜 Terms of Service" count={0} />
|
||
<p style={{ fontSize: 13, color: C.textLight, marginBottom: 16 }}>
|
||
Manage terms of service and disclaimers shown to users. One version per language.
|
||
</p>
|
||
|
||
<div style={{ display: 'grid', gap: 16 }}>
|
||
{/* Spanish Terms */}
|
||
<div style={{ background: C.card, borderRadius: 12, padding: 16 }}>
|
||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 12 }}>
|
||
<h3 style={{ margin: 0, fontSize: 16 }}>🇪🇨 Español</h3>
|
||
{terms.es && (
|
||
<span style={{ fontSize: 12, color: C.textLight }}>
|
||
Version: {terms.es.version}
|
||
</span>
|
||
)}
|
||
</div>
|
||
{terms.es ? (
|
||
<div>
|
||
<div style={{ fontWeight: 600, marginBottom: 8 }}>{terms.es.title}</div>
|
||
<div style={{ fontSize: 13, color: C.textLight, maxHeight: 120, overflow: 'auto', whiteSpace: 'pre-wrap' }}>
|
||
{terms.es.content?.substring(0, 300)}...
|
||
</div>
|
||
<Btn size="sm" variant="secondary" onClick={() => setEditing({ ...terms.es })} style={{ marginTop: 12 }}>Edit</Btn>
|
||
</div>
|
||
) : (
|
||
<Btn size="sm" onClick={() => setEditing({ language: 'es', title: '', content: '', version: '1.0' })}>Create Spanish Terms</Btn>
|
||
)}
|
||
</div>
|
||
|
||
{/* English Terms */}
|
||
<div style={{ background: C.card, borderRadius: 12, padding: 16 }}>
|
||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 12 }}>
|
||
<h3 style={{ margin: 0, fontSize: 16 }}>🇬🇧 English</h3>
|
||
{terms.en && (
|
||
<span style={{ fontSize: 12, color: C.textLight }}>
|
||
Version: {terms.en.version}
|
||
</span>
|
||
)}
|
||
</div>
|
||
{terms.en ? (
|
||
<div>
|
||
<div style={{ fontWeight: 600, marginBottom: 8 }}>{terms.en.title}</div>
|
||
<div style={{ fontSize: 13, color: C.textLight, maxHeight: 120, overflow: 'auto', whiteSpace: 'pre-wrap' }}>
|
||
{terms.en.content?.substring(0, 300)}...
|
||
</div>
|
||
<Btn size="sm" variant="secondary" onClick={() => setEditing({ ...terms.en })} style={{ marginTop: 12 }}>Edit</Btn>
|
||
</div>
|
||
) : (
|
||
<Btn size="sm" onClick={() => setEditing({ language: 'en', title: '', content: '', version: '1.0' })}>Create English Terms</Btn>
|
||
)}
|
||
</div>
|
||
</div>
|
||
|
||
{/* Edit Modal */}
|
||
{editing && (
|
||
<div style={{ position: 'fixed', top: 0, left: 0, right: 0, bottom: 0, background: 'rgba(0,0,0,0.4)', display: 'flex', alignItems: 'center', justifyContent: 'center', zIndex: 9998 }}>
|
||
<div style={{ background: '#fff', borderRadius: 16, padding: 24, maxWidth: 600, width: '90%', maxHeight: '90vh', overflow: 'auto' }}>
|
||
<h3 style={{ margin: '0 0 20px', fontSize: 18, fontWeight: 700 }}>
|
||
{editing.language === 'es' ? '🇪🇨 ' : '🇬🇧 '}Edit Terms ({editing.language.toUpperCase()})
|
||
</h3>
|
||
<div style={{ display: 'grid', gap: 12 }}>
|
||
<I label="Title" value={editing.title || ''} onChange={(v: string) => setEditing({ ...editing, title: v })} placeholder="Terms of Service and Privacy Policy" />
|
||
<TA label="Content" value={editing.content || ''} onChange={(v: string) => setEditing({ ...editing, content: v })} rows={10} placeholder="Enter the full terms of service text..." />
|
||
<I label="Version" value={editing.version || '1.0'} onChange={(v: string) => setEditing({ ...editing, version: v })} />
|
||
</div>
|
||
<div style={{ marginTop: 16, display: 'flex', gap: 8 }}>
|
||
<Btn onClick={() => saveTerm(editing)} size="sm">Save</Btn>
|
||
<Btn variant="secondary" onClick={() => setEditing(null)} size="sm">Cancel</Btn>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
)}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
/* ─── Weather Section ─── */
|
||
function WeatherSection({ onToast }: { onToast: (msg: string, type: string) => void }) {
|
||
const [enabled, setEnabled] = useState(true);
|
||
const [cities, setCities] = useState<{ name: string; lat: string; lon: string }[]>([]);
|
||
const [newCity, setNewCity] = useState({ name: '', lat: '', lon: '' });
|
||
const [loading, setLoading] = useState(true);
|
||
|
||
// Load weather config
|
||
useEffect(() => {
|
||
const loadConfig = async () => {
|
||
try {
|
||
const res = await fetch('/api/site-assets?key=weather_config');
|
||
if (res.ok) {
|
||
const data = await res.json();
|
||
if (data.data) {
|
||
const config = JSON.parse(data.data);
|
||
setEnabled(config.enabled ?? true);
|
||
setCities(config.cities ?? []);
|
||
}
|
||
}
|
||
} catch (err) {
|
||
console.error('Failed to load weather config:', err);
|
||
} finally {
|
||
setLoading(false);
|
||
}
|
||
};
|
||
loadConfig();
|
||
}, []);
|
||
|
||
const saveConfig = async () => {
|
||
try {
|
||
const config = { enabled, cities };
|
||
const res = await fetch('/api/site-assets', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ key: 'weather_config', value: JSON.stringify(config) }),
|
||
});
|
||
|
||
if (res.ok) {
|
||
onToast('Weather settings saved', 'success');
|
||
} else {
|
||
throw new Error('Failed to save');
|
||
}
|
||
} catch (err) {
|
||
onToast('Error saving weather settings', 'error');
|
||
}
|
||
};
|
||
|
||
const addCity = () => {
|
||
if (newCity.name && newCity.lat && newCity.lon) {
|
||
setCities([...cities, newCity]);
|
||
setNewCity({ name: '', lat: '', lon: '' });
|
||
}
|
||
};
|
||
|
||
const removeCity = (index: number) => {
|
||
setCities(cities.filter((_, i) => i !== index));
|
||
};
|
||
|
||
if (loading) {
|
||
return (
|
||
<div>
|
||
<SecHead title="🌤️ Weather Widget" />
|
||
<div style={{ textAlign: 'center', padding: '40px', color: C.textLight }}>
|
||
Loading...
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
return (
|
||
<div>
|
||
<SecHead title="🌤️ Weather Widget" />
|
||
<div style={{ background: C.card, borderRadius: 12, padding: '24px', marginBottom: '24px', border: `1px solid ${C.border}` }}>
|
||
<p style={{ color: C.text, marginBottom: '24px' }}>
|
||
Configure the weather widget displayed on the homepage
|
||
</p>
|
||
|
||
<div style={{ marginBottom: '24px' }}>
|
||
<label style={{ display: 'flex', alignItems: 'center', gap: '8px', cursor: 'pointer' }}>
|
||
<input
|
||
type="checkbox"
|
||
checked={enabled}
|
||
onChange={(e) => setEnabled(e.target.checked)}
|
||
style={{ width: '18px', height: '18px' }}
|
||
/>
|
||
<span style={{ fontWeight: 500, color: C.text }}>Enable Weather Widget</span>
|
||
</label>
|
||
</div>
|
||
|
||
<h3 style={{ margin: '0 0 16px', color: C.text, fontSize: '16px' }}>Cities</h3>
|
||
<div style={{ marginBottom: '16px' }}>
|
||
{cities.map((city, index) => (
|
||
<div key={index} style={{ display: 'flex', alignItems: 'center', gap: '12px', marginBottom: '8px', padding: '8px', background: C.bg, borderRadius: '8px' }}>
|
||
<span style={{ flex: 1, color: C.text }}>{city.name} ({city.lat}, {city.lon})</span>
|
||
<button
|
||
onClick={() => removeCity(index)}
|
||
style={{
|
||
background: C.danger,
|
||
color: 'white',
|
||
border: 'none',
|
||
borderRadius: '4px',
|
||
padding: '4px 8px',
|
||
cursor: 'pointer',
|
||
fontSize: '12px'
|
||
}}
|
||
>
|
||
Remove
|
||
</button>
|
||
</div>
|
||
))}
|
||
</div>
|
||
|
||
<div style={{ display: 'flex', flexDirection: 'column', gap: '12px', padding: '16px', background: C.bg, borderRadius: '8px' }}>
|
||
<h4 style={{ margin: '0 0 8px', color: C.text, fontSize: '14px' }}>Add City</h4>
|
||
<I label="City Name" value={newCity.name} onChange={(v: string) => setNewCity({...newCity, name: v})} />
|
||
<div style={{ display: 'flex', gap: '12px' }}>
|
||
<I label="Latitude" value={newCity.lat} onChange={(v: string) => setNewCity({...newCity, lat: v})} />
|
||
<I label="Longitude" value={newCity.lon} onChange={(v: string) => setNewCity({...newCity, lon: v})} />
|
||
</div>
|
||
<button
|
||
onClick={addCity}
|
||
style={{
|
||
alignSelf: 'flex-start',
|
||
background: C.gold,
|
||
color: 'white',
|
||
border: 'none',
|
||
borderRadius: '6px',
|
||
padding: '8px 16px',
|
||
cursor: 'pointer',
|
||
fontWeight: 500
|
||
}}
|
||
>
|
||
Add City
|
||
</button>
|
||
</div>
|
||
|
||
<div style={{ marginTop: '24px' }}>
|
||
<button
|
||
onClick={saveConfig}
|
||
style={{
|
||
background: C.gold,
|
||
color: 'white',
|
||
border: 'none',
|
||
borderRadius: '6px',
|
||
padding: '12px 24px',
|
||
cursor: 'pointer',
|
||
fontWeight: 600,
|
||
fontSize: '14px'
|
||
}}
|
||
>
|
||
Save Settings
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
/* ═══════════════════════════════════════════════════
|
||
MAIN PAGE
|
||
═══════════════════════════════════════════════════ */
|
||
export default function AdminPage() {
|
||
const [activeSection, setActiveSection] = useState('dashboard');
|
||
const [sidebarOpen, setSidebarOpen] = useState(false);
|
||
const [toasts, setToasts] = useState<{ id: number; message: string; type: string }[]>([]);
|
||
const [authChecked, setAuthChecked] = useState(false);
|
||
const [isAuthenticated, setIsAuthenticated] = useState(false);
|
||
|
||
const [slides, setSlides] = useState<Slide[]>([]);
|
||
const [rooms, setRooms] = useState<Room[]>([]);
|
||
const [events, setEvents] = useState<CalendarEvent[]>([]);
|
||
const [socialEvents, setSocialEvents] = useState<SocialEvent[]>([]);
|
||
const [menuItems, setMenuItems] = useState<MenuItem[]>([]);
|
||
const [places, setPlaces] = useState<Place[]>([]);
|
||
const [reviews, setReviews] = useState<Review[]>([]);
|
||
const [images, setImages] = useState<Image[]>([]);
|
||
|
||
const [imageFilter, setImageFilter] = useState('all');
|
||
const [galleryLoading, setGalleryLoading] = useState(false);
|
||
|
||
const toastId = useRef(0);
|
||
const showToast = useCallback((message: string, type: string) => {
|
||
const id = ++toastId.current;
|
||
setToasts(prev => [...prev, { id, message, type }]);
|
||
}, []);
|
||
|
||
const removeToast = useCallback((id: number) => {
|
||
setToasts(prev => prev.filter(t => t.id !== id));
|
||
}, []);
|
||
|
||
/* ── Auth check ── */
|
||
useEffect(() => {
|
||
fetch('/api/auth/me', { credentials: 'include' })
|
||
.then(r => r.json())
|
||
.then(data => {
|
||
if (data.authenticated) setIsAuthenticated(true);
|
||
setAuthChecked(true);
|
||
})
|
||
.catch(() => setAuthChecked(true));
|
||
}, []);
|
||
|
||
/* ── Data fetching ── */
|
||
const loadImages = async () => {
|
||
try {
|
||
const res = await fetch('/api/admin/images');
|
||
if (res.ok) { const d = await res.json(); setImages(Array.isArray(d) ? d : (d.images || [])); }
|
||
} catch (err) { console.error('Failed to load images:', err); }
|
||
};
|
||
|
||
useEffect(() => {
|
||
if (!isAuthenticated) return;
|
||
const fetchAll = async () => {
|
||
try {
|
||
const [slidesRes, roomsRes, calRes, socialRes, menuRes, placesRes, reviewsRes, imagesRes] = await Promise.all([
|
||
fetch('/api/slides'),
|
||
fetch('/api/rooms'),
|
||
fetch('/api/calendar_events'),
|
||
fetch('/api/social_events?includePrivate=true'),
|
||
fetch('/api/admin/menu'),
|
||
fetch('/api/places'),
|
||
fetch('/api/reviews'),
|
||
fetch('/api/admin/images').catch(() => null),
|
||
]);
|
||
if (slidesRes.ok) { const d = await slidesRes.json(); setSlides(Array.isArray(d) ? d : (d.data || [])); }
|
||
if (roomsRes.ok) { const d = await roomsRes.json(); setRooms(Array.isArray(d) ? d : (d.data || [])); }
|
||
if (calRes.ok) { const d = await calRes.json(); setEvents(Array.isArray(d) ? d : (d.data || [])); }
|
||
if (socialRes.ok) { const d = await socialRes.json(); setSocialEvents(Array.isArray(d) ? d : (d.data || [])); }
|
||
if (menuRes.ok) { const d = await menuRes.json(); setMenuItems(Array.isArray(d) ? d : (d.data || [])); }
|
||
if (placesRes.ok) { const d = await placesRes.json(); setPlaces(Array.isArray(d) ? d : (d.data || [])); }
|
||
if (reviewsRes.ok) { const d = await reviewsRes.json(); setReviews(Array.isArray(d) ? d : (d.data || [])); }
|
||
if (imagesRes?.ok) { const d = await imagesRes.json(); setImages(Array.isArray(d) ? d : (d.data || d.images || [])); }
|
||
} catch (err) { console.error('Failed to load admin data:', err); }
|
||
};
|
||
fetchAll();
|
||
}, [isAuthenticated]);
|
||
|
||
const deleteImage = async (id: number) => {
|
||
try {
|
||
await fetch(`/api/admin/images/${id}`, { method: 'DELETE' });
|
||
setImages(prev => prev.filter(img => img.id !== id));
|
||
showToast('Image deleted', 'success');
|
||
} catch { showToast('Error deleting image', 'error'); }
|
||
};
|
||
|
||
const renderSection = () => {
|
||
switch (activeSection) {
|
||
case 'dashboard': return <Dashboard rooms={rooms} slides={slides} events={events} reviews={reviews} places={places} />;
|
||
case 'brand': return <BrandSection images={images} onToast={showToast} />;
|
||
case 'gallery': return (
|
||
<div>
|
||
<SecHead title="🖼️ Image Gallery" count={images.length} />
|
||
<WebPMigration onToast={showToast} onRefresh={loadImages} />
|
||
<ImageGallery images={images} onDelete={deleteImage} />
|
||
</div>
|
||
);
|
||
case 'slides': return <SlidesSection slides={slides} setSlides={setSlides} images={images} onToast={showToast} onRefreshImages={loadImages} />;
|
||
case 'calendar': return <CalendarSection events={events} setEvents={setEvents} images={images} onToast={showToast} />;
|
||
case 'social': return <SocialSection events={socialEvents} setEvents={setSocialEvents} images={images} onToast={showToast} />;
|
||
case 'rooms': return <RoomsSection rooms={rooms} setRooms={setRooms} images={images} onToast={showToast} />;
|
||
case 'room-status': return <RoomStatusSection onToast={showToast} />;
|
||
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} />;
|
||
case 'reviews': return <ReviewsSection reviews={reviews} setReviews={setReviews} onToast={showToast} />;
|
||
case 'users': return <UsersSection onToast={showToast} />;
|
||
case 'terms': return <TermsSection onToast={showToast} />;
|
||
case 'weather': return <WeatherSection onToast={showToast} />;
|
||
case 'trips': return <TripsSection onToast={showToast} />;
|
||
case 'messages': return <MessagesSection onToast={showToast} />;
|
||
default: return <Dashboard rooms={rooms} slides={slides} events={events} reviews={reviews} places={places} />;
|
||
}
|
||
};
|
||
|
||
return (
|
||
<div style={{ minHeight: '100vh', background: C.bg, display: 'flex' }}>
|
||
{/* Toasts */}
|
||
{toasts.map(t => <Toast key={t.id} message={t.message} type={t.type} onClose={() => removeToast(t.id)} />)}
|
||
|
||
{/* Auth loading */}
|
||
{!authChecked && (
|
||
<div style={{ flex: 1, display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
|
||
<div style={{ textAlign: 'center' }}>
|
||
<div style={{ fontSize: '32px', marginBottom: '16px' }}>⏳</div>
|
||
<div style={{ fontSize: '16px', color: C.text }}>Checking authentication...</div>
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{/* Not authenticated - show login prompt */}
|
||
{authChecked && !isAuthenticated && (
|
||
<div style={{ flex: 1, display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
|
||
<div style={{ textAlign: 'center', maxWidth: 400, padding: '40px', background: '#fff', borderRadius: 16, boxShadow: C.shadow }}>
|
||
<div style={{ fontSize: '48px', marginBottom: '16px' }}>🔒</div>
|
||
<h2 style={{ margin: '0 0 12px', fontSize: '24px', color: C.navy }}>Admin Access Required</h2>
|
||
<p style={{ margin: '0 0 24px', fontSize: '14px', color: C.textLight }}>
|
||
Please log in to access the admin panel.
|
||
</p>
|
||
<a href="/login" style={{ display: 'inline-block', padding: '12px 32px', background: C.gold, color: '#fff', borderRadius: 8, textDecoration: 'none', fontWeight: 600, fontSize: '15px' }}>
|
||
Go to Login
|
||
</a>
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{/* Authenticated - show admin panel */}
|
||
{authChecked && isAuthenticated && (
|
||
<>
|
||
{/* Mobile sidebar toggle */}
|
||
<div style={{ display: 'none' }} /> {/* placeholder */}
|
||
|
||
{/* Sidebar */}
|
||
<aside style={{
|
||
width: 260, background: C.navy, height: '100vh', position: 'sticky', top: 0, overflowY: 'auto',
|
||
display: 'flex', flexDirection: 'column', transition: 'transform 300ms',
|
||
borderRight: '1px solid rgba(255,255,255,0.06)',
|
||
}}>
|
||
<div style={{ padding: '24px 20px', borderBottom: '1px solid rgba(255,255,255,0.06)' }}>
|
||
<h1 style={{ margin: 0, fontSize: '18px', fontWeight: 700, color: C.gold }}>
|
||
🏨 Hosteria La Huasca
|
||
</h1>
|
||
<p style={{ margin: '4px 0 0', fontSize: '11px', color: 'rgba(255,255,255,0.4)' }}>Admin Portal</p>
|
||
</div>
|
||
<nav style={{ padding: '16px 12px', flex: 1 }}>
|
||
{NAV.map(n => (
|
||
<button key={n.id} onClick={() => { setActiveSection(n.id); setSidebarOpen(false); }}
|
||
style={{ width: '100%', display: 'flex', alignItems: 'center', gap: '12px', padding: '10px 14px', marginBottom: '4px',
|
||
borderRadius: 10, border: 'none', cursor: 'pointer', fontSize: '14px', fontWeight: 500,
|
||
background: activeSection === n.id ? C.goldLight : 'transparent',
|
||
color: activeSection === n.id ? C.gold : 'rgba(255,255,255,0.6)',
|
||
transition: 'all 200ms', textAlign: 'left' }}
|
||
onMouseEnter={e => { if (activeSection !== n.id) { e.currentTarget.style.background = 'rgba(255,255,255,0.06)'; e.currentTarget.style.color = '#fff'; } }}
|
||
onMouseLeave={e => { if (activeSection !== n.id) { e.currentTarget.style.background = 'transparent'; e.currentTarget.style.color = 'rgba(255,255,255,0.6)'; } }}>
|
||
<span style={{ fontSize: '18px' }}>{n.icon}</span>
|
||
{n.label}
|
||
</button>
|
||
))}
|
||
</nav>
|
||
<div style={{ padding: '16px 20px', borderTop: '1px solid rgba(255,255,255,0.06)', fontSize: '11px', color: 'rgba(255,255,255,0.3)' }}>
|
||
v1.0.0
|
||
</div>
|
||
</aside>
|
||
|
||
{/* Main content */}
|
||
<main style={{ flex: 1, padding: '32px 40px', overflowY: 'auto', maxHeight: '100vh' }}>
|
||
<div style={{ maxWidth: 1200, margin: '0 auto' }}>
|
||
{renderSection()}
|
||
</div>
|
||
</main>
|
||
</>
|
||
)}
|
||
</div>
|
||
);
|
||
}
|