|
|
|
|
@@ -714,6 +714,98 @@ function BulkImportPanel({ onImported, onCancel }) {
|
|
|
|
|
// ── Calendar Views ────────────────────────────────────────────────────────────
|
|
|
|
|
// Parse keyword string into array of terms.
|
|
|
|
|
// Quoted phrases ("foo bar") count as one term; space-separated words are individual OR terms.
|
|
|
|
|
// ── Recurring event expansion ─────────────────────────────────────────────────
|
|
|
|
|
// Generates virtual occurrences from a recurring event's rule.
|
|
|
|
|
// Returns array of cloned event objects with adjusted start_at / end_at.
|
|
|
|
|
function expandRecurringEvent(ev, rangeStart, rangeEnd) {
|
|
|
|
|
const rule = ev.recurrence_rule;
|
|
|
|
|
if (!rule || !rule.freq) return [ev];
|
|
|
|
|
|
|
|
|
|
const origStart = new Date(ev.start_at);
|
|
|
|
|
const origEnd = new Date(ev.end_at);
|
|
|
|
|
const durMs = origEnd - origStart;
|
|
|
|
|
|
|
|
|
|
const occurrences = [];
|
|
|
|
|
let cur = new Date(origStart);
|
|
|
|
|
let count = 0;
|
|
|
|
|
const maxOccurrences = 500; // safety cap
|
|
|
|
|
|
|
|
|
|
// Step size based on freq/unit
|
|
|
|
|
const freq = rule.freq === 'custom' ? rule.unit : rule.freq.replace('ly','').replace('dai','day').replace('week','week').replace('month','month').replace('year','year');
|
|
|
|
|
const interval = rule.interval || 1;
|
|
|
|
|
|
|
|
|
|
const step = (d) => {
|
|
|
|
|
const n = new Date(d);
|
|
|
|
|
if (freq === 'day' || rule.freq === 'daily') n.setDate(n.getDate() + interval);
|
|
|
|
|
else if (freq === 'week' || rule.freq === 'weekly') n.setDate(n.getDate() + 7 * interval);
|
|
|
|
|
else if (freq === 'month' || rule.freq === 'monthly') n.setMonth(n.getMonth() + interval);
|
|
|
|
|
else if (freq === 'year' || rule.freq === 'yearly') n.setFullYear(n.getFullYear() + interval);
|
|
|
|
|
else n.setDate(n.getDate() + 7); // fallback weekly
|
|
|
|
|
return n;
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
// For weekly with byDay, generate per-day occurrences
|
|
|
|
|
const byDay = rule.byDay && rule.byDay.length > 0 ? rule.byDay : null;
|
|
|
|
|
const DAY_MAP = {SU:0,MO:1,TU:2,WE:3,TH:4,FR:5,SA:6};
|
|
|
|
|
|
|
|
|
|
// Determine end condition
|
|
|
|
|
const endDate = rule.ends === 'on' && rule.endDate ? new Date(rule.endDate + 'T23:59:59') : null;
|
|
|
|
|
const endCount = rule.ends === 'after' ? (rule.endCount || 13) : null;
|
|
|
|
|
|
|
|
|
|
// Start from original and step forward
|
|
|
|
|
while (count < maxOccurrences) {
|
|
|
|
|
// Check end conditions
|
|
|
|
|
if (endDate && cur > endDate) break;
|
|
|
|
|
if (endCount && occurrences.length >= endCount) break;
|
|
|
|
|
if (cur > rangeEnd) break;
|
|
|
|
|
|
|
|
|
|
if (byDay && (rule.freq === 'weekly' || freq === 'week')) {
|
|
|
|
|
// Emit one occurrence per byDay in this week
|
|
|
|
|
const weekStart = new Date(cur);
|
|
|
|
|
weekStart.setDate(cur.getDate() - cur.getDay()); // Sunday of this week
|
|
|
|
|
for (const dayKey of byDay) {
|
|
|
|
|
const dayNum = DAY_MAP[dayKey];
|
|
|
|
|
const occ = new Date(weekStart);
|
|
|
|
|
occ.setDate(weekStart.getDate() + dayNum);
|
|
|
|
|
occ.setHours(origStart.getHours(), origStart.getMinutes(), origStart.getSeconds());
|
|
|
|
|
if (occ >= rangeStart && occ <= rangeEnd) {
|
|
|
|
|
if (!endDate || occ <= endDate) {
|
|
|
|
|
const occEnd = new Date(occ.getTime() + durMs);
|
|
|
|
|
occurrences.push({...ev, start_at: occ.toISOString(), end_at: occEnd.toISOString(), _virtual: true});
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
cur = step(cur);
|
|
|
|
|
} else {
|
|
|
|
|
if (cur >= rangeStart && cur <= rangeEnd) {
|
|
|
|
|
const occEnd = new Date(cur.getTime() + durMs);
|
|
|
|
|
occurrences.push({...ev, start_at: cur.toISOString(), end_at: occEnd.toISOString(), _virtual: cur.toISOString() !== ev.start_at});
|
|
|
|
|
}
|
|
|
|
|
cur = step(cur);
|
|
|
|
|
}
|
|
|
|
|
count++;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Always include the original even if before rangeStart
|
|
|
|
|
return occurrences.length > 0 ? occurrences : [ev];
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Expand all recurring events in a list within a date range
|
|
|
|
|
function expandEvents(events, rangeStart, rangeEnd) {
|
|
|
|
|
const result = [];
|
|
|
|
|
for (const ev of events) {
|
|
|
|
|
if (ev.recurrence_rule?.freq) {
|
|
|
|
|
const expanded = expandRecurringEvent(ev, rangeStart, rangeEnd);
|
|
|
|
|
result.push(...expanded);
|
|
|
|
|
} else {
|
|
|
|
|
result.push(ev);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
// Sort by start_at
|
|
|
|
|
result.sort((a,b) => new Date(a.start_at) - new Date(b.start_at));
|
|
|
|
|
return result;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function parseKeywords(raw) {
|
|
|
|
|
const terms = [];
|
|
|
|
|
const re = /"([^"]+)"|(\S+)/g;
|
|
|
|
|
@@ -727,6 +819,9 @@ function ScheduleView({ events, selectedDate, onSelect, filterKeyword='', filter
|
|
|
|
|
const today=new Date(); today.setHours(0,0,0,0);
|
|
|
|
|
const terms=parseKeywords(filterKeyword);
|
|
|
|
|
const hasFilters = terms.length > 0 || !!filterTypeId || filterAvailability;
|
|
|
|
|
// Expand recurring events over a wide range (2 years forward)
|
|
|
|
|
const farFuture = new Date(today); farFuture.setFullYear(farFuture.getFullYear()+2);
|
|
|
|
|
const expandedEvents = expandEvents(events, new Date(y,m,1), farFuture);
|
|
|
|
|
const now = new Date(); // exact now for end-time comparison
|
|
|
|
|
const isCurrentMonth = y === today.getFullYear() && m === today.getMonth();
|
|
|
|
|
// No filters: show from today (if current month) or start of month (future months) to end of month.
|
|
|
|
|
@@ -734,7 +829,7 @@ function ScheduleView({ events, selectedDate, onSelect, filterKeyword='', filter
|
|
|
|
|
// Filters active: today + all future events.
|
|
|
|
|
const from = hasFilters ? today : (isCurrentMonth ? today : new Date(y,m,1));
|
|
|
|
|
const to = hasFilters ? new Date(9999,11,31) : new Date(y,m+1,0,23,59,59);
|
|
|
|
|
const filtered=events.filter(e=>{
|
|
|
|
|
const filtered=expandedEvents.filter(e=>{
|
|
|
|
|
const s=new Date(e.start_at);
|
|
|
|
|
if(s<from||s>to) return false;
|
|
|
|
|
if(filterTypeId && String(e.event_type_id)!==String(filterTypeId)) return false;
|
|
|
|
|
@@ -857,7 +952,10 @@ function layoutEvents(evs) {
|
|
|
|
|
return result;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function DayView({ events, selectedDate, onSelect, onSwipe }) {
|
|
|
|
|
function DayView({ events: rawEvents, selectedDate, onSelect, onSwipe }) {
|
|
|
|
|
const dayStart = new Date(selectedDate); dayStart.setHours(0,0,0,0);
|
|
|
|
|
const dayEnd = new Date(selectedDate); dayEnd.setHours(23,59,59,999);
|
|
|
|
|
const events = expandEvents(rawEvents, dayStart, dayEnd);
|
|
|
|
|
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);
|
|
|
|
|
@@ -913,7 +1011,10 @@ function DayView({ events, selectedDate, onSelect, onSwipe }) {
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function WeekView({ events, selectedDate, onSelect }) {
|
|
|
|
|
function WeekView({ events: rawEvents, selectedDate, onSelect }) {
|
|
|
|
|
const ws = weekStart(selectedDate);
|
|
|
|
|
const we = new Date(ws); we.setDate(we.getDate()+6); we.setHours(23,59,59,999);
|
|
|
|
|
const events = expandEvents(rawEvents, ws, we);
|
|
|
|
|
const ws=weekStart(selectedDate), days=Array.from({length:7},(_,i)=>{const d=new Date(ws);d.setDate(d.getDate()+i);return d;});
|
|
|
|
|
const hours=Array.from({length:DAY_END - DAY_START},(_,i)=>i+DAY_START), today=new Date();
|
|
|
|
|
const scrollRef = useRef(null);
|
|
|
|
|
@@ -983,8 +1084,10 @@ function WeekView({ events, selectedDate, onSelect }) {
|
|
|
|
|
|
|
|
|
|
const MONTH_CELL_H = 90; // fixed cell height in px
|
|
|
|
|
|
|
|
|
|
function MonthView({ events, selectedDate, onSelect, onSelectDay }) {
|
|
|
|
|
function MonthView({ events: rawEvents, selectedDate, onSelect, onSelectDay }) {
|
|
|
|
|
const y=selectedDate.getFullYear(), m=selectedDate.getMonth(), first=new Date(y,m,1).getDay(), total=daysInMonth(y,m), today=new Date();
|
|
|
|
|
const monthStart = new Date(y,m,1), monthEnd = new Date(y,m+1,0,23,59,59,999);
|
|
|
|
|
const events = expandEvents(rawEvents, monthStart, monthEnd);
|
|
|
|
|
const cells=[]; for(let i=0;i<first;i++) cells.push(null); for(let d=1;d<=total;d++) cells.push(d);
|
|
|
|
|
while(cells.length%7!==0) cells.push(null);
|
|
|
|
|
const weeks=[]; for(let i=0;i<cells.length;i+=7) weeks.push(cells.slice(i,i+7));
|
|
|
|
|
@@ -1233,11 +1336,11 @@ export default function SchedulePage({ isToolManager, isMobile, onProfile, onHel
|
|
|
|
|
)}
|
|
|
|
|
|
|
|
|
|
{/* Calendar or panel content */}
|
|
|
|
|
<div style={{ flex:1, overflowY:'auto', overflowX: panel==='eventForm'?'auto':'hidden' }}>
|
|
|
|
|
<div style={{ flex:1, display:'flex', flexDirection:'column', overflow: view==='month' && panel==='calendar' ? 'hidden' : (panel==='eventForm'?'auto':'auto'), overflowX: panel==='eventForm'?'auto':'hidden' }}>
|
|
|
|
|
{panel === 'calendar' && view === 'schedule' && <div style={{paddingBottom: isMobile ? 80 : 0}}><ScheduleView events={events} selectedDate={selDate} onSelect={openDetail} filterKeyword={filterKeyword} filterTypeId={filterTypeId} filterAvailability={filterAvailability} isMobile={isMobile}/></div>}
|
|
|
|
|
{panel === 'calendar' && view === 'day' && <DayView events={events} selectedDate={selDate} onSelect={openDetail} onSwipe={isMobile ? dir => { const d=new Date(selDate); d.setDate(d.getDate()+dir); setSelDate(d); } : undefined}/>}
|
|
|
|
|
{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 === 'calendar' && view === 'month' && <MonthView events={events} selectedDate={selDate} onSelect={openDetail} onSelectDay={d=>{setSelDate(d);setView('day');}}/>}
|
|
|
|
|
|
|
|
|
|
{panel === 'eventForm' && isToolManager && !isMobile && (
|
|
|
|
|
<div style={{ padding:28, maxWidth:1024 }}>
|
|
|
|
|
|