v0.9.48 schedule changes
This commit is contained in:
@@ -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); }
|
||||
|
||||
@@ -3,76 +3,68 @@ import { useAuth } from '../contexts/AuthContext.jsx';
|
||||
import './NavDrawer.css';
|
||||
|
||||
const NAV_ICON = {
|
||||
messages: <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/></svg>,
|
||||
messages: <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/></svg>,
|
||||
schedules: <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><rect x="3" y="4" width="18" height="18" rx="2" ry="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>,
|
||||
users: <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="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"/><path d="M16 3.13a4 4 0 0 1 0 7.75"/></svg>,
|
||||
groups: <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><rect x="2" y="7" width="20" height="14" rx="2"/><path d="M16 7V5a2 2 0 0 0-2-2h-4a2 2 0 0 0-2 2v2"/><line x1="12" y1="12" x2="12" y2="16"/><line x1="10" y1="14" x2="14" y2="14"/></svg>,
|
||||
branding: <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="M12 2a10 10 0 1 0 10 10"/></svg>,
|
||||
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>,
|
||||
users: <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="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"/><path d="M16 3.13a4 4 0 0 1 0 7.75"/></svg>,
|
||||
groups: <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><rect x="2" y="7" width="20" height="14" rx="2"/><path d="M16 7V5a2 2 0 0 0-2-2h-4a2 2 0 0 0-2 2v2"/><line x1="12" y1="12" x2="12" y2="16"/><line x1="10" y1="14" x2="14" y2="14"/></svg>,
|
||||
branding: <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="M12 2a10 10 0 1 0 10 10"/></svg>,
|
||||
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) => (
|
||||
<button
|
||||
className={`nav-drawer-item${disabled ? ' disabled' : ''}`}
|
||||
onClick={disabled ? undefined : () => { onClose(); onClick(); }}
|
||||
disabled={disabled}
|
||||
>
|
||||
{icon}
|
||||
<span>{label}</span>
|
||||
{disabled && <span className="nav-drawer-badge">Coming soon</span>}
|
||||
</button>
|
||||
);
|
||||
const item = (icon, label, onClick, opts = {}) => {
|
||||
const { active, disabled, badge } = opts;
|
||||
return (
|
||||
<button
|
||||
className={`nav-drawer-item${active ? ' active' : ''}${disabled ? ' disabled' : ''}`}
|
||||
onClick={disabled ? undefined : () => { onClose(); onClick(); }}
|
||||
disabled={disabled}
|
||||
>
|
||||
{icon}
|
||||
<span>{label}</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.groupManager && !isMobile && item(NAV_ICON.groups, 'Group Manager', onGroupManager)}
|
||||
{features.scheduleManager && item(
|
||||
NAV_ICON.schedules,
|
||||
'Schedule Manager',
|
||||
isMobile ? () => {} : onScheduleManager,
|
||||
{ disabled: isMobile, badge: isMobile ? 'Desktop only' : undefined }
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -9,21 +9,32 @@ 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) { return iso ? iso.slice(11,16) : ''; }
|
||||
function buildISO(d,t) { return d && t ? `${d}T${t}:00` : ''; }
|
||||
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 d.toISOString().slice(0,19); }
|
||||
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 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_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,122 +147,167 @@ 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}}>
|
||||
<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}}/>
|
||||
<div style={{width:'100%',maxWidth:1024,overflowX:'auto'}}>
|
||||
<div style={{minWidth:500}}>
|
||||
{/* Title */}
|
||||
<div style={{marginBottom:20}}>
|
||||
<input className="input" placeholder="Add title" value={title} onChange={e=>setTitle(e.target.value)}
|
||||
style={{fontSize:20,fontWeight:700,border:'none',borderBottom:'2px solid var(--border)',borderRadius:0,padding:'4px 0',background:'transparent',width:'100%'}}/>
|
||||
</div>
|
||||
<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
|
||||
</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)}/>}
|
||||
{/* 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>
|
||||
|
||||
{/* Groups — required when tracking */}
|
||||
<Row label="Groups" required={groupsRequired}>
|
||||
<div>
|
||||
<div style={{border:`1px solid ${groupsRequired&&grps.size===0?'var(--error)':'var(--border)'}`,borderRadius:'var(--radius)',overflow:'hidden',maxHeight:160,overflowY:'auto'}}>
|
||||
{userGroups.length===0
|
||||
?<div style={{padding:'10px 14px',fontSize:13,color:'var(--text-tertiary)'}}>No user groups yet</div>
|
||||
:userGroups.map(g=>(
|
||||
<label key={g.id} style={{display:'flex',alignItems:'center',gap:10,padding:'7px 12px',borderBottom:'1px solid var(--border)',cursor:'pointer',fontSize:13}}>
|
||||
<input type="checkbox" checked={grps.has(g.id)} onChange={()=>toggleGrp(g.id)} style={{accentColor:'var(--primary)'}}/>
|
||||
{g.name}
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
<p style={{fontSize:11,color:groupsRequired&&grps.size===0?'var(--error)':'var(--text-tertiary)',marginTop:4}}>
|
||||
{grps.size===0
|
||||
? (groupsRequired?'At least one group required for availability tracking':'No groups — event visible to all (if public)')
|
||||
: `${grps.size} group${grps.size!==1?'s':''} selected`}
|
||||
</p>
|
||||
</div>
|
||||
</Row>
|
||||
|
||||
{/* Visibility — only shown if groups selected OR tracking */}
|
||||
{(grps.size>0||track) && (
|
||||
<Row label="Visibility">
|
||||
<label style={{display:'flex',alignItems:'center',gap:10,fontSize:13,cursor:'pointer',paddingTop:6}}>
|
||||
<input type="checkbox" checked={!pub} onChange={e=>setPub(!e.target.checked)}/>
|
||||
Viewable by selected groups only (private)
|
||||
</label>
|
||||
</Row>
|
||||
)}
|
||||
|
||||
{/* 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>
|
||||
<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>
|
||||
</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>
|
||||
: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)'}}/>
|
||||
{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>
|
||||
</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>
|
||||
|
||||
<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>
|
||||
);
|
||||
}
|
||||
|
||||
// ── 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;
|
||||
avail.forEach(r=>{if(counts[r.response]!==undefined)counts[r.response]++;});
|
||||
|
||||
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,89 +511,117 @@ 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 */}
|
||||
{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>
|
||||
</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)' }}>
|
||||
{[['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>
|
||||
))}
|
||||
{/* Create button — styled like new-chat-btn */}
|
||||
{isToolManager && (
|
||||
<div style={{ position:'relative', marginBottom:12 }} ref={createRef}>
|
||||
<button className="newchat-btn" onClick={() => setCreateOpen(v=>!v)} style={{ width:'100%', justifyContent:'center', gap:8 }}>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" strokeWidth={1.5} stroke="currentColor" width="18" height="18">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M12 4.5v15m7.5-7.5h-15" />
|
||||
</svg>
|
||||
Create Event
|
||||
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><polyline points="6 9 12 15 18 9"/></svg>
|
||||
</button>
|
||||
{createOpen && (
|
||||
<div style={{ position:'absolute', top:'100%', left:0, right:0, zIndex:100, background:'var(--surface)', border:'1px solid var(--border)', borderRadius:'var(--radius)', marginTop:4, boxShadow:'0 4px 16px rgba(0,0,0,0.12)' }}>
|
||||
{[['Event', ()=>{setPanel('eventForm');setEditingEvent(null);setCreateOpen(false);}],
|
||||
['Event Type', ()=>{setPanel('eventTypes');setCreateOpen(false);}],
|
||||
['Bulk Event Import', ()=>{setPanel('bulkImport');setCreateOpen(false);}]
|
||||
].map(([label,action])=>(
|
||||
<button key={label} onClick={action} style={{display:'block',width:'100%',padding:'9px 16px',textAlign:'left',fontSize:14,background:'none',border:'none',cursor:'pointer',color:'var(--text-primary)'}}
|
||||
onMouseEnter={e=>e.currentTarget.style.background='var(--background)'} onMouseLeave={e=>e.currentTarget.style.background=''}>{label}</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Mini calendar */}
|
||||
<div style={{ padding:16 }}>
|
||||
<MiniCalendar selected={selDate} onChange={d=>{setSelDate(d);setPanel('calendar');}} eventDates={eventDates}/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Right panel */}
|
||||
<div style={{ flex:1, display:'flex', flexDirection:'column', overflow:'hidden', minWidth:0 }}>
|
||||
{/* View toolbar */}
|
||||
<div style={{ display:'flex', alignItems:'center', gap:8, padding:'8px 16px', borderBottom:'1px solid var(--border)', background:'var(--surface)', flexShrink:0, flexWrap:'nowrap' }}>
|
||||
{/* Mobile title + create */}
|
||||
{isMobile && (
|
||||
<span style={{ fontSize:15, fontWeight:700, flex:1 }}>Team Schedule</span>
|
||||
)}
|
||||
|
||||
{!isMobile && (
|
||||
<>
|
||||
<button className="btn btn-secondary btn-sm" onClick={() => setSelDate(new Date())}>Today</button>
|
||||
<div style={{ display:'flex', gap:2 }}>
|
||||
<button className="btn-icon" onClick={() => navDate(-1)} style={{ fontSize:16, padding:'2px 8px' }}>‹</button>
|
||||
<button className="btn-icon" onClick={() => navDate(1)} style={{ fontSize:16, padding:'2px 8px' }}>›</button>
|
||||
</div>
|
||||
{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}/>
|
||||
)}
|
||||
|
||||
{/* 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' }}>
|
||||
<MiniCalendar selected={selDate} onChange={d=>{setSelDate(d);setPanel('calendar');}} eventDates={eventDates} />
|
||||
</div>
|
||||
|
||||
{/* Right: calendar view or panel */}
|
||||
<div style={{ flex:1, overflow:'auto', background:'var(--background)' }}>
|
||||
{/* 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)}
|
||||
|
||||
Reference in New Issue
Block a user