Files
rosterchirp/frontend/src/components/ScheduleManagerModal.jsx

920 lines
52 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
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.
import { useState, useEffect, useCallback, useRef } from 'react';
import ReactDOM from 'react-dom';
import { api } from '../utils/api.js';
import { useToast } from '../contexts/ToastContext.jsx';
import { useAuth } from '../contexts/AuthContext.jsx';
// ── Utility ───────────────────────────────────────────────────────────────────
const DAYS = ['Sun','Mon','Tue','Wed','Thu','Fri','Sat'];
const MONTHS = ['January','February','March','April','May','June','July','August','September','October','November','December'];
const SHORT_MONTHS = ['Jan','Feb','Mar','Apr','May','Jun','Jul','Aug','Sep','Oct','Nov','Dec'];
function fmtDate(d) { return `${d.getDate()} ${SHORT_MONTHS[d.getMonth()]} ${d.getFullYear()}`; }
function fmtTime(isoStr) {
if (!isoStr) return '';
const d = new Date(isoStr);
return d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
}
function fmtTimeRange(start, end) { return `${fmtTime(start)} ${fmtTime(end)}`; }
function toLocalDateInput(isoStr) {
if (!isoStr) return '';
const d = new Date(isoStr);
const pad = n => String(n).padStart(2,'0');
return `${d.getFullYear()}-${pad(d.getMonth()+1)}-${pad(d.getDate())}`;
}
function toLocalTimeInput(isoStr) {
if (!isoStr) return '';
const d = new Date(isoStr);
const pad = n => String(n).padStart(2,'0');
return `${pad(d.getHours())}:${pad(d.getMinutes())}`;
}
function buildISO(date, time) {
if (!date || !time) return '';
const d = new Date(`${date}T${time}:00`);
const pad = n => String(n).padStart(2,'0');
const off = -d.getTimezoneOffset();
const sign = off >= 0 ? '+' : '-';
const abs = Math.abs(off);
return `${date}T${time}:00${sign}${pad(Math.floor(abs/60))}:${pad(abs%60)}`;
}
function addHours(isoStr, hrs) {
const d = new Date(isoStr);
d.setMinutes(d.getMinutes() + hrs * 60);
const pad = n => String(n).padStart(2,'0');
// Return local datetime string — do NOT use toISOString() which shifts to UTC
return `${d.getFullYear()}-${pad(d.getMonth()+1)}-${pad(d.getDate())}T${pad(d.getHours())}:${pad(d.getMinutes())}:00`;
}
function sameDay(a, b) {
return a.getFullYear()===b.getFullYear() && a.getMonth()===b.getMonth() && a.getDate()===b.getDate();
}
function startOfWeek(d) { const r=new Date(d); r.setDate(d.getDate()-d.getDay()); r.setHours(0,0,0,0); return r; }
function startOfMonth(d) { return new Date(d.getFullYear(), d.getMonth(), 1); }
function daysInMonth(y,m) { return new Date(y,m+1,0).getDate(); }
const RESPONSE_LABELS = { going: 'Going', maybe: 'Maybe', not_going: 'Not Going' };
const RESPONSE_COLOURS = { going: '#22c55e', maybe: '#f59e0b', not_going: '#ef4444' };
// ── Mini Calendar ─────────────────────────────────────────────────────────────
function MiniCalendar({ selected, onChange, eventDates = new Set() }) {
const [cursor, setCursor] = useState(() => { const d = new Date(selected||Date.now()); d.setDate(1); return d; });
const year = cursor.getFullYear(), month = cursor.getMonth();
const firstDow = new Date(year, month, 1).getDay();
const total = daysInMonth(year, month);
const today = new Date();
const cells = [];
for (let i=0;i<firstDow;i++) cells.push(null);
for (let d=1;d<=total;d++) cells.push(d);
return (
<div style={{ userSelect: 'none' }}>
<div style={{ display:'flex', alignItems:'center', justifyContent:'space-between', marginBottom:8, fontSize:13, fontWeight:600 }}>
<button style={{ background:'none',border:'none',cursor:'pointer',padding:'2px 6px',color:'var(--text-secondary)'}} onClick={() => { const n=new Date(cursor); n.setMonth(n.getMonth()-1); setCursor(n); }}></button>
<span>{MONTHS[month]} {year}</span>
<button style={{ background:'none',border:'none',cursor:'pointer',padding:'2px 6px',color:'var(--text-secondary)'}} onClick={() => { const n=new Date(cursor); n.setMonth(n.getMonth()+1); setCursor(n); }}></button>
</div>
<div style={{ display:'grid', gridTemplateColumns:'repeat(7,1fr)', gap:1, fontSize:11 }}>
{DAYS.map(d => <div key={d} style={{ textAlign:'center', fontWeight:600, color:'var(--text-tertiary)', padding:'2px 0' }}>{d[0]}</div>)}
{cells.map((d,i) => {
if (!d) return <div key={i}/>;
const date = new Date(year, month, d);
const isSel = selected && sameDay(date, new Date(selected));
const isToday = sameDay(date, today);
const hasEvent = eventDates.has(`${year}-${String(month+1).padStart(2,'0')}-${String(d).padStart(2,'0')}`);
return (
<div key={i} onClick={() => onChange(date)} style={{
textAlign:'center', padding:'3px 2px', borderRadius:4, cursor:'pointer',
background: isSel ? 'var(--primary)' : 'transparent',
color: isSel ? 'white' : isToday ? 'var(--primary)' : 'var(--text-primary)',
fontWeight: isToday ? 700 : 400, position:'relative',
}}>
{d}
{hasEvent && !isSel && <span style={{ position:'absolute', bottom:1, left:'50%', transform:'translateX(-50%)', width:4, height:4, borderRadius:'50%', background:'var(--primary)', display:'block' }}/>}
</div>
);
})}
</div>
</div>
);
}
// ── Event Type Form (popup) ───────────────────────────────────────────────────
function EventTypePopup({ userGroups, onSave, onClose, editing = null }) {
const toast = useToast();
const [name, setName] = useState(editing?.name || '');
const [colour, setColour] = useState(editing?.colour || '#6366f1');
const [defaultGroupId, setDefaultGroupId] = useState(editing?.default_user_group_id || '');
const [defaultDur, setDefaultDur] = useState(editing?.default_duration_hrs || 1);
const [setDur, setSetDur] = useState(!!(editing?.default_duration_hrs && editing.default_duration_hrs !== 1));
const [saving, setSaving] = useState(false);
const DUR_OPTIONS = [1, 1.5, 2, 2.5, 3, 3.5, 4, 4.5, 5];
const handleSave = async () => {
if (!name.trim()) return toast('Name required', 'error');
setSaving(true);
try {
const body = { name: name.trim(), colour, defaultUserGroupId: defaultGroupId || null, defaultDurationHrs: setDur ? defaultDur : 1 };
const result = editing ? await api.updateEventType(editing.id, body) : await api.createEventType(body);
onSave(result.eventType);
onClose();
} catch (e) { toast(e.message, 'error'); }
finally { setSaving(false); }
};
return (
<div style={{ position:'absolute', top:'100%', left:0, zIndex:200, background:'var(--surface)', border:'1px solid var(--border)', borderRadius:'var(--radius)', padding:16, width:280, boxShadow:'0 4px 20px rgba(0,0,0,0.15)' }}>
<div style={{ marginBottom:10 }}>
<label className="settings-section-label">Type Name</label>
<input className="input" value={name} onChange={e => setName(e.target.value)} autoComplete="new-password" style={{ marginTop:4 }} autoFocus />
</div>
<div style={{ marginBottom:10 }}>
<label className="settings-section-label">Colour</label>
<input type="color" value={colour} onChange={e => setColour(e.target.value)} style={{ marginTop:4, width:'100%', height:32, padding:2, borderRadius:4, border:'1px solid var(--border)' }} />
</div>
<div style={{ marginBottom:10 }}>
<label className="settings-section-label">Default Group</label>
<select className="input" value={defaultGroupId} onChange={e=>setDefaultGroupId(e.target.value)} style={{ marginTop:4 }}>
<option value="">None</option>
{userGroups.map(g => <option key={g.id} value={g.id}>{g.name}</option>)}
</select>
</div>
<div style={{ marginBottom:14 }}>
<label style={{ display:'flex', alignItems:'center', gap:8, fontSize:13, cursor:'pointer' }}>
<input type="checkbox" checked={setDur} onChange={e=>setSetDur(e.target.checked)} /> Set default duration
</label>
{setDur && (
<select className="input" value={defaultDur} onChange={e=>setDefaultDur(Number(e.target.value))} style={{ marginTop:6 }}>
{DUR_OPTIONS.map(d => <option key={d} value={d}>{d}hr{d!==1?'s':''}</option>)}
</select>
)}
</div>
<div style={{ display:'flex', gap:8 }}>
<button className="btn btn-primary btn-sm" onClick={handleSave} disabled={saving}>{saving?'Saving…':'Save'}</button>
<button className="btn btn-secondary btn-sm" onClick={onClose}>Cancel</button>
</div>
</div>
);
}
// ── Event Form ────────────────────────────────────────────────────────────────
function EventForm({ event, userGroups, eventTypes, selectedDate, onSave, onCancel, onDelete, isToolManager }) {
const toast = useToast();
const today = new Date();
const _defD = selectedDate || today;
const _p = n => String(n).padStart(2,'0');
const defaultDate = `${_defD.getFullYear()}-${_p(_defD.getMonth()+1)}-${_p(_defD.getDate())}`;
const [title, setTitle] = useState(event?.title || '');
const [eventTypeId, setEventTypeId] = useState(event?.event_type_id || '');
const [startDate, setStartDate] = useState(event ? toLocalDateInput(event.start_at) : defaultDate);
const [startTime, setStartTime] = useState(event ? toLocalTimeInput(event.start_at) : '09:00');
const [endDate, setEndDate] = useState(event ? toLocalDateInput(event.end_at) : defaultDate);
const [endTime, setEndTime] = useState(event ? toLocalTimeInput(event.end_at) : '10:00');
const [allDay, setAllDay] = useState(!!event?.all_day);
const [location, setLocation] = useState(event?.location || '');
const [description, setDescription] = useState(event?.description || '');
const [isPublic, setIsPublic] = useState(event ? !!event.is_public : true);
const [trackAvail, setTrackAvail] = useState(!!event?.track_availability);
const [selectedGroups, setSelectedGroups] = useState(new Set((event?.user_groups||[]).map(g=>g.id)));
const [saving, setSaving] = useState(false);
const [showTypeForm, setShowTypeForm] = useState(false);
const [localEventTypes, setLocalEventTypes] = useState(eventTypes);
const typeRef = useRef(null);
const savedDurMins = event
? (new Date(event.end_at) - new Date(event.start_at)) / 60000
: null;
const prevTypeIdRef = useRef(event?.event_type_id ? String(event.event_type_id) : '');
// Auto-update end time when event type or start time changes
useEffect(() => {
if (!startDate || !startTime) return;
const et = localEventTypes.find(t => t.id === Number(eventTypeId));
const start = buildISO(startDate, startTime);
if (!start) return;
const typeChanged = String(eventTypeId) !== prevTypeIdRef.current;
prevTypeIdRef.current = String(eventTypeId);
if (!event || typeChanged) {
// New event or type change: apply eventType duration
const dur = et?.default_duration_hrs || 1;
setEndDate(toLocalDateInput(addHours(start, dur)));
setEndTime(toLocalTimeInput(addHours(start, dur)));
} else {
// Editing with same type: preserve saved duration
const durMins = savedDurMins || 60;
setEndDate(toLocalDateInput(addHours(start, durMins/60)));
setEndTime(toLocalTimeInput(addHours(start, durMins/60)));
}
if (et?.default_user_group_id && !event) setSelectedGroups(prev => new Set([...prev, et.default_user_group_id]));
}, [eventTypeId, startDate, startTime]);
const toggleGroup = (id) => setSelectedGroups(prev => { const n=new Set(prev); n.has(id)?n.delete(id):n.add(id); return n; });
const handleSave = async () => {
if (!title.trim()) return toast('Title required', 'error');
if (!allDay && (!startDate||!startTime||!endDate||!endTime)) return toast('Start and end required', 'error');
if (endDate < startDate) return toast('End date cannot be before start date', 'error');
if (!allDay && endDate === startDate && buildISO(endDate, endTime) <= buildISO(startDate, startTime)) return toast('End time must be after start time, or use a later end date', 'error');
setSaving(true);
try {
const body = {
title: title.trim(), eventTypeId: eventTypeId || null,
startAt: allDay ? buildISO(startDate, '00:00') : buildISO(startDate, startTime),
endAt: allDay ? buildISO(endDate, '23:59') : buildISO(endDate, endTime),
allDay, location, description, isPublic, trackAvailability: trackAvail,
userGroupIds: [...selectedGroups],
};
const result = event ? await api.updateEvent(event.id, body) : await api.createEvent(body);
onSave(result.event);
} catch (e) { toast(e.message, 'error'); }
finally { setSaving(false); }
};
const Row = ({ label, children }) => (
<div style={{ display:'flex', alignItems:'flex-start', gap:16, marginBottom:14 }}>
<div style={{ width:80, flexShrink:0, fontSize:13, color:'var(--text-tertiary)', paddingTop:8 }}>{label}</div>
<div style={{ flex:1 }}>{children}</div>
</div>
);
return (
<div style={{ display:'flex', flexDirection:'column', gap:0 }}>
{/* Title */}
<input className="input" placeholder="Add title" value={title} onChange={e => setTitle(e.target.value)} autoComplete="new-password" style={{ fontSize:18, fontWeight:600, marginBottom:16, border:'none', borderBottom:'2px solid var(--border)', borderRadius:0, padding:'4px 0' }} />
{/* Date/Time */}
<Row label="">
<div style={{ display:'flex', flexWrap:'wrap', gap:8, alignItems:'center' }}>
<input type="date" className="input" value={startDate} onChange={e => setStartDate(e.target.value)} style={{ width:150 }} />
{!allDay && <input type="time" className="input" value={startTime} onChange={e => setStartTime(e.target.value)} style={{ width:120 }} />}
<span style={{ color:'var(--text-tertiary)', fontSize:13 }}>to</span>
{!allDay && <input type="time" className="input" value={endTime} onChange={e => {
const newEt = e.target.value; setEndTime(newEt);
if(startDate === endDate && newEt <= startTime) {
const d = new Date(buildISO(startDate, startTime)); d.setDate(d.getDate()+1);
const p = n => String(n).padStart(2,'0');
setEndDate(`${d.getFullYear()}-${p(d.getMonth()+1)}-${p(d.getDate())}`);
}
}} style={{ width:120 }} />}
<input type="date" className="input" value={endDate} onChange={e => setEndDate(e.target.value)} style={{ width:150 }} />
</div>
<label style={{ display:'flex', alignItems:'center', gap:8, marginTop:8, fontSize:13, cursor:'pointer' }}>
<input type="checkbox" checked={allDay} onChange={e=>setAllDay(e.target.checked)} /> All day
</label>
</Row>
{/* Event Type */}
<Row label="Event Type">
<div style={{ display:'flex', gap:8, alignItems:'center', position:'relative' }} ref={typeRef}>
<select className="input flex-1" value={eventTypeId} onChange={e=>setEventTypeId(e.target.value)}>
<option value="">Default</option>
{localEventTypes.filter(t=>!t.is_default).map(t => (
<option key={t.id} value={t.id}>{t.name}</option>
))}
</select>
{isToolManager && (
<button className="btn btn-secondary btn-sm" onClick={()=>setShowTypeForm(v=>!v)} style={{ flexShrink:0 }}>
{showTypeForm ? 'Cancel' : '+ Add Type'}
</button>
)}
{showTypeForm && (
<EventTypePopup userGroups={userGroups} onSave={et => setLocalEventTypes(prev=>[...prev,et])} onClose={()=>setShowTypeForm(false)} />
)}
</div>
</Row>
{/* Groups */}
<Row label="Groups">
<div style={{ border:'1px solid var(--border)', borderRadius:'var(--radius)', overflow:'hidden', maxHeight:160, overflowY:'auto' }}>
{userGroups.length === 0 ? (
<div style={{ padding:'10px 14px', fontSize:13, color:'var(--text-tertiary)' }}>No user groups created yet</div>
) : userGroups.map(g => (
<label key={g.id} style={{ display:'flex', alignItems:'center', gap:10, padding:'7px 12px', borderBottom:'1px solid var(--border)', cursor:'pointer', fontSize:13 }}>
<input type="checkbox" checked={selectedGroups.has(g.id)} onChange={()=>toggleGroup(g.id)} style={{ accentColor:'var(--primary)' }} />
{g.name}
</label>
))}
</div>
<div style={{ fontSize:11, color:'var(--text-tertiary)', marginTop:4 }}>
{selectedGroups.size === 0 ? 'No groups — event visible to all (if public)' : `${selectedGroups.size} group${selectedGroups.size!==1?'s':''} selected`}
</div>
</Row>
{/* Visibility + Availability */}
<Row label="Options">
<div style={{ display:'flex', flexDirection:'column', gap:8 }}>
<label style={{ display:'flex', alignItems:'center', gap:10, fontSize:13, cursor:'pointer' }}>
<input type="checkbox" checked={!isPublic} onChange={e=>setIsPublic(!e.target.checked)} />
<span>Viewable by selected groups only</span>
</label>
<label style={{ display:'flex', alignItems:'center', gap:10, fontSize:13, cursor:'pointer' }}>
<input type="checkbox" checked={trackAvail} onChange={e=>setTrackAvail(e.target.checked)} />
<span>Track availability for assigned groups</span>
</label>
</div>
</Row>
{/* Location */}
<Row label="Location">
<input className="input" placeholder="Add location" value={location} onChange={e => setLocation(e.target.value)} autoComplete="new-password" />
</Row>
{/* Description */}
<Row label="Description">
<textarea className="input" placeholder="Add description" value={description} onChange={e=>setDescription(e.target.value)} rows={3} style={{ resize:'vertical' }} />
</Row>
<div style={{ display:'flex', gap:8, marginTop:8 }}>
<button className="btn btn-primary btn-sm" onClick={handleSave} disabled={saving}>{saving?'Saving…':event?'Save Changes':'Create Event'}</button>
<button className="btn btn-secondary btn-sm" onClick={onCancel}>Cancel</button>
{event && isToolManager && (
<button className="btn btn-sm" style={{ marginLeft:'auto', background:'var(--error)', color:'white' }} onClick={()=>onDelete(event)}>Delete</button>
)}
</div>
</div>
);
}
// ── Event Detail Popup ────────────────────────────────────────────────────────
function EventDetailPopup({ event, onClose, onEdit, onAvailabilityChange, isToolManager, currentUserId }) {
const toast = useToast();
const [myResponse, setMyResponse] = useState(event.my_response);
const [avail, setAvail] = useState(event.availability || []);
const canSeeAvail = isToolManager || (event.user_groups||[]).some(g => {
// Check if current user is in assigned group — simplified: trust event.my_response existing
return true; // backend already filtered; if they can view, they're in the group
});
const isInGroup = event.track_availability && (isToolManager || event.user_groups?.length > 0);
const handleResponse = async (resp) => {
try {
if (myResponse === resp) {
await api.deleteAvailability(event.id);
setMyResponse(null);
} else {
await api.setAvailability(event.id, resp);
setMyResponse(resp);
}
onAvailabilityChange?.();
} catch (e) { toast(e.message, 'error'); }
};
const counts = { going: 0, maybe: 0, not_going: 0 };
avail.forEach(r => { if (counts[r.response] !== undefined) counts[r.response]++; });
return ReactDOM.createPortal(
<div className="modal-overlay" onClick={e=>e.target===e.currentTarget&&onClose()}>
<div className="modal" style={{ maxWidth:520 }}>
<div style={{ display:'flex', alignItems:'flex-start', justifyContent:'space-between', marginBottom:16 }}>
<div style={{ flex:1, paddingRight:16 }}>
<div style={{ display:'flex', alignItems:'center', gap:10, marginBottom:4 }}>
{event.event_type && (
<span style={{ width:12, height:12, borderRadius:'50%', background:event.event_type.colour, flexShrink:0, display:'inline-block' }}/>
)}
<h2 style={{ fontSize:20, fontWeight:700, margin:0 }}>{event.title}</h2>
</div>
<div style={{ fontSize:13, color:'var(--text-secondary)' }}>
{event.event_type?.name && <span style={{ marginRight:8 }}>{event.event_type.name}</span>}
{!event.is_public && <span style={{ background:'var(--surface-variant)', borderRadius:10, padding:'1px 8px', fontSize:11 }}>Private</span>}
</div>
</div>
<div style={{ display:'flex', gap:6 }}>
{isToolManager && <button className="btn btn-secondary btn-sm" onClick={onEdit}>Edit</button>}
<button className="btn-icon" onClick={onClose}><svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg></button>
</div>
</div>
{/* Date/Time */}
<div style={{ display:'flex', gap:8, alignItems:'center', marginBottom:12, fontSize:14 }}>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="var(--text-tertiary)" strokeWidth="2"><rect x="3" y="4" width="18" height="18" rx="2"/><line x1="16" y1="2" x2="16" y2="6"/><line x1="8" y1="2" x2="8" y2="6"/><line x1="3" y1="10" x2="21" y2="10"/></svg>
<span>{fmtDate(new Date(event.start_at))}{!event.all_day && ` · ${fmtTimeRange(event.start_at, event.end_at)}`}</span>
</div>
{event.location && (
<div style={{ display:'flex', gap:8, alignItems:'center', marginBottom:12, fontSize:14 }}>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="var(--text-tertiary)" strokeWidth="2"><path d="M21 10c0 7-9 13-9 13s-9-6-9-13a9 9 0 0 1 18 0z"/><circle cx="12" cy="10" r="3"/></svg>
<span>{event.location}</span>
</div>
)}
{event.description && (
<div style={{ display:'flex', gap:8, marginBottom:12, fontSize:14 }}>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="var(--text-tertiary)" strokeWidth="2" style={{ flexShrink:0, marginTop:2 }}><line x1="21" y1="10" x2="3" y2="10"/><line x1="21" y1="6" x2="3" y2="6"/><line x1="21" y1="14" x2="3" y2="14"/><line x1="21" y1="18" x2="3" y2="18"/></svg>
<span style={{ whiteSpace:'pre-wrap' }}>{event.description}</span>
</div>
)}
{(event.user_groups||[]).length > 0 && (
<div style={{ display:'flex', gap:8, marginBottom:16, fontSize:13, color:'var(--text-secondary)' }}>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="var(--text-tertiary)" strokeWidth="2" style={{ flexShrink:0, marginTop:2 }}><path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/><path d="M23 21v-2a4 4 0 0 0-3-3.87"/></svg>
<span>{event.user_groups.map(g=>g.name).join(', ')}</span>
</div>
)}
{/* Availability response buttons */}
{event.track_availability && (
<div style={{ borderTop:'1px solid var(--border)', paddingTop:14, marginTop:4 }}>
<div style={{ fontSize:12, fontWeight:600, color:'var(--text-tertiary)', textTransform:'uppercase', letterSpacing:'0.5px', marginBottom:8 }}>Your Availability</div>
<div style={{ display:'flex', gap:8 }}>
{Object.entries(RESPONSE_LABELS).map(([key, label]) => (
<button key={key} onClick={()=>handleResponse(key)} className="btn btn-sm"
style={{ flex:1, background: myResponse===key ? RESPONSE_COLOURS[key] : 'var(--surface-variant)', color: myResponse===key ? 'white' : 'var(--text-primary)', borderColor: RESPONSE_COLOURS[key] }}>
{label}
</button>
))}
</div>
{/* Availability breakdown (tool managers + assigned group members) */}
{isToolManager && avail.length >= 0 && (
<div style={{ marginTop:14 }}>
<div style={{ fontSize:12, fontWeight:600, color:'var(--text-tertiary)', textTransform:'uppercase', letterSpacing:'0.5px', marginBottom:8 }}>Responses</div>
<div style={{ display:'flex', gap:16, marginBottom:8 }}>
{Object.entries(counts).map(([key, n]) => (
<span key={key} style={{ fontSize:13 }}>
<span style={{ color:RESPONSE_COLOURS[key], fontWeight:600 }}>{n}</span> {RESPONSE_LABELS[key]}
</span>
))}
{event.no_response_count > 0 && (
<span style={{ fontSize:13 }}><span style={{ fontWeight:600 }}>{event.no_response_count}</span> No response</span>
)}
</div>
<div style={{ maxHeight:140, overflowY:'auto' }}>
{avail.map(r => (
<div key={r.user_id} style={{ display:'flex', alignItems:'center', gap:8, padding:'4px 0', fontSize:13 }}>
<span style={{ width:8, height:8, borderRadius:'50%', background:RESPONSE_COLOURS[r.response], flexShrink:0, display:'inline-block' }}/>
<span style={{ flex:1 }}>{r.display_name || r.name}</span>
<span style={{ color:RESPONSE_COLOURS[r.response], fontSize:12 }}>{RESPONSE_LABELS[r.response]}</span>
</div>
))}
</div>
</div>
)}
</div>
)}
</div>
</div>,
document.body
);
}
// ── Schedule (List) View ──────────────────────────────────────────────────────
function ScheduleView({ events, selectedDate, onSelectEvent }) {
const filtered = events.filter(e => new Date(e.end_at) >= (selectedDate || new Date(0)));
return (
<div style={{ overflowY:'auto', flex:1 }}>
{filtered.length === 0 && (
<div style={{ textAlign:'center', padding:'40px 20px', color:'var(--text-tertiary)', fontSize:14 }}>No upcoming events</div>
)}
{filtered.map(e => {
const start = new Date(e.start_at);
const colour = e.event_type?.colour || '#9ca3af';
return (
<div key={e.id} onClick={()=>onSelectEvent(e)} style={{ display:'flex', alignItems:'center', gap:16, padding:'12px 16px', borderBottom:'1px solid var(--border)', cursor:'pointer', transition:'background var(--transition)' }}
onMouseEnter={el=>el.currentTarget.style.background='var(--background)'}
onMouseLeave={el=>el.currentTarget.style.background=''}>
<div style={{ width:40, textAlign:'center', flexShrink:0 }}>
<div style={{ fontSize:18, fontWeight:700, lineHeight:1 }}>{start.getDate()}</div>
<div style={{ fontSize:11, color:'var(--text-tertiary)', textTransform:'uppercase' }}>{SHORT_MONTHS[start.getMonth()]}, {DAYS[start.getDay()]}</div>
</div>
<div style={{ display:'flex', alignItems:'center', gap:8, flexShrink:0, width:90, fontSize:13, color:'var(--text-secondary)' }}>
<span style={{ width:10, height:10, borderRadius:'50%', background:colour, flexShrink:0 }}/>
{e.all_day ? 'All day' : `${fmtTime(e.start_at)} ${fmtTime(e.end_at)}`}
</div>
<div style={{ flex:1, minWidth:0 }}>
<div style={{ fontSize:14, fontWeight:600, display:'flex', alignItems:'center', gap:8 }}>
{e.event_type?.name && <span style={{ fontSize:12, color:'var(--text-tertiary)', textTransform:'uppercase', letterSpacing:'0.5px' }}>{e.event_type.name}:</span>}
{e.title}
{e.track_availability && !e.my_response && (
<span style={{ width:8, height:8, borderRadius:'50%', background:'#ef4444', flexShrink:0 }} title="Awaiting your response"/>
)}
</div>
{e.location && <div style={{ fontSize:12, color:'var(--text-tertiary)', marginTop:2 }}>{e.location}</div>}
</div>
</div>
);
})}
</div>
);
}
// ── Day View ──────────────────────────────────────────────────────────────────
function DayView({ events, selectedDate, onSelectEvent }) {
const hours = Array.from({length:16}, (_,i)=>i+7); // 7am10pm
const dayEvents = events.filter(e => sameDay(new Date(e.start_at), selectedDate));
return (
<div style={{ overflowY:'auto', flex:1, position:'relative' }}>
<div style={{ display:'flex', borderBottom:'1px solid var(--border)', padding:'8px 0 8px 52px', fontSize:13, fontWeight:600, color:'var(--primary)' }}>
<div style={{ textAlign:'center' }}>
<div>{DAYS[selectedDate.getDay()]}</div>
<div style={{ fontSize:24, fontWeight:700 }}>{selectedDate.getDate()}</div>
</div>
</div>
{hours.map(h => (
<div key={h} style={{ display:'flex', borderBottom:'1px solid var(--border)', minHeight:48 }}>
<div style={{ width:52, flexShrink:0, fontSize:11, color:'var(--text-tertiary)', padding:'2px 8px 0', textAlign:'right' }}>{h > 12 ? `${h-12} PM` : h === 12 ? '12 PM' : `${h} AM`}</div>
<div style={{ flex:1, position:'relative' }}>
{dayEvents.filter(e => new Date(e.start_at).getHours()===h).map(e => (
<div key={e.id} onClick={()=>onSelectEvent(e)} style={{
margin:'2px 4px', padding:'4px 8px', borderRadius:4,
background: e.event_type?.colour || '#6366f1', color:'white',
fontSize:12, cursor:'pointer', fontWeight:600
}}>
{e.title} · {fmtTimeRange(e.start_at, e.end_at)}
</div>
))}
</div>
</div>
))}
</div>
);
}
// ── Week View ─────────────────────────────────────────────────────────────────
function WeekView({ events, selectedDate, onSelectEvent }) {
const weekStart = startOfWeek(selectedDate);
const days = Array.from({length:7}, (_,i) => { const d=new Date(weekStart); d.setDate(d.getDate()+i); return d; });
const hours = Array.from({length:16}, (_,i)=>i+7);
const today = new Date();
return (
<div style={{ overflowY:'auto', flex:1 }}>
<div style={{ display:'grid', gridTemplateColumns:'52px repeat(7,1fr)', borderBottom:'1px solid var(--border)' }}>
<div/>
{days.map((d,i) => (
<div key={i} style={{ textAlign:'center', padding:'6px 4px', fontSize:12, fontWeight:600, color:sameDay(d,today)?'var(--primary)':'var(--text-secondary)' }}>
{DAYS[d.getDay()]} {d.getDate()}
</div>
))}
</div>
{hours.map(h => (
<div key={h} style={{ display:'grid', gridTemplateColumns:'52px repeat(7,1fr)', borderBottom:'1px solid var(--border)', minHeight:44 }}>
<div style={{ fontSize:11, color:'var(--text-tertiary)', padding:'2px 8px 0', textAlign:'right' }}>
{h>12?`${h-12} PM`:h===12?'12 PM':`${h} AM`}
</div>
{days.map((d,i) => (
<div key={i} style={{ borderLeft:'1px solid var(--border)', padding:'1px 2px' }}>
{events.filter(e=>sameDay(new Date(e.start_at),d)&&new Date(e.start_at).getHours()===h).map(e=>(
<div key={e.id} onClick={()=>onSelectEvent(e)} style={{ background:e.event_type?.colour||'#6366f1', color:'white', borderRadius:3, padding:'2px 4px', fontSize:11, cursor:'pointer', marginBottom:1, fontWeight:600 }}>
{e.title}
</div>
))}
</div>
))}
</div>
))}
</div>
);
}
// ── Month View ────────────────────────────────────────────────────────────────
function MonthView({ events, selectedDate, onSelectEvent, onSelectDay }) {
const year = selectedDate.getFullYear(), month = selectedDate.getMonth();
const firstDow = new Date(year, month, 1).getDay();
const total = daysInMonth(year, month);
const today = new Date();
const cells = [];
for (let i=0;i<firstDow;i++) cells.push(null);
for (let d=1;d<=total;d++) cells.push(d);
while (cells.length % 7 !== 0) cells.push(null);
const weeks = [];
for (let i=0;i<cells.length;i+=7) weeks.push(cells.slice(i,i+7));
return (
<div style={{ flex:1, overflowY:'auto', display:'flex', flexDirection:'column' }}>
<div style={{ display:'grid', gridTemplateColumns:'repeat(7,1fr)', borderBottom:'1px solid var(--border)' }}>
{DAYS.map(d=><div key={d} style={{ textAlign:'center', padding:'6px', fontSize:12, fontWeight:600, color:'var(--text-tertiary)' }}>{d}</div>)}
</div>
{weeks.map((week,wi)=>(
<div key={wi} style={{ display:'grid', gridTemplateColumns:'repeat(7,1fr)', flex:1, minHeight:80 }}>
{week.map((d,di)=>{
if (!d) return <div key={di} style={{ borderRight:'1px solid var(--border)', borderBottom:'1px solid var(--border)', background:'var(--surface-variant)' }}/>;
const date = new Date(year, month, d);
const dayEvents = events.filter(e=>sameDay(new Date(e.start_at),date));
const isToday = sameDay(date, today);
return (
<div key={di} onClick={()=>onSelectDay(date)} style={{ borderRight:'1px solid var(--border)', borderBottom:'1px solid var(--border)', padding:'4px', cursor:'pointer', minHeight:80 }}
onMouseEnter={el=>el.currentTarget.style.background='var(--background)'}
onMouseLeave={el=>el.currentTarget.style.background=''}>
<div style={{ width:24, height:24, borderRadius:'50%', display:'flex', alignItems:'center', justifyContent:'center', marginBottom:2, fontSize:12, fontWeight:isToday?700:400, background:isToday?'var(--primary)':'transparent', color:isToday?'white':'var(--text-primary)' }}>{d}</div>
{dayEvents.slice(0,3).map(e=>(
<div key={e.id} onClick={ev=>{ev.stopPropagation();onSelectEvent(e);}} style={{ background:e.event_type?.colour||'#6366f1', color:'white', borderRadius:2, padding:'1px 5px', fontSize:11, marginBottom:1, truncate:true, cursor:'pointer', whiteSpace:'nowrap', overflow:'hidden', textOverflow:'ellipsis' }}>
{!e.all_day&&<span style={{ marginRight:3 }}>{fmtTime(e.start_at)}</span>}{e.title}
</div>
))}
{dayEvents.length>3&&<div style={{ fontSize:10, color:'var(--text-tertiary)' }}>+{dayEvents.length-3} more</div>}
</div>
);
})}
</div>
))}
</div>
);
}
// ── Bulk Import ───────────────────────────────────────────────────────────────
function BulkImportPanel({ onImported, onCancel }) {
const toast = useToast();
const [rows, setRows] = useState(null);
const [skipped, setSkipped] = useState(new Set());
const [importing, setSaving] = useState(false);
const handleFile = async (e) => {
const file = e.target.files[0];
if (!file) return;
try {
const result = await api.importPreview(file);
if (result.error) return toast(result.error, 'error');
setRows(result.rows);
setSkipped(new Set(result.rows.filter(r=>r.duplicate||r.error).map(r=>r.row)));
} catch (err) { toast('Upload failed', 'error'); }
};
const handleImport = async () => {
setSaving(true);
try {
const toImport = rows.filter(r => !skipped.has(r.row) && !r.error);
const { imported } = await api.importConfirm(toImport);
toast(`${imported} event${imported!==1?'s':''} imported`, 'success');
onImported();
} catch (e) { toast(e.message, 'error'); }
finally { setSaving(false); }
};
return (
<div>
<div className="settings-section-label">Bulk Event Import</div>
<p style={{ fontSize:12, color:'var(--text-tertiary)', marginBottom:12 }}>
CSV fields: <code>Event Title, start_date (YYYY-MM-DD), start_time (HH:MM), event_location, event_type, default_duration</code>
</p>
<input type="file" accept=".csv" onChange={handleFile} style={{ marginBottom:16 }} />
{rows && (
<>
<div style={{ overflowX:'auto', marginBottom:12 }}>
<table style={{ width:'100%', borderCollapse:'collapse', fontSize:12 }}>
<thead>
<tr style={{ borderBottom:'2px solid var(--border)' }}>
{['','Row','Title','Start','End','Type','Duration','Status'].map(h=>(
<th key={h} style={{ padding:'4px 8px', textAlign:'left', color:'var(--text-tertiary)', whiteSpace:'nowrap' }}>{h}</th>
))}
</tr>
</thead>
<tbody>
{rows.map(r=>(
<tr key={r.row} style={{ borderBottom:'1px solid var(--border)', opacity:skipped.has(r.row)?0.4:1 }}>
<td style={{ padding:'4px 8px' }}>
<input type="checkbox" checked={!skipped.has(r.row)} disabled={!!r.error}
onChange={()=>setSkipped(prev=>{const n=new Set(prev);n.has(r.row)?n.delete(r.row):n.add(r.row);return n;})} />
</td>
<td style={{ padding:'4px 8px' }}>{r.row}</td>
<td style={{ padding:'4px 8px', fontWeight:600 }}>{r.title}</td>
<td style={{ padding:'4px 8px' }}>{r.startAt?.slice(0,16).replace('T',' ')}</td>
<td style={{ padding:'4px 8px' }}>{r.endAt?.slice(0,16).replace('T',' ')}</td>
<td style={{ padding:'4px 8px' }}>{r.typeName}</td>
<td style={{ padding:'4px 8px' }}>{r.durHrs}hr</td>
<td style={{ padding:'4px 8px' }}>
{r.error ? <span style={{ color:'var(--error)' }}>{r.error}</span>
: r.duplicate ? <span style={{ color:'#f59e0b' }}> Duplicate</span>
: <span style={{ color:'var(--success)' }}> Ready</span>}
</td>
</tr>
))}
</tbody>
</table>
</div>
<div style={{ display:'flex', gap:8, alignItems:'center' }}>
<button className="btn btn-primary btn-sm" onClick={handleImport} disabled={importing}>
{importing ? 'Importing…' : `Import ${rows.filter(r=>!skipped.has(r.row)&&!r.error).length} events`}
</button>
<button className="btn btn-secondary btn-sm" onClick={onCancel}>Cancel</button>
</div>
</>
)}
</div>
);
}
// ── Event Types Manager ───────────────────────────────────────────────────────
function EventTypesPanel({ eventTypes, userGroups, onUpdated, onClose }) {
const toast = useToast();
const [editingType, setEditingType] = useState(null);
const [showForm, setShowForm] = useState(false);
const ref = useRef(null);
const handleDelete = async (et) => {
if (!confirm(`Delete event type "${et.name}"? Existing events will lose their type.`)) return;
try {
await api.deleteEventType(et.id);
toast('Event type deleted', 'success');
onUpdated();
} catch (e) { toast(e.message, 'error'); }
};
return (
<div>
<div style={{ display:'flex', alignItems:'center', justifyContent:'space-between', marginBottom:16 }}>
<div className="settings-section-label" style={{ margin:0 }}>Event Types</div>
<div style={{ position:'relative' }} ref={ref}>
<button className="btn btn-primary btn-sm" onClick={()=>{setShowForm(v=>!v);setEditingType(null);}}>+ New Type</button>
{showForm && !editingType && (
<EventTypePopup userGroups={userGroups} onSave={()=>onUpdated()} onClose={()=>setShowForm(false)} />
)}
</div>
</div>
<div style={{ display:'flex', flexDirection:'column', gap:6 }}>
{eventTypes.map(et => (
<div key={et.id} style={{ display:'flex', alignItems:'center', gap:10, padding:'8px 12px', border:'1px solid var(--border)', borderRadius:'var(--radius)' }}>
<span style={{ width:16, height:16, borderRadius:'50%', background:et.colour, flexShrink:0 }}/>
<span style={{ flex:1, fontSize:14, fontWeight:500 }}>{et.name}</span>
{et.default_duration_hrs > 1 && <span style={{ fontSize:12, color:'var(--text-tertiary)' }}>{et.default_duration_hrs}hr default</span>}
{!et.is_default && (
<>
<div style={{ position:'relative' }}>
<button className="btn btn-secondary btn-sm" onClick={()=>{setEditingType(et);setShowForm(true);}}>Edit</button>
{showForm && editingType?.id===et.id && (
<EventTypePopup editing={et} userGroups={userGroups} onSave={()=>{onUpdated();setShowForm(false);setEditingType(null);}} onClose={()=>{setShowForm(false);setEditingType(null);}} />
)}
</div>
<button className="btn btn-sm" style={{ background:'var(--error)', color:'white' }} onClick={()=>handleDelete(et)}>Delete</button>
</>
)}
{et.is_default && <span style={{ fontSize:11, color:'var(--text-tertiary)' }}>Default</span>}
</div>
))}
</div>
</div>
);
}
// ── Main Modal ────────────────────────────────────────────────────────────────
export default function ScheduleManagerModal({ onClose, isToolManager }) {
const { user } = useAuth();
const toast = useToast();
const [view, setView] = useState('schedule'); // schedule | day | week | month
const [selectedDate, setSelectedDate] = useState(new Date());
const [events, setEvents] = useState([]);
const [eventTypes, setEventTypes] = useState([]);
const [userGroups, setUserGroups] = useState([]);
const [selectedEvent, setSelectedEvent] = useState(null);
const [rightPanel, setRightPanel] = useState('calendar'); // calendar | eventForm | eventTypes | bulkImport
const [editingEvent, setEditingEvent] = useState(null);
const [createMenuOpen, setCreateMenuOpen] = useState(false);
const [loading, setLoading] = useState(true);
const createRef = useRef(null);
const loadAll = useCallback(() => {
Promise.all([
api.getEvents(),
api.getEventTypes(),
api.getUserGroups(),
]).then(([ev, et, ug]) => {
setEvents(ev.events || []);
setEventTypes(et.eventTypes || []);
setUserGroups(ug.groups || []);
setLoading(false);
}).catch(() => setLoading(false));
}, []);
useEffect(() => { loadAll(); }, [loadAll]);
// Close create menu on outside click
useEffect(() => {
if (!createMenuOpen) return;
const h = e => { if (createRef.current && !createRef.current.contains(e.target)) setCreateMenuOpen(false); };
document.addEventListener('mousedown', h);
return () => document.removeEventListener('mousedown', h);
}, [createMenuOpen]);
const eventDates = new Set(events.map(e => e.start_at?.slice(0,10)));
const navDate = (dir) => {
const d = new Date(selectedDate);
if (view === 'day') d.setDate(d.getDate() + dir);
else if (view === 'week') d.setDate(d.getDate() + dir*7);
else if (view === 'month') d.setMonth(d.getMonth() + dir);
else d.setDate(d.getDate() + dir*7);
setSelectedDate(d);
};
const navLabel = () => {
if (view === 'day') return `${DAYS[selectedDate.getDay()]} ${selectedDate.getDate()} ${MONTHS[selectedDate.getMonth()]} ${selectedDate.getFullYear()}`;
if (view === 'week') { const ws=startOfWeek(selectedDate); const we=new Date(ws); we.setDate(we.getDate()+6); return `${SHORT_MONTHS[ws.getMonth()]} ${ws.getDate()} ${SHORT_MONTHS[we.getMonth()]} ${we.getDate()} ${we.getFullYear()}`; }
return `${MONTHS[selectedDate.getMonth()]} ${selectedDate.getFullYear()}`;
};
const openEventDetail = async (e) => {
try {
const { event } = await api.getEvent(e.id);
setSelectedEvent(event);
} catch (err) { toast('Failed to load event', 'error'); }
};
const handleEventSaved = () => {
loadAll();
setRightPanel('calendar');
setEditingEvent(null);
};
const handleDeleteEvent = async (e) => {
if (!confirm(`Delete "${e.title}"?`)) return;
try { await api.deleteEvent(e.id); toast('Event deleted', 'success'); loadAll(); setSelectedEvent(null); } catch (err) { toast(err.message, 'error'); }
};
if (loading) return (
<div className="modal-overlay"><div className="modal" style={{ maxWidth:200, textAlign:'center' }}>Loading</div></div>
);
return (
<div className="modal-overlay" onClick={e=>e.target===e.currentTarget&&onClose()}>
<div className="modal" style={{ maxWidth:1024, width:'96vw', height:'90vh', display:'flex', flexDirection:'column', padding:0, overflow:'hidden' }}>
{/* Header */}
<div style={{ display:'flex', alignItems:'center', justifyContent:'space-between', padding:'12px 20px', borderBottom:'1px solid var(--border)', flexShrink:0, gap:12 }}>
<div style={{ display:'flex', alignItems:'center', gap:12 }}>
{/* Create dropdown */}
{isToolManager && (
<div style={{ position:'relative' }} ref={createRef}>
<button className="btn btn-primary btn-sm" onClick={()=>setCreateMenuOpen(v=>!v)} style={{ display:'flex', alignItems:'center', gap:6 }}>
+ Create
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><polyline points="6 9 12 15 18 9"/></svg>
</button>
{createMenuOpen && (
<div style={{ position:'absolute', top:'100%', left:0, zIndex:100, background:'var(--surface)', border:'1px solid var(--border)', borderRadius:'var(--radius)', marginTop:4, minWidth:180, boxShadow:'0 4px 16px rgba(0,0,0,0.12)' }}>
{[['Event', ()=>{setRightPanel('eventForm');setEditingEvent(null);setCreateMenuOpen(false);}],
['Event Type', ()=>{setRightPanel('eventTypes');setCreateMenuOpen(false);}],
['Bulk Event Import', ()=>{setRightPanel('bulkImport');setCreateMenuOpen(false);}]
].map(([label, action]) => (
<button key={label} onClick={action} style={{ display:'block', width:'100%', padding:'9px 14px', textAlign:'left', fontSize:14, background:'none', border:'none', cursor:'pointer', color:'var(--text-primary)' }}
onMouseEnter={e=>e.currentTarget.style.background='var(--background)'}
onMouseLeave={e=>e.currentTarget.style.background=''}>{label}</button>
))}
</div>
)}
</div>
)}
{/* Nav */}
<div style={{ display:'flex', alignItems:'center', gap:6 }}>
<button className="btn btn-secondary btn-sm" onClick={()=>setSelectedDate(new Date())}>Today</button>
<button className="btn-icon" onClick={()=>navDate(-1)}></button>
<button className="btn-icon" onClick={()=>navDate(1)}></button>
{view !== 'schedule' && <span style={{ fontSize:14, fontWeight:600, minWidth:200 }}>{navLabel()}</span>}
</div>
</div>
<div style={{ display:'flex', alignItems:'center', gap:8 }}>
{/* View switcher */}
<div style={{ display:'flex', gap:2, background:'var(--surface-variant)', borderRadius:'var(--radius)', padding:3 }}>
{[['schedule','Schedule'],['day','Day'],['week','Week'],['month','Month']].map(([v,l])=>(
<button key={v} onClick={()=>{setView(v);setRightPanel('calendar');}} style={{ padding:'4px 10px', borderRadius:5, border:'none', cursor:'pointer', fontSize:12, fontWeight:600, background: view===v ? 'var(--surface)' : 'transparent', color: view===v ? 'var(--text-primary)' : 'var(--text-tertiary)', boxShadow: view===v ? '0 1px 3px rgba(0,0,0,0.1)' : 'none' }}>{l}</button>
))}
</div>
<button className="btn-icon" onClick={onClose}><svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg></button>
</div>
</div>
{/* Body */}
<div style={{ display:'flex', flex:1, overflow:'hidden' }}>
{/* Left panel: mini calendar */}
<div style={{ width:210, flexShrink:0, borderRight:'1px solid var(--border)', padding:16, overflowY:'auto' }}>
<MiniCalendar selected={selectedDate} onChange={d=>{setSelectedDate(d);setRightPanel('calendar');}} eventDates={eventDates} />
</div>
{/* Right panel */}
<div style={{ flex:1, display:'flex', flexDirection:'column', overflow:'hidden' }}>
{rightPanel === 'calendar' && view === 'schedule' && (
<ScheduleView events={events} selectedDate={selectedDate} onSelectEvent={openEventDetail} />
)}
{rightPanel === 'calendar' && view === 'day' && (
<DayView events={events} selectedDate={selectedDate} onSelectEvent={openEventDetail} />
)}
{rightPanel === 'calendar' && view === 'week' && (
<WeekView events={events} selectedDate={selectedDate} onSelectEvent={openEventDetail} />
)}
{rightPanel === 'calendar' && view === 'month' && (
<MonthView events={events} selectedDate={selectedDate} onSelectEvent={openEventDetail}
onSelectDay={d=>{setSelectedDate(d);setView('schedule');}} />
)}
{rightPanel === 'eventForm' && (
<div style={{ padding:24, overflowY:'auto', flex:1 }}>
<EventForm event={editingEvent} userGroups={userGroups} eventTypes={eventTypes}
selectedDate={selectedDate} isToolManager={isToolManager}
onSave={handleEventSaved}
onCancel={()=>{setRightPanel('calendar');setEditingEvent(null);}}
onDelete={handleDeleteEvent} />
</div>
)}
{rightPanel === 'eventTypes' && (
<div style={{ padding:24, overflowY:'auto', flex:1 }}>
<EventTypesPanel eventTypes={eventTypes} userGroups={userGroups} onUpdated={loadAll}
onClose={()=>setRightPanel('calendar')} />
</div>
)}
{rightPanel === 'bulkImport' && (
<div style={{ padding:24, overflowY:'auto', flex:1 }}>
<BulkImportPanel onImported={()=>{loadAll();setRightPanel('calendar');}} onCancel={()=>setRightPanel('calendar')} />
</div>
)}
</div>
</div>
</div>
{/* Event detail popup */}
{selectedEvent && (
<EventDetailPopup
event={selectedEvent}
isToolManager={isToolManager}
currentUserId={user?.id}
onClose={()=>setSelectedEvent(null)}
onEdit={()=>{ setEditingEvent(selectedEvent); setRightPanel('eventForm'); setSelectedEvent(null); }}
onAvailabilityChange={()=>openEventDetail(selectedEvent)} />
)}
</div>
);
}