v0.9.47 schedules redesign

This commit is contained in:
2026-03-17 10:05:51 -04:00
parent fed5e75122
commit 0e7a20e45b
6 changed files with 706 additions and 14 deletions

View File

@@ -1,6 +1,6 @@
{
"name": "jama-frontend",
"version": "0.9.46",
"version": "0.9.47",
"private": true,
"scripts": {
"dev": "vite",

View 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>
);
}

View File

@@ -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} />}