diff --git a/.env.example b/.env.example index 9fd611d..a7df3af 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.46 +JAMA_VERSION=0.9.47 # App port — the host port Docker maps to the container PORT=3000 diff --git a/backend/package.json b/backend/package.json index 9f97aff..a421eb5 100644 --- a/backend/package.json +++ b/backend/package.json @@ -1,6 +1,6 @@ { "name": "jama-backend", - "version": "0.9.46", + "version": "0.9.47", "description": "TeamChat backend server", "main": "src/index.js", "scripts": { diff --git a/build.sh b/build.sh index 505970b..100185e 100644 --- a/build.sh +++ b/build.sh @@ -13,7 +13,7 @@ # ───────────────────────────────────────────────────────────── set -euo pipefail -VERSION="${1:-0.9.46}" +VERSION="${1:-0.9.47}" ACTION="${2:-}" REGISTRY="${REGISTRY:-}" IMAGE_NAME="jama" diff --git a/frontend/package.json b/frontend/package.json index d4a1bbb..3f5a9a1 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,6 +1,6 @@ { "name": "jama-frontend", - "version": "0.9.46", + "version": "0.9.47", "private": true, "scripts": { "dev": "vite", diff --git a/frontend/src/components/SchedulePage.jsx b/frontend/src/components/SchedulePage.jsx new file mode 100644 index 0000000..3c1841f --- /dev/null +++ b/frontend/src/components/SchedulePage.jsx @@ -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 +
+ + {MONTHS[m]} {y} + +
+
+ {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)} 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&&} +
+ ); + })} +
+
+ ); +} + +// ── 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 ( +
+
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&&} +
+
+ + +
+
+ ); +} + +// ── 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})=>( +
+
{label}
+
{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}}/> +
+ +
+ + +
+ + {isToolManager&&} + {showTypeForm&&{setLocalTypes(p=>[...p,et]);setShowTypeForm(false);}} onClose={()=>setShowTypeForm(false)}/>} +
+
+ + +
+ {userGroups.length===0?
No user groups yet
+ :userGroups.map(g=>( + + ))} +
+

{groups.size===0?'No groups — visible to all (if public)':`${groups.size} group${groups.size!==1?'s':''} selected`}

+
+ + +
+ + +
+
+ + setLoc(e.target.value)}/> +