v0.9.68 new filter rules
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "jama-frontend",
|
||||
"version": "0.9.67",
|
||||
"version": "0.9.68",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
|
||||
@@ -98,41 +98,81 @@ function MiniCalendar({ selected, onChange, eventDates=new Set() }) {
|
||||
);
|
||||
}
|
||||
|
||||
// ── Mobile Date Picker (accordion month view) ─────────────────────────────────
|
||||
function MobileDatePicker({ selected, onChange, eventDates=new Set() }) {
|
||||
// ── Mobile Filter Bar (Schedule view: keyword+type filters with month nav; Day view: calendar accordion) ──
|
||||
function MobileScheduleFilter({ selected, onMonthChange, view, eventTypes, filterKeyword, onFilterKeyword, filterTypeId, onFilterTypeId, eventDates=new Set() }) {
|
||||
// Day view: keep accordion calendar
|
||||
const [open, setOpen] = useState(false);
|
||||
const [cur, setCur] = useState(()=>{ const d=new Date(selected||Date.now()); d.setDate(1); return d; });
|
||||
const y=cur.getFullYear(), m=cur.getMonth(), first=new Date(y,m,1).getDay(), total=daysInMonth(y,m);
|
||||
const cells=[]; for(let i=0;i<first;i++) cells.push(null); for(let d=1;d<=total;d++) cells.push(d);
|
||||
const y=selected.getFullYear(), m=selected.getMonth();
|
||||
const today=new Date();
|
||||
|
||||
if(view==='day') {
|
||||
const first=new Date(y,m,1).getDay(), total=daysInMonth(y,m);
|
||||
const cells=[]; for(let i=0;i<first;i++) cells.push(null); for(let d=1;d<=total;d++) cells.push(d);
|
||||
return (
|
||||
<div style={{borderBottom:'1px solid var(--border)',background:'var(--surface)'}}>
|
||||
<button onClick={()=>setOpen(v=>!v)} style={{display:'flex',alignItems:'center',justifyContent:'space-between',width:'100%',padding:'10px 16px',background:'none',border:'none',cursor:'pointer',fontSize:14,fontWeight:600,color:'var(--text-primary)'}}>
|
||||
<span>{MONTHS[m]} {y}</span>
|
||||
<span style={{fontSize:10,transform:open?'rotate(180deg)':'none',display:'inline-block',transition:'transform 0.2s'}}>▼</span>
|
||||
</button>
|
||||
{open && (
|
||||
<div style={{padding:'8px 12px 12px',userSelect:'none'}}>
|
||||
<div style={{display:'flex',alignItems:'center',justifyContent:'space-between',marginBottom:8}}>
|
||||
<button style={{background:'none',border:'none',cursor:'pointer',padding:'4px 10px',fontSize:16,color:'var(--text-secondary)'}} onClick={()=>onMonthChange(-1)}>‹</button>
|
||||
<button style={{background:'none',border:'none',cursor:'pointer',padding:'4px 10px',fontSize:16,color:'var(--text-secondary)'}} onClick={()=>onMonthChange(1)}>›</button>
|
||||
</div>
|
||||
<div style={{display:'grid',gridTemplateColumns:'repeat(7,1fr)',gap:2,fontSize:12}}>
|
||||
{DAYS.map(d=><div key={d} style={{textAlign:'center',fontWeight:600,color:'var(--text-tertiary)',padding:'2px 0'}}>{d[0]}</div>)}
|
||||
{cells.map((d,i)=>{
|
||||
if(!d) return <div key={i}/>;
|
||||
const date=new Date(y,m,d), isSel=sameDay(date,selected), isToday=sameDay(date,today);
|
||||
const key=`${y}-${String(m+1).padStart(2,'0')}-${String(d).padStart(2,'0')}`;
|
||||
return (
|
||||
<div key={i} onClick={()=>{const nd=new Date(y,m,d);onMonthChange(0,nd);setOpen(false);}} style={{textAlign:'center',padding:'5px 2px',borderRadius:4,cursor:'pointer',background:isSel?'var(--primary)':'transparent',color:isSel?'white':isToday?'var(--primary)':'var(--text-primary)',fontWeight:isToday&&!isSel?700:400,position:'relative'}}>
|
||||
{d}
|
||||
{eventDates.has(key)&&!isSel&&<span style={{position:'absolute',bottom:2,left:'50%',transform:'translateX(-50%)',width:4,height:4,borderRadius:'50%',background:'var(--primary)',display:'block'}}/>}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Schedule view: filter bar with month nav + keyword + event type
|
||||
const hasFilters = filterKeyword || filterTypeId;
|
||||
return (
|
||||
<div style={{borderBottom:'1px solid var(--border)',background:'var(--surface)'}}>
|
||||
<button onClick={()=>setOpen(v=>!v)} style={{display:'flex',alignItems:'center',justifyContent:'space-between',width:'100%',padding:'10px 16px',background:'none',border:'none',cursor:'pointer',fontSize:14,fontWeight:600,color:'var(--text-primary)'}}>
|
||||
<span>{MONTHS[m]} {y}</span>
|
||||
<span style={{fontSize:10,transform:open?'rotate(180deg)':'none',display:'inline-block',transition:'transform 0.2s'}}>▼</span>
|
||||
</button>
|
||||
{open && (
|
||||
<div style={{padding:'8px 12px 12px',userSelect:'none'}}>
|
||||
<div style={{display:'flex',alignItems:'center',justifyContent:'space-between',marginBottom:8}}>
|
||||
<button style={{background:'none',border:'none',cursor:'pointer',padding:'4px 10px',fontSize:16,color:'var(--text-secondary)'}} onClick={()=>{const n=new Date(cur);n.setMonth(m-1);setCur(n);}}>‹</button>
|
||||
<button style={{background:'none',border:'none',cursor:'pointer',padding:'4px 10px',fontSize:16,color:'var(--text-secondary)'}} onClick={()=>{const n=new Date(cur);n.setMonth(m+1);setCur(n);}}>›</button>
|
||||
</div>
|
||||
<div style={{display:'grid',gridTemplateColumns:'repeat(7,1fr)',gap:2,fontSize:12}}>
|
||||
{DAYS.map(d=><div key={d} style={{textAlign:'center',fontWeight:600,color:'var(--text-tertiary)',padding:'2px 0'}}>{d[0]}</div>)}
|
||||
{cells.map((d,i)=>{
|
||||
if(!d) return <div key={i}/>;
|
||||
const date=new Date(y,m,d), isSel=selected&&sameDay(date,new Date(selected)), isToday=sameDay(date,today);
|
||||
const key=`${y}-${String(m+1).padStart(2,'0')}-${String(d).padStart(2,'0')}`;
|
||||
return (
|
||||
<div key={i} onClick={()=>{onChange(date);setOpen(false);}} style={{textAlign:'center',padding:'5px 2px',borderRadius:4,cursor:'pointer',background:isSel?'var(--primary)':'transparent',color:isSel?'white':isToday?'var(--primary)':'var(--text-primary)',fontWeight:isToday?700:400,position:'relative'}}>
|
||||
{d}
|
||||
{eventDates.has(key)&&!isSel&&<span style={{position:'absolute',bottom:2,left:'50%',transform:'translateX(-50%)',width:4,height:4,borderRadius:'50%',background:'var(--primary)',display:'block'}}/>}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<div style={{background:'var(--surface)',borderBottom:'1px solid var(--border)'}}>
|
||||
{/* Month nav row */}
|
||||
<div style={{display:'flex',alignItems:'center',padding:'8px 16px 0',gap:8}}>
|
||||
<button onClick={()=>onMonthChange(-1)} style={{background:'none',border:'none',cursor:'pointer',color:'var(--text-secondary)',fontSize:18,padding:'2px 6px',lineHeight:1}}>‹</button>
|
||||
<span style={{flex:1,textAlign:'center',fontSize:14,fontWeight:600}}>{MONTHS[m]} {y}</span>
|
||||
<button onClick={()=>onMonthChange(1)} style={{background:'none',border:'none',cursor:'pointer',color:'var(--text-secondary)',fontSize:18,padding:'2px 6px',lineHeight:1}}>›</button>
|
||||
</div>
|
||||
{/* Filter inputs */}
|
||||
<div style={{padding:'8px 12px 10px',display:'flex',gap:8,alignItems:'center'}}>
|
||||
<div style={{flex:1,position:'relative'}}>
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="var(--text-tertiary)" strokeWidth="2" style={{position:'absolute',left:9,top:'50%',transform:'translateY(-50%)',pointerEvents:'none'}}><circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/></svg>
|
||||
<input
|
||||
value={filterKeyword}
|
||||
onChange={e=>onFilterKeyword(e.target.value)}
|
||||
placeholder="Search events…"
|
||||
style={{width:'100%',padding:'7px 8px 7px 28px',border:'1px solid var(--border)',borderRadius:'var(--radius)',background:'var(--background)',color:'var(--text-primary)',fontSize:13,boxSizing:'border-box'}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<select
|
||||
value={filterTypeId}
|
||||
onChange={e=>onFilterTypeId(e.target.value)}
|
||||
style={{padding:'7px 8px',border:'1px solid var(--border)',borderRadius:'var(--radius)',background:'var(--background)',color:'var(--text-primary)',fontSize:13,flexShrink:0,maxWidth:130}}
|
||||
>
|
||||
<option value="">All types</option>
|
||||
{eventTypes.map(t=><option key={t.id} value={t.id}>{t.name}</option>)}
|
||||
</select>
|
||||
{hasFilters && (
|
||||
<button onClick={()=>{onFilterKeyword('');onFilterTypeId('');}} style={{background:'none',border:'none',cursor:'pointer',color:'var(--text-tertiary)',fontSize:18,padding:'2px 4px',lineHeight:1,flexShrink:0}}>✕</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -610,20 +650,39 @@ 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.
|
||||
function parseKeywords(raw) {
|
||||
const terms = [];
|
||||
const re = /"([^"]+)"|(\S+)/g;
|
||||
let match;
|
||||
while((match = re.exec(raw)) !== null) terms.push((match[1]||match[2]).toLowerCase());
|
||||
return terms;
|
||||
}
|
||||
|
||||
function ScheduleView({ events, selectedDate, onSelect, filterKeyword='', filterTypeId='', isMobile=false }) {
|
||||
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 today=new Date(); today.setHours(0,0,0,0);
|
||||
const terms=parseKeywords(filterKeyword);
|
||||
const hasFilters = terms.length > 0 || !!filterTypeId;
|
||||
// Always show from today forward (desktop and mobile).
|
||||
// Desktop: when no filters, restrict to selected month for browsing context.
|
||||
// Mobile: always from today forward regardless of filters.
|
||||
// With any filter active: always today+future on both platforms.
|
||||
const from = (hasFilters || isMobile) ? today : new Date(y,m,1);
|
||||
const to = (hasFilters || isMobile) ? new Date(9999,11,31) : new Date(y,m+1,0,23,59,59);
|
||||
const filtered=events.filter(e=>{
|
||||
const s=new Date(e.start_at);
|
||||
if(s<monthStart||s>monthEnd) return false;
|
||||
if(s<from||s>to) 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;
|
||||
if(terms.length>0) {
|
||||
const haystack=[e.title||'',e.location||'',e.description||''].join(' ').toLowerCase();
|
||||
if(!terms.some(t=>haystack.includes(t))) return false;
|
||||
}
|
||||
return true;
|
||||
});
|
||||
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>;
|
||||
const emptyMsg = hasFilters ? 'No events match your filters' : isMobile ? 'No upcoming events' : `No events in ${MONTHS[m]} ${y}`;
|
||||
if(!filtered.length) return <div style={{textAlign:'center',padding:'60px 20px',color:'var(--text-tertiary)',fontSize:14}}>{emptyMsg}</div>;
|
||||
return <>{filtered.map(e=>{const s=new Date(e.start_at);const col=e.event_type?.colour||'#9ca3af';
|
||||
// Desktop: original pre-v0.9.64 sizes. Mobile: compact sizes from v0.9.64
|
||||
const rowPad=isMobile?'12px 14px':'14px 20px';
|
||||
@@ -691,14 +750,24 @@ function layoutEvents(evs) {
|
||||
return result;
|
||||
}
|
||||
|
||||
function DayView({ events, selectedDate, onSelect }) {
|
||||
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(
|
||||
<div style={{display:'flex',flexDirection:'column',height:'100%'}}>
|
||||
<div style={{display:'flex',flexDirection:'column',height:'100%'}} onTouchStart={handleTouchStart} onTouchEnd={handleTouchEnd}>
|
||||
<div style={{display:'flex',borderBottom:'1px solid var(--border)',padding:'8px 0 8px 60px',fontSize:13,fontWeight:600,color:'var(--primary)',flexShrink:0}}>
|
||||
<div style={{textAlign:'center'}}><div>{DAYS[selectedDate.getDay()]}</div><div style={{fontSize:28,fontWeight:700}}>{selectedDate.getDate()}</div></div>
|
||||
</div>
|
||||
@@ -740,10 +809,20 @@ function WeekView({ events, selectedDate, onSelect }) {
|
||||
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);
|
||||
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(
|
||||
<div style={{display:'flex',flexDirection:'column',height:'100%'}}>
|
||||
<div style={{display:'flex',flexDirection:'column',height:'100%'}} onTouchStart={handleTouchStart} onTouchEnd={handleTouchEnd}>
|
||||
{/* Day headers */}
|
||||
<div style={{display:'grid',gridTemplateColumns:'60px repeat(7,1fr)',borderBottom:'1px solid var(--border)',background:'var(--surface)',flexShrink:0}}>
|
||||
<div/>
|
||||
@@ -947,10 +1026,10 @@ export default function SchedulePage({ isToolManager, isMobile, onProfile, onHel
|
||||
{/* 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>
|
||||
<div className="section-label" style={{ marginBottom:8 }}>Search (today & future)</div>
|
||||
<input
|
||||
className="input"
|
||||
placeholder="Keyword…"
|
||||
placeholder={`Keyword… (space = OR, "phrase")`}
|
||||
value={filterKeyword}
|
||||
onChange={e=>setFilterKeyword(e.target.value)}
|
||||
style={{ marginBottom:8, fontSize:13 }}
|
||||
@@ -1016,15 +1095,28 @@ export default function SchedulePage({ isToolManager, isMobile, onProfile, onHel
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Mobile date picker */}
|
||||
{isMobile && (
|
||||
<MobileDatePicker selected={selDate} onChange={d=>{setSelDate(d);setPanel('calendar');}} eventDates={eventDates}/>
|
||||
{/* Mobile filter bar — Schedule view: filters + month nav; Day view: calendar accordion */}
|
||||
{isMobile && panel === 'calendar' && (
|
||||
<MobileScheduleFilter
|
||||
selected={selDate}
|
||||
view={view}
|
||||
eventTypes={eventTypes}
|
||||
filterKeyword={filterKeyword}
|
||||
onFilterKeyword={setFilterKeyword}
|
||||
filterTypeId={filterTypeId}
|
||||
onFilterTypeId={setFilterTypeId}
|
||||
eventDates={eventDates}
|
||||
onMonthChange={(dir, exactDate) => {
|
||||
if(exactDate) { setSelDate(exactDate); }
|
||||
else { const d=new Date(selDate); d.setMonth(d.getMonth()+dir); d.setDate(1); setSelDate(d); }
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* 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} filterKeyword={filterKeyword} filterTypeId={filterTypeId} isMobile={isMobile}/>}
|
||||
{panel === 'calendar' && view === 'day' && <DayView events={events} selectedDate={selDate} onSelect={openDetail}/>}
|
||||
{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');}}/>}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user