diff --git a/backend/src/models/migrations/013_availability_note.sql b/backend/src/models/migrations/013_availability_note.sql new file mode 100644 index 0000000..49ab915 --- /dev/null +++ b/backend/src/models/migrations/013_availability_note.sql @@ -0,0 +1 @@ +ALTER TABLE event_availability ADD COLUMN IF NOT EXISTS note VARCHAR(20); diff --git a/backend/src/routes/schedule.js b/backend/src/routes/schedule.js index a3360ca..7a4a812 100644 --- a/backend/src/routes/schedule.js +++ b/backend/src/routes/schedule.js @@ -247,7 +247,7 @@ router.get('/:id', authMiddleware, async (req, res) => { `, [event.id, req.user.id])); if (event.track_availability && (itm || isMember)) { event.availability = await query(req.schema, ` - SELECT ea.response, ea.updated_at, u.id AS user_id, u.name, u.display_name, u.avatar + SELECT ea.response, ea.note, ea.updated_at, u.id AS user_id, u.name, u.display_name, u.avatar FROM event_availability ea JOIN users u ON u.id=ea.user_id WHERE ea.event_id=$1 `, [req.params.id]); if (itm) { @@ -259,8 +259,9 @@ router.get('/:id', authMiddleware, async (req, res) => { event.no_response_count = assignedIds.filter(id => !respondedIds.has(id)).length; } } - const mine = await queryOne(req.schema, 'SELECT response FROM event_availability WHERE event_id=$1 AND user_id=$2', [req.params.id, req.user.id]); + const mine = await queryOne(req.schema, 'SELECT response, note FROM event_availability WHERE event_id=$1 AND user_id=$2', [req.params.id, req.user.id]); event.my_response = mine?.response || null; + event.my_note = mine?.note || null; res.json({ event }); } catch (e) { res.status(500).json({ error: e.message }); } }); @@ -394,8 +395,9 @@ router.put('/:id/availability', authMiddleware, async (req, res) => { const event = await queryOne(req.schema, 'SELECT * FROM events WHERE id=$1', [req.params.id]); if (!event) return res.status(404).json({ error: 'Not found' }); if (!event.track_availability) return res.status(400).json({ error: 'Availability tracking not enabled' }); - const { response } = req.body; + const { response, note } = req.body; if (!['going','maybe','not_going'].includes(response)) return res.status(400).json({ error: 'Invalid response' }); + const trimmedNote = note ? String(note).trim().slice(0, 20) : null; const itm = await isToolManagerFn(req.schema, req.user); const inGroup = await queryOne(req.schema, ` SELECT 1 FROM event_user_groups eug JOIN user_group_members ugm ON ugm.user_group_id=eug.user_group_id @@ -403,10 +405,20 @@ router.put('/:id/availability', authMiddleware, async (req, res) => { `, [event.id, req.user.id]); if (!inGroup && !itm) return res.status(403).json({ error: 'You are not assigned to this event' }); await exec(req.schema, ` - INSERT INTO event_availability (event_id,user_id,response,updated_at) VALUES ($1,$2,$3,NOW()) - ON CONFLICT (event_id,user_id) DO UPDATE SET response=$3, updated_at=NOW() - `, [event.id, req.user.id, response]); - res.json({ success: true, response }); + INSERT INTO event_availability (event_id,user_id,response,note,updated_at) VALUES ($1,$2,$3,$4,NOW()) + ON CONFLICT (event_id,user_id) DO UPDATE SET response=$3, note=$4, updated_at=NOW() + `, [event.id, req.user.id, response, trimmedNote]); + res.json({ success: true, response, note: trimmedNote }); + } catch (e) { res.status(500).json({ error: e.message }); } +}); + +router.patch('/:id/availability/note', authMiddleware, async (req, res) => { + try { + const existing = await queryOne(req.schema, 'SELECT response FROM event_availability WHERE event_id=$1 AND user_id=$2', [req.params.id, req.user.id]); + if (!existing) return res.status(404).json({ error: 'No availability response found' }); + const trimmedNote = req.body.note ? String(req.body.note).trim().slice(0, 20) : null; + await exec(req.schema, 'UPDATE event_availability SET note=$1, updated_at=NOW() WHERE event_id=$2 AND user_id=$3', [trimmedNote, req.params.id, req.user.id]); + res.json({ success: true, note: trimmedNote }); } catch (e) { res.status(500).json({ error: e.message }); } }); diff --git a/frontend/src/components/SchedulePage.jsx b/frontend/src/components/SchedulePage.jsx index 984f77d..55f4a3e 100644 --- a/frontend/src/components/SchedulePage.jsx +++ b/frontend/src/components/SchedulePage.jsx @@ -768,23 +768,45 @@ function EventForm({ event, userGroups, eventTypes, selectedDate, onSave, onCanc function EventDetailModal({ event, onClose, onEdit, onAvailabilityChange, isToolManager, userId }) { const toast=useToast(); const [myResp,setMyResp]=useState(event.my_response); + const [myNote,setMyNote]=useState(event.my_note||''); + const [noteInput,setNoteInput]=useState(event.my_note||''); + const [noteSaving,setNoteSaving]=useState(false); const [avail,setAvail]=useState(event.availability||[]); + const [expandedNotes,setExpandedNotes]=useState(new Set()); // Sync when parent reloads event after availability change - useEffect(()=>{setMyResp(event.my_response);setAvail(event.availability||[]);},[event]); + useEffect(()=>{ + setMyResp(event.my_response); + setAvail(event.availability||[]); + setMyNote(event.my_note||''); + setNoteInput(event.my_note||''); + },[event]); const counts={going:0,maybe:0,not_going:0}; avail.forEach(r=>{if(counts[r.response]!==undefined)counts[r.response]++;}); const isPast = !event.all_day && event.end_at && new Date(event.end_at) < new Date(); + const noteChanged = noteInput.trim() !== myNote.trim(); const handleResp=async resp=>{ const prev=myResp; const next=myResp===resp?null:resp; setMyResp(next); // optimistic update try{ - if(prev===resp){await api.deleteAvailability(event.id);}else{await api.setAvailability(event.id,resp);} + if(prev===resp){await api.deleteAvailability(event.id);}else{await api.setAvailability(event.id,resp,noteInput.trim()||null);} onAvailabilityChange?.(next); // triggers parent re-fetch to update avail list }catch(e){setMyResp(prev);toast(e.message,'error');} // rollback on error }; + const handleNoteSave=async()=>{ + if(!myResp) return; // no response row to attach note to + setNoteSaving(true); + try{ + await api.setAvailabilityNote(event.id,noteInput.trim()||null); + setMyNote(noteInput.trim()); + onAvailabilityChange?.(myResp); // re-fetch to update responses list + }catch(e){toast(e.message,'error');}finally{setNoteSaving(false);} + }; + + const toggleNote=id=>setExpandedNotes(prev=>{const s=new Set(prev);s.has(id)?s.delete(id):s.add(id);return s;}); + return ReactDOM.createPortal(
Past event — availability is read-only.
) : ( -