v0.9.47 schedules redesign
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "jama-frontend",
|
||||
"version": "0.9.46",
|
||||
"version": "0.9.47",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
|
||||
681
frontend/src/components/SchedulePage.jsx
Normal file
681
frontend/src/components/SchedulePage.jsx
Normal file
@@ -0,0 +1,681 @@
|
||||
import { useState, useEffect, useCallback, useRef } from 'react';
|
||||
import ReactDOM from 'react-dom';
|
||||
import { api } from '../utils/api.js';
|
||||
import { useToast } from '../contexts/ToastContext.jsx';
|
||||
import { useAuth } from '../contexts/AuthContext.jsx';
|
||||
|
||||
// ── Utilities ─────────────────────────────────────────────────────────────────
|
||||
const DAYS = ['Sun','Mon','Tue','Wed','Thu','Fri','Sat'];
|
||||
const MONTHS = ['January','February','March','April','May','June','July','August','September','October','November','December'];
|
||||
const SHORT_MONTHS= ['Jan','Feb','Mar','Apr','May','Jun','Jul','Aug','Sep','Oct','Nov','Dec'];
|
||||
|
||||
function fmtDate(d) { return `${d.getDate()} ${SHORT_MONTHS[d.getMonth()]} ${d.getFullYear()}`; }
|
||||
function fmtTime(iso) { if (!iso) return ''; const d=new Date(iso); return d.toLocaleTimeString([],{hour:'2-digit',minute:'2-digit'}); }
|
||||
function fmtRange(s,e) { return `${fmtTime(s)} – ${fmtTime(e)}`; }
|
||||
function toDateIn(iso) { return iso ? iso.slice(0,10) : ''; }
|
||||
function toTimeIn(iso) { return iso ? iso.slice(11,16) : ''; }
|
||||
function buildISO(d,t) { return d && t ? `${d}T${t}:00` : ''; }
|
||||
function addHours(iso,h){ const d=new Date(iso); d.setMinutes(d.getMinutes()+h*60); return d.toISOString().slice(0,19); }
|
||||
function sameDay(a,b) { return a.getFullYear()===b.getFullYear()&&a.getMonth()===b.getMonth()&&a.getDate()===b.getDate(); }
|
||||
function weekStart(d) { const r=new Date(d); r.setDate(d.getDate()-d.getDay()); r.setHours(0,0,0,0); return r; }
|
||||
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' };
|
||||
|
||||
// ── Mini Calendar ─────────────────────────────────────────────────────────────
|
||||
function MiniCalendar({ selected, onChange, eventDates=new Set() }) {
|
||||
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), today=new Date();
|
||||
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={{userSelect:'none'}}>
|
||||
<div style={{display:'flex',alignItems:'center',justifyContent:'space-between',marginBottom:8,fontSize:13,fontWeight:600}}>
|
||||
<button style={{background:'none',border:'none',cursor:'pointer',padding:'2px 8px',color:'var(--text-secondary)',fontSize:16}} onClick={()=>{const n=new Date(cur);n.setMonth(m-1);setCur(n);}}>‹</button>
|
||||
<span>{MONTHS[m]} {y}</span>
|
||||
<button style={{background:'none',border:'none',cursor:'pointer',padding:'2px 8px',color:'var(--text-secondary)',fontSize:16}} onClick={()=>{const n=new Date(cur);n.setMonth(m+1);setCur(n);}}>›</button>
|
||||
</div>
|
||||
<div style={{display:'grid',gridTemplateColumns:'repeat(7,1fr)',gap:1,fontSize:11}}>
|
||||
{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)} style={{textAlign:'center',padding:'3px 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:1,left:'50%',transform:'translateX(-50%)',width:4,height:4,borderRadius:'50%',background:'var(--primary)',display:'block'}}/>}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Event Type Popup ──────────────────────────────────────────────────────────
|
||||
function EventTypePopup({ userGroups, onSave, onClose, editing=null }) {
|
||||
const toast=useToast();
|
||||
const DUR=[1,1.5,2,2.5,3,3.5,4,4.5,5];
|
||||
const [name,setName]=useState(editing?.name||'');
|
||||
const [colour,setColour]=useState(editing?.colour||'#6366f1');
|
||||
const [groupId,setGroupId]=useState(editing?.default_user_group_id||'');
|
||||
const [dur,setDur]=useState(editing?.default_duration_hrs||1);
|
||||
const [useDur,setUseDur]=useState(!!(editing?.default_duration_hrs&&editing.default_duration_hrs!==1));
|
||||
const [saving,setSaving]=useState(false);
|
||||
const handle=async()=>{
|
||||
if(!name.trim()) return toast('Name required','error');
|
||||
setSaving(true);
|
||||
try {
|
||||
const body={name:name.trim(),colour,defaultUserGroupId:groupId||null,defaultDurationHrs:useDur?dur:1};
|
||||
const r=editing ? await api.updateEventType(editing.id,body) : await api.createEventType(body);
|
||||
onSave(r.eventType); onClose();
|
||||
} catch(e){toast(e.message,'error');} finally{setSaving(false);}
|
||||
};
|
||||
return (
|
||||
<div style={{position:'absolute',top:'100%',left:0,zIndex:300,background:'var(--surface)',border:'1px solid var(--border)',borderRadius:'var(--radius)',padding:16,width:270,boxShadow:'0 4px 20px rgba(0,0,0,0.2)'}}>
|
||||
<div style={{marginBottom:8}}><label className="settings-section-label">Name</label><input className="input" value={name} onChange={e=>setName(e.target.value)} style={{marginTop:4}} autoFocus/></div>
|
||||
<div style={{marginBottom:8}}><label className="settings-section-label">Colour</label><input type="color" value={colour} onChange={e=>setColour(e.target.value)} style={{marginTop:4,width:'100%',height:32,padding:2,borderRadius:4,border:'1px solid var(--border)'}}/></div>
|
||||
<div style={{marginBottom:8}}><label className="settings-section-label">Default Group</label>
|
||||
<select className="input" value={groupId} onChange={e=>setGroupId(e.target.value)} style={{marginTop:4}}>
|
||||
<option value="">None</option>{userGroups.map(g=><option key={g.id} value={g.id}>{g.name}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
<div style={{marginBottom:12}}>
|
||||
<label style={{display:'flex',alignItems:'center',gap:8,fontSize:13,cursor:'pointer'}}><input type="checkbox" checked={useDur} onChange={e=>setUseDur(e.target.checked)}/> Set default duration</label>
|
||||
{useDur&&<select className="input" value={dur} onChange={e=>setDur(Number(e.target.value))} style={{marginTop:6}}>{DUR.map(d=><option key={d} value={d}>{d}hr{d!==1?'s':''}</option>)}</select>}
|
||||
</div>
|
||||
<div style={{display:'flex',gap:8}}>
|
||||
<button className="btn btn-primary btn-sm" onClick={handle} disabled={saving}>{saving?'…':'Save'}</button>
|
||||
<button className="btn btn-secondary btn-sm" onClick={onClose}>Cancel</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── 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 [groups,setGroups]=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 typeRef=useRef(null);
|
||||
|
||||
useEffect(()=>{
|
||||
if(!typeId||event) return;
|
||||
const typ=localTypes.find(t=>t.id===Number(typeId));
|
||||
if(!typ||!sd||!st) return;
|
||||
const start=buildISO(sd,st);
|
||||
setEd(toDateIn(addHours(start,typ.default_duration_hrs)));
|
||||
setEt(toTimeIn(addHours(start,typ.default_duration_hrs)));
|
||||
if(typ.default_user_group_id) setGroups(prev=>new Set([...prev,typ.default_user_group_id]));
|
||||
},[typeId]);
|
||||
|
||||
const toggleGrp=id=>setGroups(prev=>{const n=new Set(prev);n.has(id)?n.delete(id):n.add(id);return n;});
|
||||
|
||||
const handle=async()=>{
|
||||
if(!title.trim()) return toast('Title required','error');
|
||||
if(!allDay&&(!sd||!st||!ed||!et)) return toast('Start and end required','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:[...groups]};
|
||||
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})=>(
|
||||
<div style={{display:'flex',alignItems:'flex-start',gap:16,marginBottom:14}}>
|
||||
<div style={{width:90,flexShrink:0,fontSize:13,color:'var(--text-tertiary)',paddingTop:8,textAlign:'right'}}>{label}</div>
|
||||
<div style={{flex:1}}>{children}</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<div style={{display:'flex',flexDirection:'column',maxWidth:640}}>
|
||||
<input className="input" placeholder="Add title" value={title} onChange={e=>setTitle(e.target.value)}
|
||||
style={{fontSize:20,fontWeight:700,marginBottom:20,border:'none',borderBottom:'2px solid var(--border)',borderRadius:0,padding:'4px 0',background:'transparent'}}/>
|
||||
|
||||
<Row label="">
|
||||
<div style={{display:'flex',flexWrap:'wrap',gap:8,alignItems:'center'}}>
|
||||
<input type="date" className="input" value={sd} onChange={e=>setSd(e.target.value)} style={{width:150}}/>
|
||||
{!allDay&&<><input type="time" className="input" value={st} onChange={e=>setSt(e.target.value)} style={{width:110}}/><span style={{color:'var(--text-tertiary)',fontSize:13}}>to</span><input type="time" className="input" value={et} onChange={e=>setEt(e.target.value)} style={{width:110}}/></>}
|
||||
<input type="date" className="input" value={ed} onChange={e=>setEd(e.target.value)} style={{width:150}}/>
|
||||
</div>
|
||||
<label style={{display:'flex',alignItems:'center',gap:8,marginTop:8,fontSize:13,cursor:'pointer'}}>
|
||||
<input type="checkbox" checked={allDay} onChange={e=>setAllDay(e.target.checked)}/> All day
|
||||
</label>
|
||||
</Row>
|
||||
|
||||
<Row label="Event Type">
|
||||
<div style={{display:'flex',gap:8,alignItems:'center',position:'relative'}} ref={typeRef}>
|
||||
<select className="input flex-1" value={typeId} onChange={e=>setTypeId(e.target.value)}>
|
||||
<option value="">Default</option>
|
||||
{localTypes.filter(t=>!t.is_default).map(t=><option key={t.id} value={t.id}>{t.name}</option>)}
|
||||
</select>
|
||||
{isToolManager&&<button className="btn btn-secondary btn-sm" 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>
|
||||
|
||||
<Row label="Groups">
|
||||
<div style={{border:'1px solid var(--border)',borderRadius:'var(--radius)',overflow:'hidden',maxHeight:160,overflowY:'auto'}}>
|
||||
{userGroups.length===0?<div style={{padding:'10px 14px',fontSize:13,color:'var(--text-tertiary)'}}>No user groups yet</div>
|
||||
:userGroups.map(g=>(
|
||||
<label key={g.id} style={{display:'flex',alignItems:'center',gap:10,padding:'7px 12px',borderBottom:'1px solid var(--border)',cursor:'pointer',fontSize:13}}>
|
||||
<input type="checkbox" checked={groups.has(g.id)} onChange={()=>toggleGrp(g.id)} style={{accentColor:'var(--primary)'}}/>
|
||||
{g.name}
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
<p style={{fontSize:11,color:'var(--text-tertiary)',marginTop:4}}>{groups.size===0?'No groups — visible to all (if public)':`${groups.size} group${groups.size!==1?'s':''} selected`}</p>
|
||||
</Row>
|
||||
|
||||
<Row label="Options">
|
||||
<div style={{display:'flex',flexDirection:'column',gap:8}}>
|
||||
<label style={{display:'flex',alignItems:'center',gap:10,fontSize:13,cursor:'pointer'}}><input type="checkbox" checked={!pub} onChange={e=>setPub(!e.target.checked)}/> Viewable by selected groups only</label>
|
||||
<label style={{display:'flex',alignItems:'center',gap:10,fontSize:13,cursor:'pointer'}}><input type="checkbox" checked={track} onChange={e=>setTrack(e.target.checked)}/> Track availability for assigned groups</label>
|
||||
</div>
|
||||
</Row>
|
||||
|
||||
<Row label="Location"><input className="input" placeholder="Add location" value={loc} onChange={e=>setLoc(e.target.value)}/></Row>
|
||||
<Row label="Description"><textarea className="input" placeholder="Add description" value={desc} onChange={e=>setDesc(e.target.value)} rows={3} style={{resize:'vertical'}}/></Row>
|
||||
|
||||
<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>
|
||||
<button className="btn btn-secondary btn-sm" onClick={onCancel}>Cancel</button>
|
||||
{event&&isToolManager&&<button className="btn btn-sm" style={{marginLeft:'auto',background:'var(--error)',color:'white'}} onClick={()=>onDelete(event)}>Delete</button>}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Event Detail Modal (portal) ───────────────────────────────────────────────
|
||||
function EventDetailModal({ event, onClose, onEdit, onAvailabilityChange, isToolManager, currentUserId }) {
|
||||
const toast=useToast();
|
||||
const [myResp,setMyResp]=useState(event.my_response);
|
||||
const [avail,setAvail]=useState(event.availability||[]);
|
||||
const counts={going:0,maybe:0,not_going:0};
|
||||
avail.forEach(r=>{if(counts[r.response]!==undefined) counts[r.response]++;});
|
||||
const noRespCount=event.no_response_count||0;
|
||||
|
||||
const handleResp=async resp=>{
|
||||
try {
|
||||
if(myResp===resp){await api.deleteAvailability(event.id);setMyResp(null);}
|
||||
else{await api.setAvailability(event.id,resp);setMyResp(resp);}
|
||||
onAvailabilityChange?.();
|
||||
} catch(e){toast(e.message,'error');}
|
||||
};
|
||||
|
||||
return ReactDOM.createPortal(
|
||||
<div className="modal-overlay" onClick={e=>e.target===e.currentTarget&&onClose()}>
|
||||
<div className="modal" style={{maxWidth:540,maxHeight:'88vh',overflowY:'auto'}}>
|
||||
{/* Header */}
|
||||
<div style={{display:'flex',alignItems:'flex-start',justifyContent:'space-between',marginBottom:16}}>
|
||||
<div style={{flex:1,paddingRight:12}}>
|
||||
<div style={{display:'flex',alignItems:'center',gap:10,marginBottom:4}}>
|
||||
{event.event_type&&<span style={{width:13,height:13,borderRadius:'50%',background:event.event_type.colour,flexShrink:0,display:'inline-block'}}/>}
|
||||
<h2 style={{fontSize:20,fontWeight:700,margin:0}}>{event.title}</h2>
|
||||
</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>}
|
||||
</div>
|
||||
</div>
|
||||
<div style={{display:'flex',gap:6,flexShrink:0}}>
|
||||
{isToolManager&&<button className="btn btn-secondary btn-sm" onClick={()=>{onClose();onEdit();}}>Edit</button>}
|
||||
<button className="btn-icon" onClick={onClose}><svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg></button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Date/time */}
|
||||
<div style={{display:'flex',gap:10,alignItems:'center',marginBottom:12,fontSize:14}}>
|
||||
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="var(--text-tertiary)" strokeWidth="2"><rect x="3" y="4" width="18" height="18" rx="2"/><line x1="16" y1="2" x2="16" y2="6"/><line x1="8" y1="2" x2="8" y2="6"/><line x1="3" y1="10" x2="21" y2="10"/></svg>
|
||||
<span>{fmtDate(new Date(event.start_at))}{!event.all_day&&` · ${fmtRange(event.start_at,event.end_at)}`}</span>
|
||||
</div>
|
||||
|
||||
{event.location&&(
|
||||
<div style={{display:'flex',gap:10,alignItems:'center',marginBottom:12,fontSize:14}}>
|
||||
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="var(--text-tertiary)" strokeWidth="2"><path d="M21 10c0 7-9 13-9 13s-9-6-9-13a9 9 0 0 1 18 0z"/><circle cx="12" cy="10" r="3"/></svg>
|
||||
{event.location}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{event.description&&(
|
||||
<div style={{display:'flex',gap:10,marginBottom:12,fontSize:14}}>
|
||||
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="var(--text-tertiary)" strokeWidth="2" style={{flexShrink:0,marginTop:2}}><line x1="21" y1="10" x2="3" y2="10"/><line x1="21" y1="6" x2="3" y2="6"/><line x1="21" y1="14" x2="3" y2="14"/><line x1="21" y1="18" x2="3" y2="18"/></svg>
|
||||
<span style={{whiteSpace:'pre-wrap'}}>{event.description}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{(event.user_groups||[]).length>0&&(
|
||||
<div style={{display:'flex',gap:10,marginBottom:16,fontSize:13,color:'var(--text-secondary)'}}>
|
||||
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="var(--text-tertiary)" strokeWidth="2" style={{flexShrink:0,marginTop:2}}><path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/><path d="M23 21v-2a4 4 0 0 0-3-3.87"/></svg>
|
||||
{event.user_groups.map(g=>g.name).join(', ')}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Availability section */}
|
||||
{event.track_availability&&(
|
||||
<div style={{borderTop:'1px solid var(--border)',paddingTop:16,marginTop:4}}>
|
||||
<div style={{fontSize:12,fontWeight:700,color:'var(--text-tertiary)',textTransform:'uppercase',letterSpacing:'0.6px',marginBottom:10}}>Your Availability</div>
|
||||
<div style={{display:'flex',gap:8,marginBottom:16}}>
|
||||
{Object.entries(RESP_LABEL).map(([key,label])=>(
|
||||
<button key={key} onClick={()=>handleResp(key)} style={{flex:1,padding:'8px 4px',borderRadius:'var(--radius)',border:`2px solid ${RESP_COLOR[key]}`,background:myResp===key?RESP_COLOR[key]:'transparent',color:myResp===key?'white':RESP_COLOR[key],fontSize:13,fontWeight:600,cursor:'pointer',transition:'all 0.15s'}}>
|
||||
{myResp===key?'✓ ':''}{label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Availability breakdown */}
|
||||
{isToolManager&&(
|
||||
<>
|
||||
<div style={{fontSize:12,fontWeight:700,color:'var(--text-tertiary)',textTransform:'uppercase',letterSpacing:'0.6px',marginBottom:8}}>Responses</div>
|
||||
<div style={{display:'flex',gap:20,marginBottom:10,fontSize:13}}>
|
||||
{Object.entries(counts).map(([key,n])=>(
|
||||
<span key={key}><span style={{color:RESP_COLOR[key],fontWeight:700}}>{n}</span> {RESP_LABEL[key]}</span>
|
||||
))}
|
||||
<span><span style={{fontWeight:700}}>{noRespCount}</span> No response</span>
|
||||
</div>
|
||||
{avail.length>0&&(
|
||||
<div style={{border:'1px solid var(--border)',borderRadius:'var(--radius)',overflow:'hidden'}}>
|
||||
{avail.map(r=>(
|
||||
<div key={r.user_id} style={{display:'flex',alignItems:'center',gap:10,padding:'8px 12px',borderBottom:'1px solid var(--border)',fontSize:13}}>
|
||||
<span style={{width:9,height:9,borderRadius:'50%',background:RESP_COLOR[r.response],flexShrink:0,display:'inline-block'}}/>
|
||||
<span style={{flex:1}}>{r.display_name||r.name}</span>
|
||||
<span style={{color:RESP_COLOR[r.response],fontSize:12,fontWeight:600}}>{RESP_LABEL[r.response]}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>,
|
||||
document.body
|
||||
);
|
||||
}
|
||||
|
||||
// ── Event Types Manager Panel ─────────────────────────────────────────────────
|
||||
function EventTypesPanel({ eventTypes, userGroups, onUpdated }) {
|
||||
const toast=useToast();
|
||||
const [editingType,setEditingType]=useState(null);
|
||||
const [showForm,setShowForm]=useState(false);
|
||||
const handleDel=async et=>{
|
||||
if(!confirm(`Delete "${et.name}"?`)) return;
|
||||
try{await api.deleteEventType(et.id);toast('Deleted','success');onUpdated();}catch(e){toast(e.message,'error');}
|
||||
};
|
||||
return (
|
||||
<div style={{maxWidth:560}}>
|
||||
<div style={{display:'flex',alignItems:'center',justifyContent:'space-between',marginBottom:16}}>
|
||||
<div className="settings-section-label" style={{margin:0}}>Event Types</div>
|
||||
<div style={{position:'relative'}}>
|
||||
<button className="btn btn-primary btn-sm" onClick={()=>{setShowForm(v=>!v);setEditingType(null);}}>+ New Type</button>
|
||||
{showForm&&!editingType&&<EventTypePopup userGroups={userGroups} onSave={()=>onUpdated()} onClose={()=>setShowForm(false)}/>}
|
||||
</div>
|
||||
</div>
|
||||
<div style={{display:'flex',flexDirection:'column',gap:6}}>
|
||||
{eventTypes.map(et=>(
|
||||
<div key={et.id} style={{display:'flex',alignItems:'center',gap:10,padding:'9px 14px',border:'1px solid var(--border)',borderRadius:'var(--radius)'}}>
|
||||
<span style={{width:16,height:16,borderRadius:'50%',background:et.colour,flexShrink:0}}/>
|
||||
<span style={{flex:1,fontSize:14,fontWeight:500}}>{et.name}</span>
|
||||
{et.default_duration_hrs>1&&<span style={{fontSize:12,color:'var(--text-tertiary)'}}>{et.default_duration_hrs}hr default</span>}
|
||||
{!et.is_default?(
|
||||
<div style={{display:'flex',gap:6,position:'relative'}}>
|
||||
<button className="btn btn-secondary btn-sm" onClick={()=>{setEditingType(et);setShowForm(true);}}>Edit</button>
|
||||
{showForm&&editingType?.id===et.id&&<EventTypePopup editing={et} userGroups={userGroups} onSave={()=>{onUpdated();setShowForm(false);setEditingType(null);}} onClose={()=>{setShowForm(false);setEditingType(null);}}/>}
|
||||
<button className="btn btn-sm" style={{background:'var(--error)',color:'white'}} onClick={()=>handleDel(et)}>Delete</button>
|
||||
</div>
|
||||
):<span style={{fontSize:11,color:'var(--text-tertiary)'}}>Default</span>}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Bulk Import Panel ─────────────────────────────────────────────────────────
|
||||
function BulkImportPanel({ onImported, onCancel }) {
|
||||
const toast=useToast();
|
||||
const [rows,setRows]=useState(null);
|
||||
const [skipped,setSkipped]=useState(new Set());
|
||||
const [saving,setSaving]=useState(false);
|
||||
const handleFile=async e=>{
|
||||
const file=e.target.files[0]; if(!file) return;
|
||||
try{const r=await api.importPreview(file);if(r.error)return toast(r.error,'error');setRows(r.rows);setSkipped(new Set(r.rows.filter(r=>r.duplicate||r.error).map(r=>r.row)));}catch{toast('Upload failed','error');}
|
||||
};
|
||||
const handleImport=async()=>{
|
||||
setSaving(true);
|
||||
try{const toImport=rows.filter(r=>!skipped.has(r.row)&&!r.error);const{imported}=await api.importConfirm(toImport);toast(`${imported} event${imported!==1?'s':''} imported`,'success');onImported();}catch(e){toast(e.message,'error');}finally{setSaving(false);}
|
||||
};
|
||||
return (
|
||||
<div style={{maxWidth:800}}>
|
||||
<div className="settings-section-label">Bulk Event Import</div>
|
||||
<p style={{fontSize:12,color:'var(--text-tertiary)',marginBottom:12}}>CSV: <code>Event Title, start_date (YYYY-MM-DD), start_time (HH:MM), event_location, event_type, default_duration</code></p>
|
||||
<input type="file" accept=".csv" onChange={handleFile} style={{marginBottom:16}}/>
|
||||
{rows&&(
|
||||
<>
|
||||
<div style={{overflowX:'auto',marginBottom:12}}>
|
||||
<table style={{width:'100%',borderCollapse:'collapse',fontSize:12}}>
|
||||
<thead><tr style={{borderBottom:'2px solid var(--border)'}}>
|
||||
{['','Row','Title','Start','End','Type','Dur','Status'].map(h=><th key={h} style={{padding:'4px 8px',textAlign:'left',color:'var(--text-tertiary)',whiteSpace:'nowrap'}}>{h}</th>)}
|
||||
</tr></thead>
|
||||
<tbody>{rows.map(r=>(
|
||||
<tr key={r.row} style={{borderBottom:'1px solid var(--border)',opacity:skipped.has(r.row)?0.45:1}}>
|
||||
<td style={{padding:'4px 8px'}}><input type="checkbox" checked={!skipped.has(r.row)} disabled={!!r.error} onChange={()=>setSkipped(p=>{const n=new Set(p);n.has(r.row)?n.delete(r.row):n.add(r.row);return n;})}/></td>
|
||||
<td style={{padding:'4px 8px'}}>{r.row}</td>
|
||||
<td style={{padding:'4px 8px',fontWeight:600}}>{r.title}</td>
|
||||
<td style={{padding:'4px 8px'}}>{r.startAt?.slice(0,16).replace('T',' ')}</td>
|
||||
<td style={{padding:'4px 8px'}}>{r.endAt?.slice(0,16).replace('T',' ')}</td>
|
||||
<td style={{padding:'4px 8px'}}>{r.typeName}</td>
|
||||
<td style={{padding:'4px 8px'}}>{r.durHrs}hr</td>
|
||||
<td style={{padding:'4px 8px'}}>{r.error?<span style={{color:'var(--error)'}}>{r.error}</span>:r.duplicate?<span style={{color:'#f59e0b'}}>⚠ Duplicate</span>:<span style={{color:'var(--success)'}}>✓ Ready</span>}</td>
|
||||
</tr>
|
||||
))}</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div style={{display:'flex',gap:8,alignItems:'center'}}>
|
||||
<button className="btn btn-primary btn-sm" onClick={handleImport} disabled={saving}>{saving?'Importing…':`Import ${rows.filter(r=>!skipped.has(r.row)&&!r.error).length} events`}</button>
|
||||
<button className="btn btn-secondary btn-sm" onClick={onCancel}>Cancel</button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Calendar Views ────────────────────────────────────────────────────────────
|
||||
function ScheduleView({ events, selectedDate, onSelect }) {
|
||||
const filtered=events.filter(e=>new Date(e.end_at)>=(selectedDate||new Date(0)));
|
||||
if(!filtered.length) return <div style={{textAlign:'center',padding:'60px 20px',color:'var(--text-tertiary)',fontSize:14}}>No upcoming events</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&&<span style={{width:8,height:8,borderRadius:'50%',background:'#ef4444',flexShrink:0}} title="Awaiting your response"/>}
|
||||
</div>
|
||||
{e.location&&<div style={{fontSize:12,color:'var(--text-tertiary)',marginTop:2}}>{e.location}</div>}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
function DayView({ events, selectedDate, onSelect }) {
|
||||
const hours=Array.from({length:16},(_,i)=>i+7);
|
||||
const day=events.filter(e=>sameDay(new Date(e.start_at),selectedDate));
|
||||
return (
|
||||
<div>
|
||||
<div style={{display:'flex',borderBottom:'1px solid var(--border)',padding:'8px 0 8px 60px',fontSize:13,fontWeight:600,color:'var(--primary)'}}>
|
||||
<div style={{textAlign:'center'}}><div>{DAYS[selectedDate.getDay()]}</div><div style={{fontSize:28,fontWeight:700}}>{selectedDate.getDate()}</div></div>
|
||||
</div>
|
||||
{hours.map(h=>(
|
||||
<div key={h} style={{display:'flex',borderBottom:'1px solid var(--border)',minHeight:52}}>
|
||||
<div style={{width:60,flexShrink:0,fontSize:11,color:'var(--text-tertiary)',padding:'3px 10px 0',textAlign:'right'}}>
|
||||
{h>12?`${h-12} PM`:h===12?'12 PM':`${h} AM`}
|
||||
</div>
|
||||
<div style={{flex:1,padding:'2px 4px'}}>
|
||||
{day.filter(e=>new Date(e.start_at).getHours()===h).map(e=>(
|
||||
<div key={e.id} onClick={()=>onSelect(e)} style={{margin:'2px 0',padding:'5px 10px',borderRadius:5,background:e.event_type?.colour||'#6366f1',color:'white',fontSize:12,cursor:'pointer',fontWeight:600}}>
|
||||
{e.title} · {fmtRange(e.start_at,e.end_at)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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:16},(_,i)=>i+7), today=new Date();
|
||||
return (
|
||||
<div>
|
||||
<div style={{display:'grid',gridTemplateColumns:'60px repeat(7,1fr)',borderBottom:'1px solid var(--border)'}}>
|
||||
<div/>
|
||||
{days.map((d,i)=><div key={i} style={{textAlign:'center',padding:'6px 4px',fontSize:12,fontWeight:600,color:sameDay(d,today)?'var(--primary)':'var(--text-secondary)'}}>{DAYS[d.getDay()]} {d.getDate()}</div>)}
|
||||
</div>
|
||||
{hours.map(h=>(
|
||||
<div key={h} style={{display:'grid',gridTemplateColumns:'60px repeat(7,1fr)',borderBottom:'1px solid var(--border)',minHeight:46}}>
|
||||
<div style={{fontSize:11,color:'var(--text-tertiary)',padding:'3px 10px 0',textAlign:'right'}}>{h>12?`${h-12} PM`:h===12?'12 PM':`${h} AM`}</div>
|
||||
{days.map((d,i)=>(
|
||||
<div key={i} style={{borderLeft:'1px solid var(--border)',padding:'1px 2px'}}>
|
||||
{events.filter(e=>sameDay(new Date(e.start_at),d)&&new Date(e.start_at).getHours()===h).map(e=>(
|
||||
<div key={e.id} onClick={()=>onSelect(e)} style={{background:e.event_type?.colour||'#6366f1',color:'white',borderRadius:3,padding:'2px 5px',fontSize:11,cursor:'pointer',marginBottom:1,fontWeight:600,overflow:'hidden',whiteSpace:'nowrap',textOverflow:'ellipsis'}}>
|
||||
{e.title}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function MonthView({ events, selectedDate, onSelect, onSelectDay }) {
|
||||
const y=selectedDate.getFullYear(), m=selectedDate.getMonth();
|
||||
const first=new Date(y,m,1).getDay(), total=daysInMonth(y,m), today=new Date();
|
||||
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));
|
||||
return (
|
||||
<div>
|
||||
<div style={{display:'grid',gridTemplateColumns:'repeat(7,1fr)',borderBottom:'1px solid var(--border)'}}>
|
||||
{DAYS.map(d=><div key={d} style={{textAlign:'center',padding:'8px',fontSize:12,fontWeight:600,color:'var(--text-tertiary)'}}>{d}</div>)}
|
||||
</div>
|
||||
{weeks.map((week,wi)=>(
|
||||
<div key={wi} style={{display:'grid',gridTemplateColumns:'repeat(7,1fr)'}}>
|
||||
{week.map((d,di)=>{
|
||||
if(!d) return <div key={di} style={{borderRight:'1px solid var(--border)',borderBottom:'1px solid var(--border)',minHeight:90,background:'var(--surface-variant)'}}/>;
|
||||
const date=new Date(y,m,d), dayEvs=events.filter(e=>sameDay(new Date(e.start_at),date)), isToday=sameDay(date,today);
|
||||
return (
|
||||
<div key={di} onClick={()=>onSelectDay(date)} style={{borderRight:'1px solid var(--border)',borderBottom:'1px solid var(--border)',padding:'4px',minHeight:90,cursor:'pointer'}}
|
||||
onMouseEnter={el=>el.currentTarget.style.background='var(--background)'} onMouseLeave={el=>el.currentTarget.style.background=''}>
|
||||
<div style={{width:26,height:26,borderRadius:'50%',display:'flex',alignItems:'center',justifyContent:'center',marginBottom:3,fontSize:13,fontWeight:isToday?700:400,background:isToday?'var(--primary)':'transparent',color:isToday?'white':'var(--text-primary)'}}>{d}</div>
|
||||
{dayEvs.slice(0,3).map(e=>(
|
||||
<div key={e.id} onClick={ev=>{ev.stopPropagation();onSelect(e);}} style={{background:e.event_type?.colour||'#6366f1',color:'white',borderRadius:3,padding:'1px 5px',fontSize:11,marginBottom:2,cursor:'pointer',whiteSpace:'nowrap',overflow:'hidden',textOverflow:'ellipsis'}}>
|
||||
{!e.all_day&&<span style={{marginRight:3}}>{fmtTime(e.start_at)}</span>}{e.title}
|
||||
</div>
|
||||
))}
|
||||
{dayEvs.length>3&&<div style={{fontSize:10,color:'var(--text-tertiary)'}}>+{dayEvs.length-3} more</div>}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Main Schedule Page ────────────────────────────────────────────────────────
|
||||
export default function SchedulePage({ onBack, isToolManager }) {
|
||||
const { user } = useAuth();
|
||||
const toast = useToast();
|
||||
const [view, setView] = useState('schedule');
|
||||
const [selDate, setSelDate] = useState(new Date());
|
||||
const [events, setEvents] = useState([]);
|
||||
const [eventTypes, setEventTypes] = useState([]);
|
||||
const [userGroups, setUserGroups] = useState([]);
|
||||
const [panel, setPanel] = useState('calendar'); // calendar | eventForm | eventTypes | bulkImport
|
||||
const [editingEvent, setEditingEvent] = useState(null);
|
||||
const [detailEvent, setDetailEvent] = useState(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [createOpen, setCreateOpen] = useState(false);
|
||||
const createRef = useRef(null);
|
||||
|
||||
const load = useCallback(() => {
|
||||
Promise.all([api.getEvents(), api.getEventTypes(), api.getUserGroups()])
|
||||
.then(([ev,et,ug]) => { setEvents(ev.events||[]); setEventTypes(et.eventTypes||[]); setUserGroups(ug.groups||[]); setLoading(false); })
|
||||
.catch(() => setLoading(false));
|
||||
}, []);
|
||||
|
||||
useEffect(() => { load(); }, [load]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!createOpen) return;
|
||||
const h = e => { if (createRef.current && !createRef.current.contains(e.target)) setCreateOpen(false); };
|
||||
document.addEventListener('mousedown', h);
|
||||
return () => document.removeEventListener('mousedown', h);
|
||||
}, [createOpen]);
|
||||
|
||||
const eventDates = new Set(events.map(e => e.start_at?.slice(0,10)));
|
||||
|
||||
const navDate = dir => {
|
||||
const d = new Date(selDate);
|
||||
if (view==='day') d.setDate(d.getDate()+dir);
|
||||
else if (view==='week') d.setDate(d.getDate()+dir*7);
|
||||
else d.setMonth(d.getMonth()+dir);
|
||||
setSelDate(d);
|
||||
};
|
||||
|
||||
const navLabel = () => {
|
||||
if (view==='day') return `${DAYS[selDate.getDay()]} ${selDate.getDate()} ${MONTHS[selDate.getMonth()]} ${selDate.getFullYear()}`;
|
||||
if (view==='week') { const ws=weekStart(selDate),we=new Date(ws); we.setDate(we.getDate()+6); return `${SHORT_MONTHS[ws.getMonth()]} ${ws.getDate()} – ${SHORT_MONTHS[we.getMonth()]} ${we.getDate()} ${we.getFullYear()}`; }
|
||||
return `${MONTHS[selDate.getMonth()]} ${selDate.getFullYear()}`;
|
||||
};
|
||||
|
||||
const openDetail = async e => {
|
||||
try { const { event } = await api.getEvent(e.id); setDetailEvent(event); } catch { toast('Failed to load event','error'); }
|
||||
};
|
||||
|
||||
const handleSaved = () => { load(); setPanel('calendar'); setEditingEvent(null); };
|
||||
const handleDelete = async e => {
|
||||
if (!confirm(`Delete "${e.title}"?`)) return;
|
||||
try { await api.deleteEvent(e.id); toast('Deleted','success'); load(); setDetailEvent(null); } catch(err) { toast(err.message,'error'); }
|
||||
};
|
||||
|
||||
if (loading) return (
|
||||
<div style={{ display:'flex', alignItems:'center', justifyContent:'center', height:'100vh', color:'var(--text-tertiary)', fontSize:14 }}>Loading schedule…</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<div style={{ display:'flex', flexDirection:'column', height:'100vh', background:'var(--background)' }}>
|
||||
{/* Top bar */}
|
||||
<div style={{ display:'flex', alignItems:'center', gap:12, padding:'10px 20px', borderBottom:'1px solid var(--border)', background:'var(--surface)', flexShrink:0, flexWrap:'wrap' }}>
|
||||
{/* Back to Messages */}
|
||||
<button className="btn btn-secondary btn-sm" onClick={onBack} style={{ display:'flex', alignItems:'center', gap:6 }}>
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><polyline points="15 18 9 12 15 6"/></svg>
|
||||
Messages
|
||||
</button>
|
||||
|
||||
{/* Create dropdown */}
|
||||
{isToolManager && (
|
||||
<div style={{ position:'relative' }} ref={createRef}>
|
||||
<button className="btn btn-primary btn-sm" onClick={() => setCreateOpen(v=>!v)} style={{ display:'flex', alignItems:'center', gap:6 }}>
|
||||
+ Create <svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><polyline points="6 9 12 15 18 9"/></svg>
|
||||
</button>
|
||||
{createOpen && (
|
||||
<div style={{ position:'absolute', top:'100%', left:0, zIndex:100, background:'var(--surface)', border:'1px solid var(--border)', borderRadius:'var(--radius)', marginTop:4, minWidth:180, boxShadow:'0 4px 16px rgba(0,0,0,0.12)' }}>
|
||||
{[['Event', ()=>{setPanel('eventForm');setEditingEvent(null);setCreateOpen(false);}],
|
||||
['Event Type', ()=>{setPanel('eventTypes');setCreateOpen(false);}],
|
||||
['Bulk Event Import', ()=>{setPanel('bulkImport');setCreateOpen(false);}]
|
||||
].map(([label,action])=>(
|
||||
<button key={label} onClick={action} style={{ display:'block', width:'100%', padding:'9px 16px', textAlign:'left', fontSize:14, background:'none', border:'none', cursor:'pointer', color:'var(--text-primary)' }}
|
||||
onMouseEnter={e=>e.currentTarget.style.background='var(--background)'} onMouseLeave={e=>e.currentTarget.style.background=''}>{label}</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Navigation */}
|
||||
<button className="btn btn-secondary btn-sm" onClick={() => setSelDate(new Date())}>Today</button>
|
||||
<div style={{ display:'flex', gap:2 }}>
|
||||
<button className="btn-icon" onClick={() => navDate(-1)}>‹</button>
|
||||
<button className="btn-icon" onClick={() => navDate(1)}>›</button>
|
||||
</div>
|
||||
{view !== 'schedule' && <span style={{ fontSize:14, fontWeight:600, color:'var(--text-primary)' }}>{navLabel()}</span>}
|
||||
|
||||
<div style={{ marginLeft:'auto', display:'flex', gap:2, background:'var(--surface-variant)', borderRadius:'var(--radius)', padding:3 }}>
|
||||
{[['schedule','Schedule'],['day','Day'],['week','Week'],['month','Month']].map(([v,l])=>(
|
||||
<button key={v} onClick={()=>{setView(v);setPanel('calendar');}} style={{ padding:'4px 12px', borderRadius:5, border:'none', cursor:'pointer', fontSize:12, fontWeight:600, background:view===v?'var(--surface)':'transparent', color:view===v?'var(--text-primary)':'var(--text-tertiary)', boxShadow:view===v?'0 1px 3px rgba(0,0,0,0.1)':'none', transition:'all 0.15s' }}>{l}</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Body: left mini-cal + right content */}
|
||||
<div style={{ display:'flex', flex:1, overflow:'hidden' }}>
|
||||
{/* Left: mini calendar */}
|
||||
<div style={{ width:220, flexShrink:0, borderRight:'1px solid var(--border)', padding:16, background:'var(--surface)', overflowY:'auto' }}>
|
||||
<MiniCalendar selected={selDate} onChange={d=>{setSelDate(d);setPanel('calendar');}} eventDates={eventDates} />
|
||||
</div>
|
||||
|
||||
{/* Right: calendar view or panel */}
|
||||
<div style={{ flex:1, overflow:'auto', background:'var(--background)' }}>
|
||||
{panel === 'calendar' && view === 'schedule' && <ScheduleView events={events} selectedDate={selDate} onSelect={openDetail}/>}
|
||||
{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');}}/>}
|
||||
|
||||
{panel === 'eventForm' && (
|
||||
<div style={{ padding:28, maxWidth:680 }}>
|
||||
<h2 style={{ fontSize:18, fontWeight:700, marginBottom:24 }}>{editingEvent ? 'Edit Event' : 'New Event'}</h2>
|
||||
<EventForm event={editingEvent} userGroups={userGroups} eventTypes={eventTypes} selectedDate={selDate} isToolManager={isToolManager}
|
||||
onSave={handleSaved} onCancel={()=>{setPanel('calendar');setEditingEvent(null);}} onDelete={handleDelete}/>
|
||||
</div>
|
||||
)}
|
||||
{panel === 'eventTypes' && (
|
||||
<div style={{ padding:28 }}>
|
||||
<div style={{ display:'flex', alignItems:'center', gap:10, marginBottom:24 }}>
|
||||
<h2 style={{ fontSize:18, fontWeight:700, margin:0 }}>Event Types</h2>
|
||||
<button className="btn btn-secondary btn-sm" onClick={()=>setPanel('calendar')}>← Back</button>
|
||||
</div>
|
||||
<EventTypesPanel eventTypes={eventTypes} userGroups={userGroups} onUpdated={load}/>
|
||||
</div>
|
||||
)}
|
||||
{panel === 'bulkImport' && (
|
||||
<div style={{ padding:28 }}>
|
||||
<div style={{ display:'flex', alignItems:'center', gap:10, marginBottom:24 }}>
|
||||
<h2 style={{ fontSize:18, fontWeight:700, margin:0 }}>Bulk Event Import</h2>
|
||||
<button className="btn btn-secondary btn-sm" onClick={()=>setPanel('calendar')}>← Back</button>
|
||||
</div>
|
||||
<BulkImportPanel onImported={()=>{load();setPanel('calendar');}} onCancel={()=>setPanel('calendar')}/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Event detail modal */}
|
||||
{detailEvent && (
|
||||
<EventDetailModal
|
||||
event={detailEvent}
|
||||
isToolManager={isToolManager}
|
||||
currentUserId={user?.id}
|
||||
onClose={() => setDetailEvent(null)}
|
||||
onEdit={() => { setEditingEvent(detailEvent); setPanel('eventForm'); setDetailEvent(null); }}
|
||||
onAvailabilityChange={() => openDetail(detailEvent)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -15,7 +15,7 @@ import AboutModal from '../components/AboutModal.jsx';
|
||||
import HelpModal from '../components/HelpModal.jsx';
|
||||
import NavDrawer from '../components/NavDrawer.jsx';
|
||||
import GroupManagerModal from '../components/GroupManagerModal.jsx';
|
||||
import ScheduleManagerModal from '../components/ScheduleManagerModal.jsx';
|
||||
import SchedulePage from '../components/SchedulePage.jsx';
|
||||
import './Chat.css';
|
||||
|
||||
function urlBase64ToUint8Array(base64String) {
|
||||
@@ -37,7 +37,8 @@ export default function Chat() {
|
||||
const [activeGroupId, setActiveGroupId] = useState(null);
|
||||
const [notifications, setNotifications] = useState([]);
|
||||
const [unreadGroups, setUnreadGroups] = useState(new Map());
|
||||
const [modal, setModal] = useState(null); // 'profile' | 'users' | 'settings' | 'newchat' | 'help' | 'groupmanager' | 'schedulemanager'
|
||||
const [modal, setModal] = useState(null); // 'profile' | 'users' | 'settings' | 'newchat' | 'help' | 'groupmanager'
|
||||
const [page, setPage] = useState('chat'); // 'chat' | 'schedule'
|
||||
const [drawerOpen, setDrawerOpen] = useState(false);
|
||||
const [features, setFeatures] = useState({ branding: false, groupManager: false, scheduleManager: false, appType: 'JAMA-Chat', teamToolManagers: [] });
|
||||
const [helpDismissed, setHelpDismissed] = useState(true); // true until status loaded
|
||||
@@ -328,6 +329,21 @@ export default function Chat() {
|
||||
...(groups.privateGroups || [])
|
||||
].find(g => g.id === activeGroupId);
|
||||
|
||||
const isToolManager = user?.role === 'admin' || (features.teamToolManagers || []).some(gid => (features.userGroupMemberships || []).includes(gid));
|
||||
|
||||
if (page === 'schedule') {
|
||||
return (
|
||||
<>
|
||||
<SchedulePage
|
||||
onBack={() => setPage('chat')}
|
||||
isToolManager={isToolManager}
|
||||
features={features}
|
||||
/>
|
||||
{modal === 'settings' && <SettingsModal onClose={() => setModal(null)} onFeaturesChanged={setFeatures} />}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="chat-layout">
|
||||
{/* Global top bar — spans full width on desktop, visible on mobile sidebar view */}
|
||||
@@ -370,9 +386,9 @@ export default function Chat() {
|
||||
<NavDrawer
|
||||
open={drawerOpen}
|
||||
onClose={() => setDrawerOpen(false)}
|
||||
onMessages={() => { setDrawerOpen(false); }}
|
||||
onGroupManager={() => { setDrawerOpen(false); setModal('groupmanager'); }}
|
||||
onScheduleManager={() => { setDrawerOpen(false); setModal('schedulemanager'); }}
|
||||
onScheduleManager={() => { setDrawerOpen(false); setPage('schedule'); }}
|
||||
onMessages={() => { setDrawerOpen(false); setPage('chat'); }}
|
||||
onBranding={() => { setDrawerOpen(false); setModal('branding'); }}
|
||||
onSettings={() => { setDrawerOpen(false); setModal('settings'); }}
|
||||
onUsers={() => { setDrawerOpen(false); setModal('users'); }}
|
||||
@@ -383,12 +399,7 @@ export default function Chat() {
|
||||
{modal === 'settings' && <SettingsModal onClose={() => setModal(null)} onFeaturesChanged={setFeatures} />}
|
||||
{modal === 'branding' && <BrandingModal onClose={() => setModal(null)} />}
|
||||
{modal === 'groupmanager' && <GroupManagerModal onClose={() => setModal(null)} />}
|
||||
{modal === 'schedulemanager' && (
|
||||
<ScheduleManagerModal
|
||||
onClose={() => setModal(null)}
|
||||
isToolManager={user?.role === 'admin' || (features.teamToolManagers || []).some(gid => (features.userGroupMemberships || []).includes(gid))}
|
||||
/>
|
||||
)}
|
||||
|
||||
{modal === 'newchat' && <NewChatModal onClose={() => setModal(null)} onCreated={(g) => { loadGroups(); setModal(null); setActiveGroupId(g.id); }} />}
|
||||
{modal === 'about' && <AboutModal onClose={() => setModal(null)} />}
|
||||
{modal === 'help' && <HelpModal onClose={() => setModal(null)} dismissed={helpDismissed} />}
|
||||
|
||||
Reference in New Issue
Block a user