diff --git a/.env.example b/.env.example
index a7df3af..41daf30 100644
--- a/.env.example
+++ b/.env.example
@@ -10,7 +10,7 @@
PROJECT_NAME=jama
# Image version to run (set by build.sh, or use 'latest')
-JAMA_VERSION=0.9.47
+JAMA_VERSION=0.9.48
# App port — the host port Docker maps to the container
PORT=3000
diff --git a/backend/package.json b/backend/package.json
index a421eb5..c31af63 100644
--- a/backend/package.json
+++ b/backend/package.json
@@ -1,6 +1,6 @@
{
"name": "jama-backend",
- "version": "0.9.47",
+ "version": "0.9.48",
"description": "TeamChat backend server",
"main": "src/index.js",
"scripts": {
diff --git a/backend/src/models/db.js b/backend/src/models/db.js
index 8d10775..6b804ff 100644
--- a/backend/src/models/db.js
+++ b/backend/src/models/db.js
@@ -389,8 +389,9 @@ function initDb() {
name TEXT NOT NULL UNIQUE,
colour TEXT NOT NULL DEFAULT '#6366f1',
default_user_group_id INTEGER,
- default_duration_hrs REAL NOT NULL DEFAULT 1.0,
+ default_duration_hrs REAL,
is_default INTEGER NOT NULL DEFAULT 0,
+ is_protected INTEGER NOT NULL DEFAULT 0,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
FOREIGN KEY (default_user_group_id) REFERENCES user_groups(id) ON DELETE SET NULL
);
@@ -428,7 +429,15 @@ function initDb() {
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
);
`);
- db.prepare("INSERT OR IGNORE INTO event_types (name, colour, is_default) VALUES ('Default', '#9ca3af', 1)").run();
+ db.prepare("INSERT OR IGNORE INTO event_types (name, colour, is_default, is_protected) VALUES ('Default', '#9ca3af', 1, 1)").run();
+ db.prepare("INSERT OR IGNORE INTO event_types (name, colour, is_protected, default_duration_hrs) VALUES ('Event', '#6366f1', 1, NULL)").run();
+ db.prepare("INSERT OR IGNORE INTO event_types (name, colour, default_duration_hrs) VALUES ('Game', '#22c55e', 3.0)").run();
+ db.prepare("INSERT OR IGNORE INTO event_types (name, colour, default_duration_hrs) VALUES ('Practice', '#f59e0b', 1.0)").run();
+ // Migration: add is_protected if missing
+ try { db.exec("ALTER TABLE event_types ADD COLUMN is_protected INTEGER NOT NULL DEFAULT 0"); } catch(e) {}
+ try { db.exec("ALTER TABLE event_types ADD COLUMN default_duration_hrs REAL"); } catch(e) {}
+ // Ensure built-in types are protected
+ db.prepare("UPDATE event_types SET is_protected = 1 WHERE name IN ('Default', 'Event')").run();
console.log('[DB] Schedule Manager tables ready');
} catch (e) { console.error('[DB] Schedule Manager migration error:', e.message); }
diff --git a/backend/src/routes/schedule.js b/backend/src/routes/schedule.js
index 13cf34f..6803206 100644
--- a/backend/src/routes/schedule.js
+++ b/backend/src/routes/schedule.js
@@ -67,7 +67,7 @@ router.patch('/event-types/:id', authMiddleware, teamManagerMiddleware, (req, re
const db = getDb();
const et = db.prepare('SELECT * FROM event_types WHERE id = ?').get(req.params.id);
if (!et) return res.status(404).json({ error: 'Not found' });
- if (et.is_default) return res.status(403).json({ error: 'Cannot edit the Default event type' });
+ if (et.is_protected) return res.status(403).json({ error: 'Cannot edit a protected event type' });
const { name, colour, defaultUserGroupId, defaultDurationHrs } = req.body;
if (name && name.trim() !== et.name) {
if (db.prepare('SELECT id FROM event_types WHERE LOWER(name) = LOWER(?) AND id != ?').get(name.trim(), et.id))
@@ -86,7 +86,7 @@ router.delete('/event-types/:id', authMiddleware, teamManagerMiddleware, (req, r
const db = getDb();
const et = db.prepare('SELECT * FROM event_types WHERE id = ?').get(req.params.id);
if (!et) return res.status(404).json({ error: 'Not found' });
- if (et.is_default) return res.status(403).json({ error: 'Cannot delete the Default event type' });
+ if (et.is_default || et.is_protected) return res.status(403).json({ error: 'Cannot delete a protected event type' });
// Null out event_type_id on events using this type
db.prepare('UPDATE events SET event_type_id = NULL WHERE event_type_id = ?').run(et.id);
db.prepare('DELETE FROM event_types WHERE id = ?').run(et.id);
diff --git a/build.sh b/build.sh
index 100185e..efc80b7 100644
--- a/build.sh
+++ b/build.sh
@@ -13,7 +13,7 @@
# ─────────────────────────────────────────────────────────────
set -euo pipefail
-VERSION="${1:-0.9.47}"
+VERSION="${1:-0.9.48}"
ACTION="${2:-}"
REGISTRY="${REGISTRY:-}"
IMAGE_NAME="jama"
diff --git a/frontend/package.json b/frontend/package.json
index 3f5a9a1..c8c13c6 100644
--- a/frontend/package.json
+++ b/frontend/package.json
@@ -1,6 +1,6 @@
{
"name": "jama-frontend",
- "version": "0.9.47",
+ "version": "0.9.48",
"private": true,
"scripts": {
"dev": "vite",
diff --git a/frontend/src/components/NavDrawer.css b/frontend/src/components/NavDrawer.css
index da7673b..9434e9b 100644
--- a/frontend/src/components/NavDrawer.css
+++ b/frontend/src/components/NavDrawer.css
@@ -77,3 +77,9 @@
border-radius: 20px;
padding: 2px 7px;
}
+
+.nav-drawer-item.active {
+ background: var(--primary-light);
+ color: var(--primary);
+}
+.nav-drawer-item.active:hover { background: var(--primary-light); }
diff --git a/frontend/src/components/NavDrawer.jsx b/frontend/src/components/NavDrawer.jsx
index 46f2599..2a81dd9 100644
--- a/frontend/src/components/NavDrawer.jsx
+++ b/frontend/src/components/NavDrawer.jsx
@@ -3,76 +3,68 @@ import { useAuth } from '../contexts/AuthContext.jsx';
import './NavDrawer.css';
const NAV_ICON = {
- messages: ,
+ messages: ,
schedules: ,
- users: ,
- groups: ,
- branding: ,
- settings: ,
+ users: ,
+ groups: ,
+ branding: ,
+ settings: ,
};
-export default function NavDrawer({ open, onClose, onMessages, onGroupManager, onScheduleManager, onBranding, onSettings, onUsers, features = {} }) {
+export default function NavDrawer({ open, onClose, onMessages, onSchedule, onScheduleManager, onBranding, onSettings, onUsers, onGroupManager, features = {}, currentPage = 'chat', isMobile = false }) {
const { user } = useAuth();
const drawerRef = useRef(null);
const isAdmin = user?.role === 'admin';
- const isMobile = window.matchMedia('(pointer: coarse)').matches || window.innerWidth < 768;
-
- // Tool Manager access: admin always passes; non-admins pass if in a designated tool manager group
const userGroupIds = features.userGroupMemberships || [];
const canAccessTools = isAdmin || (features.teamToolManagers || []).some(gid => userGroupIds.includes(gid));
- // Close on outside click
useEffect(() => {
if (!open) return;
- const handler = (e) => {
- if (drawerRef.current && !drawerRef.current.contains(e.target)) onClose();
- };
- document.addEventListener('mousedown', handler);
- return () => document.removeEventListener('mousedown', handler);
+ const h = e => { if (drawerRef.current && !drawerRef.current.contains(e.target)) onClose(); };
+ document.addEventListener('mousedown', h);
+ return () => document.removeEventListener('mousedown', h);
}, [open, onClose]);
- // Close on Escape
useEffect(() => {
if (!open) return;
- const handler = (e) => { if (e.key === 'Escape') onClose(); };
- window.addEventListener('keydown', handler);
- return () => window.removeEventListener('keydown', handler);
+ const h = e => { if (e.key === 'Escape') onClose(); };
+ window.addEventListener('keydown', h);
+ return () => window.removeEventListener('keydown', h);
}, [open, onClose]);
- const item = (icon, label, onClick, disabled = false) => (
- { onClose(); onClick(); }}
- disabled={disabled}
- >
- {icon}
- {label}
- {disabled && Coming soon }
-
- );
+ const item = (icon, label, onClick, opts = {}) => {
+ const { active, disabled, badge } = opts;
+ return (
+ { onClose(); onClick(); }}
+ disabled={disabled}
+ >
+ {icon}
+ {label}
+ {badge && {badge} }
+
+ );
+ };
return (
<>
- {/* Backdrop */}
- {/* Drawer */}
+
+ {/* Close X */}
- {item(NAV_ICON.messages, 'Messages', onMessages)}
- {item(NAV_ICON.schedules, 'Schedules', () => {}, true)}
- {/* Admin-only: Branding + Settings */}
+ {/* User section */}
+ {item(NAV_ICON.messages, 'Messages', onMessages, { active: currentPage === 'chat' })}
+ {item(NAV_ICON.schedules, 'Schedules', onSchedule, { active: currentPage === 'schedule' })}
+
+ {/* Admin section */}
{isAdmin && (
<>
Admin
@@ -81,13 +73,18 @@ export default function NavDrawer({ open, onClose, onMessages, onGroupManager, o
>
)}
- {/* Tools: accessible to admins OR designated tool manager groups */}
+ {/* Tools section */}
{canAccessTools && (
<>
Tools
{item(NAV_ICON.users, 'User Manager', onUsers)}
- {features.groupManager && !isMobile && item(NAV_ICON.groups, 'Group Manager', onGroupManager)}
- {features.scheduleManager && !isMobile && item(NAV_ICON.schedules, 'Schedule Manager', onScheduleManager || (() => {}))}
+ {features.groupManager && !isMobile && item(NAV_ICON.groups, 'Group Manager', onGroupManager)}
+ {features.scheduleManager && item(
+ NAV_ICON.schedules,
+ 'Schedule Manager',
+ isMobile ? () => {} : onScheduleManager,
+ { disabled: isMobile, badge: isMobile ? 'Desktop only' : undefined }
+ )}
>
)}
diff --git a/frontend/src/components/SchedulePage.jsx b/frontend/src/components/SchedulePage.jsx
index 3c1841f..2239434 100644
--- a/frontend/src/components/SchedulePage.jsx
+++ b/frontend/src/components/SchedulePage.jsx
@@ -9,21 +9,32 @@ 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 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) { if(!iso) return ''; const d=new Date(iso); const h=String(d.getHours()).padStart(2,'0'), m=d.getMinutes()<30?'00':'30'; return `${h}:${m}`; }
+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 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' };
+const RESP_LABEL = { going:'Going', maybe:'Maybe', not_going:'Not Going' };
+const RESP_COLOR = { going:'#22c55e', maybe:'#f59e0b', not_going:'#ef4444' };
-// ── Mini Calendar ─────────────────────────────────────────────────────────────
+// 30-minute time slots
+const TIME_SLOTS = (() => {
+ const s=[];
+ for(let h=0;h<24;h++) for(let m of [0,30]) {
+ const hh=String(h).padStart(2,'0'), mm=String(m).padStart(2,'0');
+ const disp=`${h===0?12:h>12?h-12:h}:${mm} ${h<12?'AM':'PM'}`;
+ s.push({value:`${hh}:${mm}`,label:disp});
+ }
+ return s;
+})();
+
+// ── Mini Calendar (desktop) ───────────────────────────────────────────────────
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();
@@ -53,6 +64,45 @@ function MiniCalendar({ selected, onChange, eventDates=new Set() }) {
);
}
+// ── Mobile Date Picker (accordion month view) ─────────────────────────────────
+function MobileDatePicker({ selected, onChange, eventDates=new Set() }) {
+ 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
+ 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)'}}>
+ {MONTHS[m]} {y}
+ ▼
+
+ {open && (
+
+
+ {const n=new Date(cur);n.setMonth(m-1);setCur(n);}}>‹
+ {const n=new Date(cur);n.setMonth(m+1);setCur(n);}}>›
+
+
+ {DAYS.map(d=>
{d[0]}
)}
+ {cells.map((d,i)=>{
+ if(!d) return
;
+ 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 (
+
{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&&}
+
+ );
+ })}
+
+
+ )}
+
+ );
+}
+
// ── Event Type Popup ──────────────────────────────────────────────────────────
function EventTypePopup({ userGroups, onSave, onClose, editing=null }) {
const toast=useToast();
@@ -61,34 +111,23 @@ function EventTypePopup({ userGroups, onSave, onClose, editing=null }) {
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 [useDur,setUseDur]=useState(!!(editing?.default_duration_hrs));
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);}
+ try{const body={name:name.trim(),colour,defaultUserGroupId:groupId||null,defaultDurationHrs:useDur?dur:null};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 (
Name setName(e.target.value)} style={{marginTop:4}} autoFocus/>
Colour setColour(e.target.value)} style={{marginTop:4,width:'100%',height:32,padding:2,borderRadius:4,border:'1px solid var(--border)'}}/>
-
Default Group
- setGroupId(e.target.value)} style={{marginTop:4}}>
- None {userGroups.map(g=>{g.name} )}
-
-
+
Default Group setGroupId(e.target.value)} style={{marginTop:4}}>None {userGroups.map(g=>{g.name} )}
setUseDur(e.target.checked)}/> Set default duration
{useDur&&setDur(Number(e.target.value))} style={{marginTop:6}}>{DUR.map(d=>{d}hr{d!==1?'s':''} )} }
-
- {saving?'…':'Save'}
- Cancel
-
+
{saving?'…':'Save'} Cancel
);
}
@@ -108,122 +147,167 @@ function EventForm({ event, userGroups, eventTypes, selectedDate, onSave, onCanc
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 [grps,setGrps]=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);
+ // Auto end time when type changes (only for new events)
useEffect(()=>{
if(!typeId||event) return;
const typ=localTypes.find(t=>t.id===Number(typeId));
- if(!typ||!sd||!st) return;
+ if(!typ?.default_duration_hrs||!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]));
+ if(typ.default_user_group_id) setGrps(prev=>new Set([...prev,Number(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;});
+ // Auto-match end date to start date when start date changes
+ useEffect(()=>{
+ if(!event) setEd(sd);
+ },[sd]);
+
+ const toggleGrp=id=>setGrps(prev=>{const n=new Set(prev);n.has(id)?n.delete(id):n.add(id);return n;});
+ const groupsRequired=track; // when tracking, groups are required
const handle=async()=>{
if(!title.trim()) return toast('Title required','error');
if(!allDay&&(!sd||!st||!ed||!et)) return toast('Start and end required','error');
+ if(groupsRequired&&grps.size===0) return toast('Select at least one group for availability tracking','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);}
+ 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:[...grps]};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})=>(
-
-
{label}
-
{children}
+ const Row=({label,children,required})=>(
+
+
+ {label}{required&& * }
+
+
{children}
);
return (
-
-
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'}}/>
-
-
-
-
setSd(e.target.value)} style={{width:150}}/>
- {!allDay&&<>
setSt(e.target.value)} style={{width:110}}/>
to setEt(e.target.value)} style={{width:110}}/>>}
-
setEd(e.target.value)} style={{width:150}}/>
+
+
+ {/* Title */}
+
+ setTitle(e.target.value)}
+ style={{fontSize:20,fontWeight:700,border:'none',borderBottom:'2px solid var(--border)',borderRadius:0,padding:'4px 0',background:'transparent',width:'100%'}}/>
-
- setAllDay(e.target.checked)}/> All day
-
-
-
-
);
}
-// ── Event Detail Modal (portal) ───────────────────────────────────────────────
-function EventDetailModal({ event, onClose, onEdit, onAvailabilityChange, isToolManager, currentUserId }) {
+// ── Event Detail Modal ────────────────────────────────────────────────────────
+function EventDetailModal({ event, onClose, onEdit, onAvailabilityChange, isToolManager }) {
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;
+ avail.forEach(r=>{if(counts[r.response]!==undefined)counts[r.response]++;});
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');}
+ 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(
e.target===e.currentTarget&&onClose()}>
-
- {/* Header */}
+
@@ -241,54 +325,30 @@ function EventDetailModal({ event, onClose, onEdit, onAvailabilityChange, isTool
- {/* Date/time */}
{fmtDate(new Date(event.start_at))}{!event.all_day&&` · ${fmtRange(event.start_at,event.end_at)}`}
+ {event.location&&
}
+ {event.description&&
{event.description}
}
+ {(event.user_groups||[]).length>0&&
{event.user_groups.map(g=>g.name).join(', ')}
}
- {event.location&&(
-
- )}
-
- {event.description&&(
-
-
- {event.description}
-
- )}
-
- {(event.user_groups||[]).length>0&&(
-
-
- {event.user_groups.map(g=>g.name).join(', ')}
-
- )}
-
- {/* Availability section */}
{event.track_availability&&(
Your Availability
{Object.entries(RESP_LABEL).map(([key,label])=>(
- 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'}}>
+ handleResp(key)} style={{flex:1,padding:'9px 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}
))}
-
- {/* Availability breakdown */}
{isToolManager&&(
<>
Responses
- {Object.entries(counts).map(([key,n])=>(
- {n} {RESP_LABEL[key]}
- ))}
- {noRespCount} No response
+ {Object.entries(counts).map(([k,n])=>{n} {RESP_LABEL[k]} )}
+ {event.no_response_count||0} No response
{avail.length>0&&(
@@ -311,7 +371,7 @@ function EventDetailModal({ event, onClose, onEdit, onAvailabilityChange, isTool
);
}
-// ── Event Types Manager Panel ─────────────────────────────────────────────────
+// ── Event Types Panel ─────────────────────────────────────────────────────────
function EventTypesPanel({ eventTypes, userGroups, onUpdated }) {
const toast=useToast();
const [editingType,setEditingType]=useState(null);
@@ -324,24 +384,21 @@ function EventTypesPanel({ eventTypes, userGroups, onUpdated }) {
Event Types
-
- {setShowForm(v=>!v);setEditingType(null);}}>+ New Type
- {showForm&&!editingType&&onUpdated()} onClose={()=>setShowForm(false)}/>}
-
+
{setShowForm(v=>!v);setEditingType(null);}}>+ New Type {showForm&&!editingType&&onUpdated()} onClose={()=>setShowForm(false)}/>}
{eventTypes.map(et=>(
{et.name}
- {et.default_duration_hrs>1&&{et.default_duration_hrs}hr default }
- {!et.is_default?(
+ {et.default_duration_hrs&&{et.default_duration_hrs}hr default }
+ {!et.is_protected?(
{setEditingType(et);setShowForm(true);}}>Edit
{showForm&&editingType?.id===et.id&&{onUpdated();setShowForm(false);setEditingType(null);}} onClose={()=>{setShowForm(false);setEditingType(null);}}/>}
handleDel(et)}>Delete
- ):Default }
+ ):{et.is_default?'Default':'Protected'} }
))}
@@ -355,46 +412,14 @@ function BulkImportPanel({ onImported, onCancel }) {
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);}
- };
+ 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 (
Bulk Event Import
CSV: Event Title, start_date (YYYY-MM-DD), start_time (HH:MM), event_location, event_type, default_duration
- {rows&&(
- <>
-
-
-
- {['','Row','Title','Start','End','Type','Dur','Status'].map(h=>{h} )}
-
- {rows.map(r=>(
-
- setSkipped(p=>{const n=new Set(p);n.has(r.row)?n.delete(r.row):n.add(r.row);return n;})}/>
- {r.row}
- {r.title}
- {r.startAt?.slice(0,16).replace('T',' ')}
- {r.endAt?.slice(0,16).replace('T',' ')}
- {r.typeName}
- {r.durHrs}hr
- {r.error?{r.error} :r.duplicate?⚠ Duplicate :✓ Ready }
-
- ))}
-
-
-
- {saving?'Importing…':`Import ${rows.filter(r=>!skipped.has(r.row)&&!r.error).length} events`}
- Cancel
-
- >
- )}
+ {rows&&(<>
{['','Row','Title','Start','End','Type','Dur','Status'].map(h=>{h} )} {rows.map(r=>( setSkipped(p=>{const n=new Set(p);n.has(r.row)?n.delete(r.row):n.add(r.row);return n;})}/>{r.row} {r.title} {r.startAt?.slice(0,16).replace('T',' ')} {r.endAt?.slice(0,16).replace('T',' ')} {r.typeName} {r.durHrs}hr {r.error?{r.error} :r.duplicate?⚠ Duplicate :✓ Ready } ))}
{saving?'Importing…':`Import ${rows.filter(r=>!skipped.has(r.row)&&!r.error).length} events`} Cancel
>)}
);
}
@@ -402,131 +427,43 @@ function BulkImportPanel({ onImported, onCancel }) {
// ── 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
No upcoming events
;
- return filtered.map(e=>{
- const s=new Date(e.start_at); const col=e.event_type?.colour||'#9ca3af';
- return (
-
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=''}>
-
-
{s.getDate()}
-
{SHORT_MONTHS[s.getMonth()]}, {DAYS[s.getDay()]}
-
-
-
- {e.all_day?'All day':fmtRange(e.start_at,e.end_at)}
-
-
-
- {e.event_type?.name&&{e.event_type.name}: }
- {e.title}
- {e.track_availability&&!e.my_response&& }
-
- {e.location&&
{e.location}
}
-
-
- );
- });
+ if(!filtered.length) return
No upcoming events from selected date
;
+ return <>{filtered.map(e=>{const s=new Date(e.start_at);const col=e.event_type?.colour||'#9ca3af';return(
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=''}>
{s.getDate()}
{SHORT_MONTHS[s.getMonth()]}, {DAYS[s.getDay()]}
{e.all_day?'All day':fmtRange(e.start_at,e.end_at)}
{e.event_type?.name&&{e.event_type.name}: }{e.title}{e.track_availability&&!e.my_response&& }
{e.location&&
{e.location}
}
);})}>;
}
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 (
-
-
-
{DAYS[selectedDate.getDay()]}
{selectedDate.getDate()}
-
- {hours.map(h=>(
-
-
- {h>12?`${h-12} PM`:h===12?'12 PM':`${h} AM`}
-
-
- {day.filter(e=>new Date(e.start_at).getHours()===h).map(e=>(
-
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)}
-
- ))}
-
-
- ))}
-
- );
+ return(
{DAYS[selectedDate.getDay()]}
{selectedDate.getDate()}
{hours.map(h=>(
{h>12?`${h-12} PM`:h===12?'12 PM':`${h} AM`}
{day.filter(e=>new Date(e.start_at).getHours()===h).map(e=>(
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)}
))}
))}
);
}
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 (
-
-
-
- {days.map((d,i)=>
{DAYS[d.getDay()]} {d.getDate()}
)}
-
- {hours.map(h=>(
-
-
{h>12?`${h-12} PM`:h===12?'12 PM':`${h} AM`}
- {days.map((d,i)=>(
-
- {events.filter(e=>sameDay(new Date(e.start_at),d)&&new Date(e.start_at).getHours()===h).map(e=>(
-
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}
-
- ))}
-
- ))}
-
- ))}
-
- );
+ return(
{days.map((d,i)=>
{DAYS[d.getDay()]} {d.getDate()}
)}
{hours.map(h=>(
{h>12?`${h-12} PM`:h===12?'12 PM':`${h} AM`}
{days.map((d,i)=>(
{events.filter(e=>sameDay(new Date(e.start_at),d)&&new Date(e.start_at).getHours()===h).map(e=>(
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}
))}
))}
))}
);
}
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 y=selectedDate.getFullYear(), m=selectedDate.getMonth(), first=new Date(y,m,1).getDay(), total=daysInMonth(y,m), today=new Date();
const cells=[]; for(let i=0;i
-
- {weeks.map((week,wi)=>(
-
- {week.map((d,di)=>{
- if(!d) return
;
- const date=new Date(y,m,d), dayEvs=events.filter(e=>sameDay(new Date(e.start_at),date)), isToday=sameDay(date,today);
- return (
-
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=''}>
-
{d}
- {dayEvs.slice(0,3).map(e=>(
-
{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&&{fmtTime(e.start_at)} }{e.title}
-
- ))}
- {dayEvs.length>3&&
+{dayEvs.length-3} more
}
-
- );
- })}
-
- ))}
-
- );
+ return(
{weeks.map((week,wi)=>(
{week.map((d,di)=>{if(!d)return
;const date=new Date(y,m,d),dayEvs=events.filter(e=>sameDay(new Date(e.start_at),date)),isToday=sameDay(date,today);return(
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=''}>
{d}
{dayEvs.slice(0,3).map(e=>(
{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&&{fmtTime(e.start_at)} }{e.title}
))}{dayEvs.length>3&&
+{dayEvs.length-3} more
}
);})}
))}
);
}
// ── Main Schedule Page ────────────────────────────────────────────────────────
-export default function SchedulePage({ onBack, isToolManager }) {
+export default function SchedulePage({ isToolManager, isMobile }) {
const { user } = useAuth();
const toast = useToast();
+
+ // Mobile: only day + schedule views
+ const allowedViews = isMobile ? ['schedule','day'] : ['schedule','day','week','month'];
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 [panel, setPanel] = useState('calendar');
const [editingEvent, setEditingEvent] = useState(null);
const [detailEvent, setDetailEvent] = useState(null);
const [loading, setLoading] = useState(true);
@@ -574,89 +511,117 @@ export default function SchedulePage({ onBack, isToolManager }) {
try { await api.deleteEvent(e.id); toast('Deleted','success'); load(); setDetailEvent(null); } catch(err) { toast(err.message,'error'); }
};
- if (loading) return (
-
Loading schedule…
- );
+ if (loading) return
Loading schedule…
;
+
+ // ── Sidebar width matches Messages (~280px) ───────────────────────────────
+ const SIDEBAR_W = isMobile ? 0 : 260;
return (
-
- {/* Top bar */}
-
- {/* Back to Messages */}
-
-
- Messages
-
+
+ {/* Left panel — matches sidebar width */}
+ {!isMobile && (
+
+
+
Team Schedule
- {/* Create dropdown */}
- {isToolManager && (
-
-
setCreateOpen(v=>!v)} style={{ display:'flex', alignItems:'center', gap:6 }}>
- + Create
-
- {createOpen && (
-
- {[['Event', ()=>{setPanel('eventForm');setEditingEvent(null);setCreateOpen(false);}],
- ['Event Type', ()=>{setPanel('eventTypes');setCreateOpen(false);}],
- ['Bulk Event Import', ()=>{setPanel('bulkImport');setCreateOpen(false);}]
- ].map(([label,action])=>(
-
e.currentTarget.style.background='var(--background)'} onMouseLeave={e=>e.currentTarget.style.background=''}>{label}
- ))}
+ {/* Create button — styled like new-chat-btn */}
+ {isToolManager && (
+
+
setCreateOpen(v=>!v)} style={{ width:'100%', justifyContent:'center', gap:8 }}>
+
+
+
+ Create Event
+
+
+ {createOpen && (
+
+ {[['Event', ()=>{setPanel('eventForm');setEditingEvent(null);setCreateOpen(false);}],
+ ['Event Type', ()=>{setPanel('eventTypes');setCreateOpen(false);}],
+ ['Bulk Event Import', ()=>{setPanel('bulkImport');setCreateOpen(false);}]
+ ].map(([label,action])=>(
+ e.currentTarget.style.background='var(--background)'} onMouseLeave={e=>e.currentTarget.style.background=''}>{label}
+ ))}
+
+ )}
)}
+
+ {/* Mini calendar */}
+
+ {setSelDate(d);setPanel('calendar');}} eventDates={eventDates}/>
+
+
+ )}
+
+ {/* Right panel */}
+
+ {/* View toolbar */}
+
+ {/* Mobile title + create */}
+ {isMobile && (
+
Team Schedule
+ )}
+
+ {!isMobile && (
+ <>
+
setSelDate(new Date())}>Today
+
+ navDate(-1)} style={{ fontSize:16, padding:'2px 8px' }}>‹
+ navDate(1)} style={{ fontSize:16, padding:'2px 8px' }}>›
+
+ {view !== 'schedule' &&
{navLabel()} }
+
+ >
+ )}
+
+ {/* View switcher */}
+
+ {allowedViews.map(v => {
+ const labels = { schedule:'Schedule', day:'Day', week:'Week', month:'Month' };
+ return (
+ {setView(v);setPanel('calendar');}} style={{ padding:'4px 10px', 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', whiteSpace:'nowrap' }}>
+ {labels[v]}
+
+ );
+ })}
+
+
+
+ {/* Mobile date picker */}
+ {isMobile && (
+
{setSelDate(d);setPanel('calendar');}} eventDates={eventDates}/>
)}
- {/* Navigation */}
- setSelDate(new Date())}>Today
-
- navDate(-1)}>‹
- navDate(1)}>›
-
- {view !== 'schedule' && {navLabel()} }
-
-
- {[['schedule','Schedule'],['day','Day'],['week','Week'],['month','Month']].map(([v,l])=>(
- {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}
- ))}
-
-
-
- {/* Body: left mini-cal + right content */}
-
- {/* Left: mini calendar */}
-
- {setSelDate(d);setPanel('calendar');}} eventDates={eventDates} />
-
-
- {/* Right: calendar view or panel */}
-
+ {/* Calendar or panel content */}
+
{panel === 'calendar' && view === 'schedule' &&
}
{panel === 'calendar' && view === 'day' && }
{panel === 'calendar' && view === 'week' && }
{panel === 'calendar' && view === 'month' && {setSelDate(d);setView('schedule');}}/>}
- {panel === 'eventForm' && (
-
-
{editingEvent ? 'Edit Event' : 'New Event'}
+ {panel === 'eventForm' && isToolManager && (
+
+
{editingEvent?'Edit Event':'New Event'}
{setPanel('calendar');setEditingEvent(null);}} onDelete={handleDelete}/>
)}
- {panel === 'eventTypes' && (
+ {panel === 'eventTypes' && isToolManager && (
-
Event Types
+ Event Types
setPanel('calendar')}>← Back
)}
- {panel === 'bulkImport' && (
+ {panel === 'bulkImport' && isToolManager && (
-
Bulk Event Import
+ Bulk Event Import
setPanel('calendar')}>← Back
{load();setPanel('calendar');}} onCancel={()=>setPanel('calendar')}/>
@@ -670,7 +635,6 @@ export default function SchedulePage({ onBack, isToolManager }) {
setDetailEvent(null)}
onEdit={() => { setEditingEvent(detailEvent); setPanel('eventForm'); setDetailEvent(null); }}
onAvailabilityChange={() => openDetail(detailEvent)}
diff --git a/frontend/src/pages/Chat.jsx b/frontend/src/pages/Chat.jsx
index 5be48fe..70cf2b2 100644
--- a/frontend/src/pages/Chat.jsx
+++ b/frontend/src/pages/Chat.jsx
@@ -333,14 +333,32 @@ export default function Chat() {
if (page === 'schedule') {
return (
- <>
- setPage('chat')}
- isToolManager={isToolManager}
+
+
setDrawerOpen(true)} />
+
+
+
+ setDrawerOpen(false)}
+ onMessages={() => { setDrawerOpen(false); setPage('chat'); }}
+ onSchedule={() => { setDrawerOpen(false); setPage('schedule'); }}
+ onScheduleManager={() => { setDrawerOpen(false); setPage('schedule'); }}
+ onGroupManager={() => { setDrawerOpen(false); setModal('groupmanager'); }}
+ onBranding={() => { setDrawerOpen(false); setModal('branding'); }}
+ onSettings={() => { setDrawerOpen(false); setModal('settings'); }}
+ onUsers={() => { setDrawerOpen(false); setModal('users'); }}
features={features}
+ currentPage={page}
+ isMobile={isMobile}
/>
{modal === 'settings' && setModal(null)} onFeaturesChanged={setFeatures} />}
- >
+ {modal === 'groupmanager' && setModal(null)} />}
+
);
}
@@ -386,13 +404,16 @@ export default function Chat() {
setDrawerOpen(false)}
- onGroupManager={() => { setDrawerOpen(false); setModal('groupmanager'); }}
- onScheduleManager={() => { setDrawerOpen(false); setPage('schedule'); }}
onMessages={() => { setDrawerOpen(false); setPage('chat'); }}
+ onSchedule={() => { setDrawerOpen(false); setPage('schedule'); }}
+ onScheduleManager={() => { setDrawerOpen(false); setPage('schedule'); }}
+ onGroupManager={() => { setDrawerOpen(false); setModal('groupmanager'); }}
onBranding={() => { setDrawerOpen(false); setModal('branding'); }}
onSettings={() => { setDrawerOpen(false); setModal('settings'); }}
onUsers={() => { setDrawerOpen(false); setModal('users'); }}
features={features}
+ currentPage={page}
+ isMobile={isMobile}
/>
{modal === 'profile' && setModal(null)} />}
{modal === 'users' && setModal(null)} />}