/* ============================================================ page-misc.jsx — Audit, Messages, Providers, Server ============================================================ */ /* Re-bind window exports from other Babel scripts */ const StatusPill = window.StatusPill || (({ children }) => React.createElement('span', null, children)); const parseDetail = window.parseDetail || ((body, status) => body?.message || `HTTP ${status}`); /* Issue #32: human-readable audit action labels. Primary source of truth is the API field `action_label` (sent by every /api/audit row). This local table mirrors api.py:_AUDIT_ACTION_LABELS so we still render cleanly if the FE caches a row from an older API build, if action_label is missing, or if an action_type isn't in the table yet (last-resort fallback: capitalize underscores → spaces). */ const _AUDIT_ACTION_LABELS = { signin_recorded: "Sign-in recorded", signoff_recorded: "Sign-off recorded", format_strike: "Format strike", late_strike: "Late strike", missing_signoff: "Missing sign-off", late_reason_waived: "Late reason accepted", late_reason_rejected: "Late reason rejected", dm_sent: "DM sent", dm_failed: "DM failed", public_reply: "Public reply (room)", flag: "Flagged", hr_doc_uploaded: "HR document uploaded", hr_doc_viewed: "HR document viewed", hr_doc_deleted: "HR document deleted", oncall_csv_import: "On-call CSV imported", pto_approved: "PTO approved", pto_denied: "PTO denied", strike_warned: "Strike warning sent", strike_escalated: "Strike escalated to admin", availability_ping_sent: "Availability ping sent", availability_ping_responded: "Availability ping responded", availability_miss_escalated: "Availability miss escalated", user_edited: "User edited", team_created: "Team created", team_edited: "Team edited", policy_created: "Policy created", policy_edited: "Policy edited", ai_query: "AI query", ai_provider_fallback: "AI fallback (provider switch)", otp_sent: "OTP sent", otp_verified: "OTP verified", otp_failed: "OTP failed", trusted_device_added: "Trusted device added", session_revoked: "Session revoked", admin_manual_violation: "Admin manual violation", format_violation: "Format violation", admin_override_applied: "Admin override applied", container_start: "Container started", db_migration: "Database migration", health_check_fail: "Health check failed", admin_manual_strike: "Admin manual strike", thread_reply: "Bot thread reply", ip_red_flag: "IP red-flag", no_progress: "No progress detected", stuck_detected: "Stuck task detected", stalled_task: "Stalled task", repeated_accomplishment: "Repeated accomplishment", force_strike: "Force strike (admin)", force_clear_strikes: "Strikes cleared (admin)", force_decrement_strike: "Strike decremented (admin)", }; function humanizeActionType(raw) { if (!raw) return ''; if (_AUDIT_ACTION_LABELS[raw]) return _AUDIT_ACTION_LABELS[raw]; const s = String(raw).replace(/_/g, ' ').trim(); return s.charAt(0).toUpperCase() + s.slice(1); } // Expose so other Babel scripts (page-employees violations history, // hud.jsx audit widget) can use the same table. try { window.humanizeActionType = humanizeActionType; } catch (_) {} /* ── Helpers ── */ function relTime(iso) { const d = new Date(iso); const raw = Math.floor((Date.now() - d) / 1000); // clock-skew safe — abs() the magnitude and prefix "in" when iso is in the future const sec = Math.abs(raw); const future = raw < 0; if (sec < 30) return "just now"; const fmt = (n, unit) => future ? `in ${n}${unit}` : `${n}${unit} ago`; if (sec < 60) return fmt(sec, "s"); if (sec < 3600) return fmt(Math.floor(sec/60), "m"); if (sec < 86400) return fmt(Math.floor(sec/3600), "h"); return fmt(Math.floor(sec/86400), "d"); } function absTime(iso) { return new Date(iso).toLocaleString([], { month:'short', day:'numeric', hour:'2-digit', minute:'2-digit' }); } const KIND_COLOR = { 'sign-in': 'ok', 'sign_in': 'ok', 'sign-off': 'info', 'sign_off': 'info', 'sick': 'warn', 'pto': 'warn', 'vacation': 'warn', 'half_day': 'warn', 'late': 'warn', 'invalid': 'err', 'format-violation': 'err', 'unknown': 'mute', }; function kindColor(k) { return KIND_COLOR[k] || 'mute'; } const PAGE_SIZE = 50; /* ============================================================ MessagesPage ============================================================ */ /* ── Broadcast DM Section ── */ const BROADCAST_GROUPS = [ { value: 'all', label: 'Everyone' }, { value: 'admins', label: 'Admins only' }, { value: 'absent_today', label: 'Absent today' }, { value: 'late_today', label: 'Late today' }, { value: 'test_only', label: 'Test accounts only' }, ]; // NOTE: label values above are English lookup keys; wrapped with appT at the render site. function BroadcastDmSection({ employees }) { const toast = useToast(); const [mode, setMode] = useState('group'); // 'team'|'group'|'manual' const [teams, setTeams] = useState([]); const [selTeam, setSelTeam] = useState(''); const [selGroup, setSelGroup] = useState('all'); const [selManual, setSelManual] = useState([]); // matrix_ids const [msgBody, setMsgBody] = useState(''); const [preview, setPreview] = useState([]); // resolved names preview const [sending, setSending] = useState(false); const [channel, setChannel] = useState('dm'); // 'dm' (direct msgs) | 'rooms' (post in team rooms) /* load teams once */ useEffect(() => { fetch('/api/teams', { credentials: 'same-origin' }) .then(r => r.ok ? r.json() : []) .then(rows => { setTeams(rows.filter(t => t.name || t.team_name)); if (rows.length) setSelTeam(rows[0].name || rows[0].team_name || ''); }) .catch(() => {}); }, []); /* resolve preview whenever mode/values/employees change */ useEffect(() => { if (mode === 'manual') { const m = employees.filter(e => selManual.includes(e.matrix_id)); setPreview(m.map(e => e.display_name)); } else if (mode === 'team') { const m = employees.filter(e => e.team === selTeam); setPreview(m.map(e => e.display_name)); } else { /* group — we can't perfectly resolve absent/late without a live query, show a label instead */ const grp = BROADCAST_GROUPS.find(g => g.value === selGroup); setPreview(grp ? [(window.appLang&&window.appLang()==='ar') ? `(${(window.appT||(s=>s))(grp.label)} — يُحدَّد عند الإرسال)` : `(${grp.label} — resolved on send)`] : []); } }, [mode, selTeam, selGroup, selManual, employees]); const toggleManual = (mxid) => { setSelManual(prev => prev.includes(mxid) ? prev.filter(x => x !== mxid) : [...prev, mxid]); }; const send = async () => { if (!msgBody.trim()) { toast.push({ msg: 'Message body is required', kind: 'err', icon: '!' }); return; } setSending(true); try { const filter_value = mode === 'manual' ? selManual : mode === 'team' ? selTeam : selGroup; const payload = channel === 'rooms' ? { channel: 'rooms', body: msgBody } : { filter_type: mode, filter_value, body: msgBody }; const r = await fetch('/api/messages/broadcast', { method: 'POST', headers: { 'Content-Type': 'application/json' }, credentials: 'same-origin', body: JSON.stringify(payload), }); const data = await r.json().catch(() => ({})); if (!r.ok) throw new Error(data.detail || `HTTP ${r.status}`); if (channel === 'rooms') { toast.push({ msg: `Posted to ${data.posted||0} room${(data.posted||0) !== 1 ? 's' : ''}`, kind: 'ok', icon: '✓' }); } else if (data.failed > 0) { toast.push({ msg: `Sent to ${data.sent}, failed for ${data.failed} (see audit log)`, kind: 'warn', icon: '!' }); } else { toast.push({ msg: `Sent to ${data.sent} user${data.sent !== 1 ? 's' : ''}`, kind: 'ok', icon: '✓' }); } setMsgBody(''); if (mode === 'manual') setSelManual([]); } catch (e) { toast.push({ msg: `Broadcast failed: ${e.message}`, kind: 'err', icon: '!' }); } finally { setSending(false); } }; const previewFirst5 = preview.slice(0, 5); const previewRest = preview.length - 5; return (
s))("Send DM")} tip={(window.appT||(s=>s))("Compose a manual DM to one user as the bot. Optional template dropdown for prebuilt messages.")} sub={(window.appT||(s=>s))("Broadcast a direct message to one or more users")} /> {/* Delivery channel: DM each person, or post one announcement in the team rooms */}
{[['dm','Direct messages'],['rooms','Post in team rooms']].map(([v,l]) => ( ))}
{channel === 'rooms' && (
{(window.appT||(s=>s))("Posts one announcement into every team attendance + general room (everyone sees it in-channel).")}
)} {channel === 'dm' && (<> {/* Mode tabs */}
{[['group','By group'],['team','By team'],['manual','Pick manually']].map(([v,l]) => ( ))}
{/* Mode-specific selector */} {mode === 'group' && ( )} {mode === 'team' && ( )} {mode === 'manual' && (
{employees.map(e => { const sel = selManual.includes(e.matrix_id); return ( ); })}
)} {/* Recipient chip preview */} {preview.length > 0 && (
{preview.length > 5 ? ((window.appLang&&window.appLang()==='ar') ? `${preview.length} مستلمين:` : `${preview.length} recipients:`) : (window.appT||(s=>s))('To:')} {previewFirst5.map((n, i) => ( {n} ))} {previewRest > 0 && ( {(window.appLang&&window.appLang()==='ar') ? `و${previewRest} آخرين` : `and ${previewRest} more`} )}
)} {preview.length === 0 && mode !== 'group' && (
{(window.appT||(s=>s))("No recipients selected")}
)} )} {/* Message body */}