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

842 lines
55 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';
// ── 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' };
// 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 Date Picker (accordion month view) ─────────────────────────────────
function MobileDatePicker({ selected, onChange, eventDates=new Set() }) {
const [open, setOpen] = useState(false);
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);
const cells=[]; for(let i=0;i<first;i++) cells.push(null); for(let d=1;d<=total;d++) cells.push(d);
const today=new Date();
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={()=>{const n=new Date(cur);n.setMonth(m-1);setCur(n);}}></button>
<button style={{background:'none',border:'none',cursor:'pointer',padding:'4px 10px',fontSize:16,color:'var(--text-secondary)'}} onClick={()=>{const n=new Date(cur);n.setMonth(m+1);setCur(n);}}></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=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);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?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>
);
}
// ── 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>
);
}
// ── 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 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]};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);}
};
const Row=({label,children,required})=>(
<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>
);
return (
<div style={{width:'100%',maxWidth:1024,overflowX:'auto'}}>
<div style={{minWidth:500}}>
{/* 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 */}
<Row 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}}>
{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>
</Row>
{/* Date/Time */}
<Row 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>
<label style={{display:'flex',alignItems:'center',gap:8,fontSize:13,cursor:'pointer',color:'var(--text-tertiary)'}}>
<input type="checkbox" disabled title="Recurring events coming soon"/> Recurring
<span style={{fontSize:11,background:'var(--surface-variant)',borderRadius:10,padding:'1px 6px'}}>Coming soon</span>
</label>
</div>
</div>
</Row>
{/* Availability */}
<Row 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>
</Row>
{/* Groups — required when tracking */}
<Row 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>
</Row>
{/* Visibility — only shown if groups selected OR tracking */}
{(grps.size>0||track) && (
<Row 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>
</Row>
)}
{/* Location */}
<Row label="Location">
<input className="input" placeholder="Add location" value={loc} onChange={e=>setLoc(e.target.value)}/>
</Row>
{/* Description */}
<Row label="Description">
<textarea className="input" placeholder="Add description" value={desc} onChange={e=>setDesc(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={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={{background:'var(--surface-variant)',borderRadius:10,padding:'1px 8px',fontSize:11}}>Private</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.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 ────────────────────────────────────────────────────────────
function ScheduleView({ events, selectedDate, onSelect }) {
const y=selectedDate.getFullYear(), m=selectedDate.getMonth();
const monthStart=new Date(y,m,1), monthEnd=new Date(y,m+1,0,23,59,59);
const filtered=events.filter(e=>{
const s=new Date(e.start_at);
return s>=monthStart && s<=monthEnd;
});
if(!filtered.length) return <div style={{textAlign:'center',padding:'60px 20px',color:'var(--text-tertiary)',fontSize:14}}>No events in {MONTHS[m]} {y}</div>;
return <>{filtered.map(e=>{const s=new Date(e.start_at);const col=e.event_type?.colour||'#9ca3af';return(<div key={e.id} onClick={()=>onSelect(e)} style={{display:'flex',alignItems:'center',gap:20,padding:'14px 20px',borderBottom:'1px solid var(--border)',cursor:'pointer'}} onMouseEnter={el=>el.currentTarget.style.background='var(--background)'} onMouseLeave={el=>el.currentTarget.style.background=''}><div style={{width:44,textAlign:'center',flexShrink:0}}><div style={{fontSize:22,fontWeight:700,lineHeight:1}}>{s.getDate()}</div><div style={{fontSize:11,color:'var(--text-tertiary)',textTransform:'uppercase'}}>{SHORT_MONTHS[s.getMonth()]}, {DAYS[s.getDay()]}</div></div><div style={{width:100,flexShrink:0,display:'flex',alignItems:'center',gap:8,fontSize:13,color:'var(--text-secondary)'}}><span style={{width:10,height:10,borderRadius:'50%',background:col,flexShrink:0}}/>{e.all_day?'All day':fmtRange(e.start_at,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:11,color:'var(--text-tertiary)',textTransform:'uppercase',letterSpacing:'0.5px',fontWeight:600}}>{e.event_type.name}:</span>}{e.title}{e.track_availability&&!e.my_response&&(
<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">
<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>
)}</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
}
function DayView({ events, selectedDate, onSelect }) {
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);
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`;
return(
<div style={{display:'flex',flexDirection:'column',height:'100%'}}>
<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>
))}
{day.map(e=>{
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:64, right:8,
top, height,
background:e.event_type?.colour||'#6366f1', color:'white',
borderRadius:5, padding:'3px 8px', cursor:'pointer',
fontSize:12, fontWeight:600, overflow:'hidden',
boxShadow:'0 1px 3px rgba(0,0,0,0.2)',
}}>
<div style={{whiteSpace:'nowrap',overflow:'hidden',textOverflow:'ellipsis'}}>{e.title}</div>
{height>32&&<div style={{fontSize:10,opacity:0.85}}>{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);
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`;
return(
<div style={{display:'flex',flexDirection:'column',height:'100%'}}>
{/* 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)'}}/>)}
{dayEvs.map(e=>{
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',top,left:2,right:2,height,
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)',
}}>
<div style={{whiteSpace:'nowrap',overflow:'hidden',textOverflow:'ellipsis'}}>{e.title}</div>
{height>26&&<div style={{fontSize:9,opacity:0.85}}>{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 }) {
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 [detailEvent, setDetailEvent] = useState(null);
const [loading, setLoading] = useState(true);
const [createOpen, setCreateOpen] = useState(false);
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'); load(); setDetailEvent(null); } 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 }}>
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" strokeWidth={1.5} stroke="currentColor" width="18" height="18">
<path strokeLinecap="round" strokeLinejoin="round" d="M12 4.5v15m7.5-7.5h-15" />
</svg>
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)', border:'1px solid var(--border)', borderRadius:'var(--radius)', marginTop:4, boxShadow:'0 4px 16px rgba(0,0,0,0.12)' }}>
{[['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>
<div style={{ flex:1 }}/>
<UserFooter />
</div>
)}
{/* Right panel */}
<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 date picker */}
{isMobile && (
<MobileDatePicker selected={selDate} onChange={d=>{setSelDate(d);setPanel('calendar');}} eventDates={eventDates}/>
)}
{/* 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}/>}
{panel === 'calendar' && view === 'day' && <DayView events={events} selectedDate={selDate} onSelect={openDetail}/>}
{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 && (
<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>
{/* 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>
);
}