Files
la-huasca-ts/src/app/admin/page.tsx
T
muken 4e514c2fbb feat: admins can create private events with max_guests
- 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
2026-07-03 00:27:44 -05:00

3761 lines
192 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
'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>
);
}