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
{MONTHS[month]} {year}
{DAYS.map(d =>
{d[0]}
)} {cells.map((d,i) => { if (!d) return
; 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 (
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 && }
); })}
); } // ── 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 (
setName(e.target.value)} autoComplete="new-password" style={{ marginTop:4 }} autoFocus />
setColour(e.target.value)} style={{ marginTop:4, width:'100%', height:32, padding:2, borderRadius:4, border:'1px solid var(--border)' }} />
{setDur && ( )}
); } // ── 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) : ''); const mountedRef = useRef(false); // Mark mounted after first render so effects skip initial fire useEffect(() => { mountedRef.current = true; }, []); // Auto-update end time only when type, start date, or start time actually changes useEffect(() => { if (!mountedRef.current) return; // skip initial mount 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 explicit type change: apply eventType duration const dur = et?.default_duration_hrs || 1; setEndDate(toLocalDateInput(addHours(start, dur))); setEndTime(toLocalTimeInput(addHours(start, dur))); } else { // Editing start date/time 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 }) => (
{label}
{children}
); return (
{/* Title */} 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 */}
setStartDate(e.target.value)} style={{ width:150 }} /> {!allDay && setStartTime(e.target.value)} style={{ width:120 }} />} to {!allDay && { 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 }} />} setEndDate(e.target.value)} style={{ width:150 }} />
{/* Event Type */}
{isToolManager && ( )} {showTypeForm && ( setLocalEventTypes(prev=>[...prev,et])} onClose={()=>setShowTypeForm(false)} /> )}
{/* Groups */}
{userGroups.length === 0 ? (
No user groups created yet
) : userGroups.map(g => ( ))}
{selectedGroups.size === 0 ? 'No groups — event visible to all (if public)' : `${selectedGroups.size} group${selectedGroups.size!==1?'s':''} selected`}
{/* Visibility + Availability */}
{/* Location */} setLocation(e.target.value)} autoComplete="new-password" /> {/* Description */}