|
|
|
|
@@ -28,6 +28,32 @@ function daysInMonth(y,m){ return new Date(y,m+1,0).getDate(); }
|
|
|
|
|
|
|
|
|
|
const RESP_LABEL = { going:'Going', maybe:'Maybe', not_going:'Not Going' };
|
|
|
|
|
const RESP_COLOR = { going:'#22c55e', maybe:'#f59e0b', not_going:'#ef4444' };
|
|
|
|
|
const RESP_ICON = {
|
|
|
|
|
going: (color,size=15) => (
|
|
|
|
|
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" strokeWidth={1.5} stroke={color} width={size} height={size} style={{flexShrink:0}}>
|
|
|
|
|
<title>Going</title>
|
|
|
|
|
<path strokeLinecap="round" strokeLinejoin="round" d="M15.182 15.182a4.5 4.5 0 0 1-6.364 0M21 12a9 9 0 1 1-18 0 9 9 0 0 1 18 0ZM9.75 9.75c0 .414-.168.75-.375.75S9 10.164 9 9.75 9.168 9 9.375 9s.375.336.375.75Zm-.375 0h.008v.015h-.008V9.75Zm5.625 0c0 .414-.168.75-.375.75s-.375-.336-.375-.75.168-.75.375-.75.375.336.375.75Zm-.375 0h.008v.015h-.008V9.75Z" />
|
|
|
|
|
</svg>
|
|
|
|
|
),
|
|
|
|
|
maybe: (color,size=15) => (
|
|
|
|
|
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" strokeWidth={1.5} stroke={color} width={size} height={size} style={{flexShrink:0}}>
|
|
|
|
|
<title>Maybe</title>
|
|
|
|
|
<path strokeLinecap="round" strokeLinejoin="round" d="M9.879 7.519c1.171-1.025 3.071-1.025 4.242 0 1.172 1.025 1.172 2.687 0 3.712-.203.179-.43.326-.67.442-.745.361-1.45.999-1.45 1.827v.75M21 12a9 9 0 1 1-18 0 9 9 0 0 1 18 0Zm-9 5.25h.008v.008H12v-.008Z" />
|
|
|
|
|
</svg>
|
|
|
|
|
),
|
|
|
|
|
not_going: (color,size=15) => (
|
|
|
|
|
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" strokeWidth={1.5} stroke={color} width={size} height={size} style={{flexShrink:0}}>
|
|
|
|
|
<title>Not Going</title>
|
|
|
|
|
<path strokeLinecap="round" strokeLinejoin="round" d="M15.182 15.182a4.5 4.5 0 0 1-6.364 0M21 12a9 9 0 1 1-18 0 9 9 0 0 1 18 0ZM9.75 9.75c0 .414-.168.75-.375.75S9 10.164 9 9.75 9.168 9 9.375 9s.375.336.375.75Zm-.375 0h.008v.015h-.008V9.75Zm5.625 0c0 .414-.168.75-.375.75s-.375-.336-.375-.75.168-.75.375-.75.375.336.375.75Zm-.375 0h.008v.015h-.008V9.75Z" />
|
|
|
|
|
</svg>
|
|
|
|
|
),
|
|
|
|
|
};
|
|
|
|
|
const BELL_ICON = (
|
|
|
|
|
<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>
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
// 30-minute time slots
|
|
|
|
|
const TIME_SLOTS = (() => {
|
|
|
|
|
@@ -170,7 +196,14 @@ function EventForm({ event, userGroups, eventTypes, selectedDate, onSave, onCanc
|
|
|
|
|
const [showTypeForm,setShowTypeForm]=useState(false);
|
|
|
|
|
const [localTypes,setLocalTypes]=useState(eventTypes);
|
|
|
|
|
// Sync localTypes when parent provides updated eventTypes (e.g. after async load)
|
|
|
|
|
useEffect(()=>{ setLocalTypes(eventTypes); },[eventTypes]);
|
|
|
|
|
// 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)
|
|
|
|
|
@@ -373,7 +406,9 @@ function EventDetailModal({ event, onClose, onEdit, onAvailabilityChange, isTool
|
|
|
|
|
</div>
|
|
|
|
|
<div style={{fontSize:13,color:'var(--text-secondary)',display:'flex',alignItems:'center',gap:8}}>
|
|
|
|
|
{event.event_type?.name&&<span>{event.event_type.name}</span>}
|
|
|
|
|
{!event.is_public&&<span style={{background:'var(--surface-variant)',borderRadius:10,padding:'1px 8px',fontSize:11}}>Private</span>}
|
|
|
|
|
{event.is_public
|
|
|
|
|
? <span style={{color:'#22c55e',fontWeight:600,fontSize:12}}>Public Event</span>
|
|
|
|
|
: <span style={{color:'#ef4444',fontWeight:600,fontSize:12}}>Private Event</span>}
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
<div style={{display:'flex',gap:6,flexShrink:0}}>
|
|
|
|
|
@@ -482,19 +517,22 @@ function BulkImportPanel({ onImported, onCancel }) {
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ── Calendar Views ────────────────────────────────────────────────────────────
|
|
|
|
|
function ScheduleView({ events, selectedDate, onSelect }) {
|
|
|
|
|
function ScheduleView({ events, selectedDate, onSelect, filterKeyword='', filterTypeId='' }) {
|
|
|
|
|
const y=selectedDate.getFullYear(), m=selectedDate.getMonth();
|
|
|
|
|
const monthStart=new Date(y,m,1), monthEnd=new Date(y,m+1,0,23,59,59);
|
|
|
|
|
const kw=filterKeyword.toLowerCase().trim();
|
|
|
|
|
const filtered=events.filter(e=>{
|
|
|
|
|
const s=new Date(e.start_at);
|
|
|
|
|
return s>=monthStart && s<=monthEnd;
|
|
|
|
|
if(s<monthStart||s>monthEnd) return false;
|
|
|
|
|
if(filterTypeId && String(e.event_type_id)!==String(filterTypeId)) return false;
|
|
|
|
|
if(kw && ![
|
|
|
|
|
e.title||'', e.location||'', e.description||''
|
|
|
|
|
].some(f=>f.toLowerCase().includes(kw))) return false;
|
|
|
|
|
return true;
|
|
|
|
|
});
|
|
|
|
|
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</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>
|
|
|
|
|
if(!filtered.length) return <div style={{textAlign:'center',padding:'60px 20px',color:'var(--text-tertiary)',fontSize:14}}>{kw||filterTypeId?'No events match your filters':'No events in'} {!kw&&!filterTypeId&&`${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 ? RESP_ICON[e.my_response](RESP_COLOR[e.my_response]) : BELL_ICON
|
|
|
|
|
)}</div>{e.location&&<div style={{fontSize:12,color:'var(--text-tertiary)',marginTop:2}}>{e.location}</div>}</div></div>);})}</>;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
@@ -708,6 +746,8 @@ export default function SchedulePage({ isToolManager, isMobile, onProfile, onHel
|
|
|
|
|
const [userGroups, setUserGroups] = useState([]);
|
|
|
|
|
const [panel, setPanel] = useState('calendar');
|
|
|
|
|
const [editingEvent, setEditingEvent] = useState(null);
|
|
|
|
|
const [filterKeyword, setFilterKeyword] = useState('');
|
|
|
|
|
const [filterTypeId, setFilterTypeId] = useState('');
|
|
|
|
|
const [detailEvent, setDetailEvent] = useState(null);
|
|
|
|
|
const [loading, setLoading] = useState(true);
|
|
|
|
|
const [createOpen, setCreateOpen] = useState(false);
|
|
|
|
|
@@ -797,6 +837,36 @@ export default function SchedulePage({ isToolManager, isMobile, onProfile, onHel
|
|
|
|
|
<div className="section-label" style={{ marginBottom:8 }}>Filter Events</div>
|
|
|
|
|
<MiniCalendar selected={selDate} onChange={d=>{setSelDate(d);setPanel('calendar');}} eventDates={eventDates}/>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
{/* List view filters — only shown in Schedule list view */}
|
|
|
|
|
{view==='schedule' && panel==='calendar' && (
|
|
|
|
|
<div style={{ padding:'0 16px 16px' }}>
|
|
|
|
|
<div className="section-label" style={{ marginBottom:8 }}>Search</div>
|
|
|
|
|
<input
|
|
|
|
|
className="input"
|
|
|
|
|
placeholder="Keyword…"
|
|
|
|
|
value={filterKeyword}
|
|
|
|
|
onChange={e=>setFilterKeyword(e.target.value)}
|
|
|
|
|
style={{ marginBottom:8, fontSize:13 }}
|
|
|
|
|
/>
|
|
|
|
|
<select
|
|
|
|
|
className="input"
|
|
|
|
|
value={filterTypeId}
|
|
|
|
|
onChange={e=>setFilterTypeId(e.target.value)}
|
|
|
|
|
style={{ fontSize:13 }}
|
|
|
|
|
>
|
|
|
|
|
<option value="">All event types</option>
|
|
|
|
|
{eventTypes.map(t=><option key={t.id} value={t.id}>{t.name}</option>)}
|
|
|
|
|
</select>
|
|
|
|
|
{(filterKeyword||filterTypeId) && (
|
|
|
|
|
<button
|
|
|
|
|
className="btn btn-secondary btn-sm"
|
|
|
|
|
onClick={()=>{setFilterKeyword('');setFilterTypeId('');}}
|
|
|
|
|
style={{ marginTop:8, width:'100%' }}
|
|
|
|
|
>Clear filters</button>
|
|
|
|
|
)}
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
<div style={{ flex:1 }}/>
|
|
|
|
|
<UserFooter onProfile={onProfile} onHelp={onHelp} onAbout={onAbout} />
|
|
|
|
|
</div>
|
|
|
|
|
@@ -843,7 +913,7 @@ export default function SchedulePage({ isToolManager, isMobile, onProfile, onHel
|
|
|
|
|
|
|
|
|
|
{/* Calendar or panel content */}
|
|
|
|
|
<div style={{ flex:1, overflowY:'auto', overflowX: panel==='eventForm'?'auto':'hidden' }}>
|
|
|
|
|
{panel === 'calendar' && view === 'schedule' && <ScheduleView events={events} selectedDate={selDate} onSelect={openDetail}/>}
|
|
|
|
|
{panel === 'calendar' && view === 'schedule' && <ScheduleView events={events} selectedDate={selDate} onSelect={openDetail} filterKeyword={filterKeyword} filterTypeId={filterTypeId}/>}
|
|
|
|
|
{panel === 'calendar' && view === 'day' && <DayView events={events} selectedDate={selDate} onSelect={openDetail}/>}
|
|
|
|
|
{panel === 'calendar' && view === 'week' && <WeekView events={events} selectedDate={selDate} onSelect={openDetail}/>}
|
|
|
|
|
{panel === 'calendar' && view === 'month' && <MonthView events={events} selectedDate={selDate} onSelect={openDetail} onSelectDay={d=>{setSelDate(d);setView('schedule');}}/>}
|
|
|
|
|
|