/* ============================================================ page-oncall.jsx — On-call rotations per team (#26) ============================================================ */ const { useState, useEffect } = React; function OnCallPage() { const [data, setData] = useState({ current: [], upcoming: [], teams: [] }); const [loading, setLoading] = useState(true); const [employees, setEmployees] = useState([]); const [teams, setTeams] = useState([]); const [creating, setCreating] = useState(false); const toast = useToast ? useToast() : { push: () => {} }; // Form state const [fTeam, setFTeam] = useState(''); const [fUser, setFUser] = useState(''); const [fStart, setFStart] = useState(new Date().toISOString().slice(0, 10)); const [fEnd, setFEnd] = useState(() => { const d = new Date(); d.setDate(d.getDate() + 6); return d.toISOString().slice(0, 10); }); const [fRole, setFRole] = useState('primary'); const [fNotes, setFNotes] = useState(''); const [uploading, setUploading] = useState(false); const fileRef = React.useRef(null); const reload = async () => { setLoading(true); try { const [r1, r2, r3] = await Promise.all([ fetch('/api/on-call', { credentials: 'same-origin' }).then(r => r.json()), fetch('/api/employees', { credentials: 'same-origin' }).then(r => r.ok ? r.json() : []), fetch('/api/teams', { credentials: 'same-origin' }).then(r => r.ok ? r.json() : []), ]); setData(r1 || { current: [], upcoming: [], teams: [] }); setEmployees(Array.isArray(r2) ? r2 : (r2.users || [])); setTeams(Array.isArray(r3) ? r3 : []); } catch (e) { toast.push && toast.push({ msg: (window.appT||(s=>s))('Load failed: ') + e.message, kind: 'err' }); } finally { setLoading(false); } }; useEffect(() => { reload(); }, []); const downloadTemplate = () => { window.open('/api/on-call/template.csv', '_blank'); }; const onUploadFile = async (e) => { const f = e.target.files && e.target.files[0]; if (f) await uploadSchedule(f); if (fileRef.current) fileRef.current.value = ''; }; const uploadSchedule = async (f) => { const replace = window.confirm( (window.appT||(s=>s))('Replace all future on-call shifts with this file?\n\nOK = replace future shifts · Cancel = append to existing') ); setUploading(true); try { const fd = new FormData(); fd.append('file', f); const r = await fetch('/api/on-call/upload?replace=' + (replace ? 'true' : 'false'), { method: 'POST', credentials: 'same-origin', body: fd, }); const body = await r.json().catch(() => null); if (!r.ok) throw new Error(parseDetail(body, r.status)); const errs = (body && body.errors) || []; toast.push({ msg: (window.appLang && window.appLang() === 'ar' ? `تم استيراد ${body.created} مناوبة` + (errs.length ? ` · ${errs.length} مشكلة في الصفوف` : '') : `Imported ${body.created} shift${body.created === 1 ? '' : 's'}` + (errs.length ? ` · ${errs.length} row issue(s)` : '')), kind: errs.length ? 'warn' : 'ok', }); if (errs.length) errs.slice(0, 6).forEach(m => toast.push({ msg: m, kind: 'warn' })); await reload(); } catch (e) { toast.push({ msg: (window.appT||(s=>s))('Upload failed: ') + e.message, kind: 'err' }); } finally { setUploading(false); } }; const create = async () => { if (!fTeam || !fUser || !fStart || !fEnd) { toast.push({ msg: (window.appT||(s=>s))('Fill all required fields'), kind: 'warn' }); return; } setCreating(true); try { const r = await fetch('/api/on-call', { method: 'POST', credentials: 'same-origin', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ team_name: fTeam, user_id: parseInt(fUser, 10), start_date: fStart, end_date: fEnd, role: fRole, notes: fNotes || null, }), }); if (!r.ok) { const body = await r.json().catch(() => null); throw new Error(parseDetail(body, r.status)); } toast.push({ msg: (window.appT||(s=>s))('On-call rotation created'), kind: 'ok' }); setFNotes(''); await reload(); } catch (e) { toast.push({ msg: (window.appT||(s=>s))('Failed: ') + e.message, kind: 'err' }); } finally { setCreating(false); } }; const removeRotation = async (id) => { if (!confirm((window.appT||(s=>s))('Delete this on-call rotation?'))) return; try { const r = await fetch(`/api/on-call/${id}`, { method: 'DELETE', credentials: 'same-origin' }); if (!r.ok) { const body = await r.json().catch(() => null); throw new Error(parseDetail(body, r.status)); } toast.push({ msg: (window.appT||(s=>s))('Deleted'), kind: 'ok' }); await reload(); } catch (e) { toast.push({ msg: (window.appT||(s=>s))('Delete failed: ') + e.message, kind: 'err' }); } }; const renderRow = (r, allowDelete = true) => ( {r.team_name} {r.display_name} {(window.appT||(s=>s))(r.role)} {r.start_date} → {r.end_date} {r.notes || '—'} {allowDelete && ( )} ); return (
s))("On-call rotations")} meta={window.appLang && window.appLang() === 'ar' ? `${data.current.length} نشط · ${data.upcoming.length} قادم · تذكيرات الرسائل المباشرة 09:00 (يوم المناوبة) + 18:00 (اليوم السابق) بتوقيت UTC` : `${data.current.length} active · ${data.upcoming.length} upcoming · DM reminders 09:00 (day-of) + 18:00 (day-before) UTC`} right={
} /> {/* C5: month calendar grid */} {/* On-call standings — moved here from the Reports tab so all on-call surface area lives together. The component itself is still defined in page-reports.jsx (window.OnCallStandingsSection) to avoid duplicating ~140 lines of fetch + render logic. */} {typeof window.OnCallStandingsSection === 'function' && ( React.createElement(window.OnCallStandingsSection) )} {/* Active section */}
s))("Currently on-call")} tip={(window.appT||(s=>s))("Primary + secondary on-call right now (per the active rotation). Auto-resolves on every ticket landing.")} sub={window.appLang && window.appLang() === 'ar' ? `${data.current.length} مناوبة نشطة` : `${data.current.length} active rotation${data.current.length === 1 ? '' : 's'}`} /> {data.current.length === 0 ? ( } title={(window.appT||(s=>s))("Nobody on-call right now")} subtitle={(window.appT||(s=>s))("Add a rotation below to assign someone.")} /> ) : (
{data.current.map(r => renderRow(r))}
{(window.appT||(s=>s))("Team")} {(window.appT||(s=>s))("Person")} {(window.appT||(s=>s))("Role")} {(window.appT||(s=>s))("Coverage")} {(window.appT||(s=>s))("Notes")}
)}
{/* Upcoming section */}
s))("Upcoming (next 30 days)")} tip={(window.appT||(s=>s))("Next 30 days of on-call shifts. Edit any cell to override (admin only).")} sub={window.appLang && window.appLang() === 'ar' ? `${data.upcoming.length} مجدولة` : `${data.upcoming.length} scheduled`} /> {data.upcoming.length === 0 ? ( } title={(window.appT||(s=>s))("No upcoming rotations")} subtitle={(window.appT||(s=>s))("Schedule the next rotation below.")} /> ) : (
{data.upcoming.map(r => renderRow(r))}
{(window.appT||(s=>s))("Team")} {(window.appT||(s=>s))("Person")} {(window.appT||(s=>s))("Role")} {(window.appT||(s=>s))("Coverage")} {(window.appT||(s=>s))("Notes")}
)}
{/* Add rotation form */}
s))("Add rotation")} tip={(window.appT||(s=>s))("Single-shift add form. For bulk uploads use 'Import CSV' (#31). 'Export CSV' is available on the same page for round-tripping.")} sub={(window.appT||(s=>s))("Manager+ only")} />
); } // C5: month calendar grid with primary + secondary on each day. function OnCallCalendarCard({ shifts, employees }) { const today = new Date(); const [month, setMonth] = useState({ y: today.getFullYear(), m: today.getMonth() }); const monthStart = new Date(month.y, month.m, 1); const monthEnd = new Date(month.y, month.m + 1, 0); const startWeekday = monthStart.getDay(); // 0=Sun const daysInMonth = monthEnd.getDate(); const empById = Object.fromEntries(employees.map(e => [e.id, e])); // Build per-day list of EVERY assigned person — was previously primary+ // single secondary, which collapsed multi-tier fallback chains // (bassel→miro→hany) down to whoever happened to be written last and // looked like "only Hany". Now: bucket by primary / backup / escalation // and stack them all in the cell so the full chain is visible. const byDay = {}; for (const s of (shifts || [])) { const sd = new Date(s.start_date), ed = new Date(s.end_date); for (let d = new Date(sd); d <= ed; d.setDate(d.getDate() + 1)) { if (d.getMonth() !== month.m || d.getFullYear() !== month.y) continue; const key = d.getDate(); const role = (s.role || (s.is_primary ? 'primary' : 'backup')); const slot = (byDay[key] = byDay[key] || { primary: [], backup: [], escalation: [] }); // Skip placeholder pre-imports — they're real OnCallShift rows but // have no real engineer yet (mxid starts with @_oncall-pending-). const emp = empById[s.user_id]; const isPlaceholder = (emp?.matrix_id || '').startsWith('@_oncall-pending-'); if (isPlaceholder) continue; const bucket = slot[role] || slot.backup; // Dedupe by user_id so the chain row + a weekly primary row that // refer to the same person don't render twice. if (!bucket.find(x => x.user_id === s.user_id)) bucket.push(s); } } const cells = []; for (let i = 0; i < startWeekday; i++) cells.push(null); for (let d = 1; d <= daysInMonth; d++) cells.push(d); while (cells.length % 7 !== 0) cells.push(null); const monthName = monthStart.toLocaleDateString(undefined, { month: 'long', year: 'numeric' }); return (
s))("On-call calendar")} tip={(window.appT||(s=>s))("Visual month view of primary + secondary on-call assignments. Click a shift to jump to its details. Use ← → to change months.")} sub={monthName} right={
} />
{['Sun','Mon','Tue','Wed','Thu','Fri','Sat'].map(d => (
{(window.appT||(s=>s))(d)}
))} {cells.map((d, i) => { if (d === null) return
; const slot = byDay[d] || {}; const isToday = d === today.getDate() && month.m === today.getMonth() && month.y === today.getFullYear(); return (
{d}
{(slot.primary || []).map(sh => (
s))("Primary")}: ${(empById[sh.user_id] || {}).display_name || ''}`}> P · {((empById[sh.user_id] || {}).display_name || '').split(' ')[0] || sh.display_name}
))} {(slot.backup || []).map(sh => (
s))("Backup")}: ${(empById[sh.user_id] || {}).display_name || ''}`}> B · {((empById[sh.user_id] || {}).display_name || '').split(' ')[0] || sh.display_name}
))} {(slot.escalation || []).map(sh => (
s))("Escalation")}: ${(empById[sh.user_id] || {}).display_name || ''}`}> E · {((empById[sh.user_id] || {}).display_name || '').split(' ')[0] || sh.display_name}
))}
); })}
); } window.OnCallCalendarCard = OnCallCalendarCard; window.OnCallPage = OnCallPage;