/* ============================================================ page-today.jsx — the live dashboard ============================================================ */ /* Motion primitives — set by primitives.jsx before this script runs */ const AnimatedHeading = window.AnimatedHeading || (({ text, tag: Tag = 'h2', className = '', style = {} }) => {text}); /* Phase 1 — admin Edit Mode helpers (defined in primitives.jsx). */ const _EditableCell = window.EditableCell; const _useEditMode = window.useEditMode || (() => false); const _canEditUser = window.canEditUser || (() => false); const _showUndoToast = window.showUndoToast || (() => {}); /* Convert an ISO UTC timestamp to a `` value in the BROWSER's local timezone. The PATCH endpoint normalises back to UTC server-side so the round-trip is correct. */ /* Active-blockers card shows whatever the user wrote in their sign-in message — often a multi-paragraph quote with backticks. This trims it down to a single readable sentence we can clamp to 2 lines. */ function summarizeBlocker(text) { if (!text) return (window.appT||(s=>s))('Reported a blocker (no details given).'); let t = String(text); // Strip fenced code blocks and inline backticks t = t.replace(/```[\s\S]*?```/g, ' ').replace(/`+/g, ' '); // Collapse whitespace and stray "None" prefixes from older parsers t = t.replace(/\s+/g, ' ').replace(/^\s*none\s+/i, '').trim(); if (!t) return (window.appT||(s=>s))('Reported a blocker (no details given).'); // Prefer the first sentence; fall back to a hard char cap const m = t.match(/^[^.!?]{12,200}[.!?]/); let out = m ? m[0] : t; if (out.length > 180) out = out.slice(0, 177).trim() + '…'; return out; } function _isoToLocalInput(iso) { if (!iso) return ''; try { const d = new Date(iso); if (isNaN(d.getTime())) return ''; 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())}`; } catch (_) { return ''; } } function _localInputToIso(local) { if (!local) return null; // local is "YYYY-MM-DDTHH:MM" in BROWSER tz; new Date() interprets as local. const d = new Date(local); return isNaN(d.getTime()) ? null : d.toISOString(); } /* Save handler factory for the Today page hours-worked cell. Phase 4 backend: PATCH /api/daily-attendance/{user_id}/{work_date}/hours sets hours_override (NULL clears). Returns a 90s undo_token. */ function _useHoursPatch(employee, onAfter) { return async (newValue) => { const trimmed = (newValue == null ? '' : String(newValue)).trim(); let hours; if (trimmed === '' || trimmed.toLowerCase() === 'null' || trimmed === '—') { hours = null; } else { const n = parseFloat(trimmed.replace(/h$/i, '')); if (isNaN(n) || n < 0 || n > 24) { alert((window.appT||(s=>s))('Hours must be a number 0-24, or empty to clear')); throw new Error('bad input'); } hours = n; } // Browser-local YYYY-MM-DD; server interprets per team timezone. const today = new Date().toLocaleDateString('en-CA'); const r = await fetch( `/api/daily-attendance/${employee.id}/${today}/hours`, { method: 'PATCH', credentials: 'same-origin', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ hours }), } ); if (!r.ok) { const txt = await r.text(); alert((window.appLang&&window.appLang()==='ar') ? `فشل التعديل: ${txt}` : `Edit failed: ${txt}`); throw new Error(`HTTP ${r.status}`); } const data = await r.json(); if (data.undo_token) { _showUndoToast({ token: data.undo_token, label: (window.appLang&&window.appLang()==='ar') ? `${employee.display_name}: ${hours == null ? 'تم مسح الساعات' : 'تم تجاوز الساعات'}.` : `${employee.display_name}: hours ${hours == null ? 'cleared' : 'overridden'}.`, onUndone: onAfter, }); } if (typeof onAfter === 'function') onAfter(); }; } /* Save handler factory for the Today page sign-in / sign-out cells. */ function _useSignTimePatch(employee, kind /* 'in' | 'out' */, onAfter) { return async (newLocalValue) => { const id = kind === 'in' ? employee.checkin_id : employee.checkout_id; if (!id) { alert((window.appLang&&window.appLang()==='ar') ? `لا يوجد سجل ${kind === 'in' ? 'تسجيل دخول' : 'تسجيل خروج'} بعد — أنشئ واحداً أولاً.` : `No ${kind === 'in' ? 'sign-in' : 'sign-out'} row exists yet — create one first.`); throw new Error('no row'); } const iso = _localInputToIso(newLocalValue); if (!iso) throw new Error('bad input'); const url = kind === 'in' ? `/api/checkins/${id}` : `/api/checkouts/${id}`; const body = kind === 'in' ? { signed_in_at: iso } : { signed_off_at: iso }; const r = await fetch(url, { method: 'PATCH', credentials: 'same-origin', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body), }); if (!r.ok) { const txt = await r.text(); alert((window.appLang&&window.appLang()==='ar') ? `فشل التعديل: ${txt}` : `Edit failed: ${txt}`); throw new Error(`HTTP ${r.status}`); } const data = await r.json(); if (data.undo_token) { _showUndoToast({ token: data.undo_token, label: (window.appLang&&window.appLang()==='ar') ? `${employee.display_name}: تم تحديث ${kind === 'in' ? 'تسجيل الدخول' : 'تسجيل الخروج'}.` : `${employee.display_name}: ${kind === 'in' ? 'sign-in' : 'sign-out'} updated.`, onUndone: onAfter, }); } if (typeof onAfter === 'function') onAfter(); }; } // Mobile-only daily sign-in card. Posts to /api/me/checkin (mirrors the bot's // sign-in write). Hidden on desktop via .mobile-checkin-wrap (see styles-extras). function MobileCheckin() { const toast = useToast(); const [open, setOpen] = useState(false); const [yesterday, setYesterday] = useState(''); const [today, setToday] = useState(''); const [issues, setIssues] = useState('none'); const [busy, setBusy] = useState(false); // status reflects a sign-in from ANY source (Matrix bot, web, app). Loaded on // mount and whenever the app regains focus, so it auto-updates if the user // already signed in on the web/another device. const [status, setStatus] = useState(null); const [nowTick, setNowTick] = useState(Date.now()); const loadStatus = async () => { try { const r = await fetch('/api/me/checkin', { credentials: 'include' }); if (r.ok) setStatus(await r.json()); } catch (e) { /* offline / ignore */ } }; useEffect(() => { loadStatus(); const onVis = () => { if (document.visibilityState === 'visible') { loadStatus(); setNowTick(Date.now()); } }; document.addEventListener('visibilitychange', onVis); window.addEventListener('focus', onVis); const tick = setInterval(() => setNowTick(Date.now()), 60000); // Lock Screen widget intent — AppDelegate fires this when the user // tapped Sign in / Sign off on the widget. We auto-open the format // form so they fill out yesterday / today / blockers instead of // posting a blank record. const onWidgetIntent = (e) => { const kind = e && e.detail && e.detail.kind; // Refresh status first so the form picks the right initial mode loadStatus(); if (kind === 'signin' || kind === 'signoff') { setOpen(true); // Make sure the Today tab is active if the user landed on // another page from a previous session. try { if (window.setActivePage) window.setActivePage('today'); } catch (_) {} } }; window.addEventListener('k9-widget-intent', onWidgetIntent); // Cold-launch case: AppDelegate may have already fired the event // before this listener attached. Check a one-shot global. if (window.__pendingWidgetIntent) { const pending = window.__pendingWidgetIntent; delete window.__pendingWidgetIntent; setTimeout(() => onWidgetIntent({ detail: { kind: pending } }), 50); } return () => { document.removeEventListener('visibilitychange', onVis); window.removeEventListener('focus', onVis); window.removeEventListener('k9-widget-intent', onWidgetIntent); clearInterval(tick); }; }, []); // Delegate to the centralized 12-hour formatter from data.jsx so every // page renders AM/PM consistently. Accepts optional tz for per-user // rendering (Cairo morning vs NY evening on a flex schedule). const fmtTime = (iso, tz) => { if (!iso) return ''; if (window.fmtTime) return window.fmtTime(iso, tz) || ''; try { return new Intl.DateTimeFormat('en-US', { hour: 'numeric', minute: '2-digit', hour12: true, ...(tz ? { timeZone: tz } : {}), }).format(new Date(iso)); } catch (_e) { return ''; } }; const post = async () => { if (busy || !today.trim()) return; setBusy(true); try { const r = await fetch('/api/me/checkin', { method: 'POST', credentials: 'include', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ yesterday, today, issues }), }); if (!r.ok) { const j = await r.json().catch(() => ({})); throw new Error((j && j.detail) || `Failed (${r.status})`); } const d = await r.json(); setOpen(false); setStatus({ signed_in: true, signed_in_at: d.signed_in_at, work_date: d.work_date, has_blocker: d.has_blocker }); toast && toast.push && toast.push({ msg: (window.appLang&&window.appLang()==='ar') ? `تم تسجيل الدخول — مُسجّل ليوم ${d.work_date}` : `Signed in — recorded for ${d.work_date}`, kind: 'success' }); } catch (e) { toast && toast.push && toast.push({ msg: (window.appLang&&window.appLang()==='ar') ? `فشل تسجيل الدخول: ${e.message || 'خطأ'}` : `Sign-in failed: ${e.message || 'error'}`, kind: 'error' }); } finally { setBusy(false); } }; const checkout = async () => { if (busy) return; setBusy(true); try { const r = await fetch('/api/me/checkout', { method: 'POST', credentials: 'include' }); if (!r.ok) { const j = await r.json().catch(() => ({})); throw new Error((j && j.detail) || `Failed (${r.status})`); } await loadStatus(); toast && toast.push && toast.push({ msg: (window.appT||(s=>s))('Signed off — nice work today.'), kind: 'success' }); } catch (e) { toast && toast.push && toast.push({ msg: (window.appLang&&window.appLang()==='ar') ? `فشل تسجيل الخروج: ${e.message || 'خطأ'}` : `Sign-off failed: ${e.message || 'error'}`, kind: 'error' }); } finally { setBusy(false); } }; const signedIn = status && status.signed_in; const signedOff = status && status.signed_off; // Elapsed since sign-in (target 8h) for the hero progress bar. let elapsedLabel = '', pct = 0; if (signedIn && status.signed_in_at) { const ms = Math.max(0, nowTick - new Date(status.signed_in_at).getTime()); const mins = Math.floor(ms / 60000); elapsedLabel = (window.appLang&&window.appLang()==='ar') ? `مضى ${Math.floor(mins / 60)}س ${mins % 60}د · الهدف 8س` : `${Math.floor(mins / 60)}h ${mins % 60}m elapsed · target 8h`; pct = Math.min(100, Math.round((ms / (8 * 3600 * 1000)) * 100)); } // Editing form (sign-in / update). if (open) { return (
{signedIn ? (window.appT||(s=>s))('Update sign-in') : (window.appT||(s=>s))('Daily sign-in')}