v0.9.63 updated for mobile
This commit is contained in:
@@ -10,7 +10,7 @@
|
||||
PROJECT_NAME=jama
|
||||
|
||||
# Image version to run (set by build.sh, or use 'latest')
|
||||
JAMA_VERSION=0.9.62
|
||||
JAMA_VERSION=0.9.63
|
||||
|
||||
# App port — the host port Docker maps to the container
|
||||
PORT=3000
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "jama-backend",
|
||||
"version": "0.9.62",
|
||||
"version": "0.9.63",
|
||||
"description": "TeamChat backend server",
|
||||
"main": "src/index.js",
|
||||
"scripts": {
|
||||
|
||||
@@ -432,6 +432,7 @@ function initDb() {
|
||||
// Migration: add columns if missing (must run before inserts)
|
||||
try { db.exec("ALTER TABLE event_types ADD COLUMN is_protected INTEGER NOT NULL DEFAULT 0"); } catch(e) {}
|
||||
try { db.exec("ALTER TABLE event_types ADD COLUMN default_duration_hrs REAL"); } catch(e) {}
|
||||
try { db.exec("ALTER TABLE events ADD COLUMN recurrence_rule TEXT"); } catch(e) {}
|
||||
// Delete the legacy "Default" type — "Event" is the canonical default
|
||||
db.prepare("DELETE FROM event_types WHERE name = 'Default'").run();
|
||||
// Seed built-in event types — "Event" is the primary default (1hr, protected, cannot edit/delete)
|
||||
|
||||
@@ -36,6 +36,9 @@ function enrichEvent(db, event) {
|
||||
event.event_type = event.event_type_id
|
||||
? db.prepare('SELECT * FROM event_types WHERE id = ?').get(event.event_type_id)
|
||||
: null;
|
||||
if (event.recurrence_rule && typeof event.recurrence_rule === 'string') {
|
||||
try { event.recurrence_rule = JSON.parse(event.recurrence_rule); } catch(e) { event.recurrence_rule = null; }
|
||||
}
|
||||
event.user_groups = db.prepare(`
|
||||
SELECT ug.id, ug.name FROM event_user_groups eug
|
||||
JOIN user_groups ug ON ug.id = eug.user_group_id
|
||||
@@ -150,15 +153,16 @@ router.get('/:id', authMiddleware, (req, res) => {
|
||||
|
||||
// Create event
|
||||
router.post('/', authMiddleware, teamManagerMiddleware, (req, res) => {
|
||||
const { title, eventTypeId, startAt, endAt, allDay, location, description, isPublic, trackAvailability, userGroupIds = [] } = 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 (!startAt || !endAt) return res.status(400).json({ error: 'Start and end time required' });
|
||||
const db = getDb();
|
||||
const r = db.prepare(`INSERT INTO events (title, event_type_id, start_at, end_at, all_day, location, description, is_public, track_availability, created_by)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`).run(
|
||||
const r = db.prepare(`INSERT INTO events (title, event_type_id, start_at, end_at, all_day, location, description, is_public, track_availability, recurrence_rule, created_by)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`).run(
|
||||
title.trim(), eventTypeId || null, startAt, endAt,
|
||||
allDay ? 1 : 0, location || null, description || null,
|
||||
isPublic !== false ? 1 : 0, trackAvailability ? 1 : 0, req.user.id
|
||||
isPublic !== false ? 1 : 0, trackAvailability ? 1 : 0,
|
||||
recurrenceRule ? JSON.stringify(recurrenceRule) : null, req.user.id
|
||||
);
|
||||
const eventId = r.lastInsertRowid;
|
||||
for (const ugId of (Array.isArray(userGroupIds) ? userGroupIds : []))
|
||||
@@ -172,12 +176,14 @@ router.patch('/:id', authMiddleware, teamManagerMiddleware, (req, res) => {
|
||||
const db = getDb();
|
||||
const event = db.prepare('SELECT * FROM events WHERE id = ?').get(req.params.id);
|
||||
if (!event) return res.status(404).json({ error: 'Not found' });
|
||||
const { title, eventTypeId, startAt, endAt, allDay, location, description, isPublic, trackAvailability, userGroupIds } = req.body;
|
||||
const { title, eventTypeId, startAt, endAt, allDay, location, description, isPublic, trackAvailability, userGroupIds, recurrenceRule } = req.body;
|
||||
db.prepare(`UPDATE events SET
|
||||
title = COALESCE(?, title), event_type_id = ?, start_at = COALESCE(?, start_at),
|
||||
end_at = COALESCE(?, end_at), all_day = COALESCE(?, all_day),
|
||||
location = ?, description = ?, is_public = COALESCE(?, is_public),
|
||||
track_availability = COALESCE(?, track_availability), updated_at = datetime('now')
|
||||
track_availability = COALESCE(?, track_availability),
|
||||
recurrence_rule = ?,
|
||||
updated_at = datetime('now')
|
||||
WHERE id = ?`).run(
|
||||
title?.trim() || null, eventTypeId !== undefined ? (eventTypeId || null) : event.event_type_id,
|
||||
startAt || null, endAt || null, allDay !== undefined ? (allDay ? 1 : 0) : null,
|
||||
@@ -185,6 +191,7 @@ router.patch('/:id', authMiddleware, teamManagerMiddleware, (req, res) => {
|
||||
description !== undefined ? (description || null) : event.description,
|
||||
isPublic !== undefined ? (isPublic ? 1 : 0) : null,
|
||||
trackAvailability !== undefined ? (trackAvailability ? 1 : 0) : null,
|
||||
recurrenceRule !== undefined ? (recurrenceRule ? JSON.stringify(recurrenceRule) : null) : event.recurrence_rule,
|
||||
req.params.id
|
||||
);
|
||||
if (Array.isArray(userGroupIds)) {
|
||||
|
||||
2
build.sh
2
build.sh
@@ -13,7 +13,7 @@
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
set -euo pipefail
|
||||
|
||||
VERSION="${1:-0.9.62}"
|
||||
VERSION="${1:-0.9.63}"
|
||||
ACTION="${2:-}"
|
||||
REGISTRY="${REGISTRY:-}"
|
||||
IMAGE_NAME="jama"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "jama-frontend",
|
||||
"version": "0.9.62",
|
||||
"version": "0.9.63",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
|
||||
356
frontend/src/components/MobileEventForm.jsx
Normal file
356
frontend/src/components/MobileEventForm.jsx
Normal file
@@ -0,0 +1,356 @@
|
||||
import { useState, useEffect, useRef } from 'react';
|
||||
import { api } from '../utils/api.js';
|
||||
import { useToast } from '../contexts/ToastContext.jsx';
|
||||
|
||||
// ── Utilities ─────────────────────────────────────────────────────────────────
|
||||
const DAYS = ['Sun','Mon','Tue','Wed','Thu','Fri','Sat'];
|
||||
const MONTHS = ['January','February','March','April','May','June','July','August','September','October','November','December'];
|
||||
const SHORT_MONTHS = ['Jan','Feb','Mar','Apr','May','Jun','Jul','Aug','Sep','Oct','Nov','Dec'];
|
||||
const DAY_PILLS = ['S','M','T','W','T','F','S'];
|
||||
const DAY_KEYS = ['SU','MO','TU','WE','TH','FR','SA'];
|
||||
|
||||
const TIME_SLOTS = (() => {
|
||||
const s=[];
|
||||
for(let h=0;h<24;h++) for(let m of [0,30]) {
|
||||
const hh=String(h).padStart(2,'0'), mm=String(m).padStart(2,'0');
|
||||
const disp=`${h===0?12:h>12?h-12:h}:${mm} ${h<12?'AM':'PM'}`;
|
||||
s.push({value:`${hh}:${mm}`,label:disp});
|
||||
}
|
||||
return s;
|
||||
})();
|
||||
|
||||
function toDateIn(iso) { return iso ? iso.slice(0,10) : ''; }
|
||||
function toTimeIn(iso) {
|
||||
if(!iso) return '';
|
||||
const d=new Date(iso);
|
||||
const h=String(d.getHours()).padStart(2,'0'), m=d.getMinutes()<30?'00':'30';
|
||||
return `${h}:${m}`;
|
||||
}
|
||||
function buildISO(d,t) { return d&&t?`${d}T${t}:00`:''; }
|
||||
function addHours(iso,h){ const d=new Date(iso); d.setMinutes(d.getMinutes()+h*60); const pad=n=>String(n).padStart(2,'0'); return `${d.getFullYear()}-${pad(d.getMonth()+1)}-${pad(d.getDate())}T${pad(d.getHours())}:${pad(d.getMinutes())}:00`; }
|
||||
function fmtDateDisplay(iso) { if(!iso) return ''; const d=new Date(iso); return `${DAYS[d.getDay()]}, ${SHORT_MONTHS[d.getMonth()]} ${d.getDate()}, ${d.getFullYear()}`; }
|
||||
function fmtTimeDisplay(slot) { const f=TIME_SLOTS.find(s=>s.value===slot); return f?f.label:slot; }
|
||||
|
||||
const FREQ_OPTIONS = [
|
||||
{ value: '', label: 'Does not repeat' },
|
||||
{ value: 'daily', label: 'Every day' },
|
||||
{ value: 'weekly', label: 'Every week' },
|
||||
{ value: 'monthly', label: 'Every month' },
|
||||
{ value: 'yearly', label: 'Every year' },
|
||||
{ value: 'custom', label: 'Custom…' },
|
||||
];
|
||||
function recurrenceLabel(rule) {
|
||||
if (!rule || !rule.freq) return 'Does not repeat';
|
||||
if (rule.freq === 'custom') { const unit = (rule.interval||1)===1 ? rule.unit : `${rule.interval} ${rule.unit}s`; return `Every ${unit}`; }
|
||||
return FREQ_OPTIONS.find(o=>o.value===rule.freq)?.label || rule.freq;
|
||||
}
|
||||
|
||||
// ── Toggle Switch ─────────────────────────────────────────────────────────────
|
||||
function Toggle({ checked, onChange }) {
|
||||
return (
|
||||
<div onClick={()=>onChange(!checked)} style={{ width:44,height:24,borderRadius:12,background:checked?'var(--primary)':'var(--surface-variant)',cursor:'pointer',position:'relative',transition:'background 0.2s',flexShrink:0 }}>
|
||||
<div style={{ position:'absolute',top:2,left:checked?22:2,width:20,height:20,borderRadius:'50%',background:'white',transition:'left 0.2s',boxShadow:'0 1px 3px rgba(0,0,0,0.2)' }}/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Calendar Picker Overlay ───────────────────────────────────────────────────
|
||||
function CalendarPicker({ value, onChange, onClose }) {
|
||||
const [cur, setCur] = useState(() => { const d = new Date(value||Date.now()); d.setDate(1); return d; });
|
||||
const y=cur.getFullYear(), m=cur.getMonth(), first=new Date(y,m,1).getDay(), total=new Date(y,m+1,0).getDate(), today=new Date();
|
||||
const cells=[]; for(let i=0;i<first;i++) cells.push(null); for(let d=1;d<=total;d++) cells.push(d);
|
||||
const selDate = value ? new Date(value+'T00:00:00') : null;
|
||||
return (
|
||||
<div style={{ position:'fixed',inset:0,zIndex:200,display:'flex',alignItems:'flex-end' }} onClick={e=>e.target===e.currentTarget&&onClose()}>
|
||||
<div style={{ width:'100%',background:'var(--surface)',borderRadius:'16px 16px 0 0',padding:20,boxShadow:'0 -4px 20px rgba(0,0,0,0.2)' }}>
|
||||
<div style={{ fontSize:13,color:'var(--text-tertiary)',marginBottom:4 }}>Select Date</div>
|
||||
<div style={{ fontSize:22,fontWeight:700,marginBottom:12 }}>
|
||||
{selDate ? `${SHORT_MONTHS[selDate.getMonth()]} ${selDate.getDate()}, ${selDate.getFullYear()}` : '—'}
|
||||
</div>
|
||||
<div style={{ display:'flex',alignItems:'center',justifyContent:'space-between',marginBottom:8 }}>
|
||||
<button onClick={()=>{const n=new Date(cur);n.setMonth(m-1);setCur(n);}} style={{ background:'none',border:'none',fontSize:20,cursor:'pointer',color:'var(--text-secondary)',padding:'4px 10px' }}>‹</button>
|
||||
<span style={{ fontWeight:600 }}>{MONTHS[m]} {y}</span>
|
||||
<button onClick={()=>{const n=new Date(cur);n.setMonth(m+1);setCur(n);}} style={{ background:'none',border:'none',fontSize:20,cursor:'pointer',color:'var(--text-secondary)',padding:'4px 10px' }}>›</button>
|
||||
</div>
|
||||
<div style={{ display:'grid',gridTemplateColumns:'repeat(7,1fr)',gap:2,marginBottom:12 }}>
|
||||
{['S','M','T','W','T','F','S'].map((d,i)=><div key={i} style={{ textAlign:'center',fontSize:11,fontWeight:600,color:'var(--text-tertiary)',padding:'4px 0' }}>{d}</div>)}
|
||||
{cells.map((d,i) => {
|
||||
if(!d) return <div key={i}/>;
|
||||
const date=new Date(y,m,d);
|
||||
const isSel = selDate && date.toDateString()===selDate.toDateString();
|
||||
const isToday = date.toDateString()===today.toDateString();
|
||||
return <div key={i} onClick={()=>onChange(`${y}-${String(m+1).padStart(2,'0')}-${String(d).padStart(2,'0')}`)} style={{ textAlign:'center',padding:'8px 4px',borderRadius:'50%',cursor:'pointer',background:isSel?'var(--primary)':'transparent',color:isSel?'white':isToday?'var(--primary)':'var(--text-primary)',fontWeight:isToday&&!isSel?700:400,fontSize:14 }}>{d}</div>;
|
||||
})}
|
||||
</div>
|
||||
<div style={{ display:'flex',justifyContent:'flex-end',gap:12 }}>
|
||||
<button onClick={onClose} style={{ background:'none',border:'none',color:'var(--text-secondary)',fontSize:14,cursor:'pointer',padding:'8px 16px' }}>Cancel</button>
|
||||
<button onClick={onClose} style={{ background:'none',border:'none',color:'var(--primary)',fontSize:14,fontWeight:700,cursor:'pointer',padding:'8px 16px' }}>OK</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Recurrence Sheet ──────────────────────────────────────────────────────────
|
||||
function RecurrenceSheet({ value, onChange, onClose }) {
|
||||
const rule = value || {};
|
||||
const [showCustom, setShowCustom] = useState(rule.freq==='custom');
|
||||
const [customRule, setCustomRule] = useState(rule.freq==='custom' ? rule : {freq:'custom',interval:1,unit:'week',byDay:[],ends:'never',endDate:'',endCount:13});
|
||||
|
||||
const selectFreq = (freq) => {
|
||||
if(freq==='custom') { setShowCustom(true); return; }
|
||||
onChange(freq ? {freq} : null);
|
||||
onClose();
|
||||
};
|
||||
const upd = (k,v) => setCustomRule(r=>({...r,[k]:v}));
|
||||
|
||||
if(showCustom) return (
|
||||
<div style={{ position:'fixed',inset:0,zIndex:200,display:'flex',alignItems:'flex-end' }}>
|
||||
<div style={{ width:'100%',background:'var(--surface)',borderRadius:'16px 16px 0 0',padding:20,boxShadow:'0 -4px 20px rgba(0,0,0,0.2)',maxHeight:'90vh',overflowY:'auto' }}>
|
||||
<div style={{ display:'flex',alignItems:'center',justifyContent:'space-between',marginBottom:20 }}>
|
||||
<button onClick={()=>setShowCustom(false)} style={{ background:'none',border:'none',cursor:'pointer',color:'var(--text-secondary)',display:'flex',alignItems:'center',gap:6,fontSize:14 }}>
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><polyline points="15 18 9 12 15 6"/></svg>
|
||||
</button>
|
||||
<span style={{ fontWeight:700,fontSize:16 }}>Custom recurrence</span>
|
||||
<button onClick={()=>{onChange(customRule);onClose();}} style={{ background:'none',border:'none',cursor:'pointer',color:'var(--primary)',fontSize:14,fontWeight:700 }}>Done</button>
|
||||
</div>
|
||||
|
||||
<div style={{ marginBottom:16 }}>
|
||||
<div style={{ fontSize:12,color:'var(--text-tertiary)',marginBottom:8 }}>Repeats every</div>
|
||||
<div style={{ display:'flex',gap:10 }}>
|
||||
<input type="number" className="input" min={1} max={99} value={customRule.interval||1} onChange={e=>upd('interval',Math.max(1,parseInt(e.target.value)||1))} style={{ width:70,textAlign:'center',fontSize:16 }}/>
|
||||
<select className="input" value={customRule.unit||'week'} onChange={e=>upd('unit',e.target.value)} style={{ flex:1,fontSize:14 }}>
|
||||
{['day','week','month','year'].map(u=><option key={u} value={u}>{u}{(customRule.interval||1)>1?'s':''}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{(customRule.unit||'week')==='week' && (
|
||||
<div style={{ marginBottom:16 }}>
|
||||
<div style={{ fontSize:12,color:'var(--text-tertiary)',marginBottom:8 }}>Repeats on</div>
|
||||
<div style={{ display:'flex',gap:8 }}>
|
||||
{DAY_PILLS.map((d,i)=>{
|
||||
const key=DAY_KEYS[i], sel=(customRule.byDay||[]).includes(key);
|
||||
return <button key={key} type="button" onClick={()=>upd('byDay',sel?(customRule.byDay||[]).filter(x=>x!==key):[...(customRule.byDay||[]),key])} style={{ flex:1,aspectRatio:'1',borderRadius:'50%',border:'1px solid var(--border)',background:sel?'var(--primary)':'transparent',color:sel?'white':'var(--text-primary)',fontSize:12,fontWeight:600,cursor:'pointer',padding:4 }}>{d}</button>;
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div style={{ marginBottom:16 }}>
|
||||
<div style={{ fontSize:12,color:'var(--text-tertiary)',marginBottom:8 }}>Ends</div>
|
||||
{[['never','Never'],['on','On'],['after','After']].map(([val,lbl])=>(
|
||||
<div key={val} style={{ display:'flex',alignItems:'center',gap:12,padding:'12px 0',borderBottom:'1px solid var(--border)' }}>
|
||||
<div onClick={()=>upd('ends',val)} style={{ width:20,height:20,borderRadius:'50%',border:`2px solid ${(customRule.ends||'never')===val?'var(--primary)':'var(--border)'}`,display:'flex',alignItems:'center',justifyContent:'center',cursor:'pointer',flexShrink:0 }}>
|
||||
{(customRule.ends||'never')===val&&<div style={{ width:10,height:10,borderRadius:'50%',background:'var(--primary)' }}/>}
|
||||
</div>
|
||||
<span style={{ flex:1,fontSize:15 }}>{lbl}</span>
|
||||
{val==='on'&&(customRule.ends||'never')==='on'&&<input type="date" className="input" value={customRule.endDate||''} onChange={e=>upd('endDate',e.target.value)} style={{ width:150 }}/>}
|
||||
{val==='after'&&(customRule.ends||'never')==='after'&&<><input type="number" className="input" min={1} max={999} value={customRule.endCount||13} onChange={e=>upd('endCount',parseInt(e.target.value)||1)} style={{ width:64,textAlign:'center' }}/><span style={{ fontSize:13,color:'var(--text-tertiary)' }}>occurrences</span></>}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<div style={{ position:'fixed',inset:0,zIndex:200,display:'flex',alignItems:'flex-end' }} onClick={e=>e.target===e.currentTarget&&onClose()}>
|
||||
<div style={{ width:'100%',background:'var(--surface)',borderRadius:'16px 16px 0 0',padding:20,boxShadow:'0 -4px 20px rgba(0,0,0,0.2)' }}>
|
||||
{FREQ_OPTIONS.map(opt=>(
|
||||
<div key={opt.value} onClick={()=>selectFreq(opt.value)} style={{ display:'flex',alignItems:'center',gap:12,padding:'14px 4px',borderBottom:'1px solid var(--border)',cursor:'pointer' }}>
|
||||
<div style={{ width:20,height:20,borderRadius:'50%',border:`2px solid ${(rule.freq||'')===(opt.value)?'var(--primary)':'var(--border)'}`,display:'flex',alignItems:'center',justifyContent:'center',flexShrink:0 }}>
|
||||
{(rule.freq||'')===(opt.value)&&<div style={{ width:10,height:10,borderRadius:'50%',background:'var(--primary)' }}/>}
|
||||
</div>
|
||||
<span style={{ fontSize:16 }}>{opt.label}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Main Mobile Event Form ────────────────────────────────────────────────────
|
||||
export default function MobileEventForm({ event, eventTypes, userGroups, selectedDate, onSave, onCancel, onDelete, isToolManager }) {
|
||||
const toast = useToast();
|
||||
const def = selectedDate ? selectedDate.toISOString().slice(0,10) : new Date().toISOString().slice(0,10);
|
||||
const [title, setTitle] = useState(event?.title||'');
|
||||
const [typeId, setTypeId] = useState(event?.event_type_id ? String(event.event_type_id) : '');
|
||||
const [sd, setSd] = useState(event ? toDateIn(event.start_at) : def);
|
||||
const [st, setSt] = useState(event ? toTimeIn(event.start_at) : '09:00');
|
||||
const [ed, setEd] = useState(event ? toDateIn(event.end_at) : def);
|
||||
const [et, setEt] = useState(event ? toTimeIn(event.end_at) : '10:00');
|
||||
const [allDay, setAllDay] = useState(!!event?.all_day);
|
||||
const [track, setTrack] = useState(!!event?.track_availability);
|
||||
const [isPrivate, setIsPrivate] = useState(event ? !event.is_public : false);
|
||||
const [groups, setGroups] = useState(new Set((event?.user_groups||[]).map(g=>g.id)));
|
||||
const [location, setLocation] = useState(event?.location||'');
|
||||
const [description, setDescription] = useState(event?.description||'');
|
||||
const [recRule, setRecRule] = useState(event?.recurrence_rule||null);
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
// Overlay state
|
||||
const [showStartDate, setShowStartDate] = useState(false);
|
||||
const [showEndDate, setShowEndDate] = useState(false);
|
||||
const [showRecurrence, setShowRecurrence] = useState(false);
|
||||
const [showGroups, setShowGroups] = useState(false);
|
||||
|
||||
// Auto-set typeId to default event type
|
||||
useEffect(() => {
|
||||
if(!event && typeId==='' && eventTypes.length>0) {
|
||||
const def = eventTypes.find(t=>t.is_default) || eventTypes[0];
|
||||
if(def) setTypeId(String(def.id));
|
||||
}
|
||||
}, [eventTypes]);
|
||||
|
||||
// When start date changes, match end date
|
||||
useEffect(() => { if(!event) setEd(sd); }, [sd]);
|
||||
|
||||
// When type or start time changes, auto-set end time
|
||||
useEffect(() => {
|
||||
if(!sd||!st) return;
|
||||
const typ = eventTypes.find(t=>t.id===Number(typeId));
|
||||
const dur = typ?.default_duration_hrs||1;
|
||||
const start = buildISO(sd,st);
|
||||
if(start && !event) { setEd(toDateIn(addHours(start,dur))); setEt(toTimeIn(addHours(start,dur))); }
|
||||
}, [typeId, st]);
|
||||
|
||||
const handle = async () => {
|
||||
if(!title.trim()) return toast('Title required','error');
|
||||
setSaving(true);
|
||||
try {
|
||||
const body = { title:title.trim(), eventTypeId:typeId||null, startAt:allDay?`${sd}T00:00:00`:buildISO(sd,st), endAt:allDay?`${ed}T23:59:59`:buildISO(ed,et), allDay, location, description, isPublic:!isPrivate, trackAvailability:track, userGroupIds:[...groups], recurrenceRule:recRule||null };
|
||||
const r = event ? await api.updateEvent(event.id, body) : await api.createEvent(body);
|
||||
onSave(r.event);
|
||||
} catch(e) { toast(e.message,'error'); }
|
||||
finally { setSaving(false); }
|
||||
};
|
||||
|
||||
const currentType = eventTypes.find(t=>t.id===Number(typeId));
|
||||
|
||||
const Row = ({ icon, label, children, onPress, value, border=true }) => (
|
||||
<div onClick={onPress} style={{ display:'flex',alignItems:'center',gap:16,padding:'14px 20px',borderBottom:border?'1px solid var(--border)':'none',cursor:onPress?'pointer':'default',minHeight:52 }}>
|
||||
<span style={{ color:'var(--text-tertiary)',flexShrink:0,width:20,textAlign:'center' }}>{icon}</span>
|
||||
<div style={{ flex:1,minWidth:0 }}>
|
||||
{label && <div style={{ fontSize:12,color:'var(--text-tertiary)',marginBottom:2 }}>{label}</div>}
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<div style={{ display:'flex',flexDirection:'column',height:'100%',background:'var(--background)' }}>
|
||||
{/* Header */}
|
||||
<div style={{ display:'flex',alignItems:'center',justifyContent:'space-between',padding:'12px 16px',background:'var(--surface)',borderBottom:'1px solid var(--border)',flexShrink:0 }}>
|
||||
<button onClick={onCancel} style={{ background:'none',border:'none',cursor:'pointer',color:'var(--text-secondary)',display:'flex',alignItems:'center',gap:4,fontSize:14 }}>
|
||||
<svg width="20" height="20" 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>
|
||||
<span style={{ fontWeight:700,fontSize:16 }}>{event ? 'Edit Event' : 'New Event'}</span>
|
||||
<button onClick={handle} disabled={saving} style={{ background:'var(--primary)',border:'none',cursor:'pointer',color:'white',borderRadius:20,padding:'8px 20px',fontSize:14,fontWeight:700,opacity:saving?0.6:1 }}>{saving?'…':'Save'}</button>
|
||||
</div>
|
||||
|
||||
<div style={{ flex:1,overflowY:'auto' }}>
|
||||
{/* Title */}
|
||||
<div style={{ padding:'16px 20px',borderBottom:'1px solid var(--border)' }}>
|
||||
<input value={title} onChange={e=>setTitle(e.target.value)} placeholder="Add title" style={{ width:'100%',border:'none',background:'transparent',fontSize:22,fontWeight:700,color:'var(--text-primary)',outline:'none' }}/>
|
||||
</div>
|
||||
|
||||
{/* Event Type */}
|
||||
<Row icon={<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><path d="M22 2H2l8 9.46V19l4 2v-8.54L22 2z"/></svg>} label="Event Type">
|
||||
<select value={typeId} onChange={e=>setTypeId(e.target.value)} style={{ background:'transparent',border:'none',fontSize:15,color:'var(--text-primary)',width:'100%',outline:'none' }}>
|
||||
{eventTypes.map(t=><option key={t.id} value={t.id}>{t.name}</option>)}
|
||||
</select>
|
||||
</Row>
|
||||
|
||||
{/* All-day toggle */}
|
||||
<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"><circle cx="12" cy="12" r="10"/><polyline points="12 6 12 12 16 14"/></svg></span>
|
||||
<span style={{ flex:1,fontSize:15 }}>All day</span>
|
||||
<Toggle checked={allDay} onChange={setAllDay}/>
|
||||
</div>
|
||||
|
||||
{/* Start date/time */}
|
||||
<div onClick={()=>setShowStartDate(true)} style={{ display:'flex',alignItems:'center',padding:'12px 20px 6px 56px',cursor:'pointer' }}>
|
||||
<span style={{ flex:1,fontSize:15 }}>{fmtDateDisplay(sd)}</span>
|
||||
{!allDay && <span style={{ fontSize:15,color:'var(--primary)',fontWeight:600 }}>{fmtTimeDisplay(st)}</span>}
|
||||
</div>
|
||||
|
||||
{/* End date/time */}
|
||||
<div onClick={()=>setShowEndDate(true)} style={{ display:'flex',alignItems:'center',padding:'6px 20px 14px 56px',cursor:'pointer',borderBottom:'1px solid var(--border)' }}>
|
||||
<span style={{ flex:1,fontSize:15,color:'var(--text-secondary)' }}>{fmtDateDisplay(ed)}</span>
|
||||
{!allDay && (
|
||||
<select value={et} onChange={e=>setEt(e.target.value)} onClick={e=>e.stopPropagation()} style={{ fontSize:15,color:'var(--primary)',fontWeight:600,background:'transparent',border:'none',outline:'none' }}>
|
||||
{TIME_SLOTS.map(s=><option key={s.value} value={s.value}>{s.label}</option>)}
|
||||
</select>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Start time picker (show only if !allDay and user didn't tap date) */}
|
||||
{!allDay && (
|
||||
<div style={{ padding:'8px 20px 14px 56px',borderBottom:'1px solid var(--border)',display:'flex',gap:12,alignItems:'center' }}>
|
||||
<span style={{ fontSize:13,color:'var(--text-tertiary)' }}>Start</span>
|
||||
<select value={st} onChange={e=>setSt(e.target.value)} style={{ flex:1,fontSize:14,padding:'6px 8px',border:'1px solid var(--border)',borderRadius:'var(--radius)',background:'var(--surface)',color:'var(--text-primary)' }}>
|
||||
{TIME_SLOTS.map(s=><option key={s.value} value={s.value}>{s.label}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Recurrence */}
|
||||
<Row icon={<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><polyline points="23 4 23 10 17 10"/><path d="M20.49 15a9 9 0 1 1-2.12-9.36L23 10"/></svg>} onPress={()=>setShowRecurrence(true)}>
|
||||
<span style={{ fontSize:15 }}>{recurrenceLabel(recRule)}</span>
|
||||
</Row>
|
||||
|
||||
{/* Track Availability */}
|
||||
<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="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z"/></svg></span>
|
||||
<span style={{ flex:1,fontSize:15 }}>Track Availability</span>
|
||||
<Toggle checked={track} onChange={setTrack}/>
|
||||
</div>
|
||||
|
||||
{/* Groups */}
|
||||
<div>
|
||||
<div onClick={()=>setShowGroups(!showGroups)} style={{ display:'flex',alignItems:'center',padding:'14px 20px',borderBottom:'1px solid var(--border)',cursor:'pointer' }}>
|
||||
<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 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/><path d="M23 21v-2a4 4 0 0 0-3-3.87"/><path d="M16 3.13a4 4 0 0 1 0 7.75"/></svg></span>
|
||||
<span style={{ flex:1,fontSize:15 }}>{groups.size>0 ? `${groups.size} group${groups.size!==1?'s':''} selected` : 'Add Groups'}</span>
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="var(--text-tertiary)" strokeWidth="2"><polyline points={showGroups?"18 15 12 9 6 15":"6 9 12 15 18 9"}/></svg>
|
||||
</div>
|
||||
{showGroups && userGroups.map(g=>(
|
||||
<label key={g.id} style={{ display:'flex',alignItems:'center',gap:14,padding:'12px 20px 12px 56px',borderBottom:'1px solid var(--border)',cursor:'pointer' }}>
|
||||
<input type="checkbox" checked={groups.has(g.id)} onChange={()=>setGroups(prev=>{const n=new Set(prev);n.has(g.id)?n.delete(g.id):n.add(g.id);return n;})} style={{ width:18,height:18,accentColor:'var(--primary)' }}/>
|
||||
<span style={{ fontSize:15 }}>{g.name}</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Private Event */}
|
||||
<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={{ flex:1,fontSize:15 }}>Private Event</span>
|
||||
<Toggle checked={isPrivate} onChange={setIsPrivate}/>
|
||||
</div>
|
||||
|
||||
{/* Location */}
|
||||
<Row icon={<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><path d="M21 10c0 7-9 13-9 13s-9-6-9-13a9 9 0 0 1 18 0z"/><circle cx="12" cy="10" r="3"/></svg>}>
|
||||
<input value={location} onChange={e=>setLocation(e.target.value)} placeholder="Add location" style={{ width:'100%',border:'none',background:'transparent',fontSize:15,color:'var(--text-primary)',outline:'none' }}/>
|
||||
</Row>
|
||||
|
||||
{/* Description */}
|
||||
<Row icon={<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><line x1="21" y1="10" x2="3" y2="10"/><line x1="21" y1="6" x2="3" y2="6"/><line x1="21" y1="14" x2="3" y2="14"/><line x1="21" y1="18" x2="3" y2="18"/></svg>} border={false}>
|
||||
<textarea value={description} onChange={e=>setDescription(e.target.value)} placeholder="Add description" rows={3} style={{ width:'100%',border:'none',background:'transparent',fontSize:15,color:'var(--text-primary)',outline:'none',resize:'none' }}/>
|
||||
</Row>
|
||||
|
||||
{/* Delete */}
|
||||
{event && isToolManager && (
|
||||
<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>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Overlays */}
|
||||
{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)}/>}
|
||||
{showRecurrence && <RecurrenceSheet value={recRule} onChange={v=>{setRecRule(v);}} onClose={()=>setShowRecurrence(false)}/>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
131
frontend/src/components/MobileGroupManager.jsx
Normal file
131
frontend/src/components/MobileGroupManager.jsx
Normal file
@@ -0,0 +1,131 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { api } from '../utils/api.js';
|
||||
import { useToast } from '../contexts/ToastContext.jsx';
|
||||
import Avatar from './Avatar.jsx';
|
||||
|
||||
export default function MobileGroupManager({ onClose }) {
|
||||
const toast = useToast();
|
||||
const [groups, setGroups] = useState([]);
|
||||
const [allUsers, setAllUsers] = useState([]);
|
||||
const [expanded, setExpanded] = useState(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [creating, setCreating] = useState(false);
|
||||
const [newName, setNewName] = useState('');
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [screen, setScreen] = useState('list'); // list | members
|
||||
const [activeGroup, setActiveGroup] = useState(null);
|
||||
|
||||
const load = async () => {
|
||||
try {
|
||||
const [ug, us] = await Promise.all([api.getUserGroups(), api.getUsers()]);
|
||||
setGroups(ug.groups || []);
|
||||
setAllUsers(us.users || []);
|
||||
} catch(e) { toast(e.message, 'error'); }
|
||||
finally { setLoading(false); }
|
||||
};
|
||||
|
||||
useEffect(() => { load(); }, []);
|
||||
|
||||
const createGroup = async () => {
|
||||
if(!newName.trim()) return;
|
||||
setSaving(true);
|
||||
try {
|
||||
await api.createUserGroup({ name: newName.trim() });
|
||||
setNewName(''); setCreating(false); load();
|
||||
} catch(e) { toast(e.message,'error'); }
|
||||
finally { setSaving(false); }
|
||||
};
|
||||
|
||||
const deleteGroup = async (g) => {
|
||||
if(!confirm(`Delete "${g.name}"?`)) return;
|
||||
try { await api.deleteUserGroup(g.id); load(); } catch(e) { toast(e.message,'error'); }
|
||||
};
|
||||
|
||||
const toggleMember = async (groupId, userId, isMember) => {
|
||||
try {
|
||||
if(isMember) await api.removeFromUserGroup(groupId, userId);
|
||||
else await api.addToUserGroup(groupId, userId);
|
||||
// Reload group members
|
||||
const ug = await api.getUserGroups();
|
||||
setGroups(ug.groups || []);
|
||||
if(activeGroup) setActiveGroup((ug.groups||[]).find(g=>g.id===activeGroup.id)||null);
|
||||
} catch(e) { toast(e.message,'error'); }
|
||||
};
|
||||
|
||||
if(loading) return (
|
||||
<div style={{ display:'flex',alignItems:'center',justifyContent:'center',height:'100%',color:'var(--text-tertiary)' }}>Loading…</div>
|
||||
);
|
||||
|
||||
// Members screen
|
||||
if(screen==='members' && activeGroup) {
|
||||
const memberIds = new Set((activeGroup.members||[]).map(m=>m.id||m.user_id));
|
||||
return (
|
||||
<div style={{ display:'flex',flexDirection:'column',height:'100%',background:'var(--background)' }}>
|
||||
<div style={{ display:'flex',alignItems:'center',gap:12,padding:'12px 16px',background:'var(--surface)',borderBottom:'1px solid var(--border)',flexShrink:0 }}>
|
||||
<button onClick={()=>setScreen('list')} style={{ background:'none',border:'none',cursor:'pointer',color:'var(--text-secondary)',display:'flex',alignItems:'center' }}>
|
||||
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><polyline points="15 18 9 12 15 6"/></svg>
|
||||
</button>
|
||||
<span style={{ fontWeight:700,fontSize:16,flex:1 }}>{activeGroup.name}</span>
|
||||
<span style={{ fontSize:13,color:'var(--text-tertiary)' }}>{memberIds.size} member{memberIds.size!==1?'s':''}</span>
|
||||
</div>
|
||||
<div style={{ flex:1,overflowY:'auto' }}>
|
||||
<div style={{ padding:'10px 16px 4px',fontSize:12,fontWeight:600,color:'var(--text-tertiary)',textTransform:'uppercase',letterSpacing:'0.5px' }}>All Users</div>
|
||||
{allUsers.map(u => {
|
||||
const isMember = memberIds.has(u.id);
|
||||
return (
|
||||
<div key={u.id} style={{ display:'flex',alignItems:'center',gap:12,padding:'12px 16px',borderBottom:'1px solid var(--border)' }}>
|
||||
<Avatar user={u} size="sm"/>
|
||||
<div style={{ flex:1,minWidth:0 }}>
|
||||
<div style={{ fontSize:15,fontWeight:500 }}>{u.display_name||u.name}</div>
|
||||
<div style={{ fontSize:12,color:'var(--text-tertiary)' }}>{u.role}</div>
|
||||
</div>
|
||||
<button onClick={()=>toggleMember(activeGroup.id, u.id, isMember)} style={{ padding:'8px 14px',borderRadius:20,border:`1px solid ${isMember?'var(--error)':'var(--primary)'}`,background:'transparent',color:isMember?'var(--error)':'var(--primary)',fontSize:13,fontWeight:600,cursor:'pointer',flexShrink:0 }}>
|
||||
{isMember ? 'Remove' : 'Add'}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Group list screen
|
||||
return (
|
||||
<div style={{ display:'flex',flexDirection:'column',height:'100%',background:'var(--background)' }}>
|
||||
<div style={{ display:'flex',alignItems:'center',justifyContent:'space-between',padding:'12px 16px',background:'var(--surface)',borderBottom:'1px solid var(--border)',flexShrink:0 }}>
|
||||
<button onClick={onClose} style={{ background:'none',border:'none',cursor:'pointer',color:'var(--text-secondary)',display:'flex' }}>
|
||||
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><polyline points="15 18 9 12 15 6"/></svg>
|
||||
</button>
|
||||
<span style={{ fontWeight:700,fontSize:16 }}>Group Manager</span>
|
||||
<button onClick={()=>setCreating(true)} style={{ background:'none',border:'none',cursor:'pointer',color:'var(--primary)',fontSize:24,lineHeight:1 }}>+</button>
|
||||
</div>
|
||||
|
||||
{creating && (
|
||||
<div style={{ padding:'12px 16px',background:'var(--surface)',borderBottom:'1px solid var(--border)',display:'flex',gap:10 }}>
|
||||
<input autoFocus value={newName} onChange={e=>setNewName(e.target.value)} onKeyDown={e=>e.key==='Enter'&&createGroup()} placeholder="Group name…" style={{ flex:1,padding:'8px 12px',border:'1px solid var(--border)',borderRadius:'var(--radius)',background:'var(--background)',color:'var(--text-primary)',fontSize:15 }}/>
|
||||
<button onClick={createGroup} disabled={saving||!newName.trim()} style={{ padding:'8px 16px',background:'var(--primary)',color:'white',border:'none',borderRadius:'var(--radius)',fontSize:14,fontWeight:600,cursor:'pointer',opacity:saving?0.6:1 }}>{saving?'…':'Create'}</button>
|
||||
<button onClick={()=>{setCreating(false);setNewName('');}} style={{ padding:'8px',background:'none',border:'none',cursor:'pointer',color:'var(--text-secondary)' }}>✕</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div style={{ flex:1,overflowY:'auto' }}>
|
||||
{groups.length===0 && <div style={{ textAlign:'center',padding:'60px 20px',color:'var(--text-tertiary)',fontSize:14 }}>No groups yet. Tap + to create one.</div>}
|
||||
{groups.map(g => (
|
||||
<div key={g.id} style={{ borderBottom:'1px solid var(--border)' }}>
|
||||
<div style={{ display:'flex',alignItems:'center',gap:12,padding:'14px 16px',cursor:'pointer' }} onClick={()=>{setActiveGroup(g);setScreen('members');}}>
|
||||
<div style={{ width:42,height:42,borderRadius:10,background:'var(--primary)',display:'flex',alignItems:'center',justifyContent:'center',color:'white',fontWeight:700,fontSize:14,flexShrink:0 }}>
|
||||
{g.name.substring(0,2).toUpperCase()}
|
||||
</div>
|
||||
<div style={{ flex:1,minWidth:0 }}>
|
||||
<div style={{ fontSize:15,fontWeight:600 }}>{g.name}</div>
|
||||
<div style={{ fontSize:12,color:'var(--text-tertiary)' }}>{(g.members||[]).length} member{(g.members||[]).length!==1?'s':''}</div>
|
||||
</div>
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="var(--text-tertiary)" strokeWidth="2"><polyline points="9 18 15 12 9 6"/></svg>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -78,7 +78,7 @@ export default function NavDrawer({ open, onClose, onMessages, onSchedule, onSch
|
||||
<>
|
||||
<div className="nav-drawer-section-label admin">Tools</div>
|
||||
{item(NAV_ICON.users, 'User Manager', onUsers)}
|
||||
{features.groupManager && !isMobile && item(NAV_ICON.groups, 'Group Manager', onGroupManager)}
|
||||
{features.groupManager && item(NAV_ICON.groups, 'Group Manager', onGroupManager)}
|
||||
{features.scheduleManager && item(
|
||||
NAV_ICON.schedules,
|
||||
'Schedule Manager',
|
||||
|
||||
@@ -4,6 +4,8 @@ import { api } from '../utils/api.js';
|
||||
import { useToast } from '../contexts/ToastContext.jsx';
|
||||
import { useAuth } from '../contexts/AuthContext.jsx';
|
||||
import UserFooter from './UserFooter.jsx';
|
||||
import MobileEventForm from './MobileEventForm.jsx';
|
||||
import MobileGroupManager from './MobileGroupManager.jsx';
|
||||
|
||||
// ── Utilities ─────────────────────────────────────────────────────────────────
|
||||
const DAYS = ['Sun','Mon','Tue','Wed','Thu','Fri','Sat'];
|
||||
@@ -164,6 +166,89 @@ function EventTypePopup({ userGroups, onSave, onClose, editing=null }) {
|
||||
);
|
||||
}
|
||||
|
||||
// ── Recurrence helpers ────────────────────────────────────────────────────────
|
||||
const FREQ_OPTIONS = [
|
||||
{ value: '', label: 'Does not repeat' },
|
||||
{ value: 'daily', label: 'Every day' },
|
||||
{ value: 'weekly', label: 'Every week' },
|
||||
{ value: 'monthly', label: 'Every month' },
|
||||
{ value: 'yearly', label: 'Every year' },
|
||||
{ value: 'custom', label: 'Custom…' },
|
||||
];
|
||||
const DAY_PILLS = ['S','M','T','W','T','F','S'];
|
||||
const DAY_KEYS = ['SU','MO','TU','WE','TH','FR','SA'];
|
||||
|
||||
function recurrenceLabel(rule) {
|
||||
if (!rule || !rule.freq) return 'Does not repeat';
|
||||
const opt = FREQ_OPTIONS.find(o => o.value === rule.freq);
|
||||
if (rule.freq !== 'custom') return opt?.label || rule.freq;
|
||||
// Custom summary
|
||||
const unit = rule.interval === 1 ? rule.unit : `${rule.interval} ${rule.unit}s`;
|
||||
return `Every ${unit}`;
|
||||
}
|
||||
|
||||
// Desktop recurrence selector — shown inline in the form
|
||||
function RecurrenceSelector({ value, onChange }) {
|
||||
// value: { freq, interval, unit, byDay, ends, endDate, endCount } or null
|
||||
const [showCustom, setShowCustom] = useState(false);
|
||||
const rule = value || {};
|
||||
|
||||
const handleFreqChange = (freq) => {
|
||||
if (freq === '') { onChange(null); return; }
|
||||
if (freq === 'custom') { setShowCustom(true); onChange({ freq:'custom', interval:1, unit:'week', byDay:[], ends:'never', endDate:'', endCount:13 }); return; }
|
||||
setShowCustom(false);
|
||||
onChange({ freq });
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<select className="input" value={rule.freq||''} onChange={e=>handleFreqChange(e.target.value)} style={{marginBottom: (rule.freq==='custom'||showCustom) ? 12 : 0}}>
|
||||
{FREQ_OPTIONS.map(o=><option key={o.value} value={o.value}>{o.label}</option>)}
|
||||
</select>
|
||||
{(rule.freq==='custom') && (
|
||||
<CustomRecurrenceFields rule={rule} onChange={onChange}/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function CustomRecurrenceFields({ rule, onChange }) {
|
||||
const upd = (k,v) => onChange({...rule,[k]:v});
|
||||
return (
|
||||
<div style={{border:'1px solid var(--border)',borderRadius:'var(--radius)',padding:12,display:'flex',flexDirection:'column',gap:10}}>
|
||||
<div style={{display:'flex',alignItems:'center',gap:8,fontSize:13}}>
|
||||
<span style={{color:'var(--text-tertiary)'}}>Every</span>
|
||||
<input type="number" className="input" min={1} max={99} value={rule.interval||1} onChange={e=>upd('interval',Math.max(1,parseInt(e.target.value)||1))} style={{width:60,textAlign:'center'}}/>
|
||||
<select className="input" value={rule.unit||'week'} onChange={e=>upd('unit',e.target.value)} style={{flex:1}}>
|
||||
{['day','week','month','year'].map(u=><option key={u} value={u}>{u}{(rule.interval||1)>1?'s':''}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
{(rule.unit||'week')==='week' && (
|
||||
<div>
|
||||
<div style={{fontSize:12,color:'var(--text-tertiary)',marginBottom:6}}>Repeats on</div>
|
||||
<div style={{display:'flex',gap:6}}>
|
||||
{DAY_PILLS.map((d,i)=>{
|
||||
const key=DAY_KEYS[i], sel=(rule.byDay||[]).includes(key);
|
||||
return <button key={key} type="button" onClick={()=>upd('byDay',sel?(rule.byDay||[]).filter(x=>x!==key):[...(rule.byDay||[]),key])} style={{width:32,height:32,borderRadius:'50%',border:'1px solid var(--border)',background:sel?'var(--primary)':'transparent',color:sel?'white':'var(--text-primary)',fontSize:11,fontWeight:600,cursor:'pointer'}}>{d}</button>;
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<div>
|
||||
<div style={{fontSize:12,color:'var(--text-tertiary)',marginBottom:6}}>Ends</div>
|
||||
{[['never','Never'],['on','On date'],['after','After']].map(([val,lbl])=>(
|
||||
<label key={val} style={{display:'flex',alignItems:'center',gap:10,marginBottom:6,fontSize:13,cursor:'pointer'}}>
|
||||
<input type="radio" name="recur_ends" checked={(rule.ends||'never')===val} onChange={()=>upd('ends',val)}/>
|
||||
{lbl}
|
||||
{val==='on' && (rule.ends||'never')==='on' && <input type="date" className="input" value={rule.endDate||''} onChange={e=>upd('endDate',e.target.value)} style={{marginLeft:8,flex:1}}/>}
|
||||
{val==='after' && (rule.ends||'never')==='after' && <><input type="number" className="input" min={1} max={999} value={rule.endCount||13} onChange={e=>upd('endCount',parseInt(e.target.value)||1)} style={{width:64,textAlign:'center',marginLeft:8}}/><span style={{color:'var(--text-tertiary)'}}>occurrences</span></>}
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Shared Row layout — defined OUTSIDE EventForm so it's stable across renders ─
|
||||
function FormRow({ label, children, required }) {
|
||||
return (
|
||||
@@ -195,6 +280,7 @@ function EventForm({ event, userGroups, eventTypes, selectedDate, onSave, onCanc
|
||||
const [saving,setSaving]=useState(false);
|
||||
const [showTypeForm,setShowTypeForm]=useState(false);
|
||||
const [localTypes,setLocalTypes]=useState(eventTypes);
|
||||
const [recRule,setRecRule]=useState(event?.recurrence_rule||null);
|
||||
// Sync localTypes when parent provides updated eventTypes (e.g. after async load)
|
||||
// Also initialise typeId to the default event type for new events
|
||||
useEffect(()=>{
|
||||
@@ -262,7 +348,7 @@ function EventForm({ event, userGroups, eventTypes, selectedDate, onSave, onCanc
|
||||
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');
|
||||
setSaving(true);
|
||||
try{const body={title:title.trim(),eventTypeId:typeId||null,startAt:allDay?`${sd}T00:00:00`:buildISO(sd,st),endAt:allDay?`${ed}T23:59:59`:buildISO(ed,et),allDay,location:loc,description:desc,isPublic:pub,trackAvailability:track,userGroupIds:[...grps]};const r=event?await api.updateEvent(event.id,body):await api.createEvent(body);onSave(r.event);}catch(e){toast(e.message,'error');}finally{setSaving(false);}
|
||||
try{const body={title:title.trim(),eventTypeId:typeId||null,startAt:allDay?`${sd}T00:00:00`:buildISO(sd,st),endAt:allDay?`${ed}T23:59:59`:buildISO(ed,et),allDay,location:loc,description:desc,isPublic:pub,trackAvailability:track,userGroupIds:[...grps],recurrenceRule:recRule||null};const r=event?await api.updateEvent(event.id,body):await api.createEvent(body);onSave(r.event);}catch(e){toast(e.message,'error');}finally{setSaving(false);}
|
||||
};
|
||||
|
||||
return (
|
||||
@@ -308,10 +394,11 @@ function EventForm({ event, userGroups, eventTypes, selectedDate, onSave, onCanc
|
||||
<label style={{display:'flex',alignItems:'center',gap:8,fontSize:13,cursor:'pointer'}}>
|
||||
<input type="checkbox" checked={allDay} onChange={e=>setAllDay(e.target.checked)}/> All day
|
||||
</label>
|
||||
<label style={{display:'flex',alignItems:'center',gap:8,fontSize:13,cursor:'pointer',color:'var(--text-tertiary)'}}>
|
||||
<input type="checkbox" disabled title="Recurring events coming soon"/> Recurring
|
||||
<span style={{fontSize:11,background:'var(--surface-variant)',borderRadius:10,padding:'1px 6px'}}>Coming soon</span>
|
||||
</label>
|
||||
<div style={{display:'flex',alignItems:'center',gap:8,fontSize:13}}>
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="var(--text-tertiary)" strokeWidth="2"><polyline points="23 4 23 10 17 10"/><path d="M20.49 15a9 9 0 1 1-2.12-9.36L23 10"/></svg>
|
||||
<span style={{color:'var(--text-tertiary)',flexShrink:0}}>Repeat:</span>
|
||||
<div style={{flex:1}}><RecurrenceSelector value={recRule} onChange={setRecRule}/></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</FormRow>
|
||||
@@ -421,6 +508,12 @@ function EventDetailModal({ event, onClose, onEdit, onAvailabilityChange, isTool
|
||||
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="var(--text-tertiary)" strokeWidth="2"><rect x="3" y="4" width="18" height="18" rx="2"/><line x1="16" y1="2" x2="16" y2="6"/><line x1="8" y1="2" x2="8" y2="6"/><line x1="3" y1="10" x2="21" y2="10"/></svg>
|
||||
<span>{fmtDate(new Date(event.start_at))}{!event.all_day&&` · ${fmtRange(event.start_at,event.end_at)}`}</span>
|
||||
</div>
|
||||
{event.recurrence_rule?.freq&&(
|
||||
<div style={{display:'flex',gap:10,alignItems:'center',marginBottom:12,fontSize:14}}>
|
||||
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="var(--text-tertiary)" strokeWidth="2"><polyline points="23 4 23 10 17 10"/><path d="M20.49 15a9 9 0 1 1-2.12-9.36L23 10"/></svg>
|
||||
<span>{recurrenceLabel(event.recurrence_rule)}</span>
|
||||
</div>
|
||||
)}
|
||||
{event.location&&<div style={{display:'flex',gap:10,alignItems:'center',marginBottom:12,fontSize:14}}><svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="var(--text-tertiary)" strokeWidth="2"><path d="M21 10c0 7-9 13-9 13s-9-6-9-13a9 9 0 0 1 18 0z"/><circle cx="12" cy="10" r="3"/></svg>{event.location}</div>}
|
||||
{event.description&&<div style={{display:'flex',gap:10,marginBottom:12,fontSize:14}}><svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="var(--text-tertiary)" strokeWidth="2" style={{flexShrink:0,marginTop:2}}><line x1="21" y1="10" x2="3" y2="10"/><line x1="21" y1="6" x2="3" y2="6"/><line x1="21" y1="14" x2="3" y2="14"/><line x1="21" y1="18" x2="3" y2="18"/></svg><span style={{whiteSpace:'pre-wrap'}}>{event.description}</span></div>}
|
||||
{(event.user_groups||[]).length>0&&<div style={{display:'flex',gap:10,marginBottom:16,fontSize:13,color:'var(--text-secondary)'}}><svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="var(--text-tertiary)" strokeWidth="2" style={{flexShrink:0,marginTop:2}}><path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/><path d="M23 21v-2a4 4 0 0 0-3-3.87"/></svg>{event.user_groups.map(g=>g.name).join(', ')}</div>}
|
||||
@@ -751,6 +844,7 @@ export default function SchedulePage({ isToolManager, isMobile, onProfile, onHel
|
||||
const [detailEvent, setDetailEvent] = useState(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [createOpen, setCreateOpen] = useState(false);
|
||||
const [mobilePanel, setMobilePanel] = useState(null); // null | 'eventForm' | 'groupManager'
|
||||
const createRef = useRef(null);
|
||||
|
||||
const load = useCallback(() => {
|
||||
@@ -918,13 +1012,17 @@ export default function SchedulePage({ isToolManager, isMobile, onProfile, onHel
|
||||
{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('schedule');}}/>}
|
||||
|
||||
{panel === 'eventForm' && isToolManager && (
|
||||
{panel === 'eventForm' && isToolManager && !isMobile && (
|
||||
<div style={{ padding:28, maxWidth:1024 }}>
|
||||
<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}
|
||||
onSave={handleSaved} onCancel={()=>{setPanel('calendar');setEditingEvent(null);}} onDelete={handleDelete}/>
|
||||
</div>
|
||||
)}
|
||||
{panel === 'eventForm' && isToolManager && isMobile && (
|
||||
<MobileEventForm event={editingEvent} userGroups={userGroups} eventTypes={eventTypes} selectedDate={selDate} isToolManager={isToolManager}
|
||||
onSave={handleSaved} onCancel={()=>{setPanel('calendar');setEditingEvent(null);}} onDelete={handleDelete}/>
|
||||
)}
|
||||
{panel === 'eventTypes' && isToolManager && (
|
||||
<div style={{ padding:28 }}>
|
||||
<div style={{ display:'flex', alignItems:'center', gap:10, marginBottom:24 }}>
|
||||
@@ -946,6 +1044,22 @@ export default function SchedulePage({ isToolManager, isMobile, onProfile, onHel
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Mobile Group Manager */}
|
||||
{isMobile && mobilePanel === 'groupManager' && (
|
||||
<div style={{ position:'fixed',inset:0,zIndex:50,background:'var(--background)' }}>
|
||||
<MobileGroupManager onClose={() => setMobilePanel(null)}/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Mobile FAB for creating events */}
|
||||
{isMobile && isToolManager && panel === 'calendar' && (
|
||||
<button onClick={()=>{setPanel('eventForm');setEditingEvent(null);}} style={{ position:'fixed',bottom:24,right:24,zIndex:30,width:56,height:56,borderRadius:'50%',background:'var(--primary)',color:'white',border:'none',cursor:'pointer',boxShadow:'0 4px 16px rgba(0,0,0,0.25)',display:'flex',alignItems:'center',justifyContent:'center' }}>
|
||||
<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" />
|
||||
</svg>
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* Event detail modal */}
|
||||
{detailEvent && (
|
||||
<EventDetailModal
|
||||
|
||||
@@ -16,6 +16,7 @@ import HelpModal from '../components/HelpModal.jsx';
|
||||
import NavDrawer from '../components/NavDrawer.jsx';
|
||||
import GroupManagerModal from '../components/GroupManagerModal.jsx';
|
||||
import SchedulePage from '../components/SchedulePage.jsx';
|
||||
import MobileGroupManager from '../components/MobileGroupManager.jsx';
|
||||
import './Chat.css';
|
||||
|
||||
function urlBase64ToUint8Array(base64String) {
|
||||
@@ -351,7 +352,7 @@ export default function Chat() {
|
||||
onMessages={() => { setDrawerOpen(false); setPage('chat'); }}
|
||||
onSchedule={() => { setDrawerOpen(false); setPage('schedule'); }}
|
||||
onScheduleManager={() => { setDrawerOpen(false); setPage('schedule'); }}
|
||||
onGroupManager={() => { setDrawerOpen(false); setModal('groupmanager'); }}
|
||||
onGroupManager={() => { setDrawerOpen(false); if(isMobile) setModal('mobilegroupmanager'); else setModal('groupmanager'); }}
|
||||
onBranding={() => { setDrawerOpen(false); setModal('branding'); }}
|
||||
onSettings={() => { setDrawerOpen(false); setModal('settings'); }}
|
||||
onUsers={() => { setDrawerOpen(false); setModal('users'); }}
|
||||
@@ -363,6 +364,11 @@ export default function Chat() {
|
||||
{modal === 'settings' && <SettingsModal onClose={() => setModal(null)} onFeaturesChanged={setFeatures} />}
|
||||
{modal === 'branding' && <BrandingModal onClose={() => setModal(null)} />}
|
||||
{modal === 'groupmanager' && <GroupManagerModal onClose={() => setModal(null)} />}
|
||||
{modal === 'mobilegroupmanager' && (
|
||||
<div style={{ position:'fixed',inset:0,zIndex:200,background:'var(--background)' }}>
|
||||
<MobileGroupManager onClose={() => setModal(null)}/>
|
||||
</div>
|
||||
)}
|
||||
{modal === 'about' && <AboutModal onClose={() => setModal(null)} />}
|
||||
{modal === 'help' && <HelpModal onClose={() => setModal(null)} dismissed={helpDismissed} />}
|
||||
</div>
|
||||
@@ -387,7 +393,7 @@ export default function Chat() {
|
||||
onUsers={() => setModal('users')}
|
||||
onSettings={() => setModal('settings')}
|
||||
onBranding={() => setModal('branding')}
|
||||
onGroupManager={() => setModal('groupmanager')}
|
||||
onGroupManager={() => isMobile ? setModal('mobilegroupmanager') : setModal('groupmanager')}
|
||||
features={features}
|
||||
onGroupsUpdated={loadGroups}
|
||||
isMobile={isMobile}
|
||||
@@ -414,7 +420,7 @@ export default function Chat() {
|
||||
onMessages={() => { setDrawerOpen(false); setPage('chat'); }}
|
||||
onSchedule={() => { setDrawerOpen(false); setPage('schedule'); }}
|
||||
onScheduleManager={() => { setDrawerOpen(false); setPage('schedule'); }}
|
||||
onGroupManager={() => { setDrawerOpen(false); setModal('groupmanager'); }}
|
||||
onGroupManager={() => { setDrawerOpen(false); if(isMobile) setModal('mobilegroupmanager'); else setModal('groupmanager'); }}
|
||||
onBranding={() => { setDrawerOpen(false); setModal('branding'); }}
|
||||
onSettings={() => { setDrawerOpen(false); setModal('settings'); }}
|
||||
onUsers={() => { setDrawerOpen(false); setModal('users'); }}
|
||||
@@ -427,6 +433,11 @@ export default function Chat() {
|
||||
{modal === 'settings' && <SettingsModal onClose={() => setModal(null)} onFeaturesChanged={setFeatures} />}
|
||||
{modal === 'branding' && <BrandingModal onClose={() => setModal(null)} />}
|
||||
{modal === 'groupmanager' && <GroupManagerModal onClose={() => setModal(null)} />}
|
||||
{modal === 'mobilegroupmanager' && (
|
||||
<div style={{ position:'fixed',inset:0,zIndex:200,background:'var(--background)' }}>
|
||||
<MobileGroupManager onClose={() => setModal(null)}/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{modal === 'newchat' && <NewChatModal onClose={() => setModal(null)} onCreated={(g) => { loadGroups(); setModal(null); setActiveGroupId(g.id); }} />}
|
||||
{modal === 'about' && <AboutModal onClose={() => setModal(null)} />}
|
||||
|
||||
@@ -114,7 +114,7 @@ export const api = {
|
||||
return req('GET', `/schedule${qs ? '?' + qs : ''}`);
|
||||
},
|
||||
getEvent: (id) => req('GET', `/schedule/${id}`),
|
||||
createEvent: (body) => req('POST', '/schedule', body),
|
||||
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) => req('DELETE', `/schedule/${id}`),
|
||||
setAvailability: (id, response) => req('PUT', `/schedule/${id}/availability`, { response }),
|
||||
|
||||
Reference in New Issue
Block a user