|
|
|
|
@@ -138,6 +138,18 @@ function EventTypePopup({ userGroups, onSave, onClose, editing=null }) {
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ── Shared Row layout — defined OUTSIDE EventForm so it's stable across renders ─
|
|
|
|
|
function FormRow({ label, children, required }) {
|
|
|
|
|
return (
|
|
|
|
|
<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>
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ── Event Form ────────────────────────────────────────────────────────────────
|
|
|
|
|
function EventForm({ event, userGroups, eventTypes, selectedDate, onSave, onCancel, onDelete, isToolManager }) {
|
|
|
|
|
const toast=useToast();
|
|
|
|
|
@@ -218,15 +230,6 @@ function EventForm({ event, userGroups, eventTypes, selectedDate, onSave, onCanc
|
|
|
|
|
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,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={{width:'100%',maxWidth:1024,overflowX:'auto'}}>
|
|
|
|
|
<div style={{minWidth:500}}>
|
|
|
|
|
@@ -237,7 +240,7 @@ function EventForm({ event, userGroups, eventTypes, selectedDate, onSave, onCanc
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
{/* Event Type */}
|
|
|
|
|
<Row label="Event Type">
|
|
|
|
|
<FormRow 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}}>
|
|
|
|
|
{localTypes.map(t=><option key={t.id} value={t.id}>{t.name}</option>)}
|
|
|
|
|
@@ -245,10 +248,10 @@ function EventForm({ event, userGroups, eventTypes, selectedDate, onSave, onCanc
|
|
|
|
|
{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>
|
|
|
|
|
</FormRow>
|
|
|
|
|
|
|
|
|
|
{/* Date/Time */}
|
|
|
|
|
<Row label="Date & Time">
|
|
|
|
|
<FormRow 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}}/>
|
|
|
|
|
@@ -275,18 +278,18 @@ function EventForm({ event, userGroups, eventTypes, selectedDate, onSave, onCanc
|
|
|
|
|
</label>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
</Row>
|
|
|
|
|
</FormRow>
|
|
|
|
|
|
|
|
|
|
{/* Availability */}
|
|
|
|
|
<Row label="Availability">
|
|
|
|
|
<FormRow 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>
|
|
|
|
|
</FormRow>
|
|
|
|
|
|
|
|
|
|
{/* Groups — required when tracking */}
|
|
|
|
|
<Row label="Groups" required={groupsRequired}>
|
|
|
|
|
<FormRow 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
|
|
|
|
|
@@ -304,27 +307,27 @@ function EventForm({ event, userGroups, eventTypes, selectedDate, onSave, onCanc
|
|
|
|
|
: `${grps.size} group${grps.size!==1?'s':''} selected`}
|
|
|
|
|
</p>
|
|
|
|
|
</div>
|
|
|
|
|
</Row>
|
|
|
|
|
</FormRow>
|
|
|
|
|
|
|
|
|
|
{/* Visibility — only shown if groups selected OR tracking */}
|
|
|
|
|
{(grps.size>0||track) && (
|
|
|
|
|
<Row label="Visibility">
|
|
|
|
|
<FormRow 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>
|
|
|
|
|
</FormRow>
|
|
|
|
|
)}
|
|
|
|
|
|
|
|
|
|
{/* Location */}
|
|
|
|
|
<Row label="Location">
|
|
|
|
|
<FormRow label="Location">
|
|
|
|
|
<input className="input" placeholder="Add location" value={loc} onChange={e=>setLoc(e.target.value)}/>
|
|
|
|
|
</Row>
|
|
|
|
|
</FormRow>
|
|
|
|
|
|
|
|
|
|
{/* Description */}
|
|
|
|
|
<Row label="Description">
|
|
|
|
|
<FormRow label="Description">
|
|
|
|
|
<textarea className="input" placeholder="Add description" value={desc} onChange={e=>setDesc(e.target.value)} rows={3} style={{resize:'vertical'}}/>
|
|
|
|
|
</Row>
|
|
|
|
|
</FormRow>
|
|
|
|
|
|
|
|
|
|
<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>
|
|
|
|
|
@@ -485,7 +488,8 @@ function ScheduleView({ events, selectedDate, onSelect }) {
|
|
|
|
|
});
|
|
|
|
|
if(!filtered.length) return <div style={{textAlign:'center',padding:'60px 20px',color:'var(--text-tertiary)',fontSize:14}}>No events in {MONTHS[m]} {y}</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&&(
|
|
|
|
|
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" strokeWidth={1.5} stroke="#ef4444" width={15} height={15} style={{flexShrink:0}} title="Awaiting your response">
|
|
|
|
|
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" strokeWidth={1.5} stroke="#ef4444" width={15} height={15} style={{flexShrink:0}}>
|
|
|
|
|
<title>Awaiting your response</title>
|
|
|
|
|
<path strokeLinecap="round" strokeLinejoin="round" d="M14.857 17.082a23.848 23.848 0 0 0 5.454-1.31A8.967 8.967 0 0 1 18 9.75V9A6 6 0 0 0 6 9v.75a8.967 8.967 0 0 1-2.312 6.022c1.733.64 3.56 1.085 5.455 1.31m5.714 0a24.255 24.255 0 0 1-5.714 0m5.714 0a3 3 0 1 1-5.714 0" />
|
|
|
|
|
</svg>
|
|
|
|
|
)}</div>{e.location&&<div style={{fontSize:12,color:'var(--text-tertiary)',marginTop:2}}>{e.location}</div>}</div></div>);})}</>;
|
|
|
|
|
@@ -505,6 +509,46 @@ function eventHeightPx(startDate, endDate) {
|
|
|
|
|
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 }) {
|
|
|
|
|
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));
|
|
|
|
|
@@ -524,20 +568,23 @@ function DayView({ events, selectedDate, onSelect }) {
|
|
|
|
|
<div style={{flex:1}}/>
|
|
|
|
|
</div>
|
|
|
|
|
))}
|
|
|
|
|
{day.map(e=>{
|
|
|
|
|
{layoutEvents(day).map(({event:e,col,totalCols})=>{
|
|
|
|
|
const s=new Date(e.start_at), en=new Date(e.end_at);
|
|
|
|
|
const top=eventTopOffset(s), height=eventHeightPx(s,en);
|
|
|
|
|
return(
|
|
|
|
|
<div key={e.id} onClick={()=>onSelect(e)} style={{
|
|
|
|
|
position:'absolute', left:64, right:8,
|
|
|
|
|
position:'absolute',
|
|
|
|
|
left: `calc(64px + ${col / totalCols * 100}% - ${col * 64 / totalCols}px)`,
|
|
|
|
|
right: `calc(${(totalCols - col - 1) / totalCols * 100}% - ${(totalCols - col - 1) * 64 / totalCols}px + 4px)`,
|
|
|
|
|
top, height,
|
|
|
|
|
background:e.event_type?.colour||'#6366f1', color:'white',
|
|
|
|
|
borderRadius:5, padding:'3px 8px', cursor:'pointer',
|
|
|
|
|
fontSize:12, fontWeight:600, overflow:'hidden',
|
|
|
|
|
borderRadius:5, padding:'3px 6px', cursor:'pointer',
|
|
|
|
|
fontSize:11, fontWeight:600, overflow:'hidden',
|
|
|
|
|
boxShadow:'0 1px 3px rgba(0,0,0,0.2)',
|
|
|
|
|
zIndex: col,
|
|
|
|
|
}}>
|
|
|
|
|
<div style={{whiteSpace:'nowrap',overflow:'hidden',textOverflow:'ellipsis'}}>{e.title}</div>
|
|
|
|
|
{height>32&&<div style={{fontSize:10,opacity:0.85}}>{fmtRange(e.start_at,e.end_at)}</div>}
|
|
|
|
|
{height>28&&<div style={{fontSize:9,opacity:0.85,whiteSpace:'nowrap',overflow:'hidden',textOverflow:'ellipsis'}}>{fmtRange(e.start_at,e.end_at)}</div>}
|
|
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
})}
|
|
|
|
|
@@ -575,19 +622,23 @@ function WeekView({ events, selectedDate, onSelect }) {
|
|
|
|
|
return(
|
|
|
|
|
<div key={di} style={{position:'relative',borderLeft:'1px solid var(--border)'}}>
|
|
|
|
|
{hours.map(h=><div key={h} style={{height:HOUR_H,borderBottom:'1px solid var(--border)'}}/>)}
|
|
|
|
|
{dayEvs.map(e=>{
|
|
|
|
|
{layoutEvents(dayEvs).map(({event:e,col,totalCols})=>{
|
|
|
|
|
const s=new Date(e.start_at),en=new Date(e.end_at);
|
|
|
|
|
const top=eventTopOffset(s), height=eventHeightPx(s,en);
|
|
|
|
|
const pctLeft = `${col / totalCols * 100}%`;
|
|
|
|
|
const pctWidth = `calc(${100 / totalCols}% - 4px)`;
|
|
|
|
|
return(
|
|
|
|
|
<div key={e.id} onClick={()=>onSelect(e)} style={{
|
|
|
|
|
position:'absolute',top,left:2,right:2,height,
|
|
|
|
|
position:'absolute', top, height,
|
|
|
|
|
left: pctLeft, width: pctWidth,
|
|
|
|
|
background:e.event_type?.colour||'#6366f1',color:'white',
|
|
|
|
|
borderRadius:3,padding:'2px 4px',cursor:'pointer',
|
|
|
|
|
fontSize:11,fontWeight:600,overflow:'hidden',
|
|
|
|
|
boxShadow:'0 1px 2px rgba(0,0,0,0.2)',
|
|
|
|
|
zIndex: col,
|
|
|
|
|
}}>
|
|
|
|
|
<div style={{whiteSpace:'nowrap',overflow:'hidden',textOverflow:'ellipsis'}}>{e.title}</div>
|
|
|
|
|
{height>26&&<div style={{fontSize:9,opacity:0.85}}>{fmtTime(e.start_at)}-{fmtTime(e.end_at)}</div>}
|
|
|
|
|
{height>26&&<div style={{fontSize:9,opacity:0.85,whiteSpace:'nowrap',overflow:'hidden',textOverflow:'ellipsis'}}>{fmtTime(e.start_at)}-{fmtTime(e.end_at)}</div>}
|
|
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
})}
|
|
|
|
|
|