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) => ( - - ); + const item = (icon, label, onClick, opts = {}) => { + const { active, disabled, badge } = opts; + return ( + + ); + }; return ( <> - {/* Backdrop */}
- {/* Drawer */}
+ + {/* Close X */}
Menu
-
- {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 + + {open && ( +
+
+ + +
+
+ {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 (
setName(e.target.value)} style={{marginTop:4}} autoFocus/>
setColour(e.target.value)} style={{marginTop:4,width:'100%',height:32,padding:2,borderRadius:4,border:'1px solid var(--border)'}}/>
-
- -
+
{useDur&&}
-
- - -
+
); } @@ -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}}/>tosetEt(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%'}}/>
- - - -
- - {isToolManager&&} - {showTypeForm&&{setLocalTypes(p=>[...p,et]);setShowTypeForm(false);}} onClose={()=>setShowTypeForm(false)}/>} + {/* Availability (first — if enabled, groups become required) */} + + + + + {/* Groups — required when tracking */} + +
+
+ {userGroups.length===0 + ?
No user groups yet
+ :userGroups.map(g=>( + + ))} +
+

+ {grps.size===0 + ? (groupsRequired?'At least one group required for availability tracking':'No groups — event visible to all (if public)') + : `${grps.size} group${grps.size!==1?'s':''} selected`} +

+
+
+ + {/* Visibility — only shown if groups selected OR tracking */} + {(grps.size>0||track) && ( + + + + )} + + {/* Date/Time */} + +
+
+ setSd(e.target.value)} style={{width:150,flexShrink:0}}/> + {!allDay&&( + <> + + to + + setEd(e.target.value)} style={{width:150,flexShrink:0}}/> + + )} +
+ +
+
+ + {/* Event Type */} + +
+ + {isToolManager&&} + {showTypeForm&&{setLocalTypes(p=>[...p,et]);setShowTypeForm(false);}} onClose={()=>setShowTypeForm(false)}/>} +
+
+ + {/* Location */} + + setLoc(e.target.value)}/> + + + {/* Description */} + +