/* ============================================================ page-employees.jsx — directory + drawer ============================================================ */ /* Re-bind parseDetail from primitives.jsx */ const parseDetail = window.parseDetail || ((body, status) => { const d = body?.detail; if (typeof d === 'string') return d; if (Array.isArray(d)) return d.map(x => x?.msg || JSON.stringify(x)).join('; '); if (d && typeof d === 'object') return d.message || d.error || JSON.stringify(d); const m = body?.message; if (typeof m === 'string') return m; if (Array.isArray(m)) return m.map(x => (typeof x === 'string' ? x : (x?.message || JSON.stringify(x)))).join('; '); if (m && typeof m === 'object') return m.message || m.error || JSON.stringify(m); return `HTTP ${status}`; }); /* TODO Bug 6: role shows 3x — could not reproduce in JSX (only 1 render at line 1269). If still happening, check if person.role value itself contains repeated text (DB issue) or if there is a CSS ::before/::after pseudo-element in employee-card repeating content. */ /* ────────────────────────────────────────────────────────────────────────── StrikeBadge (issue #4) — full treatment -------------------------------------- Renders the user's current strike count with: (a) inline tier description: 1/3 forgiveness · 2/3 last warning · 3/3 admin reported (c) color-coded background: 0=green, 1=yellow, 2=orange, 3+=red (b) onClick opens StrikeHistoryModal showing every format_strike / late_strike / admin_manual_strike audit-log row + a "validate" line confirming the count matches the audit history. The bot's 3-strike rule (per CLAUDE.md): strike 1 → forgiveness DM ("✅ One-time forgiveness…") strike 2 → "LAST WARNING" DM strike 3 → admin report posted to STANDUP_ROOM + manager DM ────────────────────────────────────────────────────────────────────────── */ const _STRIKE_TIERS = { 0: { label: (window.appT||(s=>s))('clear'), bg: 'var(--ok)', fg: '#fff' }, 1: { label: (window.appT||(s=>s))('1/3 forgiveness'), bg: '#f1c40f', fg: '#1a1a1a' }, 2: { label: (window.appT||(s=>s))('2/3 last warning'),bg: '#e67e22', fg: '#fff' }, 3: { label: (window.appT||(s=>s))('3/3 admin reported'), bg: 'var(--err)', fg: '#fff' }, }; function StrikeBadge({ count, userId, displayName }) { const [open, setOpen] = useState(false); const tier = _STRIKE_TIERS[Math.min(3, Math.max(0, count|0))]; return ( <> {open && ( setOpen(false)} /> )} ); } function StrikeHistoryModal({ userId, displayName, currentCount, onClose }) { const [loading, setLoading] = useState(true); const [events, setEvents] = useState([]); const [error, setError] = useState(null); useEffect(() => { let cancelled = false; (async () => { try { // The /api/audit endpoint already supports user_id + action_type filter. // Pull every strike-related action across all time for this user. const types = ['format_strike','late_strike','admin_manual_strike', 'admin_force_strike','admin_manual_violation', 'format_violation','force_strike','strike_warned', 'strike_escalated','force_decrement_strike', 'force_clear_strikes']; const all = []; for (const t of types) { try { const r = await fetch(`/api/audit?user_id=${userId}&action_type=${t}&limit=200`, { credentials: 'include' }); if (r.ok) { const rows = await r.json(); all.push(...rows); } } catch(_) {} } if (cancelled) return; // Sort newest first all.sort((a, b) => (b.occurred_at || '').localeCompare(a.occurred_at || '')); setEvents(all); } catch (e) { if (!cancelled) setError(e.message); } finally { if (!cancelled) setLoading(false); } })(); return () => { cancelled = true; }; }, [userId]); // Validate: increments vs decrements should reconcile to `currentCount`. // - format_strike / late_strike / admin_manual_strike / format_violation / // admin_manual_violation / force_strike / admin_force_strike -> +1 // - force_decrement_strike -> -1 // - force_clear_strikes -> sets to 0 (we ignore prior events after this) const validation = useMemo(() => { const ordered = events.slice().reverse(); // oldest first let count = 0; let cleared_at = null; for (const ev of ordered) { const t = ev.action_type || ''; if (t === 'force_clear_strikes') { count = 0; cleared_at = ev.occurred_at; continue; } if (t === 'force_decrement_strike') { count = Math.max(0, count - 1); continue; } if (t.endsWith('_strike') || t.endsWith('_violation') || t === 'force_strike') { count += 1; } } // cap at 3 to match the bot's hardcoded cap const computed = Math.min(3, count); return { computed, expected: currentCount, matches: computed === currentCount, cleared_at, total_events: events.length, }; }, [events, currentCount]); return (
e.stopPropagation()} style={{ background: 'var(--bg-elev-1)', color: 'var(--text)', width: 'min(640px, 92vw)', maxHeight: '80vh', overflow: 'hidden', display: 'flex', flexDirection: 'column', borderRadius: 'var(--r-lg)', border: '1px solid var(--line)', boxShadow: 'var(--shadow)', }} >
{(window.appT||(s=>s))("Strike history")} — {displayName}
{(window.appT||(s=>s))("Current:")} {currentCount|0} · {(window.appT||(s=>s))("3-strike rule: 1 = forgiveness DM · 2 = last warning · 3 = admin report")}
{/* Validation row — confirms count matches audit history */} {!loading && (
{validation.matches ? '✅' : '⚠️'} {(window.appT||(s=>s))("Validated:")}{' '} {(window.appLang&&window.appLang()==='ar') ? `يُظهر سجل التدقيق ${validation.total_events} حدثًا متعلقًا بالمخالفات؛ العدد المحسوب = ` : `audit log shows ${validation.total_events} strike-related event${validation.total_events === 1 ? '' : 's'}; computed count = `}{validation.computed}{(window.appLang&&window.appLang()==='ar') ? `، العدد المُخزَّن = ` : `, stored count = `}{validation.expected}. {validation.matches ? (window.appT||(s=>s))(' Counts match — bot state machine is consistent with audit history.') : (window.appT||(s=>s))(' ⚠️ Mismatch — likely an old `force_clear_strikes` event before the audit log was added, or a bot restart lost a transient counter. Use the "Forgive all" or "Downgrade" buttons on the employee card to reconcile.')} {validation.cleared_at && (
{(window.appLang&&window.appLang()==='ar') ? `آخر مسح في ${validation.cleared_at.replace('T',' ').slice(0,19)} (تُحتسب الأحداث بعد تلك النقطة).` : `Last cleared at ${validation.cleared_at.replace('T',' ').slice(0,19)} (events after that point counted).`}
)}
)}
{loading &&
{(window.appT||(s=>s))("Loading…")}
} {!loading && events.length === 0 && (
{(window.appT||(s=>s))("No strike events on record.")} {currentCount > 0 ? ((window.appLang&&window.appLang()==='ar') ? `(العدد المُخزَّن هو ${currentCount} — على الأرجح مخالفة سابقة لسجل التدقيق؛ فكِّر في "العفو عن الكل" لإعادة التعيين.)` : `(Stored count is ${currentCount} — likely a pre-audit-log strike; consider Forgive all to reset.)`) : ''}
)} {!loading && events.length > 0 && ( {events.map(ev => ( ))}
{(window.appT||(s=>s))("When")} {(window.appT||(s=>s))("Action")} {(window.appT||(s=>s))("Detail")}
{(ev.occurred_at || '').replace('T',' ').slice(0,19)}
{ev.action_label || (window.humanizeActionType ? window.humanizeActionType(ev.action_type) : ev.action_type)} {/* Show WHO did this action — API's `actor` field carries the admin name, bot label, or generic "Admin" for admin-GUI actions. */} {ev.actor && ( {(window.appT||(s=>s))("by")} {ev.actor} )}
{(ev.body || ev.trigger || '').slice(0, 140)}
)}
); } /* ────────────────────────────────────────────────────────────────────────── Q8 — Progress timeline + admin-pickable metrics grid (issue #24) ───────────────────────────────────────────────────────────────────────── */ /* (d) Inline-SVG line chart of per-day progress score with PTO bands, blocker dots, and strike triangles overlaid. Pure SVG — no chart lib needed (the codebase intentionally avoids one for bundle size). */ function ProgressTimelineCard({ byDay, ptoHistory, violations, rangeStart, rangeEnd }) { // Build a date-keyed map of {progress, has_blocker} from by_day const byDate = useMemo(() => { const m = {}; (byDay || []).forEach(d => { m[d.date] = { score: typeof d.progress_score === 'number' ? d.progress_score : null, has_blocker: !!(d.blocker && String(d.blocker).trim()), today: (d.today || '').slice(0, 80), }; }); return m; }, [byDay]); // Compose an ordered date array across the full range so gaps render as gaps const dates = useMemo(() => { if (!rangeStart || !rangeEnd) return []; const start = new Date(rangeStart + 'T00:00:00'); const end = new Date(rangeEnd + 'T00:00:00'); const out = []; for (let d = new Date(start); d <= end; d.setDate(d.getDate() + 1)) { out.push(d.toISOString().slice(0, 10)); } return out; }, [rangeStart, rangeEnd]); // Pre-compute PTO bands as [{start_idx, end_idx, kind}] const ptoBands = useMemo(() => { if (!ptoHistory || !dates.length) return []; const idxOf = d => dates.indexOf(d); const bands = []; ptoHistory.forEach(p => { const sIdx = idxOf(p.start_date); const eIdx = idxOf(p.end_date); if (sIdx === -1 && eIdx === -1) return; bands.push({ start: Math.max(0, sIdx === -1 ? 0 : sIdx), end: Math.min(dates.length - 1, eIdx === -1 ? dates.length - 1 : eIdx), kind: p.kind || 'pto', }); }); return bands; }, [ptoHistory, dates]); // Strike marker dates (just keep the calendar date of each event) const strikeDates = useMemo(() => { const out = new Set(); (violations || []).forEach(v => { if (v.occurred_at) out.add(v.occurred_at.slice(0, 10)); }); return out; }, [violations]); const W = 720, H = 200, M = { top: 14, right: 14, bottom: 22, left: 28 }; const innerW = W - M.left - M.right; const innerH = H - M.top - M.bottom; const n = dates.length; const xFor = i => M.left + (n <= 1 ? innerW / 2 : (i * innerW) / (n - 1)); const yFor = score => M.top + innerH - (score / 2) * innerH; // score 0..2 // Build line path skipping null days (no score that day) const linePath = useMemo(() => { if (!dates.length) return ''; const segs = []; let started = false; dates.forEach((d, i) => { const rec = byDate[d]; if (rec && rec.score != null) { segs.push((started ? 'L' : 'M') + xFor(i).toFixed(1) + ',' + yFor(rec.score).toFixed(1)); started = true; } else { started = false; // gap } }); return segs.join(' '); }, [dates, byDate, n]); const _ptoColor = (k) => ({ sick: '#ef4444', vacation: '#3b82f6', pto: '#3b82f6', half_day: '#a855f7', holiday: '#9ca3af', })[k] || '#3b82f6'; return ( <> s))("Progress timeline")} tip={(window.appT||(s=>s))("Per-day progress score (0–2) across the selected period. Light vertical bands = approved PTO (color-coded by kind). Orange dots on the score line = days with a reported blocker. Red ▲ triangles at the top = strike events. Hover any day for details.")} sub={(window.appLang&&window.appLang()==='ar') ? `${dates.length} يوم · ${ptoBands.length} فترة إجازة · ${strikeDates.size} مخالفة` : `${dates.length} day${dates.length === 1 ? '' : 's'} · ${ptoBands.length} PTO block${ptoBands.length === 1 ? '' : 's'} · ${strikeDates.size} strike${strikeDates.size === 1 ? '' : 's'}`} />
{/* Y-axis grid lines + labels for score 0, 1, 2 */} {[0, 1, 2].map(s => ( {s} ))} {/* PTO bands (under the line) */} {ptoBands.map((b, i) => { const x1 = xFor(b.start) - 4; const x2 = xFor(b.end) + 4; return ( {`${b.kind.toUpperCase()} ${dates[b.start]} → ${dates[b.end]}`} ); })} {/* Progress score line */} {linePath && ( )} {/* Per-day score dot + blocker indicator */} {dates.map((d, i) => { const rec = byDate[d]; if (!rec || rec.score == null) return null; const cx = xFor(i), cy = yFor(rec.score); return ( {(window.appLang&&window.appLang()==='ar') ? `${d} · الدرجة ${rec.score}${rec.has_blocker ? ' · عائق' : ''}${rec.today ? '\n' + rec.today : ''}` : `${d} · score ${rec.score}${rec.has_blocker ? ' · blocker' : ''}${rec.today ? '\n' + rec.today : ''}`} ); })} {/* Strike triangles at top of chart */} {Array.from(strikeDates).map((d, i) => { const idx = dates.indexOf(d); if (idx === -1) return null; const x = xFor(idx); return ( {(window.appLang&&window.appLang()==='ar') ? `مخالفة في ${d}` : `Strike on ${d}`} ); })} {/* X-axis ticks (every 5 days) */} {dates.map((d, i) => ( i % 5 === 0 || i === dates.length - 1 ? ( {d.slice(5)} ) : null ))}
{(window.appT||(s=>s))("daily score")} {(window.appT||(s=>s))("blocker day")} {(window.appT||(s=>s))("strike")}     {(window.appT||(s=>s))("PTO")}     {(window.appT||(s=>s))("Sick")}
); } /* (c) Admin-pickable period metrics grid. Reads window.STATUS_DATA.ME.user_id to scope the picker preference per admin (localStorage key includes the admin's user_id, so different admins can have different default views without stomping each other). */ const _ALL_METRICS = [ { key: 'avg_signin_local', label: (window.appT||(s=>s))('Avg sign-in'), unit: '', tip: (window.appT||(s=>s))('Average wall-clock sign-in time in the user\'s local timezone.') }, { key: 'avg_signoff_local', label: (window.appT||(s=>s))('Avg sign-off'), unit: '', tip: (window.appT||(s=>s))('Average sign-off time, user\'s local TZ.') }, { key: 'signin_consistency_minutes', label: (window.appT||(s=>s))('σ sign-in'), unit: (window.appT||(s=>s))('min'), tip: (window.appT||(s=>s))('Standard deviation of sign-in minute over the period — lower = more consistent.') }, { key: 'longest_on_time_streak', label: (window.appT||(s=>s))('Longest streak'), unit: (window.appT||(s=>s))('days'), tip: (window.appT||(s=>s))('Max consecutive working days signed in on time.') }, { key: 'delivery_pct', label: (window.appT||(s=>s))('Delivery %'), unit: '%', tip: (window.appT||(s=>s))('Fraction of today_text items containing a ship-verb (shipped/done/completed/merged/deployed/finished/closed/released).') }, { key: 'delivered_items', label: (window.appT||(s=>s))('Delivered items'), unit: '', tip: (window.appT||(s=>s))('Count of today_text lines with a ship-verb across the period.') }, { key: 'total_today_items', label: (window.appT||(s=>s))('Total tasks'), unit: '', tip: (window.appT||(s=>s))('Total today_text items across the period.') }, { key: 'rolling_7d_progress_score', label: (window.appT||(s=>s))('Rolling 7-d score'), unit: '/2', tip: (window.appT||(s=>s))('Avg progress score across the last 7 working days — recent trajectory.') }, { key: 'stale_open_tasks', label: (window.appT||(s=>s))('Stale tasks'), unit: '', tip: (window.appT||(s=>s))('Open tasks >= 2 days old without a finished_at — candidates for nudge.') }, { key: 'repeated_accomplishments', label: (window.appT||(s=>s))('Repeated wins'), unit: '', tip: (window.appT||(s=>s))('Same accomplishment listed in yesterday_text 3+ days in a row — potential padding.') }, { key: 'vs_prior_attendance_pct', label: (window.appT||(s=>s))('Δ attendance% vs prior'), unit: '', tip: (window.appT||(s=>s))('Change in attendance % vs the equivalent prior window.') }, { key: 'vs_prior_hours', label: (window.appT||(s=>s))('Δ hours vs prior'), unit: 'h', tip: (window.appT||(s=>s))('Change in total hours vs the equivalent prior window.') }, ]; const _DEFAULT_VISIBLE_METRICS = [ 'avg_signin_local', 'longest_on_time_streak', 'delivery_pct', 'rolling_7d_progress_score', 'stale_open_tasks', 'vs_prior_attendance_pct', ]; function _formatMetric(m, metrics) { let raw; if (m.key === 'vs_prior_attendance_pct') { const v = (metrics.vs_prior_period && metrics.vs_prior_period.delta_attendance_pct); if (v == null) return { display: '—', color: 'var(--text-3)' }; const sign = v > 0 ? '+' : ''; return { display: `${sign}${v} ${(window.appT||(s=>s))('pts')}`, color: v >= 0 ? 'var(--ok)' : 'var(--err)' }; } if (m.key === 'vs_prior_hours') { const v = (metrics.vs_prior_period && metrics.vs_prior_period.delta_hours); if (v == null) return { display: '—', color: 'var(--text-3)' }; const sign = v > 0 ? '+' : ''; return { display: `${sign}${v}h`, color: v >= 0 ? 'var(--ok)' : 'var(--err)' }; } raw = metrics[m.key]; if (raw == null) return { display: '—', color: 'var(--text-3)' }; if (m.unit === '%') return { display: `${raw}%`, color: 'var(--text)' }; if (m.unit === 'h') return { display: `${Number(raw).toFixed(1)}h`, color: 'var(--text)' }; if (m.unit === '/2') return { display: `${Number(raw).toFixed(2)} / 2`, color: 'var(--text)' }; if (m.unit) return { display: `${raw} ${m.unit}`, color: 'var(--text)' }; return { display: String(raw), color: 'var(--text)' }; } // C3: HR documents vault — doctor notes / contracts / IDs / other. function HrDocumentsCard({ userId }) { const [docs, setDocs] = useState([]); const [loading, setLoading] = useState(false); const [uploading, setUploading] = useState(false); const [kind, setKind] = useState('doctor_note'); const [file, setFile] = useState(null); const [notes, setNotes] = useState(''); const toast = useToast ? useToast() : { push: () => {} }; const KINDS = [ { v: 'doctor_note', l: (window.appT||(s=>s))('Doctor note') }, { v: 'contract', l: (window.appT||(s=>s))('Contract') }, { v: 'id', l: (window.appT||(s=>s))('ID document') }, { v: 'tax', l: (window.appT||(s=>s))('Tax form') }, { v: 'review', l: (window.appT||(s=>s))('Performance review') }, { v: 'other', l: (window.appT||(s=>s))('Other') }, ]; const load = async () => { setLoading(true); try { const r = await fetch(`/api/employees/${userId}/documents`, { credentials: 'include' }); if (r.ok) setDocs(await r.json()); } catch (_) {} finally { setLoading(false); } }; useEffect(() => { load(); }, [userId]); const upload = async () => { if (!file) { toast.push({ msg: (window.appT||(s=>s))('Pick a file first'), kind: 'warn' }); return; } setUploading(true); const fd = new FormData(); fd.append('file', file); fd.append('kind', kind); if (notes) fd.append('notes', notes); try { const r = await fetch(`/api/employees/${userId}/documents`, { method: 'POST', credentials: 'include', body: fd, }); if (!r.ok) throw new Error(`${r.status}`); toast.push({ msg: (window.appT||(s=>s))('Uploaded'), kind: 'ok' }); setFile(null); setNotes(''); await load(); } catch (e) { toast.push({ msg: (window.appT||(s=>s))('Upload failed: ') + e.message, kind: 'err' }); } finally { setUploading(false); } }; return ( <> s))("HR documents")} tip={(window.appT||(s=>s))("Doctor notes, contracts, ID copies, tax forms, performance reviews. Visible to admins/managers/HR and the employee themselves. Stored encrypted-at-rest.")} sub={(window.appLang&&window.appLang()==='ar') ? `${docs.length} مستند` : `${docs.length} document${docs.length === 1 ? '' : 's'}`} right={} />
setFile(e.target.files[0])} style={{ fontSize: 12, flex: 1, minWidth: 200 }} /> s))("Optional notes")} value={notes} onChange={e => setNotes(e.target.value)} style={{ fontSize: 12, padding: '4px 8px', minWidth: 160 }} />
{docs.length === 0 ? ( ) : docs.map(d => ( ))}
{(window.appT||(s=>s))("Kind")}{(window.appT||(s=>s))("Name")}{(window.appT||(s=>s))("Notes")}{(window.appT||(s=>s))("Uploaded")}{(window.appT||(s=>s))("Size")}
{(window.appT||(s=>s))("No documents.")}
{d.kind} {d.filename} {d.notes || ''} {d.uploaded_at ? new Date(d.uploaded_at).toLocaleDateString() : ''} {Math.round((d.size_bytes || 0) / 1024)}k {(window.appT||(s=>s))("Download")}
); } // C2: sign-in time consistency heatmap (3/6/12 month). function SigninHeatmapCard({ userId }) { const [months, setMonths] = useState(3); const [data, setData] = useState(null); const [loading, setLoading] = useState(false); const load = async () => { setLoading(true); try { const r = await fetch(`/api/employees/${userId}/signin-heatmap?months=${months}`, { credentials: 'include' }); if (r.ok) setData(await r.json()); } catch (_) {} finally { setLoading(false); } }; useEffect(() => { load(); }, [userId, months]); const DAYS = [ (window.appT||(s=>s))('Mon'),(window.appT||(s=>s))('Tue'),(window.appT||(s=>s))('Wed'), (window.appT||(s=>s))('Thu'),(window.appT||(s=>s))('Fri'),(window.appT||(s=>s))('Sat'),(window.appT||(s=>s))('Sun')]; const HOURS = Array.from({ length: 24 }, (_, i) => i); const max = data && data.grid ? Math.max(1, ...data.grid.flat()) : 1; const cellSize = 14; return ( <> s))("Sign-in time heatmap")} tip={(window.appLang&&window.appLang()==='ar') ? `يُصنِّف أحداث تسجيل الدخول حسب يوم الأسبوع × الساعة خلال آخر ${months} أشهر. التركيز الأعلى في خلايا قليلة = توقيت ثابت. تلخِّص درجة الثبات (0..1) مدى انتظام نمط تسجيل دخولك.` : `Bins sign-in events by day-of-week × hour over the last ${months} months. Higher concentration in a few cells = consistent timing. The consistency score (0..1) summarizes how tight your sign-in pattern is.`} sub={data ? ((window.appLang&&window.appLang()==='ar') ? `${data.total} تسجيل دخول · الثبات ${(data.consistency_score * 100).toFixed(0)}%` : `${data.total} sign-ins · consistency ${(data.consistency_score * 100).toFixed(0)}%`) : (window.appT||(s=>s))('Loading…')} right={
{[3, 6, 12].map(m => ( ))}
} /> {loading &&
{(window.appT||(s=>s))("Loading…")}
} {data && data.grid && (
{HOURS.map(h => ( ))} {DAYS.map((d, di) => ( {HOURS.map(h => { const v = data.grid[di][h]; const alpha = v / max; return ( ))}
{h % 3 === 0 ? h : ''}
{d} 0 ? `color-mix(in oklch, var(--brand) ${Math.round(alpha * 100)}%, transparent)` : 'var(--bg-elev-2)', borderRadius: 2, }} /> ); })}
)} ); } function MetricsPickerCard({ metrics }) { const D = window.STATUS_DATA || {}; const myUid = D?.ME?.user_id || 'anon'; const lsKey = `emp_metrics_visible:${myUid}`; const [pickerOpen, setPickerOpen] = useState(false); const [visible, setVisible] = useState(() => { try { const stored = JSON.parse(localStorage.getItem(lsKey) || 'null'); if (Array.isArray(stored) && stored.length > 0) return stored; } catch (_) {} return _DEFAULT_VISIBLE_METRICS.slice(); }); const toggle = (k) => { setVisible(cur => { const next = cur.includes(k) ? cur.filter(x => x !== k) : cur.concat(k); try { localStorage.setItem(lsKey, JSON.stringify(next)); } catch (_) {} return next; }); }; const shown = _ALL_METRICS.filter(m => visible.includes(m.key)); return ( <> s))("Period metrics")} tip={(window.appT||(s=>s))("Computed from the same period as the rest of the drawer. Click the gear to pick which metrics to show — your selection is remembered per admin.")} sub={(window.appLang&&window.appLang()==='ar') ? `${shown.length} من ${_ALL_METRICS.length} معروض` : `${shown.length} of ${_ALL_METRICS.length} shown`} right={ } /> {pickerOpen && (
{_ALL_METRICS.map(m => ( ))}
{(window.appT||(s=>s))("Saved to localStorage per admin — your peers see their own set.")}
)} {shown.length === 0 ? (
{(window.appT||(s=>s))("No metrics selected — click")} {(window.appT||(s=>s))("Pick")} {(window.appT||(s=>s))("above to choose what to show.")}
) : (
{shown.map(m => { const f = _formatMetric(m, metrics || {}); return (
{m.label}
{f.display}
); })}
)} {/* Top tasks rendered separately since it's an array */} {Array.isArray(metrics?.top_tasks) && metrics.top_tasks.length > 0 && (
{(window.appT||(s=>s))("Top 5 tasks (by today_text frequency)")}
{metrics.top_tasks.slice(0, 5).map((t, i) => (
{t.task} {t.days}× {(window.appLang&&window.appLang()==='ar') ? 'يوم' : `day${t.days === 1 ? '' : 's'}`}
))}
)} ); } function EmployeesPage({ live }) { const employees = live.employees; const [q, setQ] = useState(''); const [team, setTeam] = useState('all'); const [statusFilter, setStatusFilter] = useState('all'); // Test accounts always hidden — the only test row is the demo account, // which stays visible by design. The opt-in toggle was removed. const showTestAccounts = false; // (#9) Filter by IP classification — backend enriches each employee with ip_class (home/office/null). const [ipFilter, setIpFilter] = useState('all'); // (#6) Export menu (CSV / PDF / XLSX) — replaces the single Export button. const [exportMenuOpen, setExportMenuOpen] = useState(false); const [view, setView] = useState('grid'); const [selectedId, setSelectedId] = useState(null); const [refreshing, setRefreshing] = useState(false); const [scoreboard, setScoreboard] = useState({}); const D = window.STATUS_DATA; const meRole = String(D?.ME?.org_role || '').toLowerCase(); const canManage = meRole === 'admin' || meRole === 'manager'; const toast = useToast(); // #35: a click on any PersonCell elsewhere in the app jumps to this // page and dispatches `focus-user-now`. Open the requested drawer // by matching either user_id or matrix_id (PersonCells in old // surfaces sometimes only have one or the other). useEffect(() => { const resolveAndOpen = (detail) => { if (!detail) return false; if (!employees.length) { // C8: employees not loaded yet — keep the hint around until they are. window.__focusUser = { ...detail, ts: detail.ts || Date.now() }; return false; } let target = null; if (detail.user_id != null) { target = employees.find(e => e.id === detail.user_id); } if (!target && detail.matrix_id) { target = employees.find(e => e.matrix_id === detail.matrix_id); } if (target) { setSelectedId(target.id); return true; } return false; }; // Handle a focus request that may have been stashed before this // component mounted (the App listener sets window.__focusUser). // Stash window extended 5s → 30s because cold-start /api/employees // can be slow on real iPhones and the user wouldn't see their tap // produce a drawer. if (window.__focusUser && (Date.now() - (window.__focusUser.ts || 0)) < 30000) { if (resolveAndOpen(window.__focusUser)) { window.__focusUser = null; } } const onFocus = (e) => { if (resolveAndOpen(e?.detail)) { window.__focusUser = null; } }; window.addEventListener('focus-user-now', onFocus); return () => window.removeEventListener('focus-user-now', onFocus); }, [employees]); // Fetch per-user weekly metrics so each row can show an attendance pie. useEffect(() => { let cancelled = false; const load = async () => { try { const r = await fetch('/api/today/scoreboard', {credentials:'include'}); if (!r.ok) return; const arr = await r.json(); if (cancelled) return; const map = {}; (Array.isArray(arr) ? arr : []).forEach(s => { map[s.user_id] = s; }); setScoreboard(map); } catch(_) {} }; load(); const id = setInterval(load, 60000); // Bug #1: strike/violation counts on these cards come from the scoreboard; // refresh immediately after a strike mutation or attendance update instead // of waiting for the 60s poll (so the count stops looking "stuck" while the // period charts have already moved). const onMutate = () => load(); window.addEventListener('admin-override-applied', onMutate); window.addEventListener('attendance-updated', onMutate); return () => { cancelled = true; clearInterval(id); window.removeEventListener('admin-override-applied', onMutate); window.removeEventListener('attendance-updated', onMutate); }; }, [employees.length]); // Per-user live K9 presence + today's online/away/offline minute split. // Polls /api/presence/live (Synapse) + /api/employees/{id}/presence-breakdown // (DB-buckets) every 15s so each card shows a small live-presence pie. const [presenceMap, setPresenceMap] = useState({}); useEffect(() => { if (!employees.length) return; let cancelled = false; const load = async () => { try { const live = await fetch('/api/presence/live', {credentials:'include'}) .then(r => r.ok ? r.json() : {users: []}) .catch(() => ({users: []})); const map = {}; (live.users || []).forEach(u => { map[u.user_id] = { presence: u.presence, last_active_ago_ms: u.last_active_ago_ms }; }); const breakdowns = await Promise.all(employees.map(e => fetch(`/api/employees/${e.id}/presence-breakdown?range=daily`, {credentials:'include'}) .then(r => r.ok ? r.json() : null).catch(() => null) )); employees.forEach((e, i) => { const b = breakdowns[i]; if (!b) return; map[e.id] = { ...(map[e.id] || {}), breakdown: b }; }); if (!cancelled) setPresenceMap(map); } catch(_) {} }; load(); const id = setInterval(load, 15000); const onAttendance = () => load(); window.addEventListener('attendance-updated', onAttendance); return () => { cancelled = true; clearInterval(id); window.removeEventListener('attendance-updated', onAttendance); }; }, [employees.length]); // Shared status precedence: absent → signed-off → late → present. // 'late-off' = was late AND has now signed off (still counts under both // the 'late' and 'signed-off' filters for compliance purposes). const computeTodayStatus = (e) => { if (!e.signed_in) return 'absent'; if (e.signed_off) return e.was_late ? 'late-off' : 'signed-off'; return e.was_late ? 'late' : 'present'; }; const rows = useMemo(() => { return employees.filter(e => { if (!showTestAccounts && e.is_test_account) return false; if (ipFilter !== 'all' && (e.ip_class || 'unknown') !== ipFilter) return false; if (q && !((e.display_name || '').toLowerCase().includes(q.toLowerCase()) || (e.matrix_id || '').toLowerCase().includes(q.toLowerCase()))) return false; if (team !== 'all' && e.team !== team) return false; const status = computeTodayStatus(e); if (statusFilter === 'all') return true; if (statusFilter === 'late') return status === 'late' || status === 'late-off'; if (statusFilter === 'signed-off') return status === 'signed-off' || status === 'late-off'; return statusFilter === status; }); }, [employees, q, team, statusFilter, showTestAccounts, ipFilter]); const enriched = rows.map(e => ({ ...e, status: computeTodayStatus(e), })); const { sorted, sort, click } = useSortable(enriched, { key:'display_name', dir:'asc' }); const selected = employees.find(e => e.id === selectedId); const [wizardOpen, setWizardOpen] = useState(false); const [bulkDmOpen, setBulkDmOpen] = useState(false); const handleInvite = () => setWizardOpen(true); const handleRefresh = async () => { setRefreshing(true); try { window.dispatchEvent(new CustomEvent('status-data-refresh-requested')); toast.push({ msg: (window.appT||(s=>s))('Refreshed'), kind: 'ok' }); } catch(e) { toast.push({ msg: (window.appT||(s=>s))('Refresh failed: ') + e.message, kind: 'err' }); } finally { setRefreshing(false); } }; // (#6) Format-aware export. We MUST fetch with credentials so the session // cookie is sent (window.open() in WKWebView opens an unauthenticated // context and the server 401s). Then trigger the download from an that's // attached to the DOM before .click() (some WebViews ignore clicks on // detached elements). const handleExport = async (fmt) => { setExportMenuOpen(false); try { const params = new URLSearchParams(); if (q) params.set('search', q); if (team !== 'all') params.set('team', team); if (statusFilter !== 'all') params.set('status', statusFilter); let url, filename; if (fmt === 'csv') { params.set('range', 'today'); url = `/api/reports.csv?${params.toString()}`; filename = 'employees.csv'; } else { params.set('format', fmt); params.set('period', 'day'); url = `/api/summary/export?${params.toString()}`; filename = `employees.${fmt}`; } const r = await fetch(url, { credentials: 'include' }); if (!r.ok) throw new Error(`${r.status} ${r.statusText}`); const blob = await r.blob(); // Use the cross-platform helper so iOS gets the Share Sheet (Save to // Files), desktop browsers get the silent . const result = await window.saveDownload(blob, filename); // Toast shows which strategy actually fired — useful when debugging // real-device vs simulator. Strategies in order: blob-window (iOS // native preview + Share btn), share-sheet (Web Share API), // a-download (desktop), or failed. if (!result || !result.ok) { throw new Error('Download failed via ' + (result && result.via || 'unknown')); } if (result.via === 'share-cancelled') { // user dismissed the Share Sheet — no toast } else if (result.via === 'blob-window') { toast.push({ msg: (window.appLang&&window.appLang()==='ar') ? `${filename} فُتح — اضغط مشاركة ← حفظ في الملفات` : `${filename} opened — tap Share → Save to Files`, kind: 'ok' }); } else if (result.via === 'share-sheet') { toast.push({ msg: (window.appLang&&window.appLang()==='ar') ? `${filename} — اختر "حفظ في الملفات" من قائمة المشاركة` : `${filename} — pick "Save to Files" in Share Sheet`, kind: 'ok' }); } else { toast.push({ msg: (window.appLang&&window.appLang()==='ar') ? `تم تنزيل ${filename} (عبر ${result.via})` : `Downloaded ${filename} (via ${result.via})`, kind: 'ok' }); } } catch (e) { toast.push({ msg: (window.appT||(s=>s))('Export failed: ') + (e.message || e), kind: 'err' }); } }; return (
s))("Employees")} meta={(window.appLang&&window.appLang()==='ar') ? `${employees.length} متابَع عبر كل الغرف · ${employees.filter(e=>e.signed_in).length} سجّلوا الدخول اليوم` : `${employees.length} tracked across all rooms · ${employees.filter(e=>e.signed_in).length} signed in today`} right={
{canManage && } {/* Native { const fmt = e.target.value; if (fmt) handleExport(fmt); e.target.value = ''; // reset so the same choice can be picked again }} style={{ padding: '6px 10px', fontSize: 14, cursor: 'pointer', appearance: 'none', WebkitAppearance: 'none', paddingRight: 26, backgroundImage: 'linear-gradient(45deg, transparent 50%, currentColor 50%), linear-gradient(135deg, currentColor 50%, transparent 50%)', backgroundPosition: 'calc(100% - 12px) 50%, calc(100% - 7px) 50%', backgroundSize: '5px 5px, 5px 5px', backgroundRepeat: 'no-repeat', }} title={(window.appT||(s=>s))("Choose an export format")} > {canManage && }
} />
s))("Search by name or K9 ID…")} />
s))('All') }, { value:'present', label:(window.appT||(s=>s))('Present') }, { value:'late', label:(window.appT||(s=>s))('Late') }, { value:'signed-off', label:(window.appT||(s=>s))('Signed off') }, { value:'absent', label:(window.appT||(s=>s))('Absent') }, ]} /> {/* Show-test-accounts toggle removed — see note at the showTestAccounts declaration above. */}
{sorted.map(e => { const sb = scoreboard[e.id]; return (
setSelectedId(e.id)} title={(window.appLang&&window.appLang()==='ar') ? `انقر لفتح لوحة نتائج ${e.display_name}` : `Click to open ${e.display_name}'s scoreboard`}>
{e.display_name}
{e.matrix_id}
{/* Two pies side-by-side: 30d attendance + today live-presence */}
{sb ? ( <> s))("Slice breakdown: green = on-time, orange = late, grey = absent (last 30 working days)")} />
s))("(on_time_days + 0.5 × half_days) / working_days × 100. Green ≥ 90%, orange 70-89%, red < 70%.")} style={{ color: sb.attendance_pct>=90?'var(--ok)':sb.attendance_pct>=70?'var(--warn)':'var(--err)' }}> {sb.attendance_pct}%
{(window.appT||(s=>s))("30-d attendance")}
) : (
)}
{(() => { const pres = presenceMap[e.id]; const buckets = pres?.breakdown?.buckets || {}; const on = buckets.online ?? 0; const aw = buckets.away ?? 0; const off = buckets.offline ?? 0; const total = on + aw + off; // Attendance is the authoritative "is this person working now" // signal — Matrix presence is unreliable for web clients // (often reports offline/unavailable even while active). So a // user who is signed in and not yet signed off shows ONLINE // regardless of Matrix presence. Once signed off, fall back to // Matrix presence / today's minute buckets. const working = e.signed_in && !e.signed_off; const status = working ? 'online' : (pres?.presence || (on > 0 ? 'online' : aw > 0 ? 'away' : 'offline')); const dotColor = status === 'online' ? 'var(--ok)' : status === 'away' ? 'var(--warn)' : 'var(--text-3)'; return ( <>
{(window.appT||(s=>s))(status)}
{(window.appLang&&window.appLang()==='ar') ? `الحضور المباشر (${total} د)` : `live presence (${total}m)`}
); })()}
s))("Days the employee signed in before (expected_start + late_buffer). Default 9:00 + 30 min.")}> {sb ? sb.on_time_days : 0} {(window.appT||(s=>s))("on-time")}
s))("Days the employee signed in but past the late buffer. A 3rd late strike escalates to admin.")}> {sb ? sb.late_days : 0} {(window.appT||(s=>s))("late")}
s))("Working days with no sign-in and no approved PTO. Counts against attendance %.")}> {sb ? sb.absent_days : 0} {(window.appT||(s=>s))("absent")}
{/* Mini-table of key stats — replaces the wide-table view */} {sb && ( )}
{(window.appT||(s=>s))("Team")} {e.team || '—'}
{(window.appT||(s=>s))("Sign-in")} {e.sign_in_at || '—'}
{(window.appT||(s=>s))("Hours today")} {e.hours_worked ? `${e.hours_worked.toFixed(1)}h` : '—'}
{(window.appT||(s=>s))("Strikes")}
{(window.appT||(s=>s))("Week hours")} {(sb.week_hours ?? 0).toFixed(1)}h
ev.stopPropagation()}>
); })}
{sorted.length === 0 && ( } title={(window.appT||(s=>s))("No people match")} subtitle={(window.appT||(s=>s))("Try a different filter or search term.")} /> )}
{selected && setSelectedId(null)} />} {wizardOpen && ( setWizardOpen(false)}> setWizardOpen(false)} /> )} {bulkDmOpen && setBulkDmOpen(false)} />}
); } function employeeApiErrorText(detail, fallback = 'Request failed') { if (!detail) return fallback; if (typeof detail === 'string') return detail; if (Array.isArray(detail)) { return detail.map(item => employeeApiErrorText(item, '')).filter(Boolean).join(', ') || fallback; } if (typeof detail === 'object') { if (typeof detail.msg === 'string') return detail.msg; if (typeof detail.message === 'string') return detail.message; if (detail.loc && detail.type) { const loc = Array.isArray(detail.loc) ? detail.loc.join('.') : detail.loc; return `${loc}: ${detail.type}`; } try { return JSON.stringify(detail); } catch (_) { return fallback; } } return String(detail); } /* ── Per-row admin action icons (8 actions, vertical or horizontal) ── currentStrikes is forwarded by the card-row that already has scoreboard data in scope; without it the downgrade/add handlers throw "sb is not defined" (fix #19). Default 0 so a missing prop just disables both actions instead of crashing. */ function EmployeeRowActions({ person, compact, vertical = false, currentStrikes = 0 }) { const D = window.STATUS_DATA || {}; const meRole = String(D?.ME?.org_role || '').toLowerCase(); // Admin-tier (admin / moderator / superadmin) + managers can run row // actions on anyone (DM, give strike, forgive, assign PTO, send // report, etc.). That stays broad — moderators still need to drive // the team day-to-day. const isSelf = D?.ME?.id === person.id || D?.ME?.matrix_id === person.matrix_id; const isModerator = meRole === 'moderator'; const isAdmin = meRole === 'admin' || meRole === 'superadmin'; const canManage = isAdmin || isModerator || meRole === 'manager'; // The Edit-profile pencil is the ONE action restricted further: // moderators can only edit their own row, even though they can // manage everyone else's strikes / PTO / DMs. const canEditProfile = isAdmin || meRole === 'manager' || (isModerator && isSelf); const [dmOpen, setDmOpen] = useState(false); const [profileOpen, setProfileOpen] = useState(false); const [ptoOpen, setPtoOpen] = useState(false); const [violationOpen, setViolationOpen] = useState(false); const [scoreboardOpen, setScoreboardOpen] = useState(false); const [busy, setBusy] = useState(null); // 'forgive' | 'downgrade' | 'send_report' const [_sendReportPickerOpen, _setSendReportPickerOpen] = useState(false); const toast = useToast(); const btnStyle = { padding:'3px 6px', fontSize: 11 }; // Phase 2 — strike PATCH (forgive / downgrade / add) via the unified // PATCH /api/users/{id}/strikes endpoint. Emits an undo token (90s) so a // misclick can be reverted. Falls back to the legacy POST endpoints only // if the new PATCH route 404s (running against an older backend). const _patchStrikes = async (newCount, intentLabel) => { const r = await fetch(`/api/users/${person.id}/strikes`, { method: 'PATCH', credentials: 'include', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ count: newCount, reason: `${intentLabel} via dashboard` }), }); if (!r.ok) { const body = await r.json().catch(() => null); throw new Error(parseDetail(body, r.status)); } const data = await r.json().catch(() => ({})); if (data.undo_token && typeof window.showUndoToast === 'function') { window.showUndoToast({ token: data.undo_token, label: `${person.display_name}: strikes ${data.old_count} → ${data.new_count}`, // Re-fetch the person row by triggering the page-level live refresh. onUndone: () => window.dispatchEvent(new CustomEvent('admin-override-applied')), }); } return data; }; const forgiveStrikes = async () => { if (!confirm((window.appLang&&window.appLang()==='ar') ? `العفو عن كل المخالفات لـ ${person.display_name}؟` : `Forgive ALL strikes for ${person.display_name}?`)) return; setBusy('forgive'); try { await _patchStrikes(0, 'Forgive all'); toast.push({ kind:'ok', msg: (window.appLang&&window.appLang()==='ar') ? `تم العفو عن كل المخالفات لـ ${person.display_name}` : `All strikes forgiven for ${person.display_name}` }); } catch(e) { toast.push({ kind:'err', msg: (window.appT||(s=>s))('Forgive failed: ') + e.message }); } finally { setBusy(null); } }; const downgradeStrikes = async () => { setBusy('downgrade'); try { const current = currentStrikes ?? 0; if (current <= 0) { toast.push({ kind:'warn', msg: (window.appLang&&window.appLang()==='ar') ? `${person.display_name} لديه بالفعل 0 مخالفة` : `${person.display_name} already has 0 strikes` }); return; } const data = await _patchStrikes(current - 1, 'Downgrade'); toast.push({ kind:'ok', msg: (window.appLang&&window.appLang()==='ar') ? `تم تخفيض المخالفة — ${person.display_name} لديه الآن ${data.new_count ?? '?'} مخالفة` : `Strike downgraded — ${person.display_name} now has ${data.new_count ?? '?'} strike(s)` }); } catch(e) { toast.push({ kind:'err', msg: (window.appT||(s=>s))('Downgrade failed: ') + e.message }); } finally { setBusy(null); } }; // Phase 2 — admin can manually ADD a strike (e.g. after a verbal warning // outside the bot's auto-detection). Caps at 3 (the auto-escalation limit). const addStrike = async () => { setBusy('add_strike'); try { const current = currentStrikes ?? 0; if (current >= 3) { toast.push({ kind:'warn', msg: (window.appLang&&window.appLang()==='ar') ? `${person.display_name} وصل بالفعل إلى الحد الأقصى 3 مخالفات` : `${person.display_name} is already at the 3-strike cap` }); return; } const data = await _patchStrikes(current + 1, 'Manual strike'); toast.push({ kind:'warn', msg: (window.appLang&&window.appLang()==='ar') ? `تم تسجيل مخالفة — ${person.display_name} لديه الآن ${data.new_count ?? '?'} مخالفة` : `Strike recorded — ${person.display_name} now has ${data.new_count ?? '?'} strike(s)` }); } catch(e) { toast.push({ kind:'err', msg: (window.appT||(s=>s))('Add strike failed: ') + e.message }); } finally { setBusy(null); } }; const quickSendReport = async (period = 'week') => { const labels = { day:(window.appT||(s=>s))('Daily'), week:(window.appT||(s=>s))('Weekly'), month:(window.appT||(s=>s))('Monthly'), quarter:(window.appT||(s=>s))('Quarterly'), '6_months':(window.appT||(s=>s))('Semi-annual'), year:(window.appT||(s=>s))('Annual'), annual:(window.appT||(s=>s))('Annual') }; if (!confirm((window.appLang&&window.appLang()==='ar') ? `إرسال ملخص الحضور ${labels[period] || period} إلى ${person.display_name} عبر رسالة خاصة؟` : `DM the ${labels[period] || period} attendance summary to ${person.display_name}?`)) return; setBusy('send_report'); _setSendReportPickerOpen(false); try { const r = await fetch(`/api/employees/${person.id}/report/send?period=${period}`, { method: 'POST', credentials: 'include', }); if (!r.ok) { const body = await r.json().catch(() => null); throw new Error(parseDetail(body, r.status)); } toast.push({ kind:'ok', msg: (window.appLang&&window.appLang()==='ar') ? `تم إرسال تقرير ${labels[period] || period} إلى ${person.display_name}` : `${labels[period] || period} report DM'd to ${person.display_name}` }); } catch(e) { toast.push({ kind:'err', msg: (window.appT||(s=>s))('Send failed: ') + e.message }); } finally { setBusy(null); } }; const containerStyle = vertical ? { display: 'flex', flexDirection: 'column', gap: 4 } : { display: 'flex', gap: 4, flexWrap: 'wrap' }; return ( <> {!canManage ? (
ev.stopPropagation()}>
) : (
ev.stopPropagation()}>
{_sendReportPickerOpen && (
e.stopPropagation()} style={{ // A15: raise z-index above the next row + ensure container // doesn't clip. Use bottom-positioned anchor when near // bottom of card. minWidth wider + position fixed-anchor. position:'absolute', top:'100%', right: 0, marginTop: 4, zIndex: 9999, background:'var(--bg-elev-2)', border:'1px solid var(--line)', borderRadius:'var(--r-md)', padding: 6, minWidth: 160, boxShadow: '0 12px 32px -4px rgba(0,0,0,.55)', pointerEvents: 'auto', }}>
{(window.appT||(s=>s))("Send report")}
{[['day',(window.appT||(s=>s))('Daily')], ['week',(window.appT||(s=>s))('Weekly')], ['month',(window.appT||(s=>s))('Monthly')], ['quarter',(window.appT||(s=>s))('Quarterly')], ['6_months',(window.appT||(s=>s))('Semi-annual')], ['annual',(window.appT||(s=>s))('Annual')]].map(([p, label]) => ( ))}
)}
)} {dmOpen && setDmOpen(false)} />} {profileOpen && ( setProfileOpen(false)}> setProfileOpen(false)} /> )} {scoreboardOpen && setScoreboardOpen(false)} />} {ptoOpen && setPtoOpen(false)} />} {violationOpen && setViolationOpen(false)} />} ); } /* ── Assign PTO modal ── */ // A9 fix: use local-date format ('en-CA' = YYYY-MM-DD in local TZ) so that // a user in a negative-UTC offset doesn't see yesterday's date stored for // "today". toISOString() returns UTC midnight which rolls back for users // west of UTC after their local 5pm. function _localISODate(d = new Date()) { try { return d.toLocaleDateString('en-CA'); } catch { return d.toISOString().slice(0, 10); } } function AssignPtoModal({ person, onClose }) { const today = _localISODate(); const [fromDate, setFromDate] = useState(today); const [toDate, setToDate] = useState(today); const [kind, setKind] = useState('vacation'); const [reason, setReason] = useState(''); const [busy, setBusy] = useState(false); const [err, setErr] = useState(''); const toast = useToast(); const submit = async () => { setErr(''); setBusy(true); try { const r = await fetch(`/api/employees/${person.id}/pto/assign`, { method: 'POST', credentials: 'same-origin', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ from_date: fromDate, to_date: toDate, kind, reason: reason || null }), }); if (!r.ok) { const body = await r.json().catch(() => null); throw new Error(parseDetail(body, r.status)); } toast.push({ msg: (window.appLang&&window.appLang()==='ar') ? `تم تعيين ${kind} ${fromDate} → ${toDate} لـ ${person.display_name}` : `Assigned ${kind} ${fromDate} → ${toDate} for ${person.display_name}`, kind: 'ok' }); onClose(); } catch(e) { setErr(e.message); } finally { setBusy(false); } }; return (
e.stopPropagation()}>
{(window.appT||(s=>s))("Assign PTO")} — {person.display_name}