1219 lines
76 KiB
JavaScript
1219 lines
76 KiB
JavaScript
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 & 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>
|
||
);
|
||
}
|