v0.12.28 new modal window for event edit/delete
This commit is contained in:
@@ -4,7 +4,7 @@
|
|||||||
|
|
||||||
**RosterChirp** is a self-hosted, closed-source, full-stack Progressive Web App for team messaging. It supports both single-tenant (selfhost) and multi-tenant (host) deployments.
|
**RosterChirp** is a self-hosted, closed-source, full-stack Progressive Web App for team messaging. It supports both single-tenant (selfhost) and multi-tenant (host) deployments.
|
||||||
|
|
||||||
**Current version:** 0.12.27
|
**Current version:** 0.12.28
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -106,7 +106,7 @@ rosterchirp/
|
|||||||
|
|
||||||
## Version Bump — Files to Update
|
## Version Bump — Files to Update
|
||||||
|
|
||||||
When bumping the version (e.g. 0.12.27 → 0.12.28), update **all three**:
|
When bumping the version (e.g. 0.12.28 → 0.12.29), update **all three**:
|
||||||
|
|
||||||
```
|
```
|
||||||
backend/package.json "version": "X.Y.Z"
|
backend/package.json "version": "X.Y.Z"
|
||||||
@@ -116,7 +116,7 @@ build.sh VERSION="${1:-X.Y.Z}"
|
|||||||
|
|
||||||
One-liner:
|
One-liner:
|
||||||
```bash
|
```bash
|
||||||
OLD=0.12.27; NEW=0.12.28
|
OLD=0.12.28; NEW=0.12.29
|
||||||
sed -i "s/\"version\": \"$OLD\"/\"version\": \"$NEW\"/" backend/package.json frontend/package.json
|
sed -i "s/\"version\": \"$OLD\"/\"version\": \"$NEW\"/" backend/package.json frontend/package.json
|
||||||
sed -i "s/VERSION=\"\${1:-$OLD}\"/VERSION=\"\${1:-$NEW}\"/" build.sh
|
sed -i "s/VERSION=\"\${1:-$OLD}\"/VERSION=\"\${1:-$NEW}\"/" build.sh
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "rosterchirp-backend",
|
"name": "rosterchirp-backend",
|
||||||
"version": "0.12.27",
|
"version": "0.12.28",
|
||||||
"description": "RosterChirp backend server",
|
"description": "RosterChirp backend server",
|
||||||
"main": "src/index.js",
|
"main": "src/index.js",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|||||||
@@ -273,7 +273,7 @@ router.patch('/:id', authMiddleware, teamManagerMiddleware, async (req, res) =>
|
|||||||
|
|
||||||
await applyEventUpdate(req.schema, req.params.id, fields, userGroupIds);
|
await applyEventUpdate(req.schema, req.params.id, fields, userGroupIds);
|
||||||
|
|
||||||
// Recurring future scope — update all future occurrences
|
// Recurring future scope — update this and all future occurrences
|
||||||
if (recurringScope === 'future' && event.recurrence_rule) {
|
if (recurringScope === 'future' && event.recurrence_rule) {
|
||||||
const futureEvents = await query(req.schema, `
|
const futureEvents = await query(req.schema, `
|
||||||
SELECT id FROM events WHERE id!=$1 AND created_by=$2 AND recurrence_rule IS NOT NULL
|
SELECT id FROM events WHERE id!=$1 AND created_by=$2 AND recurrence_rule IS NOT NULL
|
||||||
@@ -282,6 +282,14 @@ router.patch('/:id', authMiddleware, teamManagerMiddleware, async (req, res) =>
|
|||||||
for (const fe of futureEvents)
|
for (const fe of futureEvents)
|
||||||
await applyEventUpdate(req.schema, fe.id, fields, userGroupIds);
|
await applyEventUpdate(req.schema, fe.id, fields, userGroupIds);
|
||||||
}
|
}
|
||||||
|
// Recurring all scope — update every occurrence
|
||||||
|
if (recurringScope === 'all' && event.recurrence_rule) {
|
||||||
|
const allEvents = await query(req.schema, `
|
||||||
|
SELECT id FROM events WHERE id!=$1 AND created_by=$2 AND recurrence_rule IS NOT NULL AND title=$3
|
||||||
|
`, [req.params.id, event.created_by, event.title]);
|
||||||
|
for (const ae of allEvents)
|
||||||
|
await applyEventUpdate(req.schema, ae.id, fields, userGroupIds);
|
||||||
|
}
|
||||||
|
|
||||||
// Clean up availability for users removed from groups
|
// Clean up availability for users removed from groups
|
||||||
if (Array.isArray(userGroupIds)) {
|
if (Array.isArray(userGroupIds)) {
|
||||||
@@ -311,9 +319,23 @@ router.patch('/:id', authMiddleware, teamManagerMiddleware, async (req, res) =>
|
|||||||
|
|
||||||
router.delete('/:id', authMiddleware, teamManagerMiddleware, async (req, res) => {
|
router.delete('/:id', authMiddleware, teamManagerMiddleware, async (req, res) => {
|
||||||
try {
|
try {
|
||||||
if (!(await queryOne(req.schema, 'SELECT id FROM events WHERE id=$1', [req.params.id])))
|
const event = await queryOne(req.schema, 'SELECT * FROM events WHERE id=$1', [req.params.id]);
|
||||||
return res.status(404).json({ error: 'Not found' });
|
if (!event) return res.status(404).json({ error: 'Not found' });
|
||||||
await exec(req.schema, 'DELETE FROM events WHERE id=$1', [req.params.id]);
|
const { recurringScope } = req.body || {};
|
||||||
|
if (recurringScope === 'future' && event.recurrence_rule) {
|
||||||
|
// Delete this event and all future occurrences with same creator/title
|
||||||
|
await exec(req.schema, `
|
||||||
|
DELETE FROM events WHERE created_by=$1 AND recurrence_rule IS NOT NULL
|
||||||
|
AND title=$2 AND start_at >= $3
|
||||||
|
`, [event.created_by, event.title, event.start_at]);
|
||||||
|
} else if (recurringScope === 'all' && event.recurrence_rule) {
|
||||||
|
// Delete every occurrence
|
||||||
|
await exec(req.schema, `
|
||||||
|
DELETE FROM events WHERE created_by=$1 AND recurrence_rule IS NOT NULL AND title=$2
|
||||||
|
`, [event.created_by, event.title]);
|
||||||
|
} else {
|
||||||
|
await exec(req.schema, 'DELETE FROM events WHERE id=$1', [req.params.id]);
|
||||||
|
}
|
||||||
res.json({ success: true });
|
res.json({ success: true });
|
||||||
} catch (e) { res.status(500).json({ error: e.message }); }
|
} catch (e) { res.status(500).json({ error: e.message }); }
|
||||||
});
|
});
|
||||||
|
|||||||
2
build.sh
2
build.sh
@@ -13,7 +13,7 @@
|
|||||||
# ─────────────────────────────────────────────────────────────
|
# ─────────────────────────────────────────────────────────────
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
|
|
||||||
VERSION="${1:-0.12.27}"
|
VERSION="${1:-0.12.28}"
|
||||||
ACTION="${2:-}"
|
ACTION="${2:-}"
|
||||||
REGISTRY="${REGISTRY:-}"
|
REGISTRY="${REGISTRY:-}"
|
||||||
IMAGE_NAME="rosterchirp"
|
IMAGE_NAME="rosterchirp"
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "rosterchirp-frontend",
|
"name": "rosterchirp-frontend",
|
||||||
"version": "0.12.27",
|
"version": "0.12.28",
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { useState, useEffect, useRef } from 'react';
|
import { useState, useEffect, useRef } from 'react';
|
||||||
|
import ReactDOM from 'react-dom';
|
||||||
import { api } from '../utils/api.js';
|
import { api } from '../utils/api.js';
|
||||||
import ColourPickerSheet from './ColourPickerSheet.jsx';
|
import ColourPickerSheet from './ColourPickerSheet.jsx';
|
||||||
import { useToast } from '../contexts/ToastContext.jsx';
|
import { useToast } from '../contexts/ToastContext.jsx';
|
||||||
@@ -319,6 +320,31 @@ function MobileRow({ icon, label, children, onPress, border=true }) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Recurring choice modal ────────────────────────────────────────────────────
|
||||||
|
function RecurringChoiceModal({ title, onConfirm, onCancel }) {
|
||||||
|
const [choice, setChoice] = useState('this');
|
||||||
|
return ReactDOM.createPortal(
|
||||||
|
<div className="modal-overlay" onClick={e=>e.target===e.currentTarget&&onCancel()}>
|
||||||
|
<div className="modal" style={{maxWidth:360}}>
|
||||||
|
<h3 style={{fontSize:17,fontWeight:700,margin:'0 0 20px'}}>{title}</h3>
|
||||||
|
<div style={{display:'flex',flexDirection:'column',gap:14,marginBottom:24}}>
|
||||||
|
{[['this','This event'],['future','This and following events'],['all','All events']].map(([val,label])=>(
|
||||||
|
<label key={val} style={{display:'flex',alignItems:'center',gap:10,fontSize:14,cursor:'pointer'}}>
|
||||||
|
<input type="radio" name="rec-scope" value={val} checked={choice===val} onChange={()=>setChoice(val)} style={{accentColor:'var(--primary)',width:16,height:16}}/>
|
||||||
|
{label}
|
||||||
|
</label>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div style={{display:'flex',justifyContent:'flex-end',gap:8}}>
|
||||||
|
<button className="btn btn-secondary btn-sm" onClick={onCancel}>Cancel</button>
|
||||||
|
<button className="btn btn-primary btn-sm" onClick={()=>onConfirm(choice)}>OK</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>,
|
||||||
|
document.body
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// ── Main Mobile Event Form ────────────────────────────────────────────────────
|
// ── Main Mobile Event Form ────────────────────────────────────────────────────
|
||||||
export default function MobileEventForm({ event, eventTypes, userGroups, selectedDate, onSave, onCancel, onDelete, isToolManager }) {
|
export default function MobileEventForm({ event, eventTypes, userGroups, selectedDate, onSave, onCancel, onDelete, isToolManager }) {
|
||||||
const toast = useToast();
|
const toast = useToast();
|
||||||
@@ -353,6 +379,7 @@ export default function MobileEventForm({ event, eventTypes, userGroups, selecte
|
|||||||
const [description, setDescription] = useState(event?.description||'');
|
const [description, setDescription] = useState(event?.description||'');
|
||||||
const [recRule, setRecRule] = useState(event?.recurrence_rule||null);
|
const [recRule, setRecRule] = useState(event?.recurrence_rule||null);
|
||||||
const [saving, setSaving] = useState(false);
|
const [saving, setSaving] = useState(false);
|
||||||
|
const [showScopeModal, setShowScopeModal] = useState(false);
|
||||||
|
|
||||||
// Overlay state
|
// Overlay state
|
||||||
const [showStartDate, setShowStartDate] = useState(false);
|
const [showStartDate, setShowStartDate] = useState(false);
|
||||||
@@ -410,24 +437,22 @@ export default function MobileEventForm({ event, eventTypes, userGroups, selecte
|
|||||||
setEt(toTimeIn(endIso));
|
setEt(toTimeIn(endIso));
|
||||||
}, [sd, st, typeId]);
|
}, [sd, st, typeId]);
|
||||||
|
|
||||||
const handle = async () => {
|
const handle = () => {
|
||||||
if(!title.trim()) return toast('Title required','error');
|
if(!title.trim()) return toast('Title required','error');
|
||||||
// Validation rules
|
|
||||||
const startMs = new Date(buildISO(sd, allDay?'00:00':st)).getTime();
|
const startMs = new Date(buildISO(sd, allDay?'00:00':st)).getTime();
|
||||||
const endMs = new Date(buildISO(ed, allDay?'23:59':et)).getTime();
|
const endMs = new Date(buildISO(ed, allDay?'23:59':et)).getTime();
|
||||||
if(ed < sd) return toast('End date cannot be before start date','error');
|
if(ed < sd) return toast('End date cannot be before start date','error');
|
||||||
if(!allDay && endMs <= startMs && ed === sd) return toast('End time must be after start time, or set a later end date','error');
|
if(!allDay && endMs <= startMs && ed === sd) return toast('End time must be after start time, or set a later end date','error');
|
||||||
// No past start times for new events
|
|
||||||
if(!event && !allDay && new Date(buildISO(sd,st)) < new Date()) return toast('Start date and time cannot be in the past','error');
|
if(!event && !allDay && new Date(buildISO(sd,st)) < new Date()) return toast('Start date and time cannot be in the past','error');
|
||||||
if(!event && allDay && sd < toDateIn(new Date().toISOString())) return toast('Start date cannot be in the past','error');
|
if(!event && allDay && sd < toDateIn(new Date().toISOString())) return toast('Start date cannot be in the past','error');
|
||||||
|
if(event && event.recurrence_rule?.freq) { setShowScopeModal(true); return; }
|
||||||
|
doSave('this');
|
||||||
|
};
|
||||||
|
const doSave = async (scope) => {
|
||||||
|
setShowScopeModal(false);
|
||||||
setSaving(true);
|
setSaving(true);
|
||||||
try {
|
try {
|
||||||
const body = { title:title.trim(), eventTypeId:typeId||null, startAt:allDay?buildISO(sd,'00:00'):buildISO(sd,st), endAt:allDay?buildISO(ed,'23:59'):buildISO(ed,et), allDay, location, description, isPublic:!isPrivate, trackAvailability:track, userGroupIds:[...groups], recurrenceRule:recRule||null };
|
const body = { title:title.trim(), eventTypeId:typeId||null, startAt:allDay?buildISO(sd,'00:00'):buildISO(sd,st), endAt:allDay?buildISO(ed,'23:59'):buildISO(ed,et), allDay, location, description, isPublic:!isPrivate, trackAvailability:track, userGroupIds:[...groups], recurrenceRule:recRule||null };
|
||||||
let scope = 'this';
|
|
||||||
if(event && event.recurrence_rule?.freq) {
|
|
||||||
const choice = window.confirm('This is a recurring event.\n\nOK = Update this and all future occurrences\nCancel = Update this event only');
|
|
||||||
scope = choice ? 'future' : 'this';
|
|
||||||
}
|
|
||||||
const r = event ? await api.updateEvent(event.id, {...body, recurringScope:scope}) : await api.createEvent(body);
|
const r = event ? await api.updateEvent(event.id, {...body, recurringScope:scope}) : await api.createEvent(body);
|
||||||
onSave(r.event);
|
onSave(r.event);
|
||||||
} catch(e) { toast(e.message,'error'); }
|
} catch(e) { toast(e.message,'error'); }
|
||||||
@@ -558,6 +583,7 @@ export default function MobileEventForm({ event, eventTypes, userGroups, selecte
|
|||||||
{showStartDate && <CalendarPicker value={sd} onChange={v=>{setSd(v);setShowStartDate(false);}} onClose={()=>setShowStartDate(false)}/>}
|
{showStartDate && <CalendarPicker value={sd} onChange={v=>{setSd(v);setShowStartDate(false);}} onClose={()=>setShowStartDate(false)}/>}
|
||||||
{showEndDate && <CalendarPicker value={ed} onChange={v=>{setEd(v);setShowEndDate(false);}} onClose={()=>setShowEndDate(false)}/>}
|
{showEndDate && <CalendarPicker value={ed} onChange={v=>{setEd(v);setShowEndDate(false);}} onClose={()=>setShowEndDate(false)}/>}
|
||||||
{showRecurrence && <RecurrenceSheet value={recRule} onChange={v=>{setRecRule(v);}} onClose={()=>setShowRecurrence(false)}/>}
|
{showRecurrence && <RecurrenceSheet value={recRule} onChange={v=>{setRecRule(v);}} onClose={()=>setShowRecurrence(false)}/>}
|
||||||
|
{showScopeModal && <RecurringChoiceModal title="Edit recurring event" onConfirm={doSave} onCancel={()=>setShowScopeModal(false)}/>}
|
||||||
{showTypeColourPicker && (
|
{showTypeColourPicker && (
|
||||||
<ColourPickerSheet value={newTypeColour} onChange={setNewTypeColour} onClose={()=>setShowTypeColourPicker(false)} title="Event Type Colour"/>
|
<ColourPickerSheet value={newTypeColour} onChange={setNewTypeColour} onClose={()=>setShowTypeColourPicker(false)} title="Event Type Colour"/>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -495,6 +495,48 @@ function FormRow({ label, children, required }) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Recurring choice modal ────────────────────────────────────────────────────
|
||||||
|
function RecurringChoiceModal({ title, onConfirm, onCancel }) {
|
||||||
|
const [choice, setChoice] = useState('this');
|
||||||
|
return ReactDOM.createPortal(
|
||||||
|
<div className="modal-overlay" onClick={e=>e.target===e.currentTarget&&onCancel()}>
|
||||||
|
<div className="modal" style={{maxWidth:360}}>
|
||||||
|
<h3 style={{fontSize:17,fontWeight:700,margin:'0 0 20px'}}>{title}</h3>
|
||||||
|
<div style={{display:'flex',flexDirection:'column',gap:14,marginBottom:24}}>
|
||||||
|
{[['this','This event'],['future','This and following events'],['all','All events']].map(([val,label])=>(
|
||||||
|
<label key={val} style={{display:'flex',alignItems:'center',gap:10,fontSize:14,cursor:'pointer'}}>
|
||||||
|
<input type="radio" name="rec-scope" value={val} checked={choice===val} onChange={()=>setChoice(val)} style={{accentColor:'var(--primary)',width:16,height:16}}/>
|
||||||
|
{label}
|
||||||
|
</label>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div style={{display:'flex',justifyContent:'flex-end',gap:8}}>
|
||||||
|
<button className="btn btn-secondary btn-sm" onClick={onCancel}>Cancel</button>
|
||||||
|
<button className="btn btn-primary btn-sm" onClick={()=>onConfirm(choice)}>OK</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>,
|
||||||
|
document.body
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Confirm modal (non-recurring delete) ──────────────────────────────────────
|
||||||
|
function ConfirmModal({ title, message, confirmLabel='Delete', onConfirm, onCancel }) {
|
||||||
|
return ReactDOM.createPortal(
|
||||||
|
<div className="modal-overlay" onClick={e=>e.target===e.currentTarget&&onCancel()}>
|
||||||
|
<div className="modal" style={{maxWidth:360}}>
|
||||||
|
<h3 style={{fontSize:17,fontWeight:700,margin:'0 0 12px'}}>{title}</h3>
|
||||||
|
<p style={{fontSize:14,color:'var(--text-secondary)',margin:'0 0 24px'}}>{message}</p>
|
||||||
|
<div style={{display:'flex',justifyContent:'flex-end',gap:8}}>
|
||||||
|
<button className="btn btn-secondary btn-sm" onClick={onCancel}>Cancel</button>
|
||||||
|
<button className="btn btn-sm" style={{background:'var(--error)',color:'white'}} onClick={onConfirm}>{confirmLabel}</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>,
|
||||||
|
document.body
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// ── Event Form ────────────────────────────────────────────────────────────────
|
// ── Event Form ────────────────────────────────────────────────────────────────
|
||||||
function EventForm({ event, userGroups, eventTypes, selectedDate, onSave, onCancel, onDelete, isToolManager }) {
|
function EventForm({ event, userGroups, eventTypes, selectedDate, onSave, onCancel, onDelete, isToolManager }) {
|
||||||
const toast=useToast();
|
const toast=useToast();
|
||||||
@@ -517,6 +559,7 @@ function EventForm({ event, userGroups, eventTypes, selectedDate, onSave, onCanc
|
|||||||
const [showTypeForm,setShowTypeForm]=useState(false);
|
const [showTypeForm,setShowTypeForm]=useState(false);
|
||||||
const [localTypes,setLocalTypes]=useState(eventTypes);
|
const [localTypes,setLocalTypes]=useState(eventTypes);
|
||||||
const [recRule,setRecRule]=useState(event?.recurrence_rule||null);
|
const [recRule,setRecRule]=useState(event?.recurrence_rule||null);
|
||||||
|
const [showScopeModal,setShowScopeModal]=useState(false);
|
||||||
// Sync localTypes when parent provides updated eventTypes (e.g. after async load)
|
// Sync localTypes when parent provides updated eventTypes (e.g. after async load)
|
||||||
// Also initialise typeId to the default event type for new events
|
// Also initialise typeId to the default event type for new events
|
||||||
useEffect(()=>{
|
useEffect(()=>{
|
||||||
@@ -587,23 +630,22 @@ function EventForm({ event, userGroups, eventTypes, selectedDate, onSave, onCanc
|
|||||||
const toggleGrp=id=>setGrps(prev=>{const n=new Set(prev);n.has(id)?n.delete(id):n.add(id);return n;});
|
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 groupsRequired=track; // when tracking, groups are required
|
||||||
|
|
||||||
const handle=async()=>{
|
const handle=()=>{
|
||||||
if(!title.trim()) return toast('Title required','error');
|
if(!title.trim()) return toast('Title required','error');
|
||||||
if(!allDay&&(!sd||!st||!ed||!et)) return toast('Start and end 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');
|
if(groupsRequired&&grps.size===0) return toast('Select at least one group for availability tracking','error');
|
||||||
if(ed<sd) return toast('End date cannot be before start date','error');
|
if(ed<sd) return toast('End date cannot be before start date','error');
|
||||||
if(!allDay&&ed===sd&&buildISO(ed,et)<=buildISO(sd,st)) return toast('End time must be after start time, or use a later end date','error');
|
if(!allDay&&ed===sd&&buildISO(ed,et)<=buildISO(sd,st)) return toast('End time must be after start time, or use a later end date','error');
|
||||||
// No past start times for new events
|
|
||||||
if(!event && !allDay && new Date(buildISO(sd,st)) < new Date()) return toast('Start date and time cannot be in the past','error');
|
if(!event && !allDay && new Date(buildISO(sd,st)) < new Date()) return toast('Start date and time cannot be in the past','error');
|
||||||
if(!event && allDay && sd < toDateIn(new Date().toISOString())) return toast('Start date cannot be in the past','error');
|
if(!event && allDay && sd < toDateIn(new Date().toISOString())) return toast('Start date cannot be in the past','error');
|
||||||
|
if(event && event.recurrence_rule?.freq) { setShowScopeModal(true); return; }
|
||||||
|
doSave('this');
|
||||||
|
};
|
||||||
|
const doSave=async(scope)=>{
|
||||||
|
setShowScopeModal(false);
|
||||||
setSaving(true);
|
setSaving(true);
|
||||||
try{
|
try{
|
||||||
const body={title:title.trim(),eventTypeId:typeId||null,startAt:allDay?buildISO(sd,'00:00'):buildISO(sd,st),endAt:allDay?buildISO(ed,'23:59'):buildISO(ed,et),allDay,location:loc,description:desc,isPublic:pub,trackAvailability:track,userGroupIds:[...grps],recurrenceRule:recRule||null};
|
const body={title:title.trim(),eventTypeId:typeId||null,startAt:allDay?buildISO(sd,'00:00'):buildISO(sd,st),endAt:allDay?buildISO(ed,'23:59'):buildISO(ed,et),allDay,location:loc,description:desc,isPublic:pub,trackAvailability:track,userGroupIds:[...grps],recurrenceRule:recRule||null};
|
||||||
let scope='this';
|
|
||||||
if(event && event.recurrence_rule?.freq) {
|
|
||||||
const choice = window.confirm('This is a recurring event.\n\nOK = Update this and all future occurrences\nCancel = Update this event only');
|
|
||||||
scope = choice ? 'future' : 'this';
|
|
||||||
}
|
|
||||||
const r=event?await api.updateEvent(event.id,{...body,recurringScope:scope}):await api.createEvent(body);
|
const r=event?await api.updateEvent(event.id,{...body,recurringScope:scope}):await api.createEvent(body);
|
||||||
onSave(r.event);
|
onSave(r.event);
|
||||||
}catch(e){toast(e.message,'error');}finally{setSaving(false);}
|
}catch(e){toast(e.message,'error');}finally{setSaving(false);}
|
||||||
@@ -715,6 +757,7 @@ function EventForm({ event, userGroups, eventTypes, selectedDate, onSave, onCanc
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
{showScopeModal&&<RecurringChoiceModal title="Edit recurring event" onConfirm={doSave} onCancel={()=>setShowScopeModal(false)}/>}
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1483,15 +1526,18 @@ export default function SchedulePage({ isToolManager, isMobile, onProfile, onHel
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleSaved = () => { load(); setPanel('calendar'); setEditingEvent(null); };
|
const handleSaved = () => { load(); setPanel('calendar'); setEditingEvent(null); };
|
||||||
const handleDelete = async e => {
|
const [deleteTarget, setDeleteTarget] = useState(null);
|
||||||
if (!confirm(`Delete "${e.title}"?`)) return;
|
const handleDelete = (e) => setDeleteTarget(e);
|
||||||
|
const doDelete = async (scope = 'this') => {
|
||||||
|
const e = deleteTarget;
|
||||||
|
setDeleteTarget(null);
|
||||||
try {
|
try {
|
||||||
await api.deleteEvent(e.id);
|
await api.deleteEvent(e.id, scope);
|
||||||
toast('Deleted','success');
|
toast('Deleted','success');
|
||||||
setPanel('calendar');
|
setPanel('calendar');
|
||||||
setEditingEvent(null);
|
setEditingEvent(null);
|
||||||
setDetailEvent(null);
|
setDetailEvent(null);
|
||||||
load(); // reload list so deleted event disappears immediately
|
load();
|
||||||
} catch(err) { toast(err.message,'error'); }
|
} catch(err) { toast(err.message,'error'); }
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -1679,6 +1725,12 @@ export default function SchedulePage({ isToolManager, isMobile, onProfile, onHel
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Delete confirmation modals */}
|
||||||
|
{deleteTarget && deleteTarget.recurrence_rule?.freq
|
||||||
|
? <RecurringChoiceModal title="Delete recurring event" onConfirm={doDelete} onCancel={()=>setDeleteTarget(null)}/>
|
||||||
|
: deleteTarget && <ConfirmModal title="Delete event" message={`Delete "${deleteTarget.title}"?`} onConfirm={()=>doDelete('this')} onCancel={()=>setDeleteTarget(null)}/>
|
||||||
|
}
|
||||||
|
|
||||||
{/* Fixed overlays — position:fixed so they escape layout, can live anywhere in tree */}
|
{/* Fixed overlays — position:fixed so they escape layout, can live anywhere in tree */}
|
||||||
{isMobile && mobilePanel === 'groupManager' && (
|
{isMobile && mobilePanel === 'groupManager' && (
|
||||||
<div style={{ position:'fixed',inset:0,zIndex:50,background:'var(--background)' }}>
|
<div style={{ position:'fixed',inset:0,zIndex:50,background:'var(--background)' }}>
|
||||||
|
|||||||
@@ -117,7 +117,7 @@ export const api = {
|
|||||||
getEvent: (id) => req('GET', `/schedule/${id}`),
|
getEvent: (id) => req('GET', `/schedule/${id}`),
|
||||||
createEvent: (body) => req('POST', '/schedule', body), // body may include recurrenceRule: {freq,interval,byDay,ends,endDate,endCount}
|
createEvent: (body) => req('POST', '/schedule', body), // body may include recurrenceRule: {freq,interval,byDay,ends,endDate,endCount}
|
||||||
updateEvent: (id, body) => req('PATCH', `/schedule/${id}`, body),
|
updateEvent: (id, body) => req('PATCH', `/schedule/${id}`, body),
|
||||||
deleteEvent: (id) => req('DELETE', `/schedule/${id}`),
|
deleteEvent: (id, scope = 'this') => req('DELETE', `/schedule/${id}`, { recurringScope: scope }),
|
||||||
setAvailability: (id, response) => req('PUT', `/schedule/${id}/availability`, { response }),
|
setAvailability: (id, response) => req('PUT', `/schedule/${id}/availability`, { response }),
|
||||||
deleteAvailability: (id) => req('DELETE', `/schedule/${id}/availability`),
|
deleteAvailability: (id) => req('DELETE', `/schedule/${id}/availability`),
|
||||||
getPendingAvailability: () => req('GET', '/schedule/me/pending'),
|
getPendingAvailability: () => req('GET', '/schedule/me/pending'),
|
||||||
|
|||||||
Reference in New Issue
Block a user