Files
rosterchirp/frontend/src/components/SchedulePage.jsx
2026-03-18 14:15:28 -04:00

1219 lines
76 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';
import UserFooter from './UserFooter.jsx';
import MobileEventForm from './MobileEventForm.jsx';
import MobileGroupManager from './MobileGroupManager.jsx';
// ── Utilities ─────────────────────────────────────────────────────────────────
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(iso) { if(!iso) return ''; const d=new Date(iso); return d.toLocaleTimeString([],{hour:'2-digit',minute:'2-digit'}); }
function fmtRange(s,e) { return `${fmtTime(s)} ${fmtTime(e)}`; }
function toDateIn(iso) { return iso?iso.slice(0,10):''; }
function toTimeIn(iso) { if(!iso) return ''; const d=new Date(iso); const h=String(d.getHours()).padStart(2,'0'), m=d.getMinutes()<30?'00':'30'; return `${h}:${m}`; }
function buildISO(d,t) { return d&&t?`${d}T${t}:00`:''; }
function addHours(iso,h){
const d=new Date(iso); d.setMinutes(d.getMinutes()+h*60);
// Return local datetime string (YYYY-MM-DDTHH:MM:SS) — NOT toISOString() which shifts to UTC
const pad=n=>String(n).padStart(2,'0');
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 weekStart(d) { const r=new Date(d); r.setDate(d.getDate()-d.getDay()); r.setHours(0,0,0,0); return r; }
function daysInMonth(y,m){ return new Date(y,m+1,0).getDate(); }
const RESP_LABEL = { going:'Going', maybe:'Maybe', not_going:'Not Going' };
const RESP_COLOR = { going:'#22c55e', maybe:'#f59e0b', not_going:'#ef4444' };
const RESP_ICON = {
going: (color,size=15) => (
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" strokeWidth={1.5} stroke={color} width={size} height={size} style={{flexShrink:0}}>
<title>Going</title>
<path strokeLinecap="round" strokeLinejoin="round" d="M15.182 15.182a4.5 4.5 0 0 1-6.364 0M21 12a9 9 0 1 1-18 0 9 9 0 0 1 18 0ZM9.75 9.75c0 .414-.168.75-.375.75S9 10.164 9 9.75 9.168 9 9.375 9s.375.336.375.75Zm-.375 0h.008v.015h-.008V9.75Zm5.625 0c0 .414-.168.75-.375.75s-.375-.336-.375-.75.168-.75.375-.75.375.336.375.75Zm-.375 0h.008v.015h-.008V9.75Z" />
</svg>
),
maybe: (color,size=15) => (
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" strokeWidth={1.5} stroke={color} width={size} height={size} style={{flexShrink:0}}>
<title>Maybe</title>
<path strokeLinecap="round" strokeLinejoin="round" d="M9.879 7.519c1.171-1.025 3.071-1.025 4.242 0 1.172 1.025 1.172 2.687 0 3.712-.203.179-.43.326-.67.442-.745.361-1.45.999-1.45 1.827v.75M21 12a9 9 0 1 1-18 0 9 9 0 0 1 18 0Zm-9 5.25h.008v.008H12v-.008Z" />
</svg>
),
not_going: (color,size=15) => (
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" strokeWidth={1.5} stroke={color} width={size} height={size} style={{flexShrink:0}}>
<title>Not Going</title>
<path strokeLinecap="round" strokeLinejoin="round" d="M15.182 16.318A4.486 4.486 0 0 0 12.016 15a4.486 4.486 0 0 0-3.198 1.318M21 12a9 9 0 1 1-18 0 9 9 0 0 1 18 0ZM9.75 9.75c0 .414-.168.75-.375.75S9 10.164 9 9.75 9.168 9 9.375 9s.375.336.375.75Zm-.375 0h.008v.015h-.008V9.75Zm5.625 0c0 .414-.168.75-.375.75s-.375-.336-.375-.75.168-.75.375-.75.375.336.375.75Zm-.375 0h.008v.015h-.008V9.75Z" />
</svg>
),
};
const BELL_ICON = (
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" strokeWidth={1.5} stroke="#ef4444" width={15} height={15} style={{flexShrink:0}}>
<title>Awaiting your response</title>
<path strokeLinecap="round" strokeLinejoin="round" d="M14.857 17.082a23.848 23.848 0 0 0 5.454-1.31A8.967 8.967 0 0 1 18 9.75V9A6 6 0 0 0 6 9v.75a8.967 8.967 0 0 1-2.312 6.022c1.733.64 3.56 1.085 5.455 1.31m5.714 0a24.255 24.255 0 0 1-5.714 0m5.714 0a3 3 0 1 1-5.714 0" />
</svg>
);
// 30-minute time slots
const TIME_SLOTS = (() => {
const s=[];
for(let h=0;h<24;h++) for(let m of [0,30]) {
const hh=String(h).padStart(2,'0'), mm=String(m).padStart(2,'0');
const disp=`${h===0?12:h>12?h-12:h}:${mm} ${h<12?'AM':'PM'}`;
s.push({value:`${hh}:${mm}`,label:disp});
}
return s;
})();
// ── Mini Calendar (desktop) ───────────────────────────────────────────────────
function MiniCalendar({ selected, onChange, eventDates=new Set() }) {
const [cur, setCur] = useState(()=>{ const d=new Date(selected||Date.now()); d.setDate(1); return d; });
const y=cur.getFullYear(), m=cur.getMonth(), first=new Date(y,m,1).getDay(), total=daysInMonth(y,m), today=new Date();
const cells=[]; for(let i=0;i<first;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 8px',color:'var(--text-secondary)',fontSize:16}} onClick={()=>{const n=new Date(cur);n.setMonth(m-1);setCur(n);}}></button>
<span>{MONTHS[m]} {y}</span>
<button style={{background:'none',border:'none',cursor:'pointer',padding:'2px 8px',color:'var(--text-secondary)',fontSize:16}} onClick={()=>{const n=new Date(cur);n.setMonth(m+1);setCur(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(y,m,d), isSel=selected&&sameDay(date,new Date(selected)), isToday=sameDay(date,today);
const key=`${y}-${String(m+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}
{eventDates.has(key)&&!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>
);
}
// ── Mobile Filter Bar (Schedule view: keyword+type filters with month nav; Day view: calendar accordion) ──
function MobileScheduleFilter({ selected, onMonthChange, view, eventTypes, filterKeyword, onFilterKeyword, filterTypeId, onFilterTypeId, eventDates=new Set() }) {
// Day view: keep accordion calendar
const [open, setOpen] = useState(false);
const y=selected.getFullYear(), m=selected.getMonth();
const today=new Date();
if(view==='day') {
const first=new Date(y,m,1).getDay(), total=daysInMonth(y,m);
const cells=[]; for(let i=0;i<first;i++) cells.push(null); for(let d=1;d<=total;d++) cells.push(d);
return (
<div style={{borderBottom:'1px solid var(--border)',background:'var(--surface)'}}>
<button onClick={()=>setOpen(v=>!v)} style={{display:'flex',alignItems:'center',justifyContent:'space-between',width:'100%',padding:'10px 16px',background:'none',border:'none',cursor:'pointer',fontSize:14,fontWeight:600,color:'var(--text-primary)'}}>
<span>{MONTHS[m]} {y}</span>
<span style={{fontSize:10,transform:open?'rotate(180deg)':'none',display:'inline-block',transition:'transform 0.2s'}}></span>
</button>
{open && (
<div style={{padding:'8px 12px 12px',userSelect:'none'}}>
<div style={{display:'flex',alignItems:'center',justifyContent:'space-between',marginBottom:8}}>
<button style={{background:'none',border:'none',cursor:'pointer',padding:'4px 10px',fontSize:16,color:'var(--text-secondary)'}} onClick={()=>onMonthChange(-1)}></button>
<button style={{background:'none',border:'none',cursor:'pointer',padding:'4px 10px',fontSize:16,color:'var(--text-secondary)'}} onClick={()=>onMonthChange(1)}></button>
</div>
<div style={{display:'grid',gridTemplateColumns:'repeat(7,1fr)',gap:2,fontSize:12}}>
{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(y,m,d), isSel=sameDay(date,selected), isToday=sameDay(date,today);
const key=`${y}-${String(m+1).padStart(2,'0')}-${String(d).padStart(2,'0')}`;
return (
<div key={i} onClick={()=>{const nd=new Date(y,m,d);onMonthChange(0,nd);setOpen(false);}} style={{textAlign:'center',padding:'5px 2px',borderRadius:4,cursor:'pointer',background:isSel?'var(--primary)':'transparent',color:isSel?'white':isToday?'var(--primary)':'var(--text-primary)',fontWeight:isToday&&!isSel?700:400,position:'relative'}}>
{d}
{eventDates.has(key)&&!isSel&&<span style={{position:'absolute',bottom:2,left:'50%',transform:'translateX(-50%)',width:4,height:4,borderRadius:'50%',background:'var(--primary)',display:'block'}}/>}
</div>
);
})}
</div>
</div>
)}
</div>
);
}
// Schedule view: filter bar with month nav + keyword + event type
const hasFilters = filterKeyword || filterTypeId;
return (
<div style={{background:'var(--surface)',borderBottom:'1px solid var(--border)'}}>
{/* Month nav row */}
<div style={{display:'flex',alignItems:'center',padding:'8px 16px 0',gap:8}}>
<button onClick={()=>onMonthChange(-1)} style={{background:'none',border:'none',cursor:'pointer',color:'var(--text-secondary)',fontSize:18,padding:'2px 6px',lineHeight:1}}></button>
<span style={{flex:1,textAlign:'center',fontSize:14,fontWeight:600}}>{MONTHS[m]} {y}</span>
<button onClick={()=>onMonthChange(1)} style={{background:'none',border:'none',cursor:'pointer',color:'var(--text-secondary)',fontSize:18,padding:'2px 6px',lineHeight:1}}></button>
</div>
{/* Filter inputs */}
<div style={{padding:'8px 12px 10px',display:'flex',gap:8,alignItems:'center'}}>
<div style={{flex:1,position:'relative'}}>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="var(--text-tertiary)" strokeWidth="2" style={{position:'absolute',left:9,top:'50%',transform:'translateY(-50%)',pointerEvents:'none'}}><circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/></svg>
<input
value={filterKeyword}
onChange={e=>onFilterKeyword(e.target.value)}
placeholder="Search events…"
style={{width:'100%',padding:'7px 8px 7px 28px',border:'1px solid var(--border)',borderRadius:'var(--radius)',background:'var(--background)',color:'var(--text-primary)',fontSize:13,boxSizing:'border-box'}}
/>
</div>
<select
value={filterTypeId}
onChange={e=>onFilterTypeId(e.target.value)}
style={{padding:'7px 8px',border:'1px solid var(--border)',borderRadius:'var(--radius)',background:'var(--background)',color:'var(--text-primary)',fontSize:13,flexShrink:0,maxWidth:130}}
>
<option value="">All types</option>
{eventTypes.map(t=><option key={t.id} value={t.id}>{t.name}</option>)}
</select>
{hasFilters && (
<button onClick={()=>{onFilterKeyword('');onFilterTypeId('');}} style={{background:'none',border:'none',cursor:'pointer',color:'var(--text-tertiary)',fontSize:18,padding:'2px 4px',lineHeight:1,flexShrink:0}}></button>
)}
</div>
</div>
);
}
// ── Event Type Popup ──────────────────────────────────────────────────────────
function EventTypePopup({ userGroups, onSave, onClose, editing=null }) {
const toast=useToast();
const DUR=[1,1.5,2,2.5,3,3.5,4,4.5,5];
const [name,setName]=useState(editing?.name||'');
const [colour,setColour]=useState(editing?.colour||'#6366f1');
const [groupId,setGroupId]=useState(editing?.default_user_group_id||'');
const [dur,setDur]=useState(editing?.default_duration_hrs||1);
const [useDur,setUseDur]=useState(!!(editing?.default_duration_hrs));
const [saving,setSaving]=useState(false);
const handle=async()=>{
if(!name.trim()) return toast('Name required','error');
setSaving(true);
try{const body={name:name.trim(),colour,defaultUserGroupId:groupId||null,defaultDurationHrs:useDur?dur:null};const r=editing?await api.updateEventType(editing.id,body):await api.createEventType(body);onSave(r.eventType);onClose();}catch(e){toast(e.message,'error');}finally{setSaving(false);}
};
return (
<div style={{position:'absolute',top:'100%',left:0,zIndex:300,background:'var(--surface)',border:'1px solid var(--border)',borderRadius:'var(--radius)',padding:16,width:270,boxShadow:'0 4px 20px rgba(0,0,0,0.2)'}}>
<div style={{marginBottom:8}}><label className="settings-section-label">Name</label><input className="input" value={name} onChange={e=>setName(e.target.value)} style={{marginTop:4}} autoFocus/></div>
<div style={{marginBottom:8}}><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:8}}><label className="settings-section-label">Default Group</label><select className="input" value={groupId} onChange={e=>setGroupId(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:12}}>
<label style={{display:'flex',alignItems:'center',gap:8,fontSize:13,cursor:'pointer'}}><input type="checkbox" checked={useDur} onChange={e=>setUseDur(e.target.checked)}/> Set default duration</label>
{useDur&&<select className="input" value={dur} onChange={e=>setDur(Number(e.target.value))} style={{marginTop:6}}>{DUR.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={handle} disabled={saving}>{saving?'…':'Save'}</button><button className="btn btn-secondary btn-sm" onClick={onClose}>Cancel</button></div>
</div>
);
}
// ── Recurrence helpers ────────────────────────────────────────────────────────
const FREQ_OPTIONS = [
{ value: '', label: 'Does not repeat' },
{ value: 'daily', label: 'Every day' },
{ value: 'weekly', label: 'Every week' },
{ value: 'monthly', label: 'Every month' },
{ value: 'yearly', label: 'Every year' },
{ value: 'custom', label: 'Custom…' },
];
const DAY_PILLS = ['S','M','T','W','T','F','S'];
const DAY_KEYS = ['SU','MO','TU','WE','TH','FR','SA'];
function recurrenceLabel(rule) {
if (!rule || !rule.freq) return 'Does not repeat';
const opt = FREQ_OPTIONS.find(o => o.value === rule.freq);
if (rule.freq !== 'custom') return opt?.label || rule.freq;
// Custom summary
const unit = rule.interval === 1 ? rule.unit : `${rule.interval} ${rule.unit}s`;
return `Every ${unit}`;
}
// Desktop recurrence selector — shown inline in the form
function RecurrenceSelector({ value, onChange }) {
// value: { freq, interval, unit, byDay, ends, endDate, endCount } or null
const [showCustom, setShowCustom] = useState(false);
const rule = value || {};
const handleFreqChange = (freq) => {
if (freq === '') { onChange(null); return; }
if (freq === 'custom') { setShowCustom(true); onChange({ freq:'custom', interval:1, unit:'week', byDay:[], ends:'never', endDate:'', endCount:13 }); return; }
setShowCustom(false);
onChange({ freq });
};
return (
<div>
<select className="input" value={rule.freq||''} onChange={e=>handleFreqChange(e.target.value)} style={{marginBottom: (rule.freq==='custom'||showCustom) ? 12 : 0}}>
{FREQ_OPTIONS.map(o=><option key={o.value} value={o.value}>{o.label}</option>)}
</select>
{(rule.freq==='custom') && (
<CustomRecurrenceFields rule={rule} onChange={onChange}/>
)}
</div>
);
}
function CustomRecurrenceFields({ rule, onChange }) {
const upd = (k,v) => onChange({...rule,[k]:v});
return (
<div style={{border:'1px solid var(--border)',borderRadius:'var(--radius)',padding:12,display:'flex',flexDirection:'column',gap:10}}>
<div style={{display:'flex',alignItems:'center',gap:8,fontSize:13}}>
<span style={{color:'var(--text-tertiary)'}}>Every</span>
<input type="number" className="input" min={1} max={99} value={rule.interval||1} onChange={e=>upd('interval',Math.max(1,parseInt(e.target.value)||1))} style={{width:60,textAlign:'center'}}/>
<select className="input" value={rule.unit||'week'} onChange={e=>upd('unit',e.target.value)} style={{flex:1}}>
{['day','week','month','year'].map(u=><option key={u} value={u}>{u}{(rule.interval||1)>1?'s':''}</option>)}
</select>
</div>
{(rule.unit||'week')==='week' && (
<div>
<div style={{fontSize:12,color:'var(--text-tertiary)',marginBottom:6}}>Repeats on</div>
<div style={{display:'flex',gap:6}}>
{DAY_PILLS.map((d,i)=>{
const key=DAY_KEYS[i], sel=(rule.byDay||[]).includes(key);
return <button key={key} type="button" onClick={()=>upd('byDay',sel?(rule.byDay||[]).filter(x=>x!==key):[...(rule.byDay||[]),key])} style={{width:32,height:32,borderRadius:'50%',border:'1px solid var(--border)',background:sel?'var(--primary)':'transparent',color:sel?'white':'var(--text-primary)',fontSize:11,fontWeight:600,cursor:'pointer'}}>{d}</button>;
})}
</div>
</div>
)}
<div>
<div style={{fontSize:12,color:'var(--text-tertiary)',marginBottom:6}}>Ends</div>
{[['never','Never'],['on','On date'],['after','After']].map(([val,lbl])=>(
<label key={val} style={{display:'flex',alignItems:'center',gap:10,marginBottom:6,fontSize:13,cursor:'pointer'}}>
<input type="radio" name="recur_ends" checked={(rule.ends||'never')===val} onChange={()=>upd('ends',val)}/>
{lbl}
{val==='on' && (rule.ends||'never')==='on' && <input type="date" className="input" value={rule.endDate||''} onChange={e=>upd('endDate',e.target.value)} style={{marginLeft:8,flex:1}}/>}
{val==='after' && (rule.ends||'never')==='after' && <><input type="number" className="input" min={1} max={999} value={rule.endCount||13} onChange={e=>upd('endCount',parseInt(e.target.value)||1)} style={{width:64,textAlign:'center',marginLeft:8}}/><span style={{color:'var(--text-tertiary)'}}>occurrences</span></>}
</label>
))}
</div>
</div>
);
}
// ── Shared Row layout — defined OUTSIDE EventForm so it's stable across renders ─
function FormRow({ label, children, required }) {
return (
<div style={{display:'flex',alignItems:'flex-start',gap:0,marginBottom:16}}>
<div style={{width:120,flexShrink:0,fontSize:13,color:'var(--text-tertiary)',paddingTop:9,paddingRight:16,textAlign:'right',whiteSpace:'nowrap'}}>
{label}{required&&<span style={{color:'var(--error)'}}> *</span>}
</div>
<div style={{flex:1,minWidth:0}}>{children}</div>
</div>
);
}
// ── Event Form ────────────────────────────────────────────────────────────────
function EventForm({ event, userGroups, eventTypes, selectedDate, onSave, onCancel, onDelete, isToolManager }) {
const toast=useToast();
const def=selectedDate?selectedDate.toISOString().slice(0,10):new Date().toISOString().slice(0,10);
const [title,setTitle]=useState(event?.title||'');
const [typeId,setTypeId]=useState(event?.event_type_id||'');
const [sd,setSd]=useState(event?toDateIn(event.start_at):def);
const [st,setSt]=useState(event?toTimeIn(event.start_at):'09:00');
const [ed,setEd]=useState(event?toDateIn(event.end_at):def);
const [et,setEt]=useState(event?toTimeIn(event.end_at):'10:00');
const [allDay,setAllDay]=useState(!!event?.all_day);
const [loc,setLoc]=useState(event?.location||'');
const [desc,setDesc]=useState(event?.description||'');
const [pub,setPub]=useState(event?!!event.is_public:true);
const [track,setTrack]=useState(!!event?.track_availability);
const [grps,setGrps]=useState(new Set((event?.user_groups||[]).map(g=>g.id)));
const [saving,setSaving]=useState(false);
const [showTypeForm,setShowTypeForm]=useState(false);
const [localTypes,setLocalTypes]=useState(eventTypes);
const [recRule,setRecRule]=useState(event?.recurrence_rule||null);
// Sync localTypes when parent provides updated eventTypes (e.g. after async load)
// Also initialise typeId to the default event type for new events
useEffect(()=>{
setLocalTypes(eventTypes);
if(!event && typeId==='' && eventTypes.length>0) {
const def = eventTypes.find(t=>t.is_default) || eventTypes[0];
if(def) setTypeId(String(def.id));
}
},[eventTypes]);
const typeRef=useRef(null);
// Track whether the user has manually changed the end time (vs auto-computed)
const userSetEndTime = useRef(!!event); // editing mode: treat saved end as user-set
// When event type changes:
// - Creating: always apply the type's duration to compute end time
// - Editing: only apply duration if the type HAS a defined duration
// (if no duration on type, keep existing saved end time)
useEffect(()=>{
if(!sd||!st) return;
const typ=localTypes.find(t=>t.id===Number(typeId));
const start=buildISO(sd,st);
if(!start) return;
if(!event) {
// Creating new event — always apply duration (default 1hr)
const dur=typ?.default_duration_hrs||1;
setEd(toDateIn(addHours(start,dur)));
setEt(toTimeIn(addHours(start,dur)));
userSetEndTime.current = false;
} else {
// Editing — only update end time if the new type has an explicit duration
if(typ?.default_duration_hrs) {
setEd(toDateIn(addHours(start,typ.default_duration_hrs)));
setEt(toTimeIn(addHours(start,typ.default_duration_hrs)));
userSetEndTime.current = false;
}
// else: keep existing saved end time — do nothing
}
if(typ?.default_user_group_id&&!event) setGrps(prev=>new Set([...prev,Number(typ.default_user_group_id)]));
},[typeId]);
// When start date changes: match end date (both modes) unless user set it manually
useEffect(()=>{
if(!userSetEndTime.current) setEd(sd);
},[sd]);
// When start time changes: recompute end using current duration offset
useEffect(()=>{
if(!sd||!st) return;
if(userSetEndTime.current) return; // user already picked a specific end time — respect it
const typ=localTypes.find(t=>t.id===Number(typeId));
const dur=typ?.default_duration_hrs||1;
const start=buildISO(sd,st);
if(start){
setEd(toDateIn(addHours(start,dur)));
setEt(toTimeIn(addHours(start,dur)));
}
},[st]);
const toggleGrp=id=>setGrps(prev=>{const n=new Set(prev);n.has(id)?n.delete(id):n.add(id);return n;});
const groupsRequired=track; // when tracking, groups are required
const handle=async()=>{
if(!title.trim()) return toast('Title required','error');
if(!allDay&&(!sd||!st||!ed||!et)) return toast('Start and end required','error');
if(groupsRequired&&grps.size===0) return toast('Select at least one group for availability tracking','error');
setSaving(true);
try{const body={title:title.trim(),eventTypeId:typeId||null,startAt:allDay?`${sd}T00:00:00`:buildISO(sd,st),endAt:allDay?`${ed}T23:59:59`:buildISO(ed,et),allDay,location:loc,description:desc,isPublic:pub,trackAvailability:track,userGroupIds:[...grps],recurrenceRule:recRule||null};const r=event?await api.updateEvent(event.id,body):await api.createEvent(body);onSave(r.event);}catch(e){toast(e.message,'error');}finally{setSaving(false);}
};
return (
<div style={{width:'100%',maxWidth:1024,overflowX:'auto'}}>
<div style={{minWidth:500}} onKeyDown={e=>{if(e.key==='Enter'&&e.target.tagName!=='TEXTAREA') e.preventDefault();}}>
{/* Title */}
<div style={{marginBottom:20}}>
<input className="input" placeholder="Add title" value={title} onChange={e=>setTitle(e.target.value)}
style={{fontSize:20,fontWeight:700,border:'none',borderBottom:'2px solid var(--border)',borderRadius:0,padding:'4px 0',background:'transparent',width:'100%'}}/>
</div>
{/* Event Type */}
<FormRow label="Event Type">
<div style={{display:'flex',gap:8,alignItems:'center',position:'relative'}} ref={typeRef}>
<select className="input" value={typeId} onChange={e=>setTypeId(e.target.value)} style={{flex:1}}>
<option value=""> Select type </option>
{localTypes.map(t=><option key={t.id} value={t.id}>{t.name}</option>)}
</select>
{isToolManager&&<button className="btn btn-secondary btn-sm" style={{flexShrink:0}} onClick={()=>setShowTypeForm(v=>!v)}>{showTypeForm?'Cancel':'+ Type'}</button>}
{showTypeForm&&<EventTypePopup userGroups={userGroups} onSave={et=>{setLocalTypes(p=>[...p,et]);setShowTypeForm(false);}} onClose={()=>setShowTypeForm(false)}/>}
</div>
</FormRow>
{/* Date/Time */}
<FormRow label="Date & Time">
<div style={{display:'flex',flexDirection:'column',gap:8}}>
<div style={{display:'flex',alignItems:'center',gap:8,flexWrap:'nowrap'}}>
<input type="date" className="input" value={sd} onChange={e=>setSd(e.target.value)} style={{width:150,flexShrink:0}}/>
{!allDay&&(
<>
<select className="input" value={st} onChange={e=>setSt(e.target.value)} style={{width:120,flexShrink:0}}>
{TIME_SLOTS.map(s=><option key={s.value} value={s.value}>{s.label}</option>)}
</select>
<span style={{color:'var(--text-tertiary)',fontSize:13,flexShrink:0}}>to</span>
<select className="input" value={et} onChange={e=>{setEt(e.target.value);userSetEndTime.current=true;}} style={{width:120,flexShrink:0}}>
{TIME_SLOTS.map(s=><option key={s.value} value={s.value}>{s.label}</option>)}
</select>
<input type="date" className="input" value={ed} onChange={e=>{setEd(e.target.value);userSetEndTime.current=true;}} style={{width:150,flexShrink:0}}/>
</>
)}
</div>
<div style={{display:'flex',alignItems:'center',gap:16}}>
<label style={{display:'flex',alignItems:'center',gap:8,fontSize:13,cursor:'pointer'}}>
<input type="checkbox" checked={allDay} onChange={e=>setAllDay(e.target.checked)}/> All day
</label>
<div style={{display:'flex',alignItems:'center',gap:8,fontSize:13}}>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="var(--text-tertiary)" strokeWidth="2"><polyline points="23 4 23 10 17 10"/><path d="M20.49 15a9 9 0 1 1-2.12-9.36L23 10"/></svg>
<span style={{color:'var(--text-tertiary)',flexShrink:0}}>Repeat:</span>
<div style={{flex:1}}><RecurrenceSelector value={recRule} onChange={setRecRule}/></div>
</div>
</div>
</div>
</FormRow>
{/* Availability */}
<FormRow label="Availability">
<label style={{display:'flex',alignItems:'center',gap:10,fontSize:13,cursor:'pointer',paddingTop:6}}>
<input type="checkbox" checked={track} onChange={e=>{setTrack(e.target.checked);if(!e.target.checked) setPub(true);}}/>
Track availability for assigned groups
</label>
</FormRow>
{/* Groups — required when tracking */}
<FormRow label="Groups" required={groupsRequired}>
<div>
<div style={{border:`1px solid ${groupsRequired&&grps.size===0?'var(--error)':'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 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={grps.has(g.id)} onChange={()=>toggleGrp(g.id)} style={{accentColor:'var(--primary)'}}/>
{g.name}
</label>
))}
</div>
<p style={{fontSize:11,color:groupsRequired&&grps.size===0?'var(--error)':'var(--text-tertiary)',marginTop:4}}>
{grps.size===0
? (groupsRequired?'At least one group required for availability tracking':'No groups — event visible to all (if public)')
: `${grps.size} group${grps.size!==1?'s':''} selected`}
</p>
</div>
</FormRow>
{/* Visibility — only shown if groups selected OR tracking */}
{(grps.size>0||track) && (
<FormRow label="Visibility">
<label style={{display:'flex',alignItems:'center',gap:10,fontSize:13,cursor:'pointer',paddingTop:6}}>
<input type="checkbox" checked={!pub} onChange={e=>setPub(!e.target.checked)}/>
Viewable by selected groups only (private)
</label>
</FormRow>
)}
{/* Location */}
<FormRow label="Location">
<input className="input" placeholder="Add location" value={loc} onChange={e=>setLoc(e.target.value)}/>
</FormRow>
{/* Description */}
<FormRow label="Description">
<textarea className="input" placeholder="Add description" value={desc} onChange={e=>setDesc(e.target.value)} rows={3} style={{resize:'vertical'}}/>
</FormRow>
<div style={{display:'flex',gap:8,marginTop:8}}>
<button className="btn btn-primary btn-sm" onClick={handle} 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>
</div>
);
}
// ── Event Detail Modal ────────────────────────────────────────────────────────
function EventDetailModal({ event, onClose, onEdit, onAvailabilityChange, isToolManager }) {
const toast=useToast();
const [myResp,setMyResp]=useState(event.my_response);
const [avail,setAvail]=useState(event.availability||[]);
// Sync when parent reloads event after availability change
useEffect(()=>{setMyResp(event.my_response);setAvail(event.availability||[]);},[event]);
const counts={going:0,maybe:0,not_going:0};
avail.forEach(r=>{if(counts[r.response]!==undefined)counts[r.response]++;});
const handleResp=async resp=>{
const prev=myResp;
const next=myResp===resp?null:resp;
setMyResp(next); // optimistic update
try{
if(prev===resp){await api.deleteAvailability(event.id);}else{await api.setAvailability(event.id,resp);}
onAvailabilityChange?.(next); // triggers parent re-fetch to update avail list
}catch(e){setMyResp(prev);toast(e.message,'error');} // rollback on error
};
return ReactDOM.createPortal(
<div className="modal-overlay" onClick={e=>e.target===e.currentTarget&&onClose()}>
<div className="modal" style={{maxWidth:520,maxHeight:'88vh',overflowY:'auto'}}>
<div style={{display:'flex',alignItems:'flex-start',justifyContent:'space-between',marginBottom:16}}>
<div style={{flex:1,paddingRight:12}}>
<div style={{display:'flex',alignItems:'center',gap:10,marginBottom:4}}>
{event.event_type&&<span style={{width:13,height:13,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)',display:'flex',alignItems:'center',gap:8}}>
{event.event_type?.name&&<span>{event.event_type.name}</span>}
{event.is_public
? <span style={{color:'#22c55e',fontWeight:600,fontSize:12}}>Public Event</span>
: <span style={{color:'#ef4444',fontWeight:600,fontSize:12}}>Private Event</span>}
</div>
</div>
<div style={{display:'flex',gap:6,flexShrink:0}}>
{isToolManager&&<button className="btn btn-secondary btn-sm" onClick={()=>{onClose();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>
<div style={{display:'flex',gap:10,alignItems:'center',marginBottom:12,fontSize:14}}>
<svg width="15" height="15" 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&&` · ${fmtRange(event.start_at,event.end_at)}`}</span>
</div>
{event.recurrence_rule?.freq&&(
<div style={{display:'flex',gap:10,alignItems:'center',marginBottom:12,fontSize:14}}>
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="var(--text-tertiary)" strokeWidth="2"><polyline points="23 4 23 10 17 10"/><path d="M20.49 15a9 9 0 1 1-2.12-9.36L23 10"/></svg>
<span>{recurrenceLabel(event.recurrence_rule)}</span>
</div>
)}
{event.location&&<div style={{display:'flex',gap:10,alignItems:'center',marginBottom:12,fontSize:14}}><svg width="15" height="15" 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>{event.location}</div>}
{event.description&&<div style={{display:'flex',gap:10,marginBottom:12,fontSize:14}}><svg width="15" height="15" 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:10,marginBottom:16,fontSize:13,color:'var(--text-secondary)'}}><svg width="15" height="15" 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>{event.user_groups.map(g=>g.name).join(', ')}</div>}
{!!event.track_availability&&(
<div style={{borderTop:'1px solid var(--border)',paddingTop:16,marginTop:4}}>
<div style={{fontSize:12,fontWeight:700,color:'var(--text-tertiary)',textTransform:'uppercase',letterSpacing:'0.6px',marginBottom:10}}>Your Availability</div>
<div style={{display:'flex',gap:8,marginBottom:16}}>
{Object.entries(RESP_LABEL).map(([key,label])=>(
<button key={key} onClick={()=>handleResp(key)} style={{flex:1,padding:'9px 4px',borderRadius:'var(--radius)',border:`2px solid ${RESP_COLOR[key]}`,background:myResp===key?RESP_COLOR[key]:'transparent',color:myResp===key?'white':RESP_COLOR[key],fontSize:13,fontWeight:600,cursor:'pointer',transition:'all 0.15s'}}>
{myResp===key?'✓ ':''}{label}
</button>
))}
</div>
{isToolManager&&(
<>
<div style={{fontSize:12,fontWeight:700,color:'var(--text-tertiary)',textTransform:'uppercase',letterSpacing:'0.6px',marginBottom:8}}>Responses</div>
<div style={{display:'flex',gap:20,marginBottom:10,fontSize:13}}>
{Object.entries(counts).map(([k,n])=><span key={k}><span style={{color:RESP_COLOR[k],fontWeight:700}}>{n}</span> {RESP_LABEL[k]}</span>)}
<span><span style={{fontWeight:700}}>{event.no_response_count||0}</span> No response</span>
</div>
{avail.length>0&&(
<div style={{border:'1px solid var(--border)',borderRadius:'var(--radius)',overflow:'hidden'}}>
{avail.map(r=>(
<div key={r.user_id} style={{display:'flex',alignItems:'center',gap:10,padding:'8px 12px',borderBottom:'1px solid var(--border)',fontSize:13}}>
<span style={{width:9,height:9,borderRadius:'50%',background:RESP_COLOR[r.response],flexShrink:0,display:'inline-block'}}/>
<span style={{flex:1}}>{r.display_name||r.name}</span>
<span style={{color:RESP_COLOR[r.response],fontSize:12,fontWeight:600}}>{RESP_LABEL[r.response]}</span>
</div>
))}
</div>
)}
</>
)}
</div>
)}
</div>
</div>,
document.body
);
}
// ── Event Types Panel ─────────────────────────────────────────────────────────
function EventTypesPanel({ eventTypes, userGroups, onUpdated }) {
const toast=useToast();
const [editingType,setEditingType]=useState(null);
const [showForm,setShowForm]=useState(false);
const handleDel=async et=>{
if(!confirm(`Delete "${et.name}"?`)) return;
try{await api.deleteEventType(et.id);toast('Deleted','success');onUpdated();}catch(e){toast(e.message,'error');}
};
return (
<div style={{maxWidth:560}}>
<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'}}><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:'9px 14px',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&&<span style={{fontSize:12,color:'var(--text-tertiary)'}}>{et.default_duration_hrs}hr default</span>}
{!et.is_protected?(
<div style={{display:'flex',gap:6,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);}}/>}
<button className="btn btn-sm" style={{background:'var(--error)',color:'white'}} onClick={()=>handleDel(et)}>Delete</button>
</div>
):<span style={{fontSize:11,color:'var(--text-tertiary)'}}>{et.is_default?'Default':'Protected'}</span>}
</div>
))}
</div>
</div>
);
}
// ── Bulk Import Panel ─────────────────────────────────────────────────────────
function BulkImportPanel({ onImported, onCancel }) {
const toast=useToast();
const [rows,setRows]=useState(null);
const [skipped,setSkipped]=useState(new Set());
const [saving,setSaving]=useState(false);
const handleFile=async e=>{const file=e.target.files[0];if(!file)return;try{const r=await api.importPreview(file);if(r.error)return toast(r.error,'error');setRows(r.rows);setSkipped(new Set(r.rows.filter(r=>r.duplicate||r.error).map(r=>r.row)));}catch{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 style={{maxWidth:800}}>
<div className="settings-section-label">Bulk Event Import</div>
<p style={{fontSize:12,color:'var(--text-tertiary)',marginBottom:12}}>CSV: <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','Dur','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.45:1}}><td style={{padding:'4px 8px'}}><input type="checkbox" checked={!skipped.has(r.row)} disabled={!!r.error} onChange={()=>setSkipped(p=>{const n=new Set(p);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}}><button className="btn btn-primary btn-sm" onClick={handleImport} disabled={saving}>{saving?'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>
);
}
// ── Calendar Views ────────────────────────────────────────────────────────────
// Parse keyword string into array of terms.
// Quoted phrases ("foo bar") count as one term; space-separated words are individual OR terms.
function parseKeywords(raw) {
const terms = [];
const re = /"([^"]+)"|(\S+)/g;
let match;
while((match = re.exec(raw)) !== null) terms.push((match[1]||match[2]).toLowerCase());
return terms;
}
function ScheduleView({ events, selectedDate, onSelect, filterKeyword='', filterTypeId='', isMobile=false }) {
const y=selectedDate.getFullYear(), m=selectedDate.getMonth();
const today=new Date(); today.setHours(0,0,0,0);
const terms=parseKeywords(filterKeyword);
const hasFilters = terms.length > 0 || !!filterTypeId;
// Always show from today forward (desktop and mobile).
// Desktop: when no filters, restrict to selected month for browsing context.
// Mobile: always from today forward regardless of filters.
// With any filter active: always today+future on both platforms.
const from = (hasFilters || isMobile) ? today : new Date(y,m,1);
const to = (hasFilters || isMobile) ? new Date(9999,11,31) : new Date(y,m+1,0,23,59,59);
const filtered=events.filter(e=>{
const s=new Date(e.start_at);
if(s<from||s>to) return false;
if(filterTypeId && String(e.event_type_id)!==String(filterTypeId)) return false;
if(terms.length>0) {
const haystack=[e.title||'',e.location||'',e.description||''].join(' ').toLowerCase();
if(!terms.some(t=>haystack.includes(t))) return false;
}
return true;
});
const emptyMsg = hasFilters ? 'No events match your filters' : isMobile ? 'No upcoming events' : `No events in ${MONTHS[m]} ${y}`;
if(!filtered.length) return <div style={{textAlign:'center',padding:'60px 20px',color:'var(--text-tertiary)',fontSize:14}}>{emptyMsg}</div>;
return <>{filtered.map(e=>{const s=new Date(e.start_at);const col=e.event_type?.colour||'#9ca3af';
// Desktop: original pre-v0.9.64 sizes. Mobile: compact sizes from v0.9.64
const rowPad=isMobile?'12px 14px':'14px 20px';
const rowGap=isMobile?10:20;
const datW=isMobile?36:44; const datFs=isMobile?20:22; const datSFs=isMobile?10:11;
const timeW=isMobile?62:100; const timeGap=isMobile?5:8; const timeFs=isMobile?11:13;
const dotSz=isMobile?8:10;
const typeFs=isMobile?10:11; const titleGap=isMobile?6:8;
return(<div key={e.id} onClick={()=>onSelect(e)} style={{display:'flex',alignItems:'center',gap:rowGap,padding:rowPad,borderBottom:'1px solid var(--border)',cursor:'pointer'}} onMouseEnter={el=>el.currentTarget.style.background='var(--background)'} onMouseLeave={el=>el.currentTarget.style.background=''}><div style={{width:datW,textAlign:'center',flexShrink:0}}><div style={{fontSize:datFs,fontWeight:700,lineHeight:1}}>{s.getDate()}</div><div style={{fontSize:datSFs,color:'var(--text-tertiary)',textTransform:'uppercase'}}>{SHORT_MONTHS[s.getMonth()]}, {DAYS[s.getDay()]}</div></div><div style={{width:timeW,flexShrink:0,display:'flex',alignItems:'flex-start',gap:timeGap,fontSize:timeFs,color:'var(--text-secondary)'}}><span style={{width:dotSz,height:dotSz,borderRadius:'50%',background:col,flexShrink:0,marginTop:3}}/>{e.all_day?<span>All day</span>:<span style={{lineHeight:1.5}}>{fmtTime(e.start_at)} <br/>{fmtTime(e.end_at)}</span>}</div><div style={{flex:1,minWidth:0}}><div style={{fontSize:14,fontWeight:600,display:'flex',alignItems:'center',gap:titleGap,flexWrap:'nowrap'}}>{e.event_type?.name&&<span style={{fontSize:typeFs,color:'var(--text-tertiary)',textTransform:'uppercase',letterSpacing:'0.5px',fontWeight:600,flexShrink:0}}>{e.event_type.name}:</span>}<span style={{minWidth:0,overflow:'hidden',textOverflow:'ellipsis',whiteSpace:'nowrap'}}>{e.title}</span>{!!e.track_availability&&(
e.my_response ? RESP_ICON[e.my_response](RESP_COLOR[e.my_response]) : BELL_ICON
)}</div>{e.location&&<div style={{fontSize:12,color:'var(--text-tertiary)',marginTop:2}}>{e.location}</div>}</div></div>);})}</>;
}
const HOUR_H = 52; // px per hour row
const DAY_START = 0; // show from midnight
const DAY_END = 24; // to midnight
function eventTopOffset(startDate) {
const h=startDate.getHours(), m=startDate.getMinutes();
return (h - DAY_START)*HOUR_H + (m/60)*HOUR_H;
}
function eventHeightPx(startDate, endDate) {
const diffMs=endDate-startDate;
const diffHrs=diffMs/(1000*60*60);
return Math.max(diffHrs*HOUR_H, HOUR_H*0.4); // min 40% of one hour row
}
// Compute column assignments for events that overlap in time.
// Returns array of {event, col, totalCols} where col 0..totalCols-1.
function layoutEvents(evs) {
if (!evs.length) return [];
const sorted = [...evs].sort((a,b) => new Date(a.start_at) - new Date(b.start_at));
const cols = []; // each col is array of events placed there
const result = [];
for (const e of sorted) {
const eStart = new Date(e.start_at), eEnd = new Date(e.end_at);
// Find first column where this event doesn't overlap with the last event
let placed = false;
for (let ci = 0; ci < cols.length; ci++) {
const lastInCol = cols[ci][cols[ci].length - 1];
if (new Date(lastInCol.end_at) <= eStart) {
cols[ci].push(e);
result.push({ event: e, col: ci });
placed = true;
break;
}
}
if (!placed) {
cols.push([e]);
result.push({ event: e, col: cols.length - 1 });
}
}
// Determine totalCols for each event = max cols among overlapping group
for (const item of result) {
const eStart = new Date(item.event.start_at), eEnd = new Date(item.event.end_at);
let maxCol = item.col;
for (const other of result) {
const oStart = new Date(other.event.start_at), oEnd = new Date(other.event.end_at);
if (oStart < eEnd && oEnd > eStart) maxCol = Math.max(maxCol, other.col);
}
item.totalCols = maxCol + 1;
}
return result;
}
function DayView({ events, selectedDate, onSelect, onSwipe }) {
const hours=Array.from({length:DAY_END - DAY_START},(_,i)=>i+DAY_START);
const day=events.filter(e=>sameDay(new Date(e.start_at),selectedDate));
const scrollRef = useRef(null);
const touchRef = useRef({ x:0, y:0 });
useEffect(()=>{ if(scrollRef.current) scrollRef.current.scrollTop = 7 * HOUR_H; },[selectedDate]);
const fmtHour = h => h===0?'12 AM':h<12?`${h} AM`:h===12?'12 PM':`${h-12} PM`;
const handleTouchStart = e => { touchRef.current = { x:e.touches[0].clientX, y:e.touches[0].clientY }; };
const handleTouchEnd = e => {
const dx = e.changedTouches[0].clientX - touchRef.current.x;
const dy = Math.abs(e.changedTouches[0].clientY - touchRef.current.y);
// Require horizontal swipe > 60px, not too vertical, and not from left edge (< 30px = back gesture)
if(Math.abs(dx) > 60 && dy < 80 && touchRef.current.x > 30) {
onSwipe?.(dx < 0 ? 1 : -1); // left = next day, right = prev day
}
};
return(
<div style={{display:'flex',flexDirection:'column',height:'100%'}} onTouchStart={handleTouchStart} onTouchEnd={handleTouchEnd}>
<div style={{display:'flex',borderBottom:'1px solid var(--border)',padding:'8px 0 8px 60px',fontSize:13,fontWeight:600,color:'var(--primary)',flexShrink:0}}>
<div style={{textAlign:'center'}}><div>{DAYS[selectedDate.getDay()]}</div><div style={{fontSize:28,fontWeight:700}}>{selectedDate.getDate()}</div></div>
</div>
<div ref={scrollRef} style={{flex:1,overflowY:'auto',position:'relative'}}>
<div style={{position:'relative'}}>
{hours.map(h=>(
<div key={h} style={{display:'flex',borderBottom:'1px solid var(--border)',height:HOUR_H}}>
<div style={{width:60,flexShrink:0,fontSize:11,color:'var(--text-tertiary)',padding:'3px 10px 0',textAlign:'right'}}>{fmtHour(h)}</div>
<div style={{flex:1}}/>
</div>
))}
{layoutEvents(day).map(({event:e,col,totalCols})=>{
const s=new Date(e.start_at), en=new Date(e.end_at);
const top=eventTopOffset(s), height=eventHeightPx(s,en);
return(
<div key={e.id} onClick={()=>onSelect(e)} style={{
position:'absolute',
left: `calc(64px + ${col / totalCols * 100}% - ${col * 64 / totalCols}px)`,
right: `calc(${(totalCols - col - 1) / totalCols * 100}% - ${(totalCols - col - 1) * 64 / totalCols}px + 4px)`,
top, height,
background:e.event_type?.colour||'#6366f1', color:'white',
borderRadius:5, padding:'3px 6px', cursor:'pointer',
fontSize:11, fontWeight:600, overflow:'hidden',
boxShadow:'0 1px 3px rgba(0,0,0,0.2)',
zIndex: col,
}}>
<div style={{whiteSpace:'nowrap',overflow:'hidden',textOverflow:'ellipsis'}}>{e.title}</div>
{height>28&&<div style={{fontSize:9,opacity:0.85,whiteSpace:'nowrap',overflow:'hidden',textOverflow:'ellipsis'}}>{fmtRange(e.start_at,e.end_at)}</div>}
</div>
);
})}
</div>
</div>
</div>
);
}
function WeekView({ events, selectedDate, onSelect }) {
const ws=weekStart(selectedDate), days=Array.from({length:7},(_,i)=>{const d=new Date(ws);d.setDate(d.getDate()+i);return d;});
const hours=Array.from({length:DAY_END - DAY_START},(_,i)=>i+DAY_START), today=new Date();
const scrollRef = useRef(null);
const touchRef = useRef({ x:0, y:0 });
useEffect(()=>{ if(scrollRef.current) scrollRef.current.scrollTop = 7 * HOUR_H; },[selectedDate]);
const fmtHour = h => h===0?'12 AM':h<12?`${h} AM`:h===12?'12 PM':`${h-12} PM`;
const handleTouchStart = e => { touchRef.current = { x:e.touches[0].clientX, y:e.touches[0].clientY }; };
const handleTouchEnd = e => {
const dx = e.changedTouches[0].clientX - touchRef.current.x;
const dy = Math.abs(e.changedTouches[0].clientY - touchRef.current.y);
// Require horizontal swipe > 60px, not too vertical, and not from left edge (< 30px = back gesture)
if(Math.abs(dx) > 60 && dy < 80 && touchRef.current.x > 30) {
onSwipe?.(dx < 0 ? 1 : -1); // left = next day, right = prev day
}
};
return(
<div style={{display:'flex',flexDirection:'column',height:'100%'}} onTouchStart={handleTouchStart} onTouchEnd={handleTouchEnd}>
{/* Day headers */}
<div style={{display:'grid',gridTemplateColumns:'60px repeat(7,1fr)',borderBottom:'1px solid var(--border)',background:'var(--surface)',flexShrink:0}}>
<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>
{/* Scrollable time grid */}
<div ref={scrollRef} style={{flex:1,overflowY:'auto'}}>
<div style={{display:'grid',gridTemplateColumns:'60px repeat(7,1fr)',position:'relative'}}>
{/* Time labels column */}
<div>
{hours.map(h=>(
<div key={h} style={{height:HOUR_H,borderBottom:'1px solid var(--border)',fontSize:11,color:'var(--text-tertiary)',padding:'3px 10px 0',textAlign:'right'}}>{fmtHour(h)}</div>
))}
</div>
{/* Day columns */}
{days.map((d,di)=>{
const dayEvs=events.filter(e=>sameDay(new Date(e.start_at),d));
return(
<div key={di} style={{position:'relative',borderLeft:'1px solid var(--border)'}}>
{hours.map(h=><div key={h} style={{height:HOUR_H,borderBottom:'1px solid var(--border)'}}/>)}
{layoutEvents(dayEvs).map(({event:e,col,totalCols})=>{
const s=new Date(e.start_at),en=new Date(e.end_at);
const top=eventTopOffset(s), height=eventHeightPx(s,en);
const pctLeft = `${col / totalCols * 100}%`;
const pctWidth = `calc(${100 / totalCols}% - 4px)`;
return(
<div key={e.id} onClick={()=>onSelect(e)} style={{
position:'absolute', top, height,
left: pctLeft, width: pctWidth,
background:e.event_type?.colour||'#6366f1',color:'white',
borderRadius:3,padding:'2px 4px',cursor:'pointer',
fontSize:11,fontWeight:600,overflow:'hidden',
boxShadow:'0 1px 2px rgba(0,0,0,0.2)',
zIndex: col,
}}>
<div style={{whiteSpace:'nowrap',overflow:'hidden',textOverflow:'ellipsis'}}>{e.title}</div>
{height>26&&<div style={{fontSize:9,opacity:0.85,whiteSpace:'nowrap',overflow:'hidden',textOverflow:'ellipsis'}}>{fmtTime(e.start_at)}-{fmtTime(e.end_at)}</div>}
</div>
);
})}
</div>
);
})}
</div>
</div>
</div>
);
}
const MONTH_CELL_H = 90; // fixed cell height in px
function MonthView({ events, selectedDate, onSelect, onSelectDay }) {
const y=selectedDate.getFullYear(), m=selectedDate.getMonth(), first=new Date(y,m,1).getDay(), total=daysInMonth(y,m), today=new Date();
const cells=[]; for(let i=0;i<first;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>
<div style={{display:'grid',gridTemplateColumns:'repeat(7,1fr)',borderBottom:'1px solid var(--border)'}}>
{DAYS.map(d=><div key={d} style={{textAlign:'center',padding:'8px',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)'}}>
{week.map((d,di)=>{
if(!d) return <div key={di} style={{borderRight:'1px solid var(--border)',borderBottom:'1px solid var(--border)',height:MONTH_CELL_H,background:'var(--surface-variant)'}}/>;
const date=new Date(y,m,d), dayEvs=events.filter(e=>sameDay(new Date(e.start_at),date)), isToday=sameDay(date,today);
return(
<div key={di} onClick={()=>onSelectDay(date)} style={{borderRight:'1px solid var(--border)',borderBottom:'1px solid var(--border)',height:MONTH_CELL_H,padding:'3px',cursor:'pointer',overflow:'hidden',display:'flex',flexDirection:'column'}}
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)',flexShrink:0}}>{d}</div>
{dayEvs.slice(0,2).map(e=>(
<div key={e.id} onClick={ev=>{ev.stopPropagation();onSelect(e);}} style={{
background:e.event_type?.colour||'#6366f1',color:'white',
borderRadius:3,padding:'1px 4px',fontSize:11,marginBottom:1,cursor:'pointer',
whiteSpace:'nowrap',overflow:'hidden',textOverflow:'ellipsis',flexShrink:0,
}}>
{!e.all_day&&<span style={{marginRight:3,opacity:0.85}}>{fmtTime(e.start_at)}</span>}{e.title}
</div>
))}
{dayEvs.length>2&&<div style={{fontSize:10,color:'var(--text-tertiary)',flexShrink:0}}>+{dayEvs.length-2} more</div>}
</div>
);
})}
</div>
))}
</div>
);
}
// ── Main Schedule Page ────────────────────────────────────────────────────────
export default function SchedulePage({ isToolManager, isMobile, onProfile, onHelp, onAbout }) {
const { user } = useAuth();
const toast = useToast();
// Mobile: only day + schedule views
const allowedViews = isMobile ? ['schedule','day'] : ['schedule','day','week','month'];
const [view, setView] = useState('schedule');
const [selDate, setSelDate] = useState(new Date());
const [events, setEvents] = useState([]);
const [eventTypes, setEventTypes] = useState([]);
const [userGroups, setUserGroups] = useState([]);
const [panel, setPanel] = useState('calendar');
const [editingEvent, setEditingEvent] = useState(null);
const [filterKeyword, setFilterKeyword] = useState('');
const [filterTypeId, setFilterTypeId] = useState('');
const [detailEvent, setDetailEvent] = useState(null);
const [loading, setLoading] = useState(true);
const [createOpen, setCreateOpen] = useState(false);
const [mobilePanel, setMobilePanel] = useState(null); // null | 'eventForm' | 'groupManager'
const createRef = useRef(null);
const load = 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(() => { load(); }, [load]);
useEffect(() => {
if (!createOpen) return;
const h = e => { if (createRef.current && !createRef.current.contains(e.target)) setCreateOpen(false); };
document.addEventListener('mousedown', h);
return () => document.removeEventListener('mousedown', h);
}, [createOpen]);
const eventDates = new Set(events.map(e => e.start_at?.slice(0,10)));
const navDate = dir => {
const d = new Date(selDate);
if (view==='day') d.setDate(d.getDate()+dir);
else if (view==='week') d.setDate(d.getDate()+dir*7);
else d.setMonth(d.getMonth()+dir);
setSelDate(d);
};
const navLabel = () => {
if (view==='day') return `${DAYS[selDate.getDay()]} ${selDate.getDate()} ${MONTHS[selDate.getMonth()]} ${selDate.getFullYear()}`;
if (view==='week') { const ws=weekStart(selDate),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[selDate.getMonth()]} ${selDate.getFullYear()}`; // schedule + month
};
const openDetail = async e => {
try { const { event } = await api.getEvent(e.id); setDetailEvent(event); } catch { toast('Failed to load event','error'); }
};
const handleSaved = () => { load(); setPanel('calendar'); setEditingEvent(null); };
const handleDelete = async e => {
if (!confirm(`Delete "${e.title}"?`)) return;
try {
await api.deleteEvent(e.id);
toast('Deleted','success');
setPanel('calendar');
setEditingEvent(null);
setDetailEvent(null);
load(); // reload list so deleted event disappears immediately
} catch(err) { toast(err.message,'error'); }
};
if (loading) return <div style={{display:'flex',alignItems:'center',justifyContent:'center',flex:1,color:'var(--text-tertiary)',fontSize:14}}>Loading schedule</div>;
// ── Sidebar width matches Messages (~280px) ───────────────────────────────
const SIDEBAR_W = isMobile ? 0 : 260;
return (
<div style={{ display:'flex', flex:1, overflow:'hidden', minHeight:0 }}>
{/* Left panel — matches sidebar width */}
{!isMobile && (
<div style={{ width:SIDEBAR_W, flexShrink:0, borderRight:'1px solid var(--border)', display:'flex', flexDirection:'column', background:'var(--surface)', overflow:'hidden' }}>
<div style={{ padding:'16px 16px 0' }}>
<div style={{ fontSize:16, fontWeight:700, marginBottom:12, color:'var(--text-primary)' }}>Team Schedule</div>
{/* Create button — styled like new-chat-btn */}
{isToolManager && (
<div style={{ position:'relative', marginBottom:12 }} ref={createRef}>
<button className="newchat-btn" onClick={() => setCreateOpen(v=>!v)} style={{ width:'100%', justifyContent:'center', gap:8 }}>
Create Event
<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>
{createOpen && (
<div style={{ position:'absolute', top:'100%', left:0, right:0, zIndex:100, background:'var(--surface-variant)', border:'1px solid var(--border)', borderRadius:'var(--radius)', marginTop:4, boxShadow:'0 4px 16px rgba(0,0,0,0.18)' }}>
{[['Event', ()=>{setPanel('eventForm');setEditingEvent(null);setCreateOpen(false);}],
['Event Type', ()=>{setPanel('eventTypes');setCreateOpen(false);}],
['Bulk Event Import', ()=>{setPanel('bulkImport');setCreateOpen(false);}]
].map(([label,action])=>(
<button key={label} onClick={action} style={{display:'block',width:'100%',padding:'9px 16px',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>
)}
</div>
{/* Mini calendar */}
<div style={{ padding:'8px 16px 16px' }}>
<div className="section-label" style={{ marginBottom:8 }}>Filter Events</div>
<MiniCalendar selected={selDate} onChange={d=>{setSelDate(d);setPanel('calendar');}} eventDates={eventDates}/>
</div>
{/* List view filters — only shown in Schedule list view */}
{view==='schedule' && panel==='calendar' && (
<div style={{ padding:'0 16px 16px' }}>
<div className="section-label" style={{ marginBottom:8 }}>Search (today &amp; future)</div>
<input
className="input"
placeholder={`Keyword… (space = OR, "phrase")`}
value={filterKeyword}
onChange={e=>setFilterKeyword(e.target.value)}
style={{ marginBottom:8, fontSize:13 }}
/>
<select
className="input"
value={filterTypeId}
onChange={e=>setFilterTypeId(e.target.value)}
style={{ fontSize:13 }}
>
<option value="">All event types</option>
{eventTypes.map(t=><option key={t.id} value={t.id}>{t.name}</option>)}
</select>
{(filterKeyword||filterTypeId) && (
<button
className="btn btn-secondary btn-sm"
onClick={()=>{setFilterKeyword('');setFilterTypeId('');}}
style={{ marginTop:8, width:'100%' }}
>Clear filters</button>
)}
</div>
)}
<div style={{ flex:1 }}/>
<UserFooter onProfile={onProfile} onHelp={onHelp} onAbout={onAbout} />
</div>
)}
{/* Right panel + mobile bottom bar — column flex so bottom bar stays at bottom */}
<div style={{ flex:1, display:'flex', flexDirection:'column', overflow:'hidden', minWidth:0 }}>
{/* View toolbar */}
<div style={{ display:'flex', alignItems:'center', gap:8, padding:'8px 16px', borderBottom:'1px solid var(--border)', background:'var(--surface)', flexShrink:0, flexWrap:'nowrap' }}>
{/* Mobile title + create */}
{isMobile && (
<span style={{ fontSize:15, fontWeight:700, flex:1 }}>Team Schedule</span>
)}
{!isMobile && (
<>
<button className="btn btn-secondary btn-sm" onClick={() => setSelDate(new Date())}>Today</button>
<div style={{ display:'flex', gap:2 }}>
<button className="btn-icon" onClick={() => navDate(-1)} style={{ fontSize:16, padding:'2px 8px' }}></button>
<button className="btn-icon" onClick={() => navDate(1)} style={{ fontSize:16, padding:'2px 8px' }}></button>
</div>
<span style={{ fontSize:13, fontWeight:600, color:'var(--text-primary)', whiteSpace:'nowrap' }}>{navLabel()}</span>
<div style={{ marginLeft:'auto' }}/>
</>
)}
{/* View switcher */}
<div style={{ display:'flex', gap:2, background:'var(--surface-variant)', borderRadius:'var(--radius)', padding:3, flexShrink:0 }}>
{allowedViews.map(v => {
const labels = { schedule:'Schedule', day:'Day', week:'Week', month:'Month' };
return (
<button key={v} onClick={()=>{setView(v);setPanel('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', transition:'all 0.15s', whiteSpace:'nowrap' }}>
{labels[v]}
</button>
);
})}
</div>
</div>
{/* Mobile filter bar — Schedule view: filters + month nav; Day view: calendar accordion */}
{isMobile && panel === 'calendar' && (
<MobileScheduleFilter
selected={selDate}
view={view}
eventTypes={eventTypes}
filterKeyword={filterKeyword}
onFilterKeyword={setFilterKeyword}
filterTypeId={filterTypeId}
onFilterTypeId={setFilterTypeId}
eventDates={eventDates}
onMonthChange={(dir, exactDate) => {
if(exactDate) { setSelDate(exactDate); }
else { const d=new Date(selDate); d.setMonth(d.getMonth()+dir); d.setDate(1); setSelDate(d); }
}}
/>
)}
{/* Calendar or panel content */}
<div style={{ flex:1, overflowY:'auto', overflowX: panel==='eventForm'?'auto':'hidden' }}>
{panel === 'calendar' && view === 'schedule' && <ScheduleView events={events} selectedDate={selDate} onSelect={openDetail} filterKeyword={filterKeyword} filterTypeId={filterTypeId} isMobile={isMobile}/>}
{panel === 'calendar' && view === 'day' && <DayView events={events} selectedDate={selDate} onSelect={openDetail} onSwipe={isMobile ? dir => { const d=new Date(selDate); d.setDate(d.getDate()+dir); setSelDate(d); } : undefined}/>}
{panel === 'calendar' && view === 'week' && <WeekView events={events} selectedDate={selDate} onSelect={openDetail}/>}
{panel === 'calendar' && view === 'month' && <MonthView events={events} selectedDate={selDate} onSelect={openDetail} onSelectDay={d=>{setSelDate(d);setView('schedule');}}/>}
{panel === 'eventForm' && isToolManager && !isMobile && (
<div style={{ padding:28, maxWidth:1024 }}>
<h2 style={{ fontSize:17, fontWeight:700, marginBottom:24 }}>{editingEvent?'Edit Event':'New Event'}</h2>
<EventForm event={editingEvent} userGroups={userGroups} eventTypes={eventTypes} selectedDate={selDate} isToolManager={isToolManager}
onSave={handleSaved} onCancel={()=>{setPanel('calendar');setEditingEvent(null);}} onDelete={handleDelete}/>
</div>
)}
{panel === 'eventTypes' && isToolManager && (
<div style={{ padding:28 }}>
<div style={{ display:'flex', alignItems:'center', gap:10, marginBottom:24 }}>
<h2 style={{ fontSize:17, fontWeight:700, margin:0 }}>Event Types</h2>
<button className="btn btn-secondary btn-sm" onClick={()=>setPanel('calendar')}> Back</button>
</div>
<EventTypesPanel eventTypes={eventTypes} userGroups={userGroups} onUpdated={load}/>
</div>
)}
{panel === 'bulkImport' && isToolManager && (
<div style={{ padding:28 }}>
<div style={{ display:'flex', alignItems:'center', gap:10, marginBottom:24 }}>
<h2 style={{ fontSize:17, fontWeight:700, margin:0 }}>Bulk Event Import</h2>
<button className="btn btn-secondary btn-sm" onClick={()=>setPanel('calendar')}> Back</button>
</div>
<BulkImportPanel onImported={()=>{load();setPanel('calendar');}} onCancel={()=>setPanel('calendar')}/>
</div>
)}
</div>
</div>
{/* Mobile Group Manager */}
{isMobile && mobilePanel === 'groupManager' && (
<div style={{ position:'fixed',inset:0,zIndex:50,background:'var(--background)' }}>
<MobileGroupManager onClose={() => setMobilePanel(null)}/>
</div>
)}
{/* Mobile Event Form — full screen overlay, hides toolbar/date picker */}
{panel === 'eventForm' && isToolManager && isMobile && (
<div style={{ position:'fixed', top:0, left:0, right:0, bottom:0, zIndex:40, background:'var(--background)', display:'flex', flexDirection:'column' }}>
<MobileEventForm
event={editingEvent}
userGroups={userGroups}
eventTypes={eventTypes}
selectedDate={selDate}
isToolManager={isToolManager}
onSave={handleSaved}
onCancel={()=>{setPanel('calendar');setEditingEvent(null);}}
onDelete={handleDelete}
/>
</div>
)}
{/* Mobile bottom bar — matches Messages exactly: just the UserFooter */}
{isMobile && (
<div style={{ background:'var(--surface)', borderTop:'1px solid var(--border)', flexShrink:0 }}>
<UserFooter onProfile={onProfile} onHelp={onHelp} onAbout={onAbout} />
</div>
)}
{/* Mobile FAB — floating create button, same style as Messages newchat-fab */}
{isMobile && isToolManager && panel === 'calendar' && (
<div style={{ position:'fixed', bottom:84, right:16, zIndex:30 }} ref={createRef}>
<button className="newchat-fab" onClick={() => setCreateOpen(v=>!v)}>
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" strokeWidth={2} stroke="currentColor" width="24" height="24">
<path strokeLinecap="round" strokeLinejoin="round" d="M12 4.5v15m7.5-7.5h-15" />
</svg>
</button>
{createOpen && (
<div style={{ position:'absolute', bottom:'calc(100% + 8px)', right:0, zIndex:100, background:'var(--surface-variant)', border:'1px solid var(--border)', borderRadius:'var(--radius)', boxShadow:'0 -4px 16px rgba(0,0,0,0.15)', minWidth:180 }}>
{[['Event', ()=>{setPanel('eventForm');setEditingEvent(null);setCreateOpen(false);}],
['Event Type', ()=>{setPanel('eventTypes');setCreateOpen(false);}],
].map(([label,action])=>(
<button key={label} onClick={action} style={{display:'block',width:'100%',padding:'12px 16px',textAlign:'left',fontSize:15,background:'none',border:'none',cursor:'pointer',color:'var(--text-primary)',borderBottom:'1px solid var(--border)'}}
onMouseEnter={e=>e.currentTarget.style.background='var(--background)'} onMouseLeave={e=>e.currentTarget.style.background=''}>{label}</button>
))}
</div>
)}
</div>
)}
</div>
{/* Event detail modal */}
{detailEvent && (
<EventDetailModal
event={detailEvent}
isToolManager={isToolManager}
onClose={() => setDetailEvent(null)}
onEdit={() => { setEditingEvent(detailEvent); setPanel('eventForm'); setDetailEvent(null); }}
onAvailabilityChange={(resp) => {
// Update the list so the "awaiting response" dot disappears immediately
setEvents(prev => prev.map(e => e.id === detailEvent.id ? {...e, my_response: resp} : e));
openDetail(detailEvent);
}}
/>
)}
</div>
);
}