v0.9.63 updated for mobile

This commit is contained in:
2026-03-17 21:17:07 -04:00
parent 85fc75dd19
commit 4602c2e586
12 changed files with 641 additions and 21 deletions

View File

@@ -0,0 +1,356 @@
import { useState, useEffect, useRef } from 'react';
import { api } from '../utils/api.js';
import { useToast } from '../contexts/ToastContext.jsx';
// ── Utilities ─────────────────────────────────────────────────────────────────
const DAYS = ['Sun','Mon','Tue','Wed','Thu','Fri','Sat'];
const MONTHS = ['January','February','March','April','May','June','July','August','September','October','November','December'];
const SHORT_MONTHS = ['Jan','Feb','Mar','Apr','May','Jun','Jul','Aug','Sep','Oct','Nov','Dec'];
const DAY_PILLS = ['S','M','T','W','T','F','S'];
const DAY_KEYS = ['SU','MO','TU','WE','TH','FR','SA'];
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;
})();
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); const pad=n=>String(n).padStart(2,'0'); return `${d.getFullYear()}-${pad(d.getMonth()+1)}-${pad(d.getDate())}T${pad(d.getHours())}:${pad(d.getMinutes())}:00`; }
function fmtDateDisplay(iso) { if(!iso) return ''; const d=new Date(iso); return `${DAYS[d.getDay()]}, ${SHORT_MONTHS[d.getMonth()]} ${d.getDate()}, ${d.getFullYear()}`; }
function fmtTimeDisplay(slot) { const f=TIME_SLOTS.find(s=>s.value===slot); return f?f.label:slot; }
const FREQ_OPTIONS = [
{ value: '', label: 'Does not repeat' },
{ value: 'daily', label: 'Every day' },
{ value: 'weekly', label: 'Every week' },
{ value: 'monthly', label: 'Every month' },
{ value: 'yearly', label: 'Every year' },
{ value: 'custom', label: 'Custom…' },
];
function recurrenceLabel(rule) {
if (!rule || !rule.freq) return 'Does not repeat';
if (rule.freq === 'custom') { const unit = (rule.interval||1)===1 ? rule.unit : `${rule.interval} ${rule.unit}s`; return `Every ${unit}`; }
return FREQ_OPTIONS.find(o=>o.value===rule.freq)?.label || rule.freq;
}
// ── Toggle Switch ─────────────────────────────────────────────────────────────
function Toggle({ checked, onChange }) {
return (
<div onClick={()=>onChange(!checked)} style={{ width:44,height:24,borderRadius:12,background:checked?'var(--primary)':'var(--surface-variant)',cursor:'pointer',position:'relative',transition:'background 0.2s',flexShrink:0 }}>
<div style={{ position:'absolute',top:2,left:checked?22:2,width:20,height:20,borderRadius:'50%',background:'white',transition:'left 0.2s',boxShadow:'0 1px 3px rgba(0,0,0,0.2)' }}/>
</div>
);
}
// ── Calendar Picker Overlay ───────────────────────────────────────────────────
function CalendarPicker({ value, onChange, onClose }) {
const [cur, setCur] = useState(() => { const d = new Date(value||Date.now()); d.setDate(1); return d; });
const y=cur.getFullYear(), m=cur.getMonth(), first=new Date(y,m,1).getDay(), total=new Date(y,m+1,0).getDate(), 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);
const selDate = value ? new Date(value+'T00:00:00') : null;
return (
<div style={{ position:'fixed',inset:0,zIndex:200,display:'flex',alignItems:'flex-end' }} onClick={e=>e.target===e.currentTarget&&onClose()}>
<div style={{ width:'100%',background:'var(--surface)',borderRadius:'16px 16px 0 0',padding:20,boxShadow:'0 -4px 20px rgba(0,0,0,0.2)' }}>
<div style={{ fontSize:13,color:'var(--text-tertiary)',marginBottom:4 }}>Select Date</div>
<div style={{ fontSize:22,fontWeight:700,marginBottom:12 }}>
{selDate ? `${SHORT_MONTHS[selDate.getMonth()]} ${selDate.getDate()}, ${selDate.getFullYear()}` : '—'}
</div>
<div style={{ display:'flex',alignItems:'center',justifyContent:'space-between',marginBottom:8 }}>
<button onClick={()=>{const n=new Date(cur);n.setMonth(m-1);setCur(n);}} style={{ background:'none',border:'none',fontSize:20,cursor:'pointer',color:'var(--text-secondary)',padding:'4px 10px' }}></button>
<span style={{ fontWeight:600 }}>{MONTHS[m]} {y}</span>
<button onClick={()=>{const n=new Date(cur);n.setMonth(m+1);setCur(n);}} style={{ background:'none',border:'none',fontSize:20,cursor:'pointer',color:'var(--text-secondary)',padding:'4px 10px' }}></button>
</div>
<div style={{ display:'grid',gridTemplateColumns:'repeat(7,1fr)',gap:2,marginBottom:12 }}>
{['S','M','T','W','T','F','S'].map((d,i)=><div key={i} style={{ textAlign:'center',fontSize:11,fontWeight:600,color:'var(--text-tertiary)',padding:'4px 0' }}>{d}</div>)}
{cells.map((d,i) => {
if(!d) return <div key={i}/>;
const date=new Date(y,m,d);
const isSel = selDate && date.toDateString()===selDate.toDateString();
const isToday = date.toDateString()===today.toDateString();
return <div key={i} onClick={()=>onChange(`${y}-${String(m+1).padStart(2,'0')}-${String(d).padStart(2,'0')}`)} style={{ textAlign:'center',padding:'8px 4px',borderRadius:'50%',cursor:'pointer',background:isSel?'var(--primary)':'transparent',color:isSel?'white':isToday?'var(--primary)':'var(--text-primary)',fontWeight:isToday&&!isSel?700:400,fontSize:14 }}>{d}</div>;
})}
</div>
<div style={{ display:'flex',justifyContent:'flex-end',gap:12 }}>
<button onClick={onClose} style={{ background:'none',border:'none',color:'var(--text-secondary)',fontSize:14,cursor:'pointer',padding:'8px 16px' }}>Cancel</button>
<button onClick={onClose} style={{ background:'none',border:'none',color:'var(--primary)',fontSize:14,fontWeight:700,cursor:'pointer',padding:'8px 16px' }}>OK</button>
</div>
</div>
</div>
);
}
// ── Recurrence Sheet ──────────────────────────────────────────────────────────
function RecurrenceSheet({ value, onChange, onClose }) {
const rule = value || {};
const [showCustom, setShowCustom] = useState(rule.freq==='custom');
const [customRule, setCustomRule] = useState(rule.freq==='custom' ? rule : {freq:'custom',interval:1,unit:'week',byDay:[],ends:'never',endDate:'',endCount:13});
const selectFreq = (freq) => {
if(freq==='custom') { setShowCustom(true); return; }
onChange(freq ? {freq} : null);
onClose();
};
const upd = (k,v) => setCustomRule(r=>({...r,[k]:v}));
if(showCustom) return (
<div style={{ position:'fixed',inset:0,zIndex:200,display:'flex',alignItems:'flex-end' }}>
<div style={{ width:'100%',background:'var(--surface)',borderRadius:'16px 16px 0 0',padding:20,boxShadow:'0 -4px 20px rgba(0,0,0,0.2)',maxHeight:'90vh',overflowY:'auto' }}>
<div style={{ display:'flex',alignItems:'center',justifyContent:'space-between',marginBottom:20 }}>
<button onClick={()=>setShowCustom(false)} style={{ background:'none',border:'none',cursor:'pointer',color:'var(--text-secondary)',display:'flex',alignItems:'center',gap:6,fontSize:14 }}>
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><polyline points="15 18 9 12 15 6"/></svg>
</button>
<span style={{ fontWeight:700,fontSize:16 }}>Custom recurrence</span>
<button onClick={()=>{onChange(customRule);onClose();}} style={{ background:'none',border:'none',cursor:'pointer',color:'var(--primary)',fontSize:14,fontWeight:700 }}>Done</button>
</div>
<div style={{ marginBottom:16 }}>
<div style={{ fontSize:12,color:'var(--text-tertiary)',marginBottom:8 }}>Repeats every</div>
<div style={{ display:'flex',gap:10 }}>
<input type="number" className="input" min={1} max={99} value={customRule.interval||1} onChange={e=>upd('interval',Math.max(1,parseInt(e.target.value)||1))} style={{ width:70,textAlign:'center',fontSize:16 }}/>
<select className="input" value={customRule.unit||'week'} onChange={e=>upd('unit',e.target.value)} style={{ flex:1,fontSize:14 }}>
{['day','week','month','year'].map(u=><option key={u} value={u}>{u}{(customRule.interval||1)>1?'s':''}</option>)}
</select>
</div>
</div>
{(customRule.unit||'week')==='week' && (
<div style={{ marginBottom:16 }}>
<div style={{ fontSize:12,color:'var(--text-tertiary)',marginBottom:8 }}>Repeats on</div>
<div style={{ display:'flex',gap:8 }}>
{DAY_PILLS.map((d,i)=>{
const key=DAY_KEYS[i], sel=(customRule.byDay||[]).includes(key);
return <button key={key} type="button" onClick={()=>upd('byDay',sel?(customRule.byDay||[]).filter(x=>x!==key):[...(customRule.byDay||[]),key])} style={{ flex:1,aspectRatio:'1',borderRadius:'50%',border:'1px solid var(--border)',background:sel?'var(--primary)':'transparent',color:sel?'white':'var(--text-primary)',fontSize:12,fontWeight:600,cursor:'pointer',padding:4 }}>{d}</button>;
})}
</div>
</div>
)}
<div style={{ marginBottom:16 }}>
<div style={{ fontSize:12,color:'var(--text-tertiary)',marginBottom:8 }}>Ends</div>
{[['never','Never'],['on','On'],['after','After']].map(([val,lbl])=>(
<div key={val} style={{ display:'flex',alignItems:'center',gap:12,padding:'12px 0',borderBottom:'1px solid var(--border)' }}>
<div onClick={()=>upd('ends',val)} style={{ width:20,height:20,borderRadius:'50%',border:`2px solid ${(customRule.ends||'never')===val?'var(--primary)':'var(--border)'}`,display:'flex',alignItems:'center',justifyContent:'center',cursor:'pointer',flexShrink:0 }}>
{(customRule.ends||'never')===val&&<div style={{ width:10,height:10,borderRadius:'50%',background:'var(--primary)' }}/>}
</div>
<span style={{ flex:1,fontSize:15 }}>{lbl}</span>
{val==='on'&&(customRule.ends||'never')==='on'&&<input type="date" className="input" value={customRule.endDate||''} onChange={e=>upd('endDate',e.target.value)} style={{ width:150 }}/>}
{val==='after'&&(customRule.ends||'never')==='after'&&<><input type="number" className="input" min={1} max={999} value={customRule.endCount||13} onChange={e=>upd('endCount',parseInt(e.target.value)||1)} style={{ width:64,textAlign:'center' }}/><span style={{ fontSize:13,color:'var(--text-tertiary)' }}>occurrences</span></>}
</div>
))}
</div>
</div>
</div>
);
return (
<div style={{ position:'fixed',inset:0,zIndex:200,display:'flex',alignItems:'flex-end' }} onClick={e=>e.target===e.currentTarget&&onClose()}>
<div style={{ width:'100%',background:'var(--surface)',borderRadius:'16px 16px 0 0',padding:20,boxShadow:'0 -4px 20px rgba(0,0,0,0.2)' }}>
{FREQ_OPTIONS.map(opt=>(
<div key={opt.value} onClick={()=>selectFreq(opt.value)} style={{ display:'flex',alignItems:'center',gap:12,padding:'14px 4px',borderBottom:'1px solid var(--border)',cursor:'pointer' }}>
<div style={{ width:20,height:20,borderRadius:'50%',border:`2px solid ${(rule.freq||'')===(opt.value)?'var(--primary)':'var(--border)'}`,display:'flex',alignItems:'center',justifyContent:'center',flexShrink:0 }}>
{(rule.freq||'')===(opt.value)&&<div style={{ width:10,height:10,borderRadius:'50%',background:'var(--primary)' }}/>}
</div>
<span style={{ fontSize:16 }}>{opt.label}</span>
</div>
))}
</div>
</div>
);
}
// ── Main Mobile Event Form ────────────────────────────────────────────────────
export default function MobileEventForm({ event, eventTypes, userGroups, selectedDate, onSave, onCancel, onDelete, isToolManager }) {
const toast = useToast();
const def = selectedDate ? selectedDate.toISOString().slice(0,10) : new Date().toISOString().slice(0,10);
const [title, setTitle] = useState(event?.title||'');
const [typeId, setTypeId] = useState(event?.event_type_id ? String(event.event_type_id) : '');
const [sd, setSd] = useState(event ? toDateIn(event.start_at) : def);
const [st, setSt] = useState(event ? toTimeIn(event.start_at) : '09:00');
const [ed, setEd] = useState(event ? toDateIn(event.end_at) : def);
const [et, setEt] = useState(event ? toTimeIn(event.end_at) : '10:00');
const [allDay, setAllDay] = useState(!!event?.all_day);
const [track, setTrack] = useState(!!event?.track_availability);
const [isPrivate, setIsPrivate] = useState(event ? !event.is_public : false);
const [groups, setGroups] = useState(new Set((event?.user_groups||[]).map(g=>g.id)));
const [location, setLocation] = useState(event?.location||'');
const [description, setDescription] = useState(event?.description||'');
const [recRule, setRecRule] = useState(event?.recurrence_rule||null);
const [saving, setSaving] = useState(false);
// Overlay state
const [showStartDate, setShowStartDate] = useState(false);
const [showEndDate, setShowEndDate] = useState(false);
const [showRecurrence, setShowRecurrence] = useState(false);
const [showGroups, setShowGroups] = useState(false);
// Auto-set typeId to default event type
useEffect(() => {
if(!event && typeId==='' && eventTypes.length>0) {
const def = eventTypes.find(t=>t.is_default) || eventTypes[0];
if(def) setTypeId(String(def.id));
}
}, [eventTypes]);
// When start date changes, match end date
useEffect(() => { if(!event) setEd(sd); }, [sd]);
// When type or start time changes, auto-set end time
useEffect(() => {
if(!sd||!st) return;
const typ = eventTypes.find(t=>t.id===Number(typeId));
const dur = typ?.default_duration_hrs||1;
const start = buildISO(sd,st);
if(start && !event) { setEd(toDateIn(addHours(start,dur))); setEt(toTimeIn(addHours(start,dur))); }
}, [typeId, st]);
const handle = async () => {
if(!title.trim()) return toast('Title required','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, description, isPublic:!isPrivate, trackAvailability:track, userGroupIds:[...groups], recurrenceRule:recRule||null };
const r = event ? await api.updateEvent(event.id, body) : await api.createEvent(body);
onSave(r.event);
} catch(e) { toast(e.message,'error'); }
finally { setSaving(false); }
};
const currentType = eventTypes.find(t=>t.id===Number(typeId));
const Row = ({ icon, label, children, onPress, value, border=true }) => (
<div onClick={onPress} style={{ display:'flex',alignItems:'center',gap:16,padding:'14px 20px',borderBottom:border?'1px solid var(--border)':'none',cursor:onPress?'pointer':'default',minHeight:52 }}>
<span style={{ color:'var(--text-tertiary)',flexShrink:0,width:20,textAlign:'center' }}>{icon}</span>
<div style={{ flex:1,minWidth:0 }}>
{label && <div style={{ fontSize:12,color:'var(--text-tertiary)',marginBottom:2 }}>{label}</div>}
{children}
</div>
</div>
);
return (
<div style={{ display:'flex',flexDirection:'column',height:'100%',background:'var(--background)' }}>
{/* Header */}
<div style={{ display:'flex',alignItems:'center',justifyContent:'space-between',padding:'12px 16px',background:'var(--surface)',borderBottom:'1px solid var(--border)',flexShrink:0 }}>
<button onClick={onCancel} style={{ background:'none',border:'none',cursor:'pointer',color:'var(--text-secondary)',display:'flex',alignItems:'center',gap:4,fontSize:14 }}>
<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>
<span style={{ fontWeight:700,fontSize:16 }}>{event ? 'Edit Event' : 'New Event'}</span>
<button onClick={handle} disabled={saving} style={{ background:'var(--primary)',border:'none',cursor:'pointer',color:'white',borderRadius:20,padding:'8px 20px',fontSize:14,fontWeight:700,opacity:saving?0.6:1 }}>{saving?'…':'Save'}</button>
</div>
<div style={{ flex:1,overflowY:'auto' }}>
{/* Title */}
<div style={{ padding:'16px 20px',borderBottom:'1px solid var(--border)' }}>
<input value={title} onChange={e=>setTitle(e.target.value)} placeholder="Add title" style={{ width:'100%',border:'none',background:'transparent',fontSize:22,fontWeight:700,color:'var(--text-primary)',outline:'none' }}/>
</div>
{/* Event Type */}
<Row icon={<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><path d="M22 2H2l8 9.46V19l4 2v-8.54L22 2z"/></svg>} label="Event Type">
<select value={typeId} onChange={e=>setTypeId(e.target.value)} style={{ background:'transparent',border:'none',fontSize:15,color:'var(--text-primary)',width:'100%',outline:'none' }}>
{eventTypes.map(t=><option key={t.id} value={t.id}>{t.name}</option>)}
</select>
</Row>
{/* All-day toggle */}
<div style={{ display:'flex',alignItems:'center',padding:'14px 20px',borderBottom:'1px solid var(--border)' }}>
<span style={{ color:'var(--text-tertiary)',width:20,textAlign:'center',marginRight:16 }}><svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><circle cx="12" cy="12" r="10"/><polyline points="12 6 12 12 16 14"/></svg></span>
<span style={{ flex:1,fontSize:15 }}>All day</span>
<Toggle checked={allDay} onChange={setAllDay}/>
</div>
{/* Start date/time */}
<div onClick={()=>setShowStartDate(true)} style={{ display:'flex',alignItems:'center',padding:'12px 20px 6px 56px',cursor:'pointer' }}>
<span style={{ flex:1,fontSize:15 }}>{fmtDateDisplay(sd)}</span>
{!allDay && <span style={{ fontSize:15,color:'var(--primary)',fontWeight:600 }}>{fmtTimeDisplay(st)}</span>}
</div>
{/* End date/time */}
<div onClick={()=>setShowEndDate(true)} style={{ display:'flex',alignItems:'center',padding:'6px 20px 14px 56px',cursor:'pointer',borderBottom:'1px solid var(--border)' }}>
<span style={{ flex:1,fontSize:15,color:'var(--text-secondary)' }}>{fmtDateDisplay(ed)}</span>
{!allDay && (
<select value={et} onChange={e=>setEt(e.target.value)} onClick={e=>e.stopPropagation()} style={{ fontSize:15,color:'var(--primary)',fontWeight:600,background:'transparent',border:'none',outline:'none' }}>
{TIME_SLOTS.map(s=><option key={s.value} value={s.value}>{s.label}</option>)}
</select>
)}
</div>
{/* Start time picker (show only if !allDay and user didn't tap date) */}
{!allDay && (
<div style={{ padding:'8px 20px 14px 56px',borderBottom:'1px solid var(--border)',display:'flex',gap:12,alignItems:'center' }}>
<span style={{ fontSize:13,color:'var(--text-tertiary)' }}>Start</span>
<select value={st} onChange={e=>setSt(e.target.value)} style={{ flex:1,fontSize:14,padding:'6px 8px',border:'1px solid var(--border)',borderRadius:'var(--radius)',background:'var(--surface)',color:'var(--text-primary)' }}>
{TIME_SLOTS.map(s=><option key={s.value} value={s.value}>{s.label}</option>)}
</select>
</div>
)}
{/* Recurrence */}
<Row icon={<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><polyline points="23 4 23 10 17 10"/><path d="M20.49 15a9 9 0 1 1-2.12-9.36L23 10"/></svg>} onPress={()=>setShowRecurrence(true)}>
<span style={{ fontSize:15 }}>{recurrenceLabel(recRule)}</span>
</Row>
{/* Track Availability */}
<div style={{ display:'flex',alignItems:'center',padding:'14px 20px',borderBottom:'1px solid var(--border)' }}>
<span style={{ color:'var(--text-tertiary)',width:20,textAlign:'center',marginRight:16 }}><svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z"/></svg></span>
<span style={{ flex:1,fontSize:15 }}>Track Availability</span>
<Toggle checked={track} onChange={setTrack}/>
</div>
{/* Groups */}
<div>
<div onClick={()=>setShowGroups(!showGroups)} style={{ display:'flex',alignItems:'center',padding:'14px 20px',borderBottom:'1px solid var(--border)',cursor:'pointer' }}>
<span style={{ color:'var(--text-tertiary)',width:20,textAlign:'center',marginRight:16 }}><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></span>
<span style={{ flex:1,fontSize:15 }}>{groups.size>0 ? `${groups.size} group${groups.size!==1?'s':''} selected` : 'Add Groups'}</span>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="var(--text-tertiary)" strokeWidth="2"><polyline points={showGroups?"18 15 12 9 6 15":"6 9 12 15 18 9"}/></svg>
</div>
{showGroups && userGroups.map(g=>(
<label key={g.id} style={{ display:'flex',alignItems:'center',gap:14,padding:'12px 20px 12px 56px',borderBottom:'1px solid var(--border)',cursor:'pointer' }}>
<input type="checkbox" checked={groups.has(g.id)} onChange={()=>setGroups(prev=>{const n=new Set(prev);n.has(g.id)?n.delete(g.id):n.add(g.id);return n;})} style={{ width:18,height:18,accentColor:'var(--primary)' }}/>
<span style={{ fontSize:15 }}>{g.name}</span>
</label>
))}
</div>
{/* Private Event */}
<div style={{ display:'flex',alignItems:'center',padding:'14px 20px',borderBottom:'1px solid var(--border)' }}>
<span style={{ color:'var(--text-tertiary)',width:20,textAlign:'center',marginRight:16 }}><svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><path d="M17.94 17.94A10.07 10.07 0 0 1 12 20c-7 0-11-8-11-8a18.45 18.45 0 0 1 5.06-5.94"/><path d="M9.9 4.24A9.12 9.12 0 0 1 12 4c7 0 11 8 11 8a18.5 18.5 0 0 1-2.16 3.19"/><line x1="1" y1="1" x2="23" y2="23"/></svg></span>
<span style={{ flex:1,fontSize:15 }}>Private Event</span>
<Toggle checked={isPrivate} onChange={setIsPrivate}/>
</div>
{/* Location */}
<Row icon={<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" 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>}>
<input value={location} onChange={e=>setLocation(e.target.value)} placeholder="Add location" style={{ width:'100%',border:'none',background:'transparent',fontSize:15,color:'var(--text-primary)',outline:'none' }}/>
</Row>
{/* Description */}
<Row icon={<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="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>} border={false}>
<textarea value={description} onChange={e=>setDescription(e.target.value)} placeholder="Add description" rows={3} style={{ width:'100%',border:'none',background:'transparent',fontSize:15,color:'var(--text-primary)',outline:'none',resize:'none' }}/>
</Row>
{/* Delete */}
{event && isToolManager && (
<div style={{ padding:'16px 20px' }}>
<button onClick={()=>onDelete(event)} style={{ width:'100%',padding:'14px',border:'1px solid var(--error)',borderRadius:'var(--radius)',background:'transparent',color:'var(--error)',fontSize:15,fontWeight:600,cursor:'pointer' }}>Delete Event</button>
</div>
)}
</div>
{/* Overlays */}
{showStartDate && <CalendarPicker value={sd} onChange={v=>{setSd(v);setShowStartDate(false);}} onClose={()=>setShowStartDate(false)}/>}
{showEndDate && <CalendarPicker value={ed} onChange={v=>{setEd(v);setShowEndDate(false);}} onClose={()=>setShowEndDate(false)}/>}
{showRecurrence && <RecurrenceSheet value={recRule} onChange={v=>{setRecRule(v);}} onClose={()=>setShowRecurrence(false)}/>}
</div>
);
}

View File

@@ -0,0 +1,131 @@
import { useState, useEffect } from 'react';
import { api } from '../utils/api.js';
import { useToast } from '../contexts/ToastContext.jsx';
import Avatar from './Avatar.jsx';
export default function MobileGroupManager({ onClose }) {
const toast = useToast();
const [groups, setGroups] = useState([]);
const [allUsers, setAllUsers] = useState([]);
const [expanded, setExpanded] = useState(null);
const [loading, setLoading] = useState(true);
const [creating, setCreating] = useState(false);
const [newName, setNewName] = useState('');
const [saving, setSaving] = useState(false);
const [screen, setScreen] = useState('list'); // list | members
const [activeGroup, setActiveGroup] = useState(null);
const load = async () => {
try {
const [ug, us] = await Promise.all([api.getUserGroups(), api.getUsers()]);
setGroups(ug.groups || []);
setAllUsers(us.users || []);
} catch(e) { toast(e.message, 'error'); }
finally { setLoading(false); }
};
useEffect(() => { load(); }, []);
const createGroup = async () => {
if(!newName.trim()) return;
setSaving(true);
try {
await api.createUserGroup({ name: newName.trim() });
setNewName(''); setCreating(false); load();
} catch(e) { toast(e.message,'error'); }
finally { setSaving(false); }
};
const deleteGroup = async (g) => {
if(!confirm(`Delete "${g.name}"?`)) return;
try { await api.deleteUserGroup(g.id); load(); } catch(e) { toast(e.message,'error'); }
};
const toggleMember = async (groupId, userId, isMember) => {
try {
if(isMember) await api.removeFromUserGroup(groupId, userId);
else await api.addToUserGroup(groupId, userId);
// Reload group members
const ug = await api.getUserGroups();
setGroups(ug.groups || []);
if(activeGroup) setActiveGroup((ug.groups||[]).find(g=>g.id===activeGroup.id)||null);
} catch(e) { toast(e.message,'error'); }
};
if(loading) return (
<div style={{ display:'flex',alignItems:'center',justifyContent:'center',height:'100%',color:'var(--text-tertiary)' }}>Loading</div>
);
// Members screen
if(screen==='members' && activeGroup) {
const memberIds = new Set((activeGroup.members||[]).map(m=>m.id||m.user_id));
return (
<div style={{ display:'flex',flexDirection:'column',height:'100%',background:'var(--background)' }}>
<div style={{ display:'flex',alignItems:'center',gap:12,padding:'12px 16px',background:'var(--surface)',borderBottom:'1px solid var(--border)',flexShrink:0 }}>
<button onClick={()=>setScreen('list')} style={{ background:'none',border:'none',cursor:'pointer',color:'var(--text-secondary)',display:'flex',alignItems:'center' }}>
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><polyline points="15 18 9 12 15 6"/></svg>
</button>
<span style={{ fontWeight:700,fontSize:16,flex:1 }}>{activeGroup.name}</span>
<span style={{ fontSize:13,color:'var(--text-tertiary)' }}>{memberIds.size} member{memberIds.size!==1?'s':''}</span>
</div>
<div style={{ flex:1,overflowY:'auto' }}>
<div style={{ padding:'10px 16px 4px',fontSize:12,fontWeight:600,color:'var(--text-tertiary)',textTransform:'uppercase',letterSpacing:'0.5px' }}>All Users</div>
{allUsers.map(u => {
const isMember = memberIds.has(u.id);
return (
<div key={u.id} style={{ display:'flex',alignItems:'center',gap:12,padding:'12px 16px',borderBottom:'1px solid var(--border)' }}>
<Avatar user={u} size="sm"/>
<div style={{ flex:1,minWidth:0 }}>
<div style={{ fontSize:15,fontWeight:500 }}>{u.display_name||u.name}</div>
<div style={{ fontSize:12,color:'var(--text-tertiary)' }}>{u.role}</div>
</div>
<button onClick={()=>toggleMember(activeGroup.id, u.id, isMember)} style={{ padding:'8px 14px',borderRadius:20,border:`1px solid ${isMember?'var(--error)':'var(--primary)'}`,background:'transparent',color:isMember?'var(--error)':'var(--primary)',fontSize:13,fontWeight:600,cursor:'pointer',flexShrink:0 }}>
{isMember ? 'Remove' : 'Add'}
</button>
</div>
);
})}
</div>
</div>
);
}
// Group list screen
return (
<div style={{ display:'flex',flexDirection:'column',height:'100%',background:'var(--background)' }}>
<div style={{ display:'flex',alignItems:'center',justifyContent:'space-between',padding:'12px 16px',background:'var(--surface)',borderBottom:'1px solid var(--border)',flexShrink:0 }}>
<button onClick={onClose} style={{ background:'none',border:'none',cursor:'pointer',color:'var(--text-secondary)',display:'flex' }}>
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><polyline points="15 18 9 12 15 6"/></svg>
</button>
<span style={{ fontWeight:700,fontSize:16 }}>Group Manager</span>
<button onClick={()=>setCreating(true)} style={{ background:'none',border:'none',cursor:'pointer',color:'var(--primary)',fontSize:24,lineHeight:1 }}>+</button>
</div>
{creating && (
<div style={{ padding:'12px 16px',background:'var(--surface)',borderBottom:'1px solid var(--border)',display:'flex',gap:10 }}>
<input autoFocus value={newName} onChange={e=>setNewName(e.target.value)} onKeyDown={e=>e.key==='Enter'&&createGroup()} placeholder="Group name…" style={{ flex:1,padding:'8px 12px',border:'1px solid var(--border)',borderRadius:'var(--radius)',background:'var(--background)',color:'var(--text-primary)',fontSize:15 }}/>
<button onClick={createGroup} disabled={saving||!newName.trim()} style={{ padding:'8px 16px',background:'var(--primary)',color:'white',border:'none',borderRadius:'var(--radius)',fontSize:14,fontWeight:600,cursor:'pointer',opacity:saving?0.6:1 }}>{saving?'…':'Create'}</button>
<button onClick={()=>{setCreating(false);setNewName('');}} style={{ padding:'8px',background:'none',border:'none',cursor:'pointer',color:'var(--text-secondary)' }}></button>
</div>
)}
<div style={{ flex:1,overflowY:'auto' }}>
{groups.length===0 && <div style={{ textAlign:'center',padding:'60px 20px',color:'var(--text-tertiary)',fontSize:14 }}>No groups yet. Tap + to create one.</div>}
{groups.map(g => (
<div key={g.id} style={{ borderBottom:'1px solid var(--border)' }}>
<div style={{ display:'flex',alignItems:'center',gap:12,padding:'14px 16px',cursor:'pointer' }} onClick={()=>{setActiveGroup(g);setScreen('members');}}>
<div style={{ width:42,height:42,borderRadius:10,background:'var(--primary)',display:'flex',alignItems:'center',justifyContent:'center',color:'white',fontWeight:700,fontSize:14,flexShrink:0 }}>
{g.name.substring(0,2).toUpperCase()}
</div>
<div style={{ flex:1,minWidth:0 }}>
<div style={{ fontSize:15,fontWeight:600 }}>{g.name}</div>
<div style={{ fontSize:12,color:'var(--text-tertiary)' }}>{(g.members||[]).length} member{(g.members||[]).length!==1?'s':''}</div>
</div>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="var(--text-tertiary)" strokeWidth="2"><polyline points="9 18 15 12 9 6"/></svg>
</div>
</div>
))}
</div>
</div>
);
}

View File

@@ -78,7 +78,7 @@ export default function NavDrawer({ open, onClose, onMessages, onSchedule, onSch
<>
<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.groupManager && item(NAV_ICON.groups, 'Group Manager', onGroupManager)}
{features.scheduleManager && item(
NAV_ICON.schedules,
'Schedule Manager',

View File

@@ -4,6 +4,8 @@ import { api } from '../utils/api.js';
import { useToast } from '../contexts/ToastContext.jsx';
import { useAuth } from '../contexts/AuthContext.jsx';
import UserFooter from './UserFooter.jsx';
import MobileEventForm from './MobileEventForm.jsx';
import MobileGroupManager from './MobileGroupManager.jsx';
// ── Utilities ─────────────────────────────────────────────────────────────────
const DAYS = ['Sun','Mon','Tue','Wed','Thu','Fri','Sat'];
@@ -164,6 +166,89 @@ function EventTypePopup({ userGroups, onSave, onClose, editing=null }) {
);
}
// ── Recurrence helpers ────────────────────────────────────────────────────────
const FREQ_OPTIONS = [
{ value: '', label: 'Does not repeat' },
{ value: 'daily', label: 'Every day' },
{ value: 'weekly', label: 'Every week' },
{ value: 'monthly', label: 'Every month' },
{ value: 'yearly', label: 'Every year' },
{ value: 'custom', label: 'Custom…' },
];
const DAY_PILLS = ['S','M','T','W','T','F','S'];
const DAY_KEYS = ['SU','MO','TU','WE','TH','FR','SA'];
function recurrenceLabel(rule) {
if (!rule || !rule.freq) return 'Does not repeat';
const opt = FREQ_OPTIONS.find(o => o.value === rule.freq);
if (rule.freq !== 'custom') return opt?.label || rule.freq;
// Custom summary
const unit = rule.interval === 1 ? rule.unit : `${rule.interval} ${rule.unit}s`;
return `Every ${unit}`;
}
// Desktop recurrence selector — shown inline in the form
function RecurrenceSelector({ value, onChange }) {
// value: { freq, interval, unit, byDay, ends, endDate, endCount } or null
const [showCustom, setShowCustom] = useState(false);
const rule = value || {};
const handleFreqChange = (freq) => {
if (freq === '') { onChange(null); return; }
if (freq === 'custom') { setShowCustom(true); onChange({ freq:'custom', interval:1, unit:'week', byDay:[], ends:'never', endDate:'', endCount:13 }); return; }
setShowCustom(false);
onChange({ freq });
};
return (
<div>
<select className="input" value={rule.freq||''} onChange={e=>handleFreqChange(e.target.value)} style={{marginBottom: (rule.freq==='custom'||showCustom) ? 12 : 0}}>
{FREQ_OPTIONS.map(o=><option key={o.value} value={o.value}>{o.label}</option>)}
</select>
{(rule.freq==='custom') && (
<CustomRecurrenceFields rule={rule} onChange={onChange}/>
)}
</div>
);
}
function CustomRecurrenceFields({ rule, onChange }) {
const upd = (k,v) => onChange({...rule,[k]:v});
return (
<div style={{border:'1px solid var(--border)',borderRadius:'var(--radius)',padding:12,display:'flex',flexDirection:'column',gap:10}}>
<div style={{display:'flex',alignItems:'center',gap:8,fontSize:13}}>
<span style={{color:'var(--text-tertiary)'}}>Every</span>
<input type="number" className="input" min={1} max={99} value={rule.interval||1} onChange={e=>upd('interval',Math.max(1,parseInt(e.target.value)||1))} style={{width:60,textAlign:'center'}}/>
<select className="input" value={rule.unit||'week'} onChange={e=>upd('unit',e.target.value)} style={{flex:1}}>
{['day','week','month','year'].map(u=><option key={u} value={u}>{u}{(rule.interval||1)>1?'s':''}</option>)}
</select>
</div>
{(rule.unit||'week')==='week' && (
<div>
<div style={{fontSize:12,color:'var(--text-tertiary)',marginBottom:6}}>Repeats on</div>
<div style={{display:'flex',gap:6}}>
{DAY_PILLS.map((d,i)=>{
const key=DAY_KEYS[i], sel=(rule.byDay||[]).includes(key);
return <button key={key} type="button" onClick={()=>upd('byDay',sel?(rule.byDay||[]).filter(x=>x!==key):[...(rule.byDay||[]),key])} style={{width:32,height:32,borderRadius:'50%',border:'1px solid var(--border)',background:sel?'var(--primary)':'transparent',color:sel?'white':'var(--text-primary)',fontSize:11,fontWeight:600,cursor:'pointer'}}>{d}</button>;
})}
</div>
</div>
)}
<div>
<div style={{fontSize:12,color:'var(--text-tertiary)',marginBottom:6}}>Ends</div>
{[['never','Never'],['on','On date'],['after','After']].map(([val,lbl])=>(
<label key={val} style={{display:'flex',alignItems:'center',gap:10,marginBottom:6,fontSize:13,cursor:'pointer'}}>
<input type="radio" name="recur_ends" checked={(rule.ends||'never')===val} onChange={()=>upd('ends',val)}/>
{lbl}
{val==='on' && (rule.ends||'never')==='on' && <input type="date" className="input" value={rule.endDate||''} onChange={e=>upd('endDate',e.target.value)} style={{marginLeft:8,flex:1}}/>}
{val==='after' && (rule.ends||'never')==='after' && <><input type="number" className="input" min={1} max={999} value={rule.endCount||13} onChange={e=>upd('endCount',parseInt(e.target.value)||1)} style={{width:64,textAlign:'center',marginLeft:8}}/><span style={{color:'var(--text-tertiary)'}}>occurrences</span></>}
</label>
))}
</div>
</div>
);
}
// ── Shared Row layout — defined OUTSIDE EventForm so it's stable across renders ─
function FormRow({ label, children, required }) {
return (
@@ -195,6 +280,7 @@ function EventForm({ event, userGroups, eventTypes, selectedDate, onSave, onCanc
const [saving,setSaving]=useState(false);
const [showTypeForm,setShowTypeForm]=useState(false);
const [localTypes,setLocalTypes]=useState(eventTypes);
const [recRule,setRecRule]=useState(event?.recurrence_rule||null);
// Sync localTypes when parent provides updated eventTypes (e.g. after async load)
// Also initialise typeId to the default event type for new events
useEffect(()=>{
@@ -262,7 +348,7 @@ function EventForm({ event, userGroups, eventTypes, selectedDate, onSave, onCanc
if(!allDay&&(!sd||!st||!ed||!et)) return toast('Start and end required','error');
if(groupsRequired&&grps.size===0) return toast('Select at least one group for availability tracking','error');
setSaving(true);
try{const body={title:title.trim(),eventTypeId:typeId||null,startAt:allDay?`${sd}T00:00:00`:buildISO(sd,st),endAt:allDay?`${ed}T23:59:59`:buildISO(ed,et),allDay,location:loc,description:desc,isPublic:pub,trackAvailability:track,userGroupIds:[...grps]};const r=event?await api.updateEvent(event.id,body):await api.createEvent(body);onSave(r.event);}catch(e){toast(e.message,'error');}finally{setSaving(false);}
try{const body={title:title.trim(),eventTypeId:typeId||null,startAt:allDay?`${sd}T00:00:00`:buildISO(sd,st),endAt:allDay?`${ed}T23:59:59`:buildISO(ed,et),allDay,location:loc,description:desc,isPublic:pub,trackAvailability:track,userGroupIds:[...grps],recurrenceRule:recRule||null};const r=event?await api.updateEvent(event.id,body):await api.createEvent(body);onSave(r.event);}catch(e){toast(e.message,'error');}finally{setSaving(false);}
};
return (
@@ -308,10 +394,11 @@ function EventForm({ event, userGroups, eventTypes, selectedDate, onSave, onCanc
<label style={{display:'flex',alignItems:'center',gap:8,fontSize:13,cursor:'pointer'}}>
<input type="checkbox" checked={allDay} onChange={e=>setAllDay(e.target.checked)}/> All day
</label>
<label style={{display:'flex',alignItems:'center',gap:8,fontSize:13,cursor:'pointer',color:'var(--text-tertiary)'}}>
<input type="checkbox" disabled title="Recurring events coming soon"/> Recurring
<span style={{fontSize:11,background:'var(--surface-variant)',borderRadius:10,padding:'1px 6px'}}>Coming soon</span>
</label>
<div style={{display:'flex',alignItems:'center',gap:8,fontSize:13}}>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="var(--text-tertiary)" strokeWidth="2"><polyline points="23 4 23 10 17 10"/><path d="M20.49 15a9 9 0 1 1-2.12-9.36L23 10"/></svg>
<span style={{color:'var(--text-tertiary)',flexShrink:0}}>Repeat:</span>
<div style={{flex:1}}><RecurrenceSelector value={recRule} onChange={setRecRule}/></div>
</div>
</div>
</div>
</FormRow>
@@ -421,6 +508,12 @@ function EventDetailModal({ event, onClose, onEdit, onAvailabilityChange, isTool
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="var(--text-tertiary)" strokeWidth="2"><rect x="3" y="4" width="18" height="18" rx="2"/><line x1="16" y1="2" x2="16" y2="6"/><line x1="8" y1="2" x2="8" y2="6"/><line x1="3" y1="10" x2="21" y2="10"/></svg>
<span>{fmtDate(new Date(event.start_at))}{!event.all_day&&` · ${fmtRange(event.start_at,event.end_at)}`}</span>
</div>
{event.recurrence_rule?.freq&&(
<div style={{display:'flex',gap:10,alignItems:'center',marginBottom:12,fontSize:14}}>
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="var(--text-tertiary)" strokeWidth="2"><polyline points="23 4 23 10 17 10"/><path d="M20.49 15a9 9 0 1 1-2.12-9.36L23 10"/></svg>
<span>{recurrenceLabel(event.recurrence_rule)}</span>
</div>
)}
{event.location&&<div style={{display:'flex',gap:10,alignItems:'center',marginBottom:12,fontSize:14}}><svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="var(--text-tertiary)" strokeWidth="2"><path d="M21 10c0 7-9 13-9 13s-9-6-9-13a9 9 0 0 1 18 0z"/><circle cx="12" cy="10" r="3"/></svg>{event.location}</div>}
{event.description&&<div style={{display:'flex',gap:10,marginBottom:12,fontSize:14}}><svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="var(--text-tertiary)" strokeWidth="2" style={{flexShrink:0,marginTop:2}}><line x1="21" y1="10" x2="3" y2="10"/><line x1="21" y1="6" x2="3" y2="6"/><line x1="21" y1="14" x2="3" y2="14"/><line x1="21" y1="18" x2="3" y2="18"/></svg><span style={{whiteSpace:'pre-wrap'}}>{event.description}</span></div>}
{(event.user_groups||[]).length>0&&<div style={{display:'flex',gap:10,marginBottom:16,fontSize:13,color:'var(--text-secondary)'}}><svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="var(--text-tertiary)" strokeWidth="2" style={{flexShrink:0,marginTop:2}}><path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/><path d="M23 21v-2a4 4 0 0 0-3-3.87"/></svg>{event.user_groups.map(g=>g.name).join(', ')}</div>}
@@ -751,6 +844,7 @@ export default function SchedulePage({ isToolManager, isMobile, onProfile, onHel
const [detailEvent, setDetailEvent] = useState(null);
const [loading, setLoading] = useState(true);
const [createOpen, setCreateOpen] = useState(false);
const [mobilePanel, setMobilePanel] = useState(null); // null | 'eventForm' | 'groupManager'
const createRef = useRef(null);
const load = useCallback(() => {
@@ -918,13 +1012,17 @@ export default function SchedulePage({ isToolManager, isMobile, onProfile, onHel
{panel === 'calendar' && view === 'week' && <WeekView events={events} selectedDate={selDate} onSelect={openDetail}/>}
{panel === 'calendar' && view === 'month' && <MonthView events={events} selectedDate={selDate} onSelect={openDetail} onSelectDay={d=>{setSelDate(d);setView('schedule');}}/>}
{panel === 'eventForm' && isToolManager && (
{panel === 'eventForm' && isToolManager && !isMobile && (
<div style={{ padding:28, maxWidth:1024 }}>
<h2 style={{ fontSize:17, fontWeight:700, marginBottom:24 }}>{editingEvent?'Edit Event':'New Event'}</h2>
<EventForm event={editingEvent} userGroups={userGroups} eventTypes={eventTypes} selectedDate={selDate} isToolManager={isToolManager}
onSave={handleSaved} onCancel={()=>{setPanel('calendar');setEditingEvent(null);}} onDelete={handleDelete}/>
</div>
)}
{panel === 'eventForm' && isToolManager && isMobile && (
<MobileEventForm event={editingEvent} userGroups={userGroups} eventTypes={eventTypes} selectedDate={selDate} isToolManager={isToolManager}
onSave={handleSaved} onCancel={()=>{setPanel('calendar');setEditingEvent(null);}} onDelete={handleDelete}/>
)}
{panel === 'eventTypes' && isToolManager && (
<div style={{ padding:28 }}>
<div style={{ display:'flex', alignItems:'center', gap:10, marginBottom:24 }}>
@@ -946,6 +1044,22 @@ export default function SchedulePage({ isToolManager, isMobile, onProfile, onHel
</div>
</div>
{/* Mobile Group Manager */}
{isMobile && mobilePanel === 'groupManager' && (
<div style={{ position:'fixed',inset:0,zIndex:50,background:'var(--background)' }}>
<MobileGroupManager onClose={() => setMobilePanel(null)}/>
</div>
)}
{/* Mobile FAB for creating events */}
{isMobile && isToolManager && panel === 'calendar' && (
<button onClick={()=>{setPanel('eventForm');setEditingEvent(null);}} style={{ position:'fixed',bottom:24,right:24,zIndex:30,width:56,height:56,borderRadius:'50%',background:'var(--primary)',color:'white',border:'none',cursor:'pointer',boxShadow:'0 4px 16px rgba(0,0,0,0.25)',display:'flex',alignItems:'center',justifyContent:'center' }}>
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" strokeWidth={2} stroke="currentColor" width="24" height="24">
<path strokeLinecap="round" strokeLinejoin="round" d="M12 4.5v15m7.5-7.5h-15" />
</svg>
</button>
)}
{/* Event detail modal */}
{detailEvent && (
<EventDetailModal

View File

@@ -16,6 +16,7 @@ import HelpModal from '../components/HelpModal.jsx';
import NavDrawer from '../components/NavDrawer.jsx';
import GroupManagerModal from '../components/GroupManagerModal.jsx';
import SchedulePage from '../components/SchedulePage.jsx';
import MobileGroupManager from '../components/MobileGroupManager.jsx';
import './Chat.css';
function urlBase64ToUint8Array(base64String) {
@@ -351,7 +352,7 @@ export default function Chat() {
onMessages={() => { setDrawerOpen(false); setPage('chat'); }}
onSchedule={() => { setDrawerOpen(false); setPage('schedule'); }}
onScheduleManager={() => { setDrawerOpen(false); setPage('schedule'); }}
onGroupManager={() => { setDrawerOpen(false); setModal('groupmanager'); }}
onGroupManager={() => { setDrawerOpen(false); if(isMobile) setModal('mobilegroupmanager'); else setModal('groupmanager'); }}
onBranding={() => { setDrawerOpen(false); setModal('branding'); }}
onSettings={() => { setDrawerOpen(false); setModal('settings'); }}
onUsers={() => { setDrawerOpen(false); setModal('users'); }}
@@ -363,6 +364,11 @@ export default function Chat() {
{modal === 'settings' && <SettingsModal onClose={() => setModal(null)} onFeaturesChanged={setFeatures} />}
{modal === 'branding' && <BrandingModal onClose={() => setModal(null)} />}
{modal === 'groupmanager' && <GroupManagerModal onClose={() => setModal(null)} />}
{modal === 'mobilegroupmanager' && (
<div style={{ position:'fixed',inset:0,zIndex:200,background:'var(--background)' }}>
<MobileGroupManager onClose={() => setModal(null)}/>
</div>
)}
{modal === 'about' && <AboutModal onClose={() => setModal(null)} />}
{modal === 'help' && <HelpModal onClose={() => setModal(null)} dismissed={helpDismissed} />}
</div>
@@ -387,7 +393,7 @@ export default function Chat() {
onUsers={() => setModal('users')}
onSettings={() => setModal('settings')}
onBranding={() => setModal('branding')}
onGroupManager={() => setModal('groupmanager')}
onGroupManager={() => isMobile ? setModal('mobilegroupmanager') : setModal('groupmanager')}
features={features}
onGroupsUpdated={loadGroups}
isMobile={isMobile}
@@ -414,7 +420,7 @@ export default function Chat() {
onMessages={() => { setDrawerOpen(false); setPage('chat'); }}
onSchedule={() => { setDrawerOpen(false); setPage('schedule'); }}
onScheduleManager={() => { setDrawerOpen(false); setPage('schedule'); }}
onGroupManager={() => { setDrawerOpen(false); setModal('groupmanager'); }}
onGroupManager={() => { setDrawerOpen(false); if(isMobile) setModal('mobilegroupmanager'); else setModal('groupmanager'); }}
onBranding={() => { setDrawerOpen(false); setModal('branding'); }}
onSettings={() => { setDrawerOpen(false); setModal('settings'); }}
onUsers={() => { setDrawerOpen(false); setModal('users'); }}
@@ -427,6 +433,11 @@ export default function Chat() {
{modal === 'settings' && <SettingsModal onClose={() => setModal(null)} onFeaturesChanged={setFeatures} />}
{modal === 'branding' && <BrandingModal onClose={() => setModal(null)} />}
{modal === 'groupmanager' && <GroupManagerModal onClose={() => setModal(null)} />}
{modal === 'mobilegroupmanager' && (
<div style={{ position:'fixed',inset:0,zIndex:200,background:'var(--background)' }}>
<MobileGroupManager onClose={() => setModal(null)}/>
</div>
)}
{modal === 'newchat' && <NewChatModal onClose={() => setModal(null)} onCreated={(g) => { loadGroups(); setModal(null); setActiveGroupId(g.id); }} />}
{modal === 'about' && <AboutModal onClose={() => setModal(null)} />}

View File

@@ -114,7 +114,7 @@ export const api = {
return req('GET', `/schedule${qs ? '?' + qs : ''}`);
},
getEvent: (id) => req('GET', `/schedule/${id}`),
createEvent: (body) => req('POST', '/schedule', body),
createEvent: (body) => req('POST', '/schedule', body), // body may include recurrenceRule: {freq,interval,byDay,ends,endDate,endCount}
updateEvent: (id, body) => req('PATCH', `/schedule/${id}`, body),
deleteEvent: (id) => req('DELETE', `/schedule/${id}`),
setAvailability: (id, response) => req('PUT', `/schedule/${id}/availability`, { response }),