);
}
// ── Shared Row layout — defined OUTSIDE EventForm so it's stable across renders ─
function FormRow({ label, children, required }) {
return (
{label}{required&& *}
{children}
);
}
// ── Event Form ────────────────────────────────────────────────────────────────
function EventForm({ event, userGroups, eventTypes, selectedDate, onSave, onCancel, onDelete, isToolManager }) {
const toast=useToast();
const def=selectedDate?selectedDate.toISOString().slice(0,10):new Date().toISOString().slice(0,10);
const [title,setTitle]=useState(event?.title||'');
const [typeId,setTypeId]=useState(event?.event_type_id||'');
const [sd,setSd]=useState(event?toDateIn(event.start_at):def);
const [st,setSt]=useState(event?toTimeIn(event.start_at):'09:00');
const [ed,setEd]=useState(event?toDateIn(event.end_at):def);
const [et,setEt]=useState(event?toTimeIn(event.end_at):'10:00');
const [allDay,setAllDay]=useState(!!event?.all_day);
const [loc,setLoc]=useState(event?.location||'');
const [desc,setDesc]=useState(event?.description||'');
const [pub,setPub]=useState(event?!!event.is_public:true);
const [track,setTrack]=useState(!!event?.track_availability);
const [grps,setGrps]=useState(new Set((event?.user_groups||[]).map(g=>g.id)));
const [saving,setSaving]=useState(false);
const [showTypeForm,setShowTypeForm]=useState(false);
const [localTypes,setLocalTypes]=useState(eventTypes);
const [recRule,setRecRule]=useState(event?.recurrence_rule||null);
// Sync localTypes when parent provides updated eventTypes (e.g. after async load)
// Also initialise typeId to the default event type for new events
useEffect(()=>{
setLocalTypes(eventTypes);
if(!event && typeId==='' && eventTypes.length>0) {
const def = eventTypes.find(t=>t.is_default) || eventTypes[0];
if(def) setTypeId(String(def.id));
}
},[eventTypes]);
const typeRef=useRef(null);
// Track whether the user has manually changed the end time (vs auto-computed)
const userSetEndTime = useRef(!!event); // editing mode: treat saved end as user-set
// When event type changes:
// - Creating: always apply the type's duration to compute end time
// - Editing: only apply duration if the type HAS a defined duration
// (if no duration on type, keep existing saved end time)
useEffect(()=>{
if(!sd||!st) return;
const typ=localTypes.find(t=>t.id===Number(typeId));
const start=buildISO(sd,st);
if(!start) return;
if(!event) {
// Creating new event — always apply duration (default 1hr)
const dur=typ?.default_duration_hrs||1;
setEd(toDateIn(addHours(start,dur)));
setEt(toTimeIn(addHours(start,dur)));
userSetEndTime.current = false;
} else {
// Editing — only update end time if the new type has an explicit duration
if(typ?.default_duration_hrs) {
setEd(toDateIn(addHours(start,typ.default_duration_hrs)));
setEt(toTimeIn(addHours(start,typ.default_duration_hrs)));
userSetEndTime.current = false;
}
// else: keep existing saved end time — do nothing
}
if(typ?.default_user_group_id&&!event) setGrps(prev=>new Set([...prev,Number(typ.default_user_group_id)]));
},[typeId]);
// When start date changes: match end date (both modes) unless user set it manually
useEffect(()=>{
if(!userSetEndTime.current) setEd(sd);
},[sd]);
// When start time changes: recompute end using current duration offset
useEffect(()=>{
if(!sd||!st) return;
if(userSetEndTime.current) return; // user already picked a specific end time — respect it
const typ=localTypes.find(t=>t.id===Number(typeId));
const dur=typ?.default_duration_hrs||1;
const start=buildISO(sd,st);
if(start){
setEd(toDateIn(addHours(start,dur)));
setEt(toTimeIn(addHours(start,dur)));
}
},[st]);
const toggleGrp=id=>setGrps(prev=>{const n=new Set(prev);n.has(id)?n.delete(id):n.add(id);return n;});
const groupsRequired=track; // when tracking, groups are required
const handle=async()=>{
if(!title.trim()) return toast('Title required','error');
if(!allDay&&(!sd||!st||!ed||!et)) return toast('Start and end required','error');
if(groupsRequired&&grps.size===0) return toast('Select at least one group for availability tracking','error');
setSaving(true);
try{const body={title:title.trim(),eventTypeId:typeId||null,startAt:allDay?`${sd}T00:00:00`:buildISO(sd,st),endAt:allDay?`${ed}T23:59:59`:buildISO(ed,et),allDay,location:loc,description:desc,isPublic:pub,trackAvailability:track,userGroupIds:[...grps],recurrenceRule:recRule||null};const r=event?await api.updateEvent(event.id,body):await api.createEvent(body);onSave(r.event);}catch(e){toast(e.message,'error');}finally{setSaving(false);}
};
return (
{if(e.key==='Enter'&&e.target.tagName!=='TEXTAREA') e.preventDefault();}}>
{/* Title */}
setSd(e.target.value)} style={{width:150,flexShrink:0}}/>
{!allDay&&(
<>
to
{setEd(e.target.value);userSetEndTime.current=true;}} style={{width:150,flexShrink:0}}/>
>
)}
Repeat:
{/* Availability */}
{/* Groups — required when tracking */}
{userGroups.length===0
?
No user groups yet
:userGroups.map(g=>(
))}
{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`}
{/* Visibility — only shown if groups selected OR tracking */}
{(grps.size>0||track) && (
)}
{/* Location */}
setLoc(e.target.value)}/>
{/* Description */}
);})}>;
}
const HOUR_H = 52; // px per hour row
const DAY_START = 0; // show from midnight
const DAY_END = 24; // to midnight
function eventTopOffset(startDate) {
const h=startDate.getHours(), m=startDate.getMinutes();
return (h - DAY_START)*HOUR_H + (m/60)*HOUR_H;
}
function eventHeightPx(startDate, endDate) {
const diffMs=endDate-startDate;
const diffHrs=diffMs/(1000*60*60);
return Math.max(diffHrs*HOUR_H, HOUR_H*0.4); // min 40% of one hour row
}
// Compute column assignments for events that overlap in time.
// Returns array of {event, col, totalCols} where col 0..totalCols-1.
function layoutEvents(evs) {
if (!evs.length) return [];
const sorted = [...evs].sort((a,b) => new Date(a.start_at) - new Date(b.start_at));
const cols = []; // each col is array of events placed there
const result = [];
for (const e of sorted) {
const eStart = new Date(e.start_at), eEnd = new Date(e.end_at);
// Find first column where this event doesn't overlap with the last event
let placed = false;
for (let ci = 0; ci < cols.length; ci++) {
const lastInCol = cols[ci][cols[ci].length - 1];
if (new Date(lastInCol.end_at) <= eStart) {
cols[ci].push(e);
result.push({ event: e, col: ci });
placed = true;
break;
}
}
if (!placed) {
cols.push([e]);
result.push({ event: e, col: cols.length - 1 });
}
}
// Determine totalCols for each event = max cols among overlapping group
for (const item of result) {
const eStart = new Date(item.event.start_at), eEnd = new Date(item.event.end_at);
let maxCol = item.col;
for (const other of result) {
const oStart = new Date(other.event.start_at), oEnd = new Date(other.event.end_at);
if (oStart < eEnd && oEnd > eStart) maxCol = Math.max(maxCol, other.col);
}
item.totalCols = maxCol + 1;
}
return result;
}
function DayView({ events, selectedDate, onSelect, onSwipe }) {
const hours=Array.from({length:DAY_END - DAY_START},(_,i)=>i+DAY_START);
const day=events.filter(e=>sameDay(new Date(e.start_at),selectedDate));
const scrollRef = useRef(null);
const touchRef = useRef({ x:0, y:0 });
useEffect(()=>{ if(scrollRef.current) scrollRef.current.scrollTop = 7 * HOUR_H; },[selectedDate]);
const fmtHour = h => h===0?'12 AM':h<12?`${h} AM`:h===12?'12 PM':`${h-12} PM`;
const handleTouchStart = e => { touchRef.current = { x:e.touches[0].clientX, y:e.touches[0].clientY }; };
const handleTouchEnd = e => {
const dx = e.changedTouches[0].clientX - touchRef.current.x;
const dy = Math.abs(e.changedTouches[0].clientY - touchRef.current.y);
// Require horizontal swipe > 60px, not too vertical, and not from left edge (< 30px = back gesture)
if(Math.abs(dx) > 60 && dy < 80 && touchRef.current.x > 30) {
onSwipe?.(dx < 0 ? 1 : -1); // left = next day, right = prev day
}
};
return(