add the option for the user to add a note to their availability
This commit is contained in:
1
backend/src/models/migrations/013_availability_note.sql
Normal file
1
backend/src/models/migrations/013_availability_note.sql
Normal file
@@ -0,0 +1 @@
|
||||
ALTER TABLE event_availability ADD COLUMN IF NOT EXISTS note VARCHAR(20);
|
||||
@@ -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 }); }
|
||||
});
|
||||
|
||||
|
||||
@@ -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(
|
||||
<div className="modal-overlay" onClick={e=>e.target===e.currentTarget&&onClose()}>
|
||||
<div className="modal" style={{maxWidth:520,maxHeight:'88vh',overflowY:'auto'}}>
|
||||
@@ -827,13 +849,31 @@ function EventDetailModal({ event, onClose, onEdit, onAvailabilityChange, isTool
|
||||
{isPast ? (
|
||||
<p style={{fontSize:13,color:'var(--text-tertiary)',marginBottom:16}}>Past event — availability is read-only.</p>
|
||||
) : (
|
||||
<div style={{display:'flex',gap:8,marginBottom:16}}>
|
||||
<>
|
||||
<div style={{display:'flex',gap:8,marginBottom:12}}>
|
||||
{Object.entries(RESP_LABEL).map(([key,label])=>(
|
||||
<button key={key} onClick={()=>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}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<div style={{display:'flex',gap:8,alignItems:'center',marginBottom:16}}>
|
||||
<input
|
||||
type="text"
|
||||
value={noteInput}
|
||||
onChange={e=>setNoteInput(e.target.value.slice(0,20))}
|
||||
placeholder="Add a note (optional)"
|
||||
maxLength={20}
|
||||
style={{flex:1,padding:'7px 10px',borderRadius:'var(--radius)',border:'1px solid var(--border)',background:'var(--surface)',color:'var(--text-primary)',fontSize:13,outline:'none'}}
|
||||
/>
|
||||
<span style={{fontSize:11,color:'var(--text-tertiary)',flexShrink:0,minWidth:32,textAlign:'right'}}>{noteInput.length}/20</span>
|
||||
{myResp&¬eChanged&&(
|
||||
<button onClick={handleNoteSave} disabled={noteSaving} className="btn btn-primary btn-sm" style={{flexShrink:0}}>
|
||||
{noteSaving?'…':'Save'}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
{(isToolManager||avail.length>0)&&(
|
||||
<>
|
||||
@@ -844,13 +884,30 @@ function EventDetailModal({ event, onClose, onEdit, onAvailabilityChange, isTool
|
||||
</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}}>
|
||||
{avail.map(r=>{
|
||||
const hasNote=!!(r.note&&r.note.trim());
|
||||
const expanded=expandedNotes.has(r.user_id);
|
||||
return(
|
||||
<div key={r.user_id} style={{borderBottom:'1px solid var(--border)'}}>
|
||||
<div
|
||||
style={{display:'flex',alignItems:'center',gap:10,padding:'8px 12px',fontSize:13,cursor:hasNote?'pointer':'default'}}
|
||||
onClick={hasNote?()=>toggleNote(r.user_id):undefined}
|
||||
>
|
||||
<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>
|
||||
{hasNote&&(
|
||||
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="var(--text-tertiary)" strokeWidth="2.5" style={{flexShrink:0,transition:'transform 0.15s',transform:expanded?'rotate(180deg)':'rotate(0deg)'}}><polyline points="6 9 12 15 18 9"/></svg>
|
||||
)}
|
||||
<span style={{color:RESP_COLOR[r.response],fontSize:12,fontWeight:600}}>{RESP_LABEL[r.response]}</span>
|
||||
</div>
|
||||
))}
|
||||
{hasNote&&expanded&&(
|
||||
<div style={{padding:'0 12px 10px 31px',fontSize:12,color:'var(--text-secondary)',fontStyle:'italic'}}>
|
||||
{r.note}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
|
||||
@@ -119,7 +119,8 @@ export const api = {
|
||||
createEvent: (body) => req('POST', '/schedule', body), // body may include recurrenceRule: {freq,interval,byDay,ends,endDate,endCount}
|
||||
updateEvent: (id, body) => req('PATCH', `/schedule/${id}`, body),
|
||||
deleteEvent: (id, scope = 'this') => req('DELETE', `/schedule/${id}`, { recurringScope: scope }),
|
||||
setAvailability: (id, response) => req('PUT', `/schedule/${id}/availability`, { response }),
|
||||
setAvailability: (id, response, note) => req('PUT', `/schedule/${id}/availability`, { response, note }),
|
||||
setAvailabilityNote: (id, note) => req('PATCH', `/schedule/${id}/availability/note`, { note }),
|
||||
deleteAvailability: (id) => req('DELETE', `/schedule/${id}/availability`),
|
||||
getPendingAvailability: () => req('GET', '/schedule/me/pending'),
|
||||
bulkAvailability: (responses) => req('POST', '/schedule/me/bulk-availability', { responses }),
|
||||
|
||||
Reference in New Issue
Block a user