476 lines
31 KiB
JavaScript
476 lines
31 KiB
JavaScript
import { useState, useEffect, useRef } from 'react';
|
||
import { api } from '../utils/api.js';
|
||
import ColourPickerSheet from './ColourPickerSheet.jsx';
|
||
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) {
|
||
if (!iso) return '';
|
||
const d = new Date(iso);
|
||
const pad = n => String(n).padStart(2,'0');
|
||
return `${d.getFullYear()}-${pad(d.getMonth()+1)}-${pad(d.getDate())}`;
|
||
}
|
||
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(date, time) {
|
||
if (!date || !time) return '';
|
||
const d = new Date(`${date}T${time}:00`);
|
||
const pad = n => String(n).padStart(2,'0');
|
||
const off = -d.getTimezoneOffset();
|
||
const sign = off >= 0 ? '+' : '-';
|
||
const abs = Math.abs(off);
|
||
return `${date}T${time}:00${sign}${pad(Math.floor(abs/60))}:${pad(abs%60)}`;
|
||
}
|
||
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>
|
||
);
|
||
}
|
||
|
||
// ── Row — must be defined OUTSIDE the component to avoid focus loss ─────────────
|
||
function MobileRow({ icon, label, children, onPress, border=true }) {
|
||
return (
|
||
<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>
|
||
);
|
||
}
|
||
|
||
// ── Main Mobile Event Form ────────────────────────────────────────────────────
|
||
export default function MobileEventForm({ event, eventTypes, userGroups, selectedDate, onSave, onCancel, onDelete, isToolManager }) {
|
||
const toast = useToast();
|
||
// Use local date for default, not UTC slice (avoids off-by-one for UTC- timezones)
|
||
const defDate = selectedDate || new Date();
|
||
const _pad = n => String(n).padStart(2,'0');
|
||
const def = `${defDate.getFullYear()}-${_pad(defDate.getMonth()+1)}-${_pad(defDate.getDate())}`;
|
||
const [title, setTitle] = useState(event?.title||'');
|
||
const [typeId, setTypeId] = useState(event?.event_type_id ? String(event.event_type_id) : '');
|
||
const [localTypes, setLocalTypes] = useState(eventTypes);
|
||
const [showAddType, setShowAddType] = useState(false);
|
||
const [newTypeName, setNewTypeName] = useState('');
|
||
const [newTypeColour, setNewTypeColour] = useState('#6366f1');
|
||
const [showTypeColourPicker, setShowTypeColourPicker] = useState(false);
|
||
const [savingType, setSavingType] = useState(false);
|
||
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');
|
||
// Track the saved event duration (minutes) so editing preserves it
|
||
const savedDurMins = event
|
||
? (new Date(event.end_at) - new Date(event.start_at)) / 60000
|
||
: null;
|
||
// Track previous typeId so we can detect a type change vs start time change
|
||
const prevTypeIdRef = useRef(event?.event_type_id ? String(event.event_type_id) : '');
|
||
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);
|
||
|
||
// Sync and initialise typeId
|
||
useEffect(() => {
|
||
setLocalTypes(eventTypes);
|
||
if(!event && typeId==='' && eventTypes.length>0) {
|
||
const def = eventTypes.find(t=>t.is_default) || eventTypes[0];
|
||
if(def) setTypeId(String(def.id));
|
||
}
|
||
}, [eventTypes]);
|
||
|
||
const createEventType = async () => {
|
||
if(!newTypeName.trim()) return;
|
||
setSavingType(true);
|
||
try {
|
||
const r = await api.createEventType({ name: newTypeName.trim(), colour: newTypeColour });
|
||
setLocalTypes(prev => [...prev, r.eventType]);
|
||
setTypeId(String(r.eventType.id));
|
||
setNewTypeName(''); setShowAddType(false);
|
||
} catch(e) { toast(e.message, 'error'); }
|
||
finally { setSavingType(false); }
|
||
};
|
||
|
||
// Auto-calculate end date/time when start date, start time, or event type changes.
|
||
// Rules:
|
||
// - New event: use eventType duration (default 1hr)
|
||
// - Editing + type changed: use new eventType duration
|
||
// - Editing + type same: use saved event duration (preserve original length)
|
||
// - Always: if end < start, advance end date by 1 day (overnight events)
|
||
useEffect(() => {
|
||
if(!sd||!st) return;
|
||
const start = buildISO(sd,st);
|
||
if(!start) return;
|
||
|
||
const typeChanged = typeId !== prevTypeIdRef.current;
|
||
prevTypeIdRef.current = typeId;
|
||
|
||
let durMins;
|
||
if(!event || typeChanged) {
|
||
// New event or type change: use eventType duration
|
||
const typ = localTypes.find(t=>t.id===Number(typeId));
|
||
durMins = (typ?.default_duration_hrs||1) * 60;
|
||
} else {
|
||
// Editing with same type: preserve the saved event duration
|
||
durMins = savedDurMins || 60;
|
||
}
|
||
|
||
const endIso = addHours(start, durMins/60);
|
||
setEd(toDateIn(endIso));
|
||
setEt(toTimeIn(endIso));
|
||
}, [sd, st, typeId]);
|
||
|
||
const handle = async () => {
|
||
if(!title.trim()) return toast('Title required','error');
|
||
// Validation rules
|
||
const startMs = new Date(buildISO(sd, allDay?'00:00':st)).getTime();
|
||
const endMs = new Date(buildISO(ed, allDay?'23:59':et)).getTime();
|
||
if(ed < sd) return toast('End date cannot be before start date','error');
|
||
if(!allDay && endMs <= startMs && ed === sd) return toast('End time must be after start time, or set a later end date','error');
|
||
setSaving(true);
|
||
try {
|
||
const body = { title:title.trim(), eventTypeId:typeId||null, startAt:allDay?buildISO(sd,'00:00'):buildISO(sd,st), endAt:allDay?buildISO(ed,'23:59'):buildISO(ed,et), allDay, location, description, isPublic:!isPrivate, trackAvailability:track, userGroupIds:[...groups], recurrenceRule:recRule||null };
|
||
let scope = 'this';
|
||
if(event && event.recurrence_rule?.freq) {
|
||
const choice = window.confirm('This is a recurring event.\n\nOK = Update this and all future occurrences\nCancel = Update this event only');
|
||
scope = choice ? 'future' : 'this';
|
||
}
|
||
const r = event ? await api.updateEvent(event.id, {...body, recurringScope:scope}) : 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));
|
||
|
||
|
||
|
||
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)} autoComplete="new-password" placeholder="Add title" autoComplete="new-password" autoCorrect="off" autoCapitalize="sentences" spellCheck={false} style={{ width:'100%',border:'none',background:'transparent',fontSize:22,fontWeight:700,color:'var(--text-primary)',outline:'none' }}/>
|
||
</div>
|
||
|
||
{/* Event Type */}
|
||
<MobileRow 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">
|
||
<div style={{ display:'flex', alignItems:'center', gap:8 }}>
|
||
<select value={typeId} onChange={e=>setTypeId(e.target.value)} style={{ background:'transparent',border:'none',fontSize:15,color:'var(--text-primary)',flex:1,outline:'none' }}>
|
||
{localTypes.map(t=><option key={t.id} value={t.id}>{t.name}</option>)}
|
||
</select>
|
||
{isToolManager && (
|
||
<button onClick={()=>setShowAddType(true)} style={{ background:'none',border:'none',cursor:'pointer',color:'var(--primary)',fontSize:13,fontWeight:600,flexShrink:0,padding:'2px 4px' }}>
|
||
+ Type
|
||
</button>
|
||
)}
|
||
</div>
|
||
</MobileRow>
|
||
|
||
{/* 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 style={{ display:'flex',alignItems:'center',padding:'12px 20px 6px 56px' }}>
|
||
<span onClick={()=>setShowStartDate(true)} style={{ flex:1,fontSize:15,cursor:'pointer' }}>{fmtDateDisplay(sd)}</span>
|
||
{!allDay && (
|
||
<select value={st} onChange={e=>setSt(e.target.value)} style={{ fontSize:15,color:'var(--primary)',fontWeight:600,background:'transparent',border:'none',outline:'none',cursor:'pointer' }}>
|
||
{TIME_SLOTS.map(s=><option key={s.value} value={s.value}>{s.label}</option>)}
|
||
</select>
|
||
)}
|
||
</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=>{
|
||
const newEt = e.target.value;
|
||
setEt(newEt);
|
||
// If end time is earlier than start time on the same day, roll end date to next day
|
||
if(sd === ed && newEt <= st) {
|
||
const nextDay = addHours(buildISO(sd, st), 0);
|
||
const d = new Date(nextDay); d.setDate(d.getDate()+1);
|
||
const pad = n => String(n).padStart(2,'0');
|
||
setEd(`${d.getFullYear()}-${pad(d.getMonth()+1)}-${pad(d.getDate())}`);
|
||
}
|
||
}} 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>
|
||
|
||
|
||
|
||
{/* Recurrence */}
|
||
<MobileRow 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>
|
||
</MobileRow>
|
||
|
||
{/* 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 */}
|
||
<MobileRow 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)} autoComplete="new-password" placeholder="Add location" autoComplete="new-password" autoCorrect="off" autoCapitalize="off" spellCheck={false} style={{ width:'100%',border:'none',background:'transparent',fontSize:15,color:'var(--text-primary)',outline:'none' }}/>
|
||
</MobileRow>
|
||
|
||
{/* Description */}
|
||
<MobileRow 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} autoComplete="new-password" autoCorrect="off" spellCheck={false} style={{ width:'100%',border:'none',background:'transparent',fontSize:15,color:'var(--text-primary)',outline:'none',resize:'none' }}/>
|
||
</MobileRow>
|
||
|
||
{/* 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)}/>}
|
||
{showTypeColourPicker && (
|
||
<ColourPickerSheet value={newTypeColour} onChange={setNewTypeColour} onClose={()=>setShowTypeColourPicker(false)} title="Event Type Colour"/>
|
||
)}
|
||
{showAddType && (
|
||
<div style={{ position:'fixed',inset:0,zIndex:200,display:'flex',alignItems:'flex-end' }} onClick={e=>e.target===e.currentTarget&&setShowAddType(false)}>
|
||
<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={{ display:'flex',alignItems:'center',justifyContent:'space-between',marginBottom:16 }}>
|
||
<span style={{ fontWeight:700,fontSize:16 }}>New Event Type</span>
|
||
<button onClick={()=>{setShowAddType(false);setNewTypeName('');setNewTypeColour('#6366f1');}} style={{ background:'none',border:'none',cursor:'pointer',color:'var(--text-secondary)',fontSize:20,lineHeight:1 }}>✕</button>
|
||
</div>
|
||
<input
|
||
autoFocus
|
||
value={newTypeName}
|
||
onChange={e => setNewTypeName(e.target.value)} autoComplete="new-password" onKeyDown={e=>e.key==='Enter'&&createEventType()}
|
||
placeholder="Type name…" autoComplete="new-password" autoCorrect="off" autoCapitalize="words" spellCheck={false}
|
||
style={{ width:'100%',padding:'12px 14px',border:'1px solid var(--border)',borderRadius:'var(--radius)',fontSize:16,marginBottom:12,boxSizing:'border-box',background:'var(--background)',color:'var(--text-primary)' }} />
|
||
<div style={{ display:'flex',alignItems:'center',gap:12,marginBottom:16 }}>
|
||
<label style={{ fontSize:14,color:'var(--text-tertiary)',flexShrink:0 }}>Colour</label>
|
||
<button onClick={()=>setShowTypeColourPicker(true)} style={{ flex:1,height:40,borderRadius:'var(--radius)',border:'2px solid var(--border)',background:newTypeColour,cursor:'pointer' }}/>
|
||
</div>
|
||
<button
|
||
onClick={createEventType}
|
||
disabled={savingType||!newTypeName.trim()}
|
||
style={{ width:'100%',padding:'14px',background:'var(--primary)',color:'white',border:'none',borderRadius:'var(--radius)',fontSize:16,fontWeight:700,cursor:'pointer',opacity:savingType?0.6:1 }}
|
||
>{savingType?'Creating…':'Create Type'}</button>
|
||
</div>
|
||
</div>
|
||
)}
|
||
</div>
|
||
);
|
||
}
|