v0.12.31 multiple UI changes
This commit is contained in:
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "rosterchirp-backend",
|
"name": "rosterchirp-backend",
|
||||||
"version": "0.12.30",
|
"version": "0.12.31",
|
||||||
"description": "RosterChirp backend server",
|
"description": "RosterChirp backend server",
|
||||||
"main": "src/index.js",
|
"main": "src/index.js",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|||||||
@@ -177,6 +177,20 @@ router.delete('/event-types/:id', authMiddleware, teamManagerMiddleware, async (
|
|||||||
} catch (e) { res.status(500).json({ error: e.message }); }
|
} catch (e) { res.status(500).json({ error: e.message }); }
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ── User's own groups (for regular users creating events) ─────────────────────
|
||||||
|
|
||||||
|
router.get('/my-groups', authMiddleware, async (req, res) => {
|
||||||
|
try {
|
||||||
|
const groups = await query(req.schema, `
|
||||||
|
SELECT ug.id, ug.name FROM user_groups ug
|
||||||
|
JOIN user_group_members ugm ON ugm.user_group_id = ug.id
|
||||||
|
WHERE ugm.user_id = $1
|
||||||
|
ORDER BY ug.name ASC
|
||||||
|
`, [req.user.id]);
|
||||||
|
res.json({ groups });
|
||||||
|
} catch (e) { res.status(500).json({ error: e.message }); }
|
||||||
|
});
|
||||||
|
|
||||||
// ── Events ────────────────────────────────────────────────────────────────────
|
// ── Events ────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
router.get('/', authMiddleware, async (req, res) => {
|
router.get('/', authMiddleware, async (req, res) => {
|
||||||
@@ -244,31 +258,55 @@ router.get('/:id', authMiddleware, async (req, res) => {
|
|||||||
} catch (e) { res.status(500).json({ error: e.message }); }
|
} catch (e) { res.status(500).json({ error: e.message }); }
|
||||||
});
|
});
|
||||||
|
|
||||||
router.post('/', authMiddleware, teamManagerMiddleware, async (req, res) => {
|
router.post('/', authMiddleware, async (req, res) => {
|
||||||
const { title, eventTypeId, startAt, endAt, allDay, location, description, isPublic, trackAvailability, userGroupIds=[], recurrenceRule } = req.body;
|
const { title, eventTypeId, startAt, endAt, allDay, location, description, isPublic, trackAvailability, userGroupIds=[], recurrenceRule } = req.body;
|
||||||
if (!title?.trim()) return res.status(400).json({ error: 'Title required' });
|
if (!title?.trim()) return res.status(400).json({ error: 'Title required' });
|
||||||
if (!startAt || !endAt) return res.status(400).json({ error: 'Start and end time required' });
|
if (!startAt || !endAt) return res.status(400).json({ error: 'Start and end time required' });
|
||||||
try {
|
try {
|
||||||
|
const itm = await isToolManagerFn(req.schema, req.user);
|
||||||
|
const groupIds = Array.isArray(userGroupIds) ? userGroupIds : [];
|
||||||
|
if (!itm) {
|
||||||
|
// Regular users: must select at least one group they belong to; event always private
|
||||||
|
if (!groupIds.length) return res.status(400).json({ error: 'Select at least one group' });
|
||||||
|
for (const ugId of groupIds) {
|
||||||
|
const member = await queryOne(req.schema, 'SELECT 1 FROM user_group_members WHERE user_id=$1 AND user_group_id=$2', [req.user.id, ugId]);
|
||||||
|
if (!member) return res.status(403).json({ error: 'You can only assign groups you belong to' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const effectiveIsPublic = itm ? (isPublic !== false) : false;
|
||||||
const r = await queryResult(req.schema, `
|
const r = await queryResult(req.schema, `
|
||||||
INSERT INTO events (title,event_type_id,start_at,end_at,all_day,location,description,is_public,track_availability,recurrence_rule,created_by)
|
INSERT INTO events (title,event_type_id,start_at,end_at,all_day,location,description,is_public,track_availability,recurrence_rule,created_by)
|
||||||
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11) RETURNING id
|
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11) RETURNING id
|
||||||
`, [title.trim(), eventTypeId||null, startAt, endAt, !!allDay, location||null, description||null,
|
`, [title.trim(), eventTypeId||null, startAt, endAt, !!allDay, location||null, description||null,
|
||||||
isPublic!==false, !!trackAvailability, recurrenceRule||null, req.user.id]);
|
effectiveIsPublic, !!trackAvailability, recurrenceRule||null, req.user.id]);
|
||||||
const eventId = r.rows[0].id;
|
const eventId = r.rows[0].id;
|
||||||
for (const ugId of (Array.isArray(userGroupIds) ? userGroupIds : []))
|
for (const ugId of groupIds)
|
||||||
await exec(req.schema, 'INSERT INTO event_user_groups (event_id,user_group_id) VALUES ($1,$2) ON CONFLICT DO NOTHING', [eventId, ugId]);
|
await exec(req.schema, 'INSERT INTO event_user_groups (event_id,user_group_id) VALUES ($1,$2) ON CONFLICT DO NOTHING', [eventId, ugId]);
|
||||||
if (Array.isArray(userGroupIds) && userGroupIds.length > 0)
|
if (groupIds.length > 0)
|
||||||
await postEventNotification(req.schema, eventId, req.user.id, false);
|
await postEventNotification(req.schema, eventId, req.user.id, false);
|
||||||
const event = await queryOne(req.schema, 'SELECT * FROM events WHERE id=$1', [eventId]);
|
const event = await queryOne(req.schema, 'SELECT * FROM events WHERE id=$1', [eventId]);
|
||||||
res.json({ event: await enrichEvent(req.schema, event) });
|
res.json({ event: await enrichEvent(req.schema, event) });
|
||||||
} catch (e) { res.status(500).json({ error: e.message }); }
|
} catch (e) { res.status(500).json({ error: e.message }); }
|
||||||
});
|
});
|
||||||
|
|
||||||
router.patch('/:id', authMiddleware, teamManagerMiddleware, async (req, res) => {
|
router.patch('/:id', authMiddleware, async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const event = await queryOne(req.schema, 'SELECT * FROM events WHERE id=$1', [req.params.id]);
|
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) return res.status(404).json({ error: 'Not found' });
|
||||||
const { title, eventTypeId, startAt, endAt, allDay, location, description, isPublic, trackAvailability, userGroupIds, recurrenceRule, recurringScope } = req.body;
|
const itm = await isToolManagerFn(req.schema, req.user);
|
||||||
|
if (!itm && event.created_by !== req.user.id) return res.status(403).json({ error: 'Access denied' });
|
||||||
|
let { title, eventTypeId, startAt, endAt, allDay, location, description, isPublic, trackAvailability, userGroupIds, recurrenceRule, recurringScope } = req.body;
|
||||||
|
if (!itm) {
|
||||||
|
// Regular users editing their own event: force private, validate group membership
|
||||||
|
isPublic = false;
|
||||||
|
if (Array.isArray(userGroupIds)) {
|
||||||
|
if (!userGroupIds.length) return res.status(400).json({ error: 'Select at least one group' });
|
||||||
|
for (const ugId of userGroupIds) {
|
||||||
|
const member = await queryOne(req.schema, 'SELECT 1 FROM user_group_members WHERE user_id=$1 AND user_group_id=$2', [req.user.id, ugId]);
|
||||||
|
if (!member) return res.status(403).json({ error: 'You can only assign groups you belong to' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
const fields = { title, eventTypeId, startAt, endAt, allDay, location, description, isPublic, trackAvailability, recurrenceRule, origEvent: event };
|
const fields = { title, eventTypeId, startAt, endAt, allDay, location, description, isPublic, trackAvailability, recurrenceRule, origEvent: event };
|
||||||
|
|
||||||
await applyEventUpdate(req.schema, req.params.id, fields, userGroupIds);
|
await applyEventUpdate(req.schema, req.params.id, fields, userGroupIds);
|
||||||
@@ -317,10 +355,12 @@ router.patch('/:id', authMiddleware, teamManagerMiddleware, async (req, res) =>
|
|||||||
} catch (e) { res.status(500).json({ error: e.message }); }
|
} catch (e) { res.status(500).json({ error: e.message }); }
|
||||||
});
|
});
|
||||||
|
|
||||||
router.delete('/:id', authMiddleware, teamManagerMiddleware, async (req, res) => {
|
router.delete('/:id', authMiddleware, async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const event = await queryOne(req.schema, 'SELECT * FROM events WHERE id=$1', [req.params.id]);
|
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) return res.status(404).json({ error: 'Not found' });
|
||||||
|
const itm = await isToolManagerFn(req.schema, req.user);
|
||||||
|
if (!itm && event.created_by !== req.user.id) return res.status(403).json({ error: 'Access denied' });
|
||||||
const { recurringScope } = req.body || {};
|
const { recurringScope } = req.body || {};
|
||||||
if (recurringScope === 'future' && event.recurrence_rule) {
|
if (recurringScope === 'future' && event.recurrence_rule) {
|
||||||
// Delete this event and all future occurrences with same creator/title
|
// Delete this event and all future occurrences with same creator/title
|
||||||
|
|||||||
2
build.sh
2
build.sh
@@ -13,7 +13,7 @@
|
|||||||
# ─────────────────────────────────────────────────────────────
|
# ─────────────────────────────────────────────────────────────
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
|
|
||||||
VERSION="${1:-0.12.30}"
|
VERSION="${1:-0.12.31}"
|
||||||
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.30",
|
"version": "0.12.31",
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
|
|||||||
@@ -39,6 +39,18 @@ export default function ChatWindow({ group, onBack, onGroupUpdated, onDirectMess
|
|||||||
return () => window.removeEventListener('resize', onResize);
|
return () => window.removeEventListener('resize', onResize);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
// On mobile, when the soft keyboard opens the visual viewport shrinks but the
|
||||||
|
// messages-container scroll position stays where it was, leaving the latest
|
||||||
|
// messages hidden behind the keyboard. Scroll to bottom whenever the visual
|
||||||
|
// viewport resizes (keyboard appear/dismiss) so the last message stays visible.
|
||||||
|
useEffect(() => {
|
||||||
|
const vv = window.visualViewport;
|
||||||
|
if (!vv) return;
|
||||||
|
const onVVResize = () => scrollToBottom();
|
||||||
|
vv.addEventListener('resize', onVVResize);
|
||||||
|
return () => vv.removeEventListener('resize', onVVResize);
|
||||||
|
}, [scrollToBottom]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
api.getSettings().then(({ settings }) => {
|
api.getSettings().then(({ settings }) => {
|
||||||
setIconGroupInfo(settings.icon_groupinfo || '');
|
setIconGroupInfo(settings.icon_groupinfo || '');
|
||||||
@@ -339,7 +351,7 @@ export default function ChatWindow({ group, onBack, onGroupUpdated, onDirectMess
|
|||||||
This channel is read-only
|
This channel is read-only
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<MessageInput group={group} currentUser={currentUser} onSend={handleSend} socket={socket} replyTo={replyTo} onCancelReply={() => setReplyTo(null)} onTyping={() => {}} onTextChange={val => onHasTextChange?.(!!val.trim())} />
|
<MessageInput group={group} currentUser={currentUser} onSend={handleSend} socket={socket} replyTo={replyTo} onCancelReply={() => setReplyTo(null)} onTyping={() => {}} onTextChange={val => onHasTextChange?.(!!val.trim())} onInputFocus={() => scrollToBottom()} />
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
{showInfo && (
|
{showInfo && (
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ function isEmojiOnly(str) {
|
|||||||
return emojiRegex.test(str.trim());
|
return emojiRegex.test(str.trim());
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function MessageInput({ group, replyTo, onCancelReply, onSend, onTyping, onTextChange, onlineUserIds = new Set() }) {
|
export default function MessageInput({ group, replyTo, onCancelReply, onSend, onTyping, onTextChange, onInputFocus, onlineUserIds = new Set() }) {
|
||||||
const [text, setText] = useState('');
|
const [text, setText] = useState('');
|
||||||
const [imageFile, setImageFile] = useState(null);
|
const [imageFile, setImageFile] = useState(null);
|
||||||
const [imagePreview, setImagePreview] = useState(null);
|
const [imagePreview, setImagePreview] = useState(null);
|
||||||
@@ -380,10 +380,11 @@ export default function MessageInput({ group, replyTo, onCancelReply, onSend, on
|
|||||||
<textarea
|
<textarea
|
||||||
ref={inputRef}
|
ref={inputRef}
|
||||||
className="msg-input"
|
className="msg-input"
|
||||||
placeholder={`Message ${group?.name || ''}...`}
|
placeholder="Text message"
|
||||||
value={text}
|
value={text}
|
||||||
onChange={handleChange}
|
onChange={handleChange}
|
||||||
onKeyDown={handleKeyDown}
|
onKeyDown={handleKeyDown}
|
||||||
|
onFocus={onInputFocus}
|
||||||
rows={1}
|
rows={1}
|
||||||
style={{ resize: 'none' }} />
|
style={{ resize: 'none' }} />
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -346,7 +346,7 @@ function RecurringChoiceModal({ title, onConfirm, onCancel }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ── 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, userId }) {
|
||||||
const toast = useToast();
|
const toast = useToast();
|
||||||
// Use local date for default, not UTC slice (avoids off-by-one for UTC- timezones)
|
// Use local date for default, not UTC slice (avoids off-by-one for UTC- timezones)
|
||||||
const defDate = selectedDate || new Date();
|
const defDate = selectedDate || new Date();
|
||||||
@@ -373,7 +373,7 @@ export default function MobileEventForm({ event, eventTypes, userGroups, selecte
|
|||||||
const mountedRef = useRef(false);
|
const mountedRef = useRef(false);
|
||||||
const [allDay, setAllDay] = useState(!!event?.all_day);
|
const [allDay, setAllDay] = useState(!!event?.all_day);
|
||||||
const [track, setTrack] = useState(!!event?.track_availability);
|
const [track, setTrack] = useState(!!event?.track_availability);
|
||||||
const [isPrivate, setIsPrivate] = useState(event ? !event.is_public : false);
|
const [isPrivate, setIsPrivate] = useState(event ? !event.is_public : !isToolManager);
|
||||||
const [groups, setGroups] = useState(new Set((event?.user_groups||[]).map(g=>g.id)));
|
const [groups, setGroups] = useState(new Set((event?.user_groups||[]).map(g=>g.id)));
|
||||||
const [location, setLocation] = useState(event?.location||'');
|
const [location, setLocation] = useState(event?.location||'');
|
||||||
const [description, setDescription] = useState(event?.description||'');
|
const [description, setDescription] = useState(event?.description||'');
|
||||||
@@ -439,6 +439,7 @@ export default function MobileEventForm({ event, eventTypes, userGroups, selecte
|
|||||||
|
|
||||||
const handle = () => {
|
const handle = () => {
|
||||||
if(!title.trim()) return toast('Title required','error');
|
if(!title.trim()) return toast('Title required','error');
|
||||||
|
if(!isToolManager && groups.size === 0) return toast('Select at least one group','error');
|
||||||
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');
|
||||||
@@ -452,7 +453,7 @@ export default function MobileEventForm({ event, eventTypes, userGroups, selecte
|
|||||||
setShowScopeModal(false);
|
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:isToolManager?!isPrivate:false, trackAvailability:track, userGroupIds:[...groups], recurrenceRule:recRule||null };
|
||||||
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'); }
|
||||||
@@ -554,11 +555,14 @@ export default function MobileEventForm({ event, eventTypes, userGroups, selecte
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Private Event */}
|
{/* Private Event — tool managers can toggle; regular users always private */}
|
||||||
<div style={{ display:'flex',alignItems:'center',padding:'14px 20px',borderBottom:'1px solid var(--border)' }}>
|
<div style={{ display:'flex',alignItems:'center',padding:'14px 20px',borderBottom:'1px solid var(--border)' }}>
|
||||||
<span style={{ color:'var(--text-tertiary)',width:20,textAlign:'center',marginRight:16 }}><svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><path d="M17.94 17.94A10.07 10.07 0 0 1 12 20c-7 0-11-8-11-8a18.45 18.45 0 0 1 5.06-5.94"/><path d="M9.9 4.24A9.12 9.12 0 0 1 12 4c7 0 11 8 11 8a18.5 18.5 0 0 1-2.16 3.19"/><line x1="1" y1="1" x2="23" y2="23"/></svg></span>
|
<span style={{ color:'var(--text-tertiary)',width:20,textAlign:'center',marginRight:16 }}><svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><path d="M17.94 17.94A10.07 10.07 0 0 1 12 20c-7 0-11-8-11-8a18.45 18.45 0 0 1 5.06-5.94"/><path d="M9.9 4.24A9.12 9.12 0 0 1 12 4c7 0 11 8 11 8a18.5 18.5 0 0 1-2.16 3.19"/><line x1="1" y1="1" x2="23" y2="23"/></svg></span>
|
||||||
<span style={{ flex:1,fontSize:15 }}>Private Event</span>
|
<span style={{ flex:1,fontSize:15 }}>Private Event</span>
|
||||||
<Toggle checked={isPrivate} onChange={setIsPrivate}/>
|
{isToolManager
|
||||||
|
? <Toggle checked={isPrivate} onChange={setIsPrivate}/>
|
||||||
|
: <span style={{ fontSize:13,color:'var(--text-tertiary)' }}>Always private</span>
|
||||||
|
}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Location */}
|
{/* Location */}
|
||||||
@@ -572,7 +576,7 @@ export default function MobileEventForm({ event, eventTypes, userGroups, selecte
|
|||||||
</MobileRow>
|
</MobileRow>
|
||||||
|
|
||||||
{/* Delete */}
|
{/* Delete */}
|
||||||
{event && isToolManager && (
|
{event && (isToolManager || (userId && event.created_by === userId)) && (
|
||||||
<div style={{ padding:'16px 20px' }}>
|
<div style={{ padding:'16px 20px' }}>
|
||||||
<button onClick={()=>onDelete(event)} style={{ width:'100%',padding:'14px',border:'1px solid var(--error)',borderRadius:'var(--radius)',background:'transparent',color:'var(--error)',fontSize:15,fontWeight:600,cursor:'pointer' }}>Delete Event</button>
|
<button onClick={()=>onDelete(event)} style={{ width:'100%',padding:'14px',border:'1px solid var(--error)',borderRadius:'var(--radius)',background:'transparent',color:'var(--error)',fontSize:15,fontWeight:600,cursor:'pointer' }}>Delete Event</button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -538,7 +538,7 @@ function ConfirmModal({ title, message, confirmLabel='Delete', onConfirm, onCanc
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ── Event Form ────────────────────────────────────────────────────────────────
|
// ── Event Form ────────────────────────────────────────────────────────────────
|
||||||
function EventForm({ event, userGroups, eventTypes, selectedDate, onSave, onCancel, onDelete, isToolManager }) {
|
function EventForm({ event, userGroups, eventTypes, selectedDate, onSave, onCancel, onDelete, isToolManager, userId }) {
|
||||||
const toast=useToast();
|
const toast=useToast();
|
||||||
const _defD = selectedDate || new Date();
|
const _defD = selectedDate || new Date();
|
||||||
const _p = n => String(n).padStart(2,'0');
|
const _p = n => String(n).padStart(2,'0');
|
||||||
@@ -552,7 +552,7 @@ function EventForm({ event, userGroups, eventTypes, selectedDate, onSave, onCanc
|
|||||||
const [allDay,setAllDay]=useState(!!event?.all_day);
|
const [allDay,setAllDay]=useState(!!event?.all_day);
|
||||||
const [loc,setLoc]=useState(event?.location||'');
|
const [loc,setLoc]=useState(event?.location||'');
|
||||||
const [desc,setDesc]=useState(event?.description||'');
|
const [desc,setDesc]=useState(event?.description||'');
|
||||||
const [pub,setPub]=useState(event?!!event.is_public:true);
|
const [pub,setPub]=useState(event?!!event.is_public:!!isToolManager);
|
||||||
const [track,setTrack]=useState(!!event?.track_availability);
|
const [track,setTrack]=useState(!!event?.track_availability);
|
||||||
const [grps,setGrps]=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 [saving,setSaving]=useState(false);
|
||||||
@@ -628,12 +628,12 @@ function EventForm({ event, userGroups, eventTypes, selectedDate, onSave, onCanc
|
|||||||
useEffect(()=>{ mountedRef.current = true; },[]);
|
useEffect(()=>{ mountedRef.current = true; },[]);
|
||||||
|
|
||||||
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 || !isToolManager; // tracking requires groups; non-managers always require groups
|
||||||
|
|
||||||
const handle=()=>{
|
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','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');
|
||||||
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');
|
||||||
@@ -645,7 +645,7 @@ function EventForm({ event, userGroups, eventTypes, selectedDate, onSave, onCanc
|
|||||||
setShowScopeModal(false);
|
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:isToolManager?pub:false,trackAvailability:track,userGroupIds:[...grps],recurrenceRule:recRule||null};
|
||||||
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);}
|
||||||
@@ -725,14 +725,14 @@ function EventForm({ event, userGroups, eventTypes, selectedDate, onSave, onCanc
|
|||||||
</div>
|
</div>
|
||||||
<p style={{fontSize:11,color:groupsRequired&&grps.size===0?'var(--error)':'var(--text-tertiary)',marginTop:4}}>
|
<p style={{fontSize:11,color:groupsRequired&&grps.size===0?'var(--error)':'var(--text-tertiary)',marginTop:4}}>
|
||||||
{grps.size===0
|
{grps.size===0
|
||||||
? (groupsRequired?'At least one group required for availability tracking':'No groups — event visible to all (if public)')
|
? (groupsRequired?'At least one group required':'No groups — event visible to all (if public)')
|
||||||
: `${grps.size} group${grps.size!==1?'s':''} selected`}
|
: `${grps.size} group${grps.size!==1?'s':''} selected`}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</FormRow>
|
</FormRow>
|
||||||
|
|
||||||
{/* Visibility — only shown if groups selected OR tracking */}
|
{/* Visibility — only tool managers can set; regular users always create private events */}
|
||||||
{(grps.size>0||track) && (
|
{isToolManager && (grps.size>0||track) && (
|
||||||
<FormRow label="Visibility">
|
<FormRow label="Visibility">
|
||||||
<label style={{display:'flex',alignItems:'center',gap:10,fontSize:13,cursor:'pointer',paddingTop:6}}>
|
<label style={{display:'flex',alignItems:'center',gap:10,fontSize:13,cursor:'pointer',paddingTop:6}}>
|
||||||
<input type="checkbox" checked={!pub} onChange={e=>setPub(!e.target.checked)}/>
|
<input type="checkbox" checked={!pub} onChange={e=>setPub(!e.target.checked)}/>
|
||||||
@@ -754,7 +754,7 @@ function EventForm({ event, userGroups, eventTypes, selectedDate, onSave, onCanc
|
|||||||
<div style={{display:'flex',gap:8,marginTop:8}}>
|
<div style={{display:'flex',gap:8,marginTop:8}}>
|
||||||
<button className="btn btn-primary btn-sm" onClick={handle} disabled={saving}>{saving?'Saving…':event?'Save Changes':'Create Event'}</button>
|
<button className="btn btn-primary btn-sm" onClick={handle} disabled={saving}>{saving?'Saving…':event?'Save Changes':'Create Event'}</button>
|
||||||
<button className="btn btn-secondary btn-sm" onClick={onCancel}>Cancel</button>
|
<button className="btn btn-secondary btn-sm" onClick={onCancel}>Cancel</button>
|
||||||
{event&&isToolManager&&<button className="btn btn-sm" style={{marginLeft:'auto',background:'var(--error)',color:'white'}} onClick={()=>onDelete(event)}>Delete</button>}
|
{event&&(isToolManager||(userId&&event.created_by===userId))&&<button className="btn btn-sm" style={{marginLeft:'auto',background:'var(--error)',color:'white'}} onClick={()=>onDelete(event)}>Delete</button>}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -764,7 +764,7 @@ function EventForm({ event, userGroups, eventTypes, selectedDate, onSave, onCanc
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ── Event Detail Modal ────────────────────────────────────────────────────────
|
// ── Event Detail Modal ────────────────────────────────────────────────────────
|
||||||
function EventDetailModal({ event, onClose, onEdit, onAvailabilityChange, isToolManager }) {
|
function EventDetailModal({ event, onClose, onEdit, onAvailabilityChange, isToolManager, userId }) {
|
||||||
const toast=useToast();
|
const toast=useToast();
|
||||||
const [myResp,setMyResp]=useState(event.my_response);
|
const [myResp,setMyResp]=useState(event.my_response);
|
||||||
const [avail,setAvail]=useState(event.availability||[]);
|
const [avail,setAvail]=useState(event.availability||[]);
|
||||||
@@ -800,7 +800,7 @@ function EventDetailModal({ event, onClose, onEdit, onAvailabilityChange, isTool
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div style={{display:'flex',gap:6,flexShrink:0}}>
|
<div style={{display:'flex',gap:6,flexShrink:0}}>
|
||||||
{isToolManager&&<button className="btn btn-secondary btn-sm" onClick={()=>{onClose();onEdit();}}>Edit</button>}
|
{(isToolManager||(userId&&event.created_by===userId))&&<button className="btn btn-secondary btn-sm" onClick={()=>{onClose();onEdit();}}>Edit</button>}
|
||||||
<button className="btn-icon" onClick={onClose}><svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg></button>
|
<button className="btn-icon" onClick={onClose}><svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg></button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -1081,6 +1081,10 @@ function parseKeywords(raw) {
|
|||||||
function ScheduleView({ events, selectedDate, onSelect, filterKeyword='', filterTypeId='', filterAvailability=false, filterFromDate=null, isMobile=false }) {
|
function ScheduleView({ events, selectedDate, onSelect, filterKeyword='', filterTypeId='', filterAvailability=false, filterFromDate=null, isMobile=false }) {
|
||||||
const y=selectedDate.getFullYear(), m=selectedDate.getMonth();
|
const y=selectedDate.getFullYear(), m=selectedDate.getMonth();
|
||||||
const today=new Date(); today.setHours(0,0,0,0);
|
const today=new Date(); today.setHours(0,0,0,0);
|
||||||
|
const todayRef = useRef(null);
|
||||||
|
useEffect(()=>{
|
||||||
|
if(todayRef.current) todayRef.current.scrollIntoView({ block:'start', behavior:'instant' });
|
||||||
|
},[selectedDate.getFullYear(), selectedDate.getMonth()]);
|
||||||
const terms=parseKeywords(filterKeyword);
|
const terms=parseKeywords(filterKeyword);
|
||||||
const hasFilters = terms.length > 0 || !!filterTypeId || filterAvailability;
|
const hasFilters = terms.length > 0 || !!filterTypeId || filterAvailability;
|
||||||
// Only keyword/availability filters should shift the date window to today-onwards.
|
// Only keyword/availability filters should shift the date window to today-onwards.
|
||||||
@@ -1135,9 +1139,13 @@ function ScheduleView({ events, selectedDate, onSelect, filterKeyword='', filter
|
|||||||
? `No events — ${MONTHS[m]} ${y} is in the past`
|
? `No events — ${MONTHS[m]} ${y} is in the past`
|
||||||
: `No events in ${MONTHS[m]} ${y}`;
|
: `No events in ${MONTHS[m]} ${y}`;
|
||||||
if(!filtered.length) return <div style={{textAlign:'center',padding:'60px 20px',color:'var(--text-tertiary)',fontSize:14}}>{emptyMsg}</div>;
|
if(!filtered.length) return <div style={{textAlign:'center',padding:'60px 20px',color:'var(--text-tertiary)',fontSize:14}}>{emptyMsg}</div>;
|
||||||
|
let todayMarked = false;
|
||||||
return <>{filtered.map(e=>{
|
return <>{filtered.map(e=>{
|
||||||
const s=new Date(e.start_at);
|
const s=new Date(e.start_at);
|
||||||
const end=new Date(e.end_at);
|
const end=new Date(e.end_at);
|
||||||
|
const sDay=new Date(s); sDay.setHours(0,0,0,0);
|
||||||
|
const isFirstTodayOrFuture = !todayMarked && sDay >= today;
|
||||||
|
if(isFirstTodayOrFuture) todayMarked = true;
|
||||||
const isPast = !e.all_day && end < now; // event fully ended
|
const isPast = !e.all_day && end < now; // event fully ended
|
||||||
const col = isPast ? '#9ca3af' : (e.event_type?.colour||'#9ca3af');
|
const col = isPast ? '#9ca3af' : (e.event_type?.colour||'#9ca3af');
|
||||||
const textColor = isPast ? 'var(--text-tertiary)' : 'var(--text-primary)';
|
const textColor = isPast ? 'var(--text-tertiary)' : 'var(--text-primary)';
|
||||||
@@ -1158,7 +1166,7 @@ function ScheduleView({ events, selectedDate, onSelect, filterKeyword='', filter
|
|||||||
: BELL_ICON
|
: BELL_ICON
|
||||||
);
|
);
|
||||||
return(
|
return(
|
||||||
<div key={`${e.id}-${e.start_at}`} onClick={()=>onSelect(e)} style={{display:'flex',alignItems:'center',gap:rowGap,padding:rowPad,borderBottom:'1px solid var(--border)',cursor:'pointer',opacity:isPast?0.7:1}} onMouseEnter={el=>el.currentTarget.style.background='var(--background)'} onMouseLeave={el=>el.currentTarget.style.background=''}>
|
<div key={`${e.id}-${e.start_at}`} ref={isFirstTodayOrFuture ? todayRef : null} onClick={()=>onSelect(e)} style={{display:'flex',alignItems:'center',gap:rowGap,padding:rowPad,borderBottom:'1px solid var(--border)',cursor:'pointer',opacity:isPast?0.7:1}} onMouseEnter={el=>el.currentTarget.style.background='var(--background)'} onMouseLeave={el=>el.currentTarget.style.background=''}>
|
||||||
{/* Date column */}
|
{/* Date column */}
|
||||||
<div style={{width:datW,textAlign:'center',flexShrink:0}}>
|
<div style={{width:datW,textAlign:'center',flexShrink:0}}>
|
||||||
<div style={{fontSize:datFs,fontWeight:700,lineHeight:1,color:textColor}}>{s.getDate()}</div>
|
<div style={{fontSize:datFs,fontWeight:700,lineHeight:1,color:textColor}}>{s.getDate()}</div>
|
||||||
@@ -1254,7 +1262,12 @@ function DayView({ events: rawEvents, selectedDate, onSelect, onSwipe }) {
|
|||||||
const tzLabel=`GMT${tzOff>=0?'+':'-'}${String(Math.floor(Math.abs(tzOff)/60)).padStart(2,'0')}`;
|
const tzLabel=`GMT${tzOff>=0?'+':'-'}${String(Math.floor(Math.abs(tzOff)/60)).padStart(2,'0')}`;
|
||||||
const scrollRef = useRef(null);
|
const scrollRef = useRef(null);
|
||||||
const touchRef = useRef({ x:0, y:0 });
|
const touchRef = useRef({ x:0, y:0 });
|
||||||
useEffect(()=>{ if(scrollRef.current) scrollRef.current.scrollTop = 7 * HOUR_H; },[selectedDate]);
|
useEffect(()=>{
|
||||||
|
if(!scrollRef.current) return;
|
||||||
|
const now = new Date();
|
||||||
|
const topPx = Math.max(0, now.getHours() * HOUR_H + (now.getMinutes() / 60) * HOUR_H - 2 * HOUR_H);
|
||||||
|
scrollRef.current.scrollTop = topPx;
|
||||||
|
},[selectedDate]);
|
||||||
const fmtHour = h => h===0?'12 AM':h<12?`${h} AM`:h===12?'12 PM':`${h-12} PM`;
|
const fmtHour = h => h===0?'12 AM':h<12?`${h} AM`:h===12?'12 PM':`${h-12} PM`;
|
||||||
const handleTouchStart = e => { touchRef.current = { x:e.touches[0].clientX, y:e.touches[0].clientY }; };
|
const handleTouchStart = e => { touchRef.current = { x:e.touches[0].clientX, y:e.touches[0].clientY }; };
|
||||||
const handleTouchEnd = e => {
|
const handleTouchEnd = e => {
|
||||||
@@ -1325,7 +1338,12 @@ function WeekView({ events: rawEvents, selectedDate, onSelect }) {
|
|||||||
const tzLabel=`GMT${tzOff>=0?'+':'-'}${String(Math.floor(Math.abs(tzOff)/60)).padStart(2,'0')}`;
|
const tzLabel=`GMT${tzOff>=0?'+':'-'}${String(Math.floor(Math.abs(tzOff)/60)).padStart(2,'0')}`;
|
||||||
const scrollRef = useRef(null);
|
const scrollRef = useRef(null);
|
||||||
const touchRef = useRef({ x:0, y:0 });
|
const touchRef = useRef({ x:0, y:0 });
|
||||||
useEffect(()=>{ if(scrollRef.current) scrollRef.current.scrollTop = 7 * HOUR_H; },[selectedDate]);
|
useEffect(()=>{
|
||||||
|
if(!scrollRef.current) return;
|
||||||
|
const now = new Date();
|
||||||
|
const topPx = Math.max(0, now.getHours() * HOUR_H + (now.getMinutes() / 60) * HOUR_H - 2 * HOUR_H);
|
||||||
|
scrollRef.current.scrollTop = topPx;
|
||||||
|
},[selectedDate]);
|
||||||
const fmtHour = h => h===0?'12 AM':h<12?`${h} AM`:h===12?'12 PM':`${h-12} PM`;
|
const fmtHour = h => h===0?'12 AM':h<12?`${h} AM`:h===12?'12 PM':`${h-12} PM`;
|
||||||
const handleTouchStart = e => { touchRef.current = { x:e.touches[0].clientX, y:e.touches[0].clientY }; };
|
const handleTouchStart = e => { touchRef.current = { x:e.touches[0].clientX, y:e.touches[0].clientY }; };
|
||||||
const handleTouchEnd = e => {
|
const handleTouchEnd = e => {
|
||||||
@@ -1476,7 +1494,7 @@ export default function SchedulePage({ isToolManager, isMobile, onProfile, onHel
|
|||||||
const contentRef = useRef(null);
|
const contentRef = useRef(null);
|
||||||
|
|
||||||
const load = useCallback(() => {
|
const load = useCallback(() => {
|
||||||
const ugPromise = isToolManager ? api.getUserGroups() : Promise.resolve({ groups: [] });
|
const ugPromise = isToolManager ? api.getUserGroups() : api.getMyScheduleGroups();
|
||||||
Promise.all([api.getEvents(), api.getEventTypes(), ugPromise])
|
Promise.all([api.getEvents(), api.getEventTypes(), ugPromise])
|
||||||
.then(([ev,et,ug]) => { setEvents(ev.events||[]); setEventTypes(et.eventTypes||[]); setUserGroups(ug.groups||[]); setLoading(false); })
|
.then(([ev,et,ug]) => { setEvents(ev.events||[]); setEventTypes(et.eventTypes||[]); setUserGroups(ug.groups||[]); setLoading(false); })
|
||||||
.catch(() => setLoading(false));
|
.catch(() => setLoading(false));
|
||||||
@@ -1484,8 +1502,8 @@ export default function SchedulePage({ isToolManager, isMobile, onProfile, onHel
|
|||||||
|
|
||||||
useEffect(() => { load(); }, [load]);
|
useEffect(() => { load(); }, [load]);
|
||||||
|
|
||||||
// Reset scroll to top whenever the selected date or view changes
|
// Reset scroll to top on date/view change; schedule view scrolls to today via ScheduleView's own effect
|
||||||
useEffect(() => { if (contentRef.current) contentRef.current.scrollTop = 0; }, [selDate, view]);
|
useEffect(() => { if (contentRef.current && view !== 'schedule') contentRef.current.scrollTop = 0; }, [selDate, view]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!createOpen) return;
|
if (!createOpen) return;
|
||||||
@@ -1556,26 +1574,27 @@ export default function SchedulePage({ isToolManager, isMobile, onProfile, onHel
|
|||||||
<div style={{ padding:'16px 16px 0' }}>
|
<div style={{ padding:'16px 16px 0' }}>
|
||||||
<div style={{ fontSize:16, fontWeight:700, marginBottom:12, color:'var(--text-primary)' }}>Team Schedule</div>
|
<div style={{ fontSize:16, fontWeight:700, marginBottom:12, color:'var(--text-primary)' }}>Team Schedule</div>
|
||||||
|
|
||||||
{/* Create button — styled like new-chat-btn */}
|
{/* Create button — visible to all users */}
|
||||||
{isToolManager && (
|
<div style={{ position:'relative', marginBottom:12 }} ref={createRef}>
|
||||||
<div style={{ position:'relative', marginBottom:12 }} ref={createRef}>
|
<button className="newchat-btn" onClick={() => setCreateOpen(v=>!v)} style={{ width:'100%', justifyContent:'center', gap:8 }}>
|
||||||
<button className="newchat-btn" onClick={() => setCreateOpen(v=>!v)} style={{ width:'100%', justifyContent:'center', gap:8 }}>
|
Create Event
|
||||||
Create Event
|
{isToolManager && <svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><polyline points="6 9 12 15 18 9"/></svg>}
|
||||||
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><polyline points="6 9 12 15 18 9"/></svg>
|
</button>
|
||||||
</button>
|
{createOpen && (
|
||||||
{createOpen && (
|
<div style={{ position:'absolute', top:'100%', left:0, right:0, zIndex:100, background:'var(--surface-variant)', border:'1px solid var(--border)', borderRadius:'var(--radius)', marginTop:4, boxShadow:'0 4px 16px rgba(0,0,0,0.18)' }}>
|
||||||
<div style={{ position:'absolute', top:'100%', left:0, right:0, zIndex:100, background:'var(--surface-variant)', border:'1px solid var(--border)', borderRadius:'var(--radius)', marginTop:4, boxShadow:'0 4px 16px rgba(0,0,0,0.18)' }}>
|
{[
|
||||||
{[['Event', ()=>{setPanel('eventForm');setEditingEvent(null);setCreateOpen(false);setFilterKeyword('');setFilterTypeId('');}],
|
['Event', ()=>{setPanel('eventForm');setEditingEvent(null);setCreateOpen(false);setFilterKeyword('');setFilterTypeId('');}],
|
||||||
|
...(isToolManager ? [
|
||||||
['Event Type', ()=>{setPanel('eventTypes');setCreateOpen(false);setFilterKeyword('');setFilterTypeId('');}],
|
['Event Type', ()=>{setPanel('eventTypes');setCreateOpen(false);setFilterKeyword('');setFilterTypeId('');}],
|
||||||
['Bulk Event Import', ()=>{setPanel('bulkImport');setCreateOpen(false);}]
|
['Bulk Event Import', ()=>{setPanel('bulkImport');setCreateOpen(false);}],
|
||||||
].map(([label,action])=>(
|
] : []),
|
||||||
<button key={label} onClick={action} style={{display:'block',width:'100%',padding:'9px 16px',textAlign:'left',fontSize:14,background:'none',border:'none',cursor:'pointer',color:'var(--text-primary)'}}
|
].map(([label,action])=>(
|
||||||
onMouseEnter={e=>e.currentTarget.style.background='var(--background)'} onMouseLeave={e=>e.currentTarget.style.background=''}>{label}</button>
|
<button key={label} onClick={action} style={{display:'block',width:'100%',padding:'9px 16px',textAlign:'left',fontSize:14,background:'none',border:'none',cursor:'pointer',color:'var(--text-primary)'}}
|
||||||
))}
|
onMouseEnter={e=>e.currentTarget.style.background='var(--background)'} onMouseLeave={e=>e.currentTarget.style.background=''}>{label}</button>
|
||||||
</div>
|
))}
|
||||||
)}
|
</div>
|
||||||
</div>
|
)}
|
||||||
)}
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Mini calendar */}
|
{/* Mini calendar */}
|
||||||
@@ -1691,10 +1710,10 @@ export default function SchedulePage({ isToolManager, isMobile, onProfile, onHel
|
|||||||
{panel === 'calendar' && view === 'week' && <WeekView events={events} selectedDate={selDate} onSelect={openDetail}/>}
|
{panel === 'calendar' && view === 'week' && <WeekView events={events} selectedDate={selDate} onSelect={openDetail}/>}
|
||||||
{panel === 'calendar' && view === 'month' && <MonthView events={events} selectedDate={selDate} onSelect={openDetail} onSelectDay={d=>{setSelDate(d);setView('day');}}/>}
|
{panel === 'calendar' && view === 'month' && <MonthView events={events} selectedDate={selDate} onSelect={openDetail} onSelectDay={d=>{setSelDate(d);setView('day');}}/>}
|
||||||
|
|
||||||
{panel === 'eventForm' && isToolManager && !isMobile && (
|
{panel === 'eventForm' && !isMobile && (
|
||||||
<div style={{ padding:28, maxWidth:1024 }}>
|
<div style={{ padding:28, maxWidth:1024 }}>
|
||||||
<h2 style={{ fontSize:17, fontWeight:700, marginBottom:24 }}>{editingEvent?'Edit Event':'New Event'}</h2>
|
<h2 style={{ fontSize:17, fontWeight:700, marginBottom:24 }}>{editingEvent?'Edit Event':'New Event'}</h2>
|
||||||
<EventForm event={editingEvent} userGroups={userGroups} eventTypes={eventTypes} selectedDate={selDate} isToolManager={isToolManager}
|
<EventForm event={editingEvent} userGroups={userGroups} eventTypes={eventTypes} selectedDate={selDate} isToolManager={isToolManager} userId={user.id}
|
||||||
onSave={handleSaved} onCancel={()=>{setPanel('calendar');setEditingEvent(null);setFilterKeyword('');setFilterTypeId('');}} onDelete={handleDelete}/>
|
onSave={handleSaved} onCancel={()=>{setPanel('calendar');setEditingEvent(null);setFilterKeyword('');setFilterTypeId('');}} onDelete={handleDelete}/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -1739,7 +1758,7 @@ export default function SchedulePage({ isToolManager, isMobile, onProfile, onHel
|
|||||||
<MobileGroupManager onClose={() => setMobilePanel(null)}/>
|
<MobileGroupManager onClose={() => setMobilePanel(null)}/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{panel === 'eventForm' && isToolManager && isMobile && (
|
{panel === 'eventForm' && isMobile && (
|
||||||
<div style={{ position:'fixed', top:0, left:0, right:0, bottom:0, zIndex:40, background:'var(--background)', display:'flex', flexDirection:'column' }}>
|
<div style={{ position:'fixed', top:0, left:0, right:0, bottom:0, zIndex:40, background:'var(--background)', display:'flex', flexDirection:'column' }}>
|
||||||
<MobileEventForm
|
<MobileEventForm
|
||||||
event={editingEvent}
|
event={editingEvent}
|
||||||
@@ -1747,6 +1766,7 @@ export default function SchedulePage({ isToolManager, isMobile, onProfile, onHel
|
|||||||
eventTypes={eventTypes}
|
eventTypes={eventTypes}
|
||||||
selectedDate={selDate}
|
selectedDate={selDate}
|
||||||
isToolManager={isToolManager}
|
isToolManager={isToolManager}
|
||||||
|
userId={user.id}
|
||||||
onSave={handleSaved}
|
onSave={handleSaved}
|
||||||
onCancel={()=>{setPanel('calendar');setEditingEvent(null);setFilterKeyword('');setFilterTypeId('');}}
|
onCancel={()=>{setPanel('calendar');setEditingEvent(null);setFilterKeyword('');setFilterTypeId('');}}
|
||||||
onDelete={handleDelete} />
|
onDelete={handleDelete} />
|
||||||
@@ -1754,14 +1774,17 @@ export default function SchedulePage({ isToolManager, isMobile, onProfile, onHel
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Mobile FAB — same position as Messages newchat-fab */}
|
{/* Mobile FAB — same position as Messages newchat-fab */}
|
||||||
{isMobile && isToolManager && panel === 'calendar' && (
|
{isMobile && panel === 'calendar' && (
|
||||||
<div ref={createRef} style={{ position:'fixed', bottom:80, right:16, zIndex:30 }}>
|
<div ref={createRef} style={{ position:'fixed', bottom:80, right:16, zIndex:30 }}>
|
||||||
<button className="newchat-fab" style={{ position:'static' }} onClick={() => setCreateOpen(v=>!v)}>
|
<button className="newchat-fab" style={{ position:'static' }} onClick={() => {
|
||||||
|
if (isToolManager) { setCreateOpen(v=>!v); }
|
||||||
|
else { setPanel('eventForm'); setEditingEvent(null); setFilterKeyword(''); setFilterTypeId(''); }
|
||||||
|
}}>
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" strokeWidth={2} stroke="currentColor" width="24" height="24">
|
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" strokeWidth={2} stroke="currentColor" width="24" height="24">
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" d="M12 4.5v15m7.5-7.5h-15" />
|
<path strokeLinecap="round" strokeLinejoin="round" d="M12 4.5v15m7.5-7.5h-15" />
|
||||||
</svg>
|
</svg>
|
||||||
</button>
|
</button>
|
||||||
{createOpen && (
|
{isToolManager && createOpen && (
|
||||||
<div style={{ position:'absolute', bottom:'calc(100% + 8px)', right:0, zIndex:100, background:'var(--surface-variant)', border:'1px solid var(--border)', borderRadius:'var(--radius)', boxShadow:'0 -4px 16px rgba(0,0,0,0.15)', minWidth:180 }}>
|
<div style={{ position:'absolute', bottom:'calc(100% + 8px)', right:0, zIndex:100, background:'var(--surface-variant)', border:'1px solid var(--border)', borderRadius:'var(--radius)', boxShadow:'0 -4px 16px rgba(0,0,0,0.15)', minWidth:180 }}>
|
||||||
{[['Event', ()=>{setPanel('eventForm');setEditingEvent(null);setCreateOpen(false);setFilterKeyword('');setFilterTypeId('');}],
|
{[['Event', ()=>{setPanel('eventForm');setEditingEvent(null);setCreateOpen(false);setFilterKeyword('');setFilterTypeId('');}],
|
||||||
['Event Type', ()=>{setPanel('eventTypes');setCreateOpen(false);setFilterKeyword('');setFilterTypeId('');}],
|
['Event Type', ()=>{setPanel('eventTypes');setCreateOpen(false);setFilterKeyword('');setFilterTypeId('');}],
|
||||||
@@ -1779,6 +1802,7 @@ export default function SchedulePage({ isToolManager, isMobile, onProfile, onHel
|
|||||||
<EventDetailModal
|
<EventDetailModal
|
||||||
event={detailEvent}
|
event={detailEvent}
|
||||||
isToolManager={isToolManager}
|
isToolManager={isToolManager}
|
||||||
|
userId={user.id}
|
||||||
onClose={() => setDetailEvent(null)}
|
onClose={() => setDetailEvent(null)}
|
||||||
onEdit={() => { setEditingEvent(detailEvent); setPanel('eventForm'); setDetailEvent(null); }}
|
onEdit={() => { setEditingEvent(detailEvent); setPanel('eventForm'); setDetailEvent(null); }}
|
||||||
onAvailabilityChange={(resp) => {
|
onAvailabilityChange={(resp) => {
|
||||||
|
|||||||
@@ -447,27 +447,50 @@ export default function Chat() {
|
|||||||
setActiveGroupId(id);
|
setActiveGroupId(id);
|
||||||
if (isMobile) {
|
if (isMobile) {
|
||||||
setShowSidebar(false);
|
setShowSidebar(false);
|
||||||
// Push a history entry so swipe-back returns to sidebar instead of exiting the app
|
// The mount sentinel covers the first back gesture — no extra push needed here
|
||||||
window.history.pushState({ rosterchirpChatOpen: true }, '');
|
|
||||||
}
|
}
|
||||||
// Clear notifications and unread count for this group
|
// Clear notifications and unread count for this group
|
||||||
setNotifications(prev => prev.filter(n => n.groupId !== id));
|
setNotifications(prev => prev.filter(n => n.groupId !== id));
|
||||||
setUnreadGroups(prev => { const next = new Map(prev); next.delete(id); return next; });
|
setUnreadGroups(prev => { const next = new Map(prev); next.delete(id); return next; });
|
||||||
};
|
};
|
||||||
|
|
||||||
// Handle browser back gesture on mobile — return to sidebar instead of exiting
|
// Establish one history sentinel on mount (mobile only) so back gestures are
|
||||||
|
// always interceptable without accumulating extra entries.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const handlePopState = (e) => {
|
if (window.innerWidth < 768) {
|
||||||
if (isMobile && activeGroupId) {
|
window.history.replaceState({ rc: 'chat' }, '');
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Handle browser back gesture on mobile — step through the navigation hierarchy:
|
||||||
|
// chat open → list view for the current page → Messages → exit app
|
||||||
|
useEffect(() => {
|
||||||
|
const handlePopState = () => {
|
||||||
|
if (!isMobile) return;
|
||||||
|
|
||||||
|
if (activeGroupId) {
|
||||||
|
// Close the open chat, stay on the current page's list (chat or groupmessages)
|
||||||
setShowSidebar(true);
|
setShowSidebar(true);
|
||||||
setActiveGroupId(null);
|
setActiveGroupId(null);
|
||||||
// Push another entry so subsequent back gestures are also intercepted
|
setChatHasText(false);
|
||||||
window.history.pushState({ rosterchirpChatOpen: true }, '');
|
window.history.pushState({ rc: 'chat' }, '');
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (page !== 'chat') {
|
||||||
|
// On a secondary page (groupmessages / users / groups / schedule / hostpanel)
|
||||||
|
// — return to the default Messages page
|
||||||
|
setPage('chat');
|
||||||
|
window.history.pushState({ rc: 'chat' }, '');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Already at root (Messages list, no chat open) — let the browser handle
|
||||||
|
// it so the next gesture actually exits the PWA. Don't re-push.
|
||||||
};
|
};
|
||||||
window.addEventListener('popstate', handlePopState);
|
window.addEventListener('popstate', handlePopState);
|
||||||
return () => window.removeEventListener('popstate', handlePopState);
|
return () => window.removeEventListener('popstate', handlePopState);
|
||||||
}, [isMobile, activeGroupId]);
|
}, [isMobile, activeGroupId, page]);
|
||||||
|
|
||||||
// Update page title AND PWA app badge with total unread count
|
// Update page title AND PWA app badge with total unread count
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|||||||
@@ -106,6 +106,7 @@ export const api = {
|
|||||||
updateTeamSettings: (body) => req('PATCH', '/settings/team', body),
|
updateTeamSettings: (body) => req('PATCH', '/settings/team', body),
|
||||||
|
|
||||||
// Schedule Manager
|
// Schedule Manager
|
||||||
|
getMyScheduleGroups: () => req('GET', '/schedule/my-groups'),
|
||||||
getEventTypes: () => req('GET', '/schedule/event-types'),
|
getEventTypes: () => req('GET', '/schedule/event-types'),
|
||||||
createEventType: (body) => req('POST', '/schedule/event-types', body),
|
createEventType: (body) => req('POST', '/schedule/event-types', body),
|
||||||
updateEventType: (id, body) => req('PATCH', `/schedule/event-types/${id}`, body),
|
updateEventType: (id, body) => req('PATCH', `/schedule/event-types/${id}`, body),
|
||||||
|
|||||||
Reference in New Issue
Block a user