v0.9.48 schedule changes

This commit is contained in:
2026-03-17 11:11:19 -04:00
parent 0e7a20e45b
commit 417952af40
10 changed files with 388 additions and 391 deletions

View File

@@ -10,7 +10,7 @@
PROJECT_NAME=jama
# Image version to run (set by build.sh, or use 'latest')
JAMA_VERSION=0.9.47
JAMA_VERSION=0.9.48
# App port — the host port Docker maps to the container
PORT=3000

View File

@@ -1,6 +1,6 @@
{
"name": "jama-backend",
"version": "0.9.47",
"version": "0.9.48",
"description": "TeamChat backend server",
"main": "src/index.js",
"scripts": {

View File

@@ -389,8 +389,9 @@ function initDb() {
name TEXT NOT NULL UNIQUE,
colour TEXT NOT NULL DEFAULT '#6366f1',
default_user_group_id INTEGER,
default_duration_hrs REAL NOT NULL DEFAULT 1.0,
default_duration_hrs REAL,
is_default INTEGER NOT NULL DEFAULT 0,
is_protected INTEGER NOT NULL DEFAULT 0,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
FOREIGN KEY (default_user_group_id) REFERENCES user_groups(id) ON DELETE SET NULL
);
@@ -428,7 +429,15 @@ function initDb() {
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
);
`);
db.prepare("INSERT OR IGNORE INTO event_types (name, colour, is_default) VALUES ('Default', '#9ca3af', 1)").run();
db.prepare("INSERT OR IGNORE INTO event_types (name, colour, is_default, is_protected) VALUES ('Default', '#9ca3af', 1, 1)").run();
db.prepare("INSERT OR IGNORE INTO event_types (name, colour, is_protected, default_duration_hrs) VALUES ('Event', '#6366f1', 1, NULL)").run();
db.prepare("INSERT OR IGNORE INTO event_types (name, colour, default_duration_hrs) VALUES ('Game', '#22c55e', 3.0)").run();
db.prepare("INSERT OR IGNORE INTO event_types (name, colour, default_duration_hrs) VALUES ('Practice', '#f59e0b', 1.0)").run();
// Migration: add is_protected if missing
try { db.exec("ALTER TABLE event_types ADD COLUMN is_protected INTEGER NOT NULL DEFAULT 0"); } catch(e) {}
try { db.exec("ALTER TABLE event_types ADD COLUMN default_duration_hrs REAL"); } catch(e) {}
// Ensure built-in types are protected
db.prepare("UPDATE event_types SET is_protected = 1 WHERE name IN ('Default', 'Event')").run();
console.log('[DB] Schedule Manager tables ready');
} catch (e) { console.error('[DB] Schedule Manager migration error:', e.message); }

View File

@@ -67,7 +67,7 @@ router.patch('/event-types/:id', authMiddleware, teamManagerMiddleware, (req, re
const db = getDb();
const et = db.prepare('SELECT * FROM event_types WHERE id = ?').get(req.params.id);
if (!et) return res.status(404).json({ error: 'Not found' });
if (et.is_default) return res.status(403).json({ error: 'Cannot edit the Default event type' });
if (et.is_protected) return res.status(403).json({ error: 'Cannot edit a protected event type' });
const { name, colour, defaultUserGroupId, defaultDurationHrs } = req.body;
if (name && name.trim() !== et.name) {
if (db.prepare('SELECT id FROM event_types WHERE LOWER(name) = LOWER(?) AND id != ?').get(name.trim(), et.id))
@@ -86,7 +86,7 @@ router.delete('/event-types/:id', authMiddleware, teamManagerMiddleware, (req, r
const db = getDb();
const et = db.prepare('SELECT * FROM event_types WHERE id = ?').get(req.params.id);
if (!et) return res.status(404).json({ error: 'Not found' });
if (et.is_default) return res.status(403).json({ error: 'Cannot delete the Default event type' });
if (et.is_default || et.is_protected) return res.status(403).json({ error: 'Cannot delete a protected event type' });
// Null out event_type_id on events using this type
db.prepare('UPDATE events SET event_type_id = NULL WHERE event_type_id = ?').run(et.id);
db.prepare('DELETE FROM event_types WHERE id = ?').run(et.id);

View File

@@ -13,7 +13,7 @@
# ─────────────────────────────────────────────────────────────
set -euo pipefail
VERSION="${1:-0.9.47}"
VERSION="${1:-0.9.48}"
ACTION="${2:-}"
REGISTRY="${REGISTRY:-}"
IMAGE_NAME="jama"

View File

@@ -1,6 +1,6 @@
{
"name": "jama-frontend",
"version": "0.9.47",
"version": "0.9.48",
"private": true,
"scripts": {
"dev": "vite",

View File

@@ -77,3 +77,9 @@
border-radius: 20px;
padding: 2px 7px;
}
.nav-drawer-item.active {
background: var(--primary-light);
color: var(--primary);
}
.nav-drawer-item.active:hover { background: var(--primary-light); }

View File

@@ -11,68 +11,60 @@ const NAV_ICON = {
settings: <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><circle cx="12" cy="12" r="3"/><path d="M19.07 4.93l-1.41 1.41M5.34 18.66l-1.41 1.41M12 2v2M12 20v2M4.93 4.93l1.41 1.41M18.66 18.66l1.41 1.41M2 12h2M20 12h2"/></svg>,
};
export default function NavDrawer({ open, onClose, onMessages, onGroupManager, onScheduleManager, onBranding, onSettings, onUsers, features = {} }) {
export default function NavDrawer({ open, onClose, onMessages, onSchedule, onScheduleManager, onBranding, onSettings, onUsers, onGroupManager, features = {}, currentPage = 'chat', isMobile = false }) {
const { user } = useAuth();
const drawerRef = useRef(null);
const isAdmin = user?.role === 'admin';
const isMobile = window.matchMedia('(pointer: coarse)').matches || window.innerWidth < 768;
// Tool Manager access: admin always passes; non-admins pass if in a designated tool manager group
const userGroupIds = features.userGroupMemberships || [];
const canAccessTools = isAdmin || (features.teamToolManagers || []).some(gid => userGroupIds.includes(gid));
// Close on outside click
useEffect(() => {
if (!open) return;
const handler = (e) => {
if (drawerRef.current && !drawerRef.current.contains(e.target)) onClose();
};
document.addEventListener('mousedown', handler);
return () => document.removeEventListener('mousedown', handler);
const h = e => { if (drawerRef.current && !drawerRef.current.contains(e.target)) onClose(); };
document.addEventListener('mousedown', h);
return () => document.removeEventListener('mousedown', h);
}, [open, onClose]);
// Close on Escape
useEffect(() => {
if (!open) return;
const handler = (e) => { if (e.key === 'Escape') onClose(); };
window.addEventListener('keydown', handler);
return () => window.removeEventListener('keydown', handler);
const h = e => { if (e.key === 'Escape') onClose(); };
window.addEventListener('keydown', h);
return () => window.removeEventListener('keydown', h);
}, [open, onClose]);
const item = (icon, label, onClick, disabled = false) => (
const item = (icon, label, onClick, opts = {}) => {
const { active, disabled, badge } = opts;
return (
<button
className={`nav-drawer-item${disabled ? ' disabled' : ''}`}
className={`nav-drawer-item${active ? ' active' : ''}${disabled ? ' disabled' : ''}`}
onClick={disabled ? undefined : () => { onClose(); onClick(); }}
disabled={disabled}
>
{icon}
<span>{label}</span>
{disabled && <span className="nav-drawer-badge">Coming soon</span>}
{badge && <span className="nav-drawer-badge">{badge}</span>}
</button>
);
};
return (
<>
{/* Backdrop */}
<div className={`nav-drawer-backdrop${open ? ' open' : ''}`} onClick={onClose} />
{/* Drawer */}
<div ref={drawerRef} className={`nav-drawer${open ? ' open' : ''}`}>
{/* Close X */}
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 8 }}>
<div className="nav-drawer-section-label" style={{ margin: 0, padding: 0 }}>Menu</div>
<button
onClick={onClose}
style={{ background: 'none', border: 'none', cursor: 'pointer', color: 'var(--text-secondary)', padding: 4, borderRadius: 6, display: 'flex', alignItems: 'center' }}
aria-label="Close menu"
>
<svg width="20" height="20" 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 onClick={onClose} style={{ background: 'none', border: 'none', cursor: 'pointer', color: 'var(--text-secondary)', padding: 4, borderRadius: 6, display: 'flex', alignItems: 'center' }} aria-label="Close menu">
<svg width="20" height="20" 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>
{item(NAV_ICON.messages, 'Messages', onMessages)}
{item(NAV_ICON.schedules, 'Schedules', () => {}, true)}
{/* Admin-only: Branding + Settings */}
{/* User section */}
{item(NAV_ICON.messages, 'Messages', onMessages, { active: currentPage === 'chat' })}
{item(NAV_ICON.schedules, 'Schedules', onSchedule, { active: currentPage === 'schedule' })}
{/* Admin section */}
{isAdmin && (
<>
<div className="nav-drawer-section-label admin">Admin</div>
@@ -81,13 +73,18 @@ export default function NavDrawer({ open, onClose, onMessages, onGroupManager, o
</>
)}
{/* Tools: accessible to admins OR designated tool manager groups */}
{/* Tools section */}
{canAccessTools && (
<>
<div className="nav-drawer-section-label admin">Tools</div>
{item(NAV_ICON.users, 'User Manager', onUsers)}
{features.groupManager && !isMobile && item(NAV_ICON.groups, 'Group Manager', onGroupManager)}
{features.scheduleManager && !isMobile && item(NAV_ICON.schedules, 'Schedule Manager', onScheduleManager || (() => {}))}
{features.scheduleManager && item(
NAV_ICON.schedules,
'Schedule Manager',
isMobile ? () => {} : onScheduleManager,
{ disabled: isMobile, badge: isMobile ? 'Desktop only' : undefined }
)}
</>
)}
</div>

View File

@@ -13,7 +13,7 @@ function fmtDate(d) { return `${d.getDate()} ${SHORT_MONTHS[d.getMonth()]} $
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) { return iso ? iso.slice(11,16) : ''; }
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 d.toISOString().slice(0,19); }
function sameDay(a,b) { return a.getFullYear()===b.getFullYear()&&a.getMonth()===b.getMonth()&&a.getDate()===b.getDate(); }
@@ -23,7 +23,18 @@ 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' };
// ── Mini Calendar ─────────────────────────────────────────────────────────────
// 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();
@@ -53,6 +64,45 @@ function MiniCalendar({ selected, onChange, eventDates=new Set() }) {
);
}
// ── 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();
@@ -61,34 +111,23 @@ function EventTypePopup({ userGroups, onSave, onClose, editing=null }) {
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&&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:1};
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);}
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: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 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>
);
}
@@ -108,91 +147,141 @@ function EventForm({ event, userGroups, eventTypes, selectedDate, onSave, onCanc
const [desc,setDesc]=useState(event?.description||'');
const [pub,setPub]=useState(event?!!event.is_public:true);
const [track,setTrack]=useState(!!event?.track_availability);
const [groups,setGroups]=useState(new Set((event?.user_groups||[]).map(g=>g.id)));
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);
// Auto end time when type changes (only for new events)
useEffect(()=>{
if(!typeId||event) return;
const typ=localTypes.find(t=>t.id===Number(typeId));
if(!typ||!sd||!st) return;
if(!typ?.default_duration_hrs||!sd||!st) return;
const start=buildISO(sd,st);
setEd(toDateIn(addHours(start,typ.default_duration_hrs)));
setEt(toTimeIn(addHours(start,typ.default_duration_hrs)));
if(typ.default_user_group_id) setGroups(prev=>new Set([...prev,typ.default_user_group_id]));
if(typ.default_user_group_id) setGrps(prev=>new Set([...prev,Number(typ.default_user_group_id)]));
},[typeId]);
const toggleGrp=id=>setGroups(prev=>{const n=new Set(prev);n.has(id)?n.delete(id):n.add(id);return n;});
// Auto-match end date to start date when start date changes
useEffect(()=>{
if(!event) setEd(sd);
},[sd]);
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:[...groups]};
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);}
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})=>(
<div style={{display:'flex',alignItems:'flex-start',gap:16,marginBottom:14}}>
<div style={{width:90,flexShrink:0,fontSize:13,color:'var(--text-tertiary)',paddingTop:8,textAlign:'right'}}>{label}</div>
<div style={{flex:1}}>{children}</div>
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={{display:'flex',flexDirection:'column',maxWidth:640}}>
<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,marginBottom:20,border:'none',borderBottom:'2px solid var(--border)',borderRadius:0,padding:'4px 0',background:'transparent'}}/>
<Row label="">
<div style={{display:'flex',flexWrap:'wrap',gap:8,alignItems:'center'}}>
<input type="date" className="input" value={sd} onChange={e=>setSd(e.target.value)} style={{width:150}}/>
{!allDay&&<><input type="time" className="input" value={st} onChange={e=>setSt(e.target.value)} style={{width:110}}/><span style={{color:'var(--text-tertiary)',fontSize:13}}>to</span><input type="time" className="input" value={et} onChange={e=>setEt(e.target.value)} style={{width:110}}/></>}
<input type="date" className="input" value={ed} onChange={e=>setEd(e.target.value)} style={{width:150}}/>
style={{fontSize:20,fontWeight:700,border:'none',borderBottom:'2px solid var(--border)',borderRadius:0,padding:'4px 0',background:'transparent',width:'100%'}}/>
</div>
<label style={{display:'flex',alignItems:'center',gap:8,marginTop:8,fontSize:13,cursor:'pointer'}}>
<input type="checkbox" checked={allDay} onChange={e=>setAllDay(e.target.checked)}/> All day
{/* Availability (first — if enabled, groups become required) */}
<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>
<Row label="Event Type">
<div style={{display:'flex',gap:8,alignItems:'center',position:'relative'}} ref={typeRef}>
<select className="input flex-1" value={typeId} onChange={e=>setTypeId(e.target.value)}>
<option value="">Default</option>
{localTypes.filter(t=>!t.is_default).map(t=><option key={t.id} value={t.id}>{t.name}</option>)}
</select>
{isToolManager&&<button className="btn btn-secondary btn-sm" onClick={()=>setShowTypeForm(v=>!v)}>{showTypeForm?'Cancel':'+ Type'}</button>}
{showTypeForm&&<EventTypePopup userGroups={userGroups} onSave={et=>{setLocalTypes(p=>[...p,et]);setShowTypeForm(false);}} onClose={()=>setShowTypeForm(false)}/>}
</div>
</Row>
<Row label="Groups">
<div style={{border:'1px solid var(--border)',borderRadius:'var(--radius)',overflow:'hidden',maxHeight:160,overflowY:'auto'}}>
{userGroups.length===0?<div style={{padding:'10px 14px',fontSize:13,color:'var(--text-tertiary)'}}>No user groups yet</div>
{/* 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={groups.has(g.id)} onChange={()=>toggleGrp(g.id)} style={{accentColor:'var(--primary)'}}/>
<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:'var(--text-tertiary)',marginTop:4}}>{groups.size===0?'No groups — visible to all (if public)':`${groups.size} group${groups.size!==1?'s':''} selected`}</p>
</Row>
<Row label="Options">
<div style={{display:'flex',flexDirection:'column',gap:8}}>
<label style={{display:'flex',alignItems:'center',gap:10,fontSize:13,cursor:'pointer'}}><input type="checkbox" checked={!pub} onChange={e=>setPub(!e.target.checked)}/> Viewable by selected groups only</label>
<label style={{display:'flex',alignItems:'center',gap:10,fontSize:13,cursor:'pointer'}}><input type="checkbox" checked={track} onChange={e=>setTrack(e.target.checked)}/> Track availability for assigned groups</label>
<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>
<Row label="Location"><input className="input" placeholder="Add location" value={loc} onChange={e=>setLoc(e.target.value)}/></Row>
<Row label="Description"><textarea className="input" placeholder="Add description" value={desc} onChange={e=>setDesc(e.target.value)} rows={3} style={{resize:'vertical'}}/></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>
)}
{/* 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)} 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)} style={{width:150,flexShrink:0}}/>
</>
)}
</div>
<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>
</Row>
{/* 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}}>
<option value="">Default</option>
{localTypes.filter(t=>!t.is_default).map(t=><option key={t.id} value={t.id}>{t.name}</option>)}
</select>
{isToolManager&&<button className="btn btn-secondary btn-sm" 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>
{/* 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>
@@ -200,30 +289,25 @@ function EventForm({ event, userGroups, eventTypes, selectedDate, onSave, onCanc
{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 (portal) ───────────────────────────────────────────────
function EventDetailModal({ event, onClose, onEdit, onAvailabilityChange, isToolManager, currentUserId }) {
// ── 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||[]);
const counts={going:0,maybe:0,not_going:0};
avail.forEach(r=>{if(counts[r.response]!==undefined)counts[r.response]++;});
const noRespCount=event.no_response_count||0;
const handleResp=async resp=>{
try {
if(myResp===resp){await api.deleteAvailability(event.id);setMyResp(null);}
else{await api.setAvailability(event.id,resp);setMyResp(resp);}
onAvailabilityChange?.();
} catch(e){toast(e.message,'error');}
try{if(myResp===resp){await api.deleteAvailability(event.id);setMyResp(null);}else{await api.setAvailability(event.id,resp);setMyResp(resp);}onAvailabilityChange?.();}catch(e){toast(e.message,'error');}
};
return ReactDOM.createPortal(
<div className="modal-overlay" onClick={e=>e.target===e.currentTarget&&onClose()}>
<div className="modal" style={{maxWidth:540,maxHeight:'88vh',overflowY:'auto'}}>
{/* Header */}
<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}}>
@@ -241,54 +325,30 @@ function EventDetailModal({ event, onClose, onEdit, onAvailabilityChange, isTool
</div>
</div>
{/* Date/time */}
<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.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>
)}
{/* Availability section */}
{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:'8px 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'}}>
<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>
{/* Availability breakdown */}
{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(([key,n])=>(
<span key={key}><span style={{color:RESP_COLOR[key],fontWeight:700}}>{n}</span> {RESP_LABEL[key]}</span>
))}
<span><span style={{fontWeight:700}}>{noRespCount}</span> No response</span>
{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'}}>
@@ -311,7 +371,7 @@ function EventDetailModal({ event, onClose, onEdit, onAvailabilityChange, isTool
);
}
// ── Event Types Manager Panel ─────────────────────────────────────────────────
// ── Event Types Panel ─────────────────────────────────────────────────────────
function EventTypesPanel({ eventTypes, userGroups, onUpdated }) {
const toast=useToast();
const [editingType,setEditingType]=useState(null);
@@ -324,24 +384,21 @@ function EventTypesPanel({ eventTypes, userGroups, onUpdated }) {
<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 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>1&&<span style={{fontSize:12,color:'var(--text-tertiary)'}}>{et.default_duration_hrs}hr default</span>}
{!et.is_default?(
{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)'}}>Default</span>}
):<span style={{fontSize:11,color:'var(--text-tertiary)'}}>{et.is_default?'Default':'Protected'}</span>}
</div>
))}
</div>
@@ -355,46 +412,14 @@ function BulkImportPanel({ onImported, onCancel }) {
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);}
};
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,alignItems:'center'}}>
<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>
</>
)}
{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>
);
}
@@ -402,131 +427,43 @@ function BulkImportPanel({ onImported, onCancel }) {
// ── Calendar Views ────────────────────────────────────────────────────────────
function ScheduleView({ events, selectedDate, onSelect }) {
const filtered=events.filter(e=>new Date(e.end_at)>=(selectedDate||new Date(0)));
if(!filtered.length) return <div style={{textAlign:'center',padding:'60px 20px',color:'var(--text-tertiary)',fontSize:14}}>No upcoming events</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&&<span style={{width:8,height:8,borderRadius:'50%',background:'#ef4444',flexShrink:0}} title="Awaiting your response"/>}
</div>
{e.location&&<div style={{fontSize:12,color:'var(--text-tertiary)',marginTop:2}}>{e.location}</div>}
</div>
</div>
);
});
if(!filtered.length) return <div style={{textAlign:'center',padding:'60px 20px',color:'var(--text-tertiary)',fontSize:14}}>No upcoming events from selected date</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&&<span style={{width:8,height:8,borderRadius:'50%',background:'#ef4444',flexShrink:0}} title="Awaiting your response"/>}</div>{e.location&&<div style={{fontSize:12,color:'var(--text-tertiary)',marginTop:2}}>{e.location}</div>}</div></div>);})}</>;
}
function DayView({ events, selectedDate, onSelect }) {
const hours=Array.from({length:16},(_,i)=>i+7);
const day=events.filter(e=>sameDay(new Date(e.start_at),selectedDate));
return (
<div>
<div style={{display:'flex',borderBottom:'1px solid var(--border)',padding:'8px 0 8px 60px',fontSize:13,fontWeight:600,color:'var(--primary)'}}>
<div style={{textAlign:'center'}}><div>{DAYS[selectedDate.getDay()]}</div><div style={{fontSize:28,fontWeight:700}}>{selectedDate.getDate()}</div></div>
</div>
{hours.map(h=>(
<div key={h} style={{display:'flex',borderBottom:'1px solid var(--border)',minHeight:52}}>
<div style={{width:60,flexShrink:0,fontSize:11,color:'var(--text-tertiary)',padding:'3px 10px 0',textAlign:'right'}}>
{h>12?`${h-12} PM`:h===12?'12 PM':`${h} AM`}
</div>
<div style={{flex:1,padding:'2px 4px'}}>
{day.filter(e=>new Date(e.start_at).getHours()===h).map(e=>(
<div key={e.id} onClick={()=>onSelect(e)} style={{margin:'2px 0',padding:'5px 10px',borderRadius:5,background:e.event_type?.colour||'#6366f1',color:'white',fontSize:12,cursor:'pointer',fontWeight:600}}>
{e.title} · {fmtRange(e.start_at,e.end_at)}
</div>
))}
</div>
</div>
))}
</div>
);
return(<div><div style={{display:'flex',borderBottom:'1px solid var(--border)',padding:'8px 0 8px 60px',fontSize:13,fontWeight:600,color:'var(--primary)'}}><div style={{textAlign:'center'}}><div>{DAYS[selectedDate.getDay()]}</div><div style={{fontSize:28,fontWeight:700}}>{selectedDate.getDate()}</div></div></div>{hours.map(h=>(<div key={h} style={{display:'flex',borderBottom:'1px solid var(--border)',minHeight:52}}><div style={{width:60,flexShrink:0,fontSize:11,color:'var(--text-tertiary)',padding:'3px 10px 0',textAlign:'right'}}>{h>12?`${h-12} PM`:h===12?'12 PM':`${h} AM`}</div><div style={{flex:1,padding:'2px 4px'}}>{day.filter(e=>new Date(e.start_at).getHours()===h).map(e=>(<div key={e.id} onClick={()=>onSelect(e)} style={{margin:'2px 0',padding:'5px 10px',borderRadius:5,background:e.event_type?.colour||'#6366f1',color:'white',fontSize:12,cursor:'pointer',fontWeight:600}}>{e.title} · {fmtRange(e.start_at,e.end_at)}</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:16},(_,i)=>i+7), today=new Date();
return (
<div>
<div style={{display:'grid',gridTemplateColumns:'60px repeat(7,1fr)',borderBottom:'1px solid var(--border)'}}>
<div/>
{days.map((d,i)=><div key={i} style={{textAlign:'center',padding:'6px 4px',fontSize:12,fontWeight:600,color:sameDay(d,today)?'var(--primary)':'var(--text-secondary)'}}>{DAYS[d.getDay()]} {d.getDate()}</div>)}
</div>
{hours.map(h=>(
<div key={h} style={{display:'grid',gridTemplateColumns:'60px repeat(7,1fr)',borderBottom:'1px solid var(--border)',minHeight:46}}>
<div style={{fontSize:11,color:'var(--text-tertiary)',padding:'3px 10px 0',textAlign:'right'}}>{h>12?`${h-12} PM`:h===12?'12 PM':`${h} AM`}</div>
{days.map((d,i)=>(
<div key={i} style={{borderLeft:'1px solid var(--border)',padding:'1px 2px'}}>
{events.filter(e=>sameDay(new Date(e.start_at),d)&&new Date(e.start_at).getHours()===h).map(e=>(
<div key={e.id} onClick={()=>onSelect(e)} style={{background:e.event_type?.colour||'#6366f1',color:'white',borderRadius:3,padding:'2px 5px',fontSize:11,cursor:'pointer',marginBottom:1,fontWeight:600,overflow:'hidden',whiteSpace:'nowrap',textOverflow:'ellipsis'}}>
{e.title}
</div>
))}
</div>
))}
</div>
))}
</div>
);
return(<div><div style={{display:'grid',gridTemplateColumns:'60px repeat(7,1fr)',borderBottom:'1px solid var(--border)'}}><div/>{days.map((d,i)=><div key={i} style={{textAlign:'center',padding:'6px 4px',fontSize:12,fontWeight:600,color:sameDay(d,today)?'var(--primary)':'var(--text-secondary)'}}>{DAYS[d.getDay()]} {d.getDate()}</div>)}</div>{hours.map(h=>(<div key={h} style={{display:'grid',gridTemplateColumns:'60px repeat(7,1fr)',borderBottom:'1px solid var(--border)',minHeight:46}}><div style={{fontSize:11,color:'var(--text-tertiary)',padding:'3px 10px 0',textAlign:'right'}}>{h>12?`${h-12} PM`:h===12?'12 PM':`${h} AM`}</div>{days.map((d,i)=>(<div key={i} style={{borderLeft:'1px solid var(--border)',padding:'1px 2px'}}>{events.filter(e=>sameDay(new Date(e.start_at),d)&&new Date(e.start_at).getHours()===h).map(e=>(<div key={e.id} onClick={()=>onSelect(e)} style={{background:e.event_type?.colour||'#6366f1',color:'white',borderRadius:3,padding:'2px 5px',fontSize:11,cursor:'pointer',marginBottom:1,fontWeight:600,overflow:'hidden',whiteSpace:'nowrap',textOverflow:'ellipsis'}}>{e.title}</div>))}</div>))}</div>))}</div>);
}
function MonthView({ events, selectedDate, onSelect, onSelectDay }) {
const y=selectedDate.getFullYear(), m=selectedDate.getMonth();
const first=new Date(y,m,1).getDay(), total=daysInMonth(y,m), today=new Date();
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)',minHeight:90,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)',padding:'4px',minHeight:90,cursor:'pointer'}}
onMouseEnter={el=>el.currentTarget.style.background='var(--background)'} onMouseLeave={el=>el.currentTarget.style.background=''}>
<div style={{width:26,height:26,borderRadius:'50%',display:'flex',alignItems:'center',justifyContent:'center',marginBottom:3,fontSize:13,fontWeight:isToday?700:400,background:isToday?'var(--primary)':'transparent',color:isToday?'white':'var(--text-primary)'}}>{d}</div>
{dayEvs.slice(0,3).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 5px',fontSize:11,marginBottom:2,cursor:'pointer',whiteSpace:'nowrap',overflow:'hidden',textOverflow:'ellipsis'}}>
{!e.all_day&&<span style={{marginRight:3}}>{fmtTime(e.start_at)}</span>}{e.title}
</div>
))}
{dayEvs.length>3&&<div style={{fontSize:10,color:'var(--text-tertiary)'}}>+{dayEvs.length-3} more</div>}
</div>
);
})}
</div>
))}
</div>
);
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)',minHeight:90,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)',padding:'4px',minHeight:90,cursor:'pointer'}} onMouseEnter={el=>el.currentTarget.style.background='var(--background)'} onMouseLeave={el=>el.currentTarget.style.background=''}><div style={{width:26,height:26,borderRadius:'50%',display:'flex',alignItems:'center',justifyContent:'center',marginBottom:3,fontSize:13,fontWeight:isToday?700:400,background:isToday?'var(--primary)':'transparent',color:isToday?'white':'var(--text-primary)'}}>{d}</div>{dayEvs.slice(0,3).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 5px',fontSize:11,marginBottom:2,cursor:'pointer',whiteSpace:'nowrap',overflow:'hidden',textOverflow:'ellipsis'}}>{!e.all_day&&<span style={{marginRight:3}}>{fmtTime(e.start_at)}</span>}{e.title}</div>))}{dayEvs.length>3&&<div style={{fontSize:10,color:'var(--text-tertiary)'}}>+{dayEvs.length-3} more</div>}</div>);})}</div>))}</div>);
}
// ── Main Schedule Page ────────────────────────────────────────────────────────
export default function SchedulePage({ onBack, isToolManager }) {
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'); // calendar | eventForm | eventTypes | bulkImport
const [panel, setPanel] = useState('calendar');
const [editingEvent, setEditingEvent] = useState(null);
const [detailEvent, setDetailEvent] = useState(null);
const [loading, setLoading] = useState(true);
@@ -574,28 +511,31 @@ export default function SchedulePage({ onBack, isToolManager }) {
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', height:'100vh', color:'var(--text-tertiary)', fontSize:14 }}>Loading schedule</div>
);
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', flexDirection:'column', height:'100vh', background:'var(--background)' }}>
{/* Top bar */}
<div style={{ display:'flex', alignItems:'center', gap:12, padding:'10px 20px', borderBottom:'1px solid var(--border)', background:'var(--surface)', flexShrink:0, flexWrap:'wrap' }}>
{/* Back to Messages */}
<button className="btn btn-secondary btn-sm" onClick={onBack} style={{ display:'flex', alignItems:'center', gap:6 }}>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><polyline points="15 18 9 12 15 6"/></svg>
Messages
</button>
<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 8px', borderBottom:'1px solid var(--border)' }}>
<div style={{ fontSize:16, fontWeight:700, marginBottom:12, color:'var(--text-primary)' }}>Team Schedule</div>
{/* Create dropdown */}
{/* Create button — styled like new-chat-btn */}
{isToolManager && (
<div style={{ position:'relative' }} ref={createRef}>
<button className="btn btn-primary btn-sm" onClick={() => setCreateOpen(v=>!v)} style={{ display:'flex', alignItems:'center', gap:6 }}>
+ Create <svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><polyline points="6 9 12 15 18 9"/></svg>
<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, zIndex:100, background:'var(--surface)', border:'1px solid var(--border)', borderRadius:'var(--radius)', marginTop:4, minWidth:180, boxShadow:'0 4px 16px rgba(0,0,0,0.12)' }}>
<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);}]
@@ -607,56 +547,81 @@ export default function SchedulePage({ onBack, isToolManager }) {
)}
</div>
)}
{/* Navigation */}
<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)}></button>
<button className="btn-icon" onClick={() => navDate(1)}></button>
</div>
{view !== 'schedule' && <span style={{ fontSize:14, fontWeight:600, color:'var(--text-primary)' }}>{navLabel()}</span>}
<div style={{ marginLeft:'auto', display:'flex', gap:2, background:'var(--surface-variant)', borderRadius:'var(--radius)', padding:3 }}>
{[['schedule','Schedule'],['day','Day'],['week','Week'],['month','Month']].map(([v,l])=>(
<button key={v} onClick={()=>{setView(v);setPanel('calendar');}} style={{ padding:'4px 12px', 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' }}>{l}</button>
))}
</div>
</div>
{/* Body: left mini-cal + right content */}
<div style={{ display:'flex', flex:1, overflow:'hidden' }}>
{/* Left: mini calendar */}
<div style={{ width:220, flexShrink:0, borderRight:'1px solid var(--border)', padding:16, background:'var(--surface)', overflowY:'auto' }}>
{/* Mini calendar */}
<div style={{ padding:16 }}>
<MiniCalendar selected={selDate} onChange={d=>{setSelDate(d);setPanel('calendar');}} eventDates={eventDates}/>
</div>
</div>
)}
{/* Right: calendar view or panel */}
<div style={{ flex:1, overflow:'auto', background:'var(--background)' }}>
{/* 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>
{view !== 'schedule' && <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' && (
<div style={{ padding:28, maxWidth:680 }}>
<h2 style={{ fontSize:18, fontWeight:700, marginBottom:24 }}>{editingEvent ? 'Edit Event' : 'New Event'}</h2>
{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' && (
{panel === 'eventTypes' && isToolManager && (
<div style={{ padding:28 }}>
<div style={{ display:'flex', alignItems:'center', gap:10, marginBottom:24 }}>
<h2 style={{ fontSize:18, fontWeight:700, margin:0 }}>Event Types</h2>
<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' && (
{panel === 'bulkImport' && isToolManager && (
<div style={{ padding:28 }}>
<div style={{ display:'flex', alignItems:'center', gap:10, marginBottom:24 }}>
<h2 style={{ fontSize:18, fontWeight:700, margin:0 }}>Bulk Event Import</h2>
<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')}/>
@@ -670,7 +635,6 @@ export default function SchedulePage({ onBack, isToolManager }) {
<EventDetailModal
event={detailEvent}
isToolManager={isToolManager}
currentUserId={user?.id}
onClose={() => setDetailEvent(null)}
onEdit={() => { setEditingEvent(detailEvent); setPanel('eventForm'); setDetailEvent(null); }}
onAvailabilityChange={() => openDetail(detailEvent)}

View File

@@ -333,14 +333,32 @@ export default function Chat() {
if (page === 'schedule') {
return (
<>
<div className="chat-layout">
<GlobalBar isMobile={isMobile} showSidebar={true} onBurger={() => setDrawerOpen(true)} />
<div className="chat-body" style={{ overflow: 'hidden' }}>
<SchedulePage
onBack={() => setPage('chat')}
isToolManager={isToolManager}
isMobile={isMobile}
features={features}
/>
</div>
<NavDrawer
open={drawerOpen}
onClose={() => setDrawerOpen(false)}
onMessages={() => { setDrawerOpen(false); setPage('chat'); }}
onSchedule={() => { setDrawerOpen(false); setPage('schedule'); }}
onScheduleManager={() => { setDrawerOpen(false); setPage('schedule'); }}
onGroupManager={() => { setDrawerOpen(false); setModal('groupmanager'); }}
onBranding={() => { setDrawerOpen(false); setModal('branding'); }}
onSettings={() => { setDrawerOpen(false); setModal('settings'); }}
onUsers={() => { setDrawerOpen(false); setModal('users'); }}
features={features}
currentPage={page}
isMobile={isMobile}
/>
{modal === 'settings' && <SettingsModal onClose={() => setModal(null)} onFeaturesChanged={setFeatures} />}
</>
{modal === 'groupmanager' && <GroupManagerModal onClose={() => setModal(null)} />}
</div>
);
}
@@ -386,13 +404,16 @@ export default function Chat() {
<NavDrawer
open={drawerOpen}
onClose={() => setDrawerOpen(false)}
onGroupManager={() => { setDrawerOpen(false); setModal('groupmanager'); }}
onScheduleManager={() => { setDrawerOpen(false); setPage('schedule'); }}
onMessages={() => { setDrawerOpen(false); setPage('chat'); }}
onSchedule={() => { setDrawerOpen(false); setPage('schedule'); }}
onScheduleManager={() => { setDrawerOpen(false); setPage('schedule'); }}
onGroupManager={() => { setDrawerOpen(false); setModal('groupmanager'); }}
onBranding={() => { setDrawerOpen(false); setModal('branding'); }}
onSettings={() => { setDrawerOpen(false); setModal('settings'); }}
onUsers={() => { setDrawerOpen(false); setModal('users'); }}
features={features}
currentPage={page}
isMobile={isMobile}
/>
{modal === 'profile' && <ProfileModal onClose={() => setModal(null)} />}
{modal === 'users' && <UserManagerModal onClose={() => setModal(null)} />}