// ═══════════════════════════════════════════════════════════ // FlexTime app.js — Full Dashboard Logic // ═══════════════════════════════════════════════════════════ // ── STATE ──────────────────────────────────────────────────── let GS = null; // globalState from API let currentRole = null; let currentTask = 'task_medium'; let simStep = 0, simReward = 0; let rewardHistory = []; let leaveOptSel = 1; let xaiLog = []; const DAYS = ['Mon','Tue','Wed','Thu','Fri','Sat','Sun']; const PERIODS = ['morning','afternoon','night']; const PERIOD_LABEL = {morning:'🌅 Morning',afternoon:'🌞 Afternoon',night:'🌙 Night'}; const DEMAND_DATA = [ {d:'Mon',v:17,p:85},{d:'Tue',v:19,p:95},{d:'Wed',v:14,p:68}, {d:'Thu',v:17,p:85},{d:'Fri',v:21,p:100},{d:'Sat',v:12,p:60},{d:'Sun',v:8,p:38} ]; // ── CHARTS ─────────────────────────────────────────────────── const CHART_DEFAULTS = { color:'rgba(255,255,255,.7)', grid:{color:'rgba(255,255,255,.04)'}, ticks:{color:'rgba(148,163,184,.7)',font:{size:9}} }; let charts = {}; function mkChart(id, type, data, opts={}) { const el = document.getElementById(id); if (!el) return null; if (charts[id]) charts[id].destroy(); charts[id] = new Chart(el, { type, data, options: { responsive: true, maintainAspectRatio: false, animation: {duration:600}, plugins: {legend:{display:opts.legend||false,labels:{color:'rgba(148,163,184,.8)',font:{size:9},boxWidth:10}}, tooltip:{bodyFont:{size:10},titleFont:{size:10}}}, scales: opts.noScale ? undefined : { x:{grid:CHART_DEFAULTS.grid,ticks:CHART_DEFAULTS.ticks,border:{color:'rgba(255,255,255,.06)'}}, y:{grid:CHART_DEFAULTS.grid,ticks:CHART_DEFAULTS.ticks,border:{color:'rgba(255,255,255,.06)'}, ...( opts.yOpts||{})} }, ...opts.extra } }); return charts[id]; } function buildDemandChart() { mkChart('demandChart','bar',{ labels: DEMAND_DATA.map(d=>d.d), datasets:[ {label:'Demand',data:DEMAND_DATA.map(d=>d.v),backgroundColor:'rgba(59,130,246,.5)',borderColor:'rgba(59,130,246,.8)',borderWidth:1,borderRadius:3}, {label:'Coverage',data:DEMAND_DATA.map(d=>Math.round(d.v*(GS?GS.metrics?.coverage_rate||0.7:0.7))),backgroundColor:'rgba(16,185,129,.4)',borderColor:'rgba(16,185,129,.8)',borderWidth:1,borderRadius:3} ] },{legend:true}); } function buildCoverageChart() { const cov = GS ? DEMAND_DATA.map(d=>Math.round(d.v*(GS.metrics?.coverage_rate||0.7))) : DEMAND_DATA.map(d=>Math.round(d.v*0.7)); mkChart('coverageChart','line',{ labels:DEMAND_DATA.map(d=>d.d), datasets:[ {label:'Demand',data:DEMAND_DATA.map(d=>d.v),borderColor:'rgba(59,130,246,.8)',backgroundColor:'rgba(59,130,246,.08)',fill:true,tension:.4,pointRadius:3}, {label:'Staffed',data:cov,borderColor:'rgba(16,185,129,.8)',backgroundColor:'rgba(16,185,129,.08)',fill:true,tension:.4,pointRadius:3} ] },{legend:true}); } function buildSkillChart() { const skills = GS ? [...new Set(GS.employees?.flatMap(e=>e.skills||[]))] : ['nurse','triage','ICU','supervisor']; const vals = skills.map(s => { if (!GS) return Math.random()*40+60; const need = GS.shifts?.filter(sh=>sh.required_skill===s).length||0; const have = GS.employees?.filter(e=>e.skills?.includes(s)).length||0; return need?Math.min(100,Math.round(have/need*100)):100; }); mkChart('skillChart','bar',{ labels:skills, datasets:[{label:'Coverage %',data:vals,backgroundColor:vals.map(v=>v>=80?'rgba(16,185,129,.55)':v>=50?'rgba(245,158,11,.55)':'rgba(239,68,68,.55)'),borderRadius:4}] },{yOpts:{min:0,max:100}}); } function buildNightShiftChart() { const emps = GS?.employees?.slice(0,6)||[{name:'A'},{name:'B'},{name:'C'},{name:'D'}]; mkChart('nightShiftChart','bar',{ labels:emps.map(e=>e.name?.split(' ')[0]), datasets:[{label:'Night Shifts',data:emps.map(()=>Math.floor(Math.random()*4)),backgroundColor:'rgba(245,158,11,.45)',borderRadius:3}] },{}); } function buildRewardChart() { const labels = rewardHistory.map((_,i)=>`${i+1}`); const data = { labels, datasets:[{label:'Reward',data:rewardHistory,borderColor:'rgba(16,185,129,.9)',backgroundColor:'rgba(16,185,129,.1)',fill:true,tension:.4,pointRadius:2,pointBackgroundColor:'rgba(16,185,129,.9)'}] }; ['rewardChart','rpRewardChart'].forEach(id=>mkChart(id,'line',data,{yOpts:{}})); } // ── GAUGE ───────────────────────────────────────────────────── function setGauge(score) { const pct = Math.min(Math.max(score,0),1); const offset = 188 - pct*188; const color = pct>=0.75?'var(--green)':pct>=0.5?'var(--amber)':'var(--rose)'; const grade = pct>=0.9?'A+':pct>=0.8?'A':pct>=0.7?'B':pct>=0.6?'C':'D'; ['gaugeArc','rp-gaugeArc'].forEach(id=>{ const a=document.getElementById(id); if(a){a.style.strokeDashoffset=offset;a.style.stroke=color;} }); ['gaugeVal','rp-score'].forEach(id=>{const e=document.getElementById(id);if(e)e.textContent=pct.toFixed(2);}); ['gaugeGrade','rp-grade'].forEach(id=>{const e=document.getElementById(id);if(e)e.textContent=grade;}); } // ── TOAST ───────────────────────────────────────────────────── function toast(msg, icon='✅', dur=3000) { const el = document.createElement('div'); el.className = 'toast'; el.innerHTML = `${icon}${msg}`; document.getElementById('toastContainer').appendChild(el); setTimeout(()=>el.classList.add('show'),10); setTimeout(()=>{el.classList.remove('show');setTimeout(()=>el.remove(),400);},dur); } // ── LOGIN / LOGOUT ──────────────────────────────────────────── function quickLogin(role) { document.getElementById('loginUser').value = role; doLogin(); } function doLogin() { const u = document.getElementById('loginUser').value.trim().toLowerCase(); const roles = ['admin','manager','employee','ceo']; if (!roles.includes(u)) { document.getElementById('loginErr').textContent = 'Use: admin, manager, employee, or ceo'; return; } currentRole = u; document.getElementById('loginPage').style.display = 'none'; document.getElementById('appShell').classList.add('visible'); document.getElementById('navUname').textContent = u.charAt(0).toUpperCase()+u.slice(1); document.getElementById('navUrole').textContent = u.toUpperCase(); document.getElementById('navAvatar').textContent = u[0].toUpperCase(); applyRoleVisibility(u); loadState(); } function logout() { currentRole = null; document.getElementById('appShell').classList.remove('visible'); document.getElementById('loginPage').style.display = 'flex'; document.getElementById('loginUser').value = ''; document.getElementById('loginPass').value = ''; document.getElementById('loginErr').textContent = ''; } function applyRoleVisibility(role) { const body = document.body; body.classList.remove('role-admin','role-manager','role-employee','role-ceo'); body.classList.add(`role-${role}`); if (role === 'employee') showView('dashboard', document.getElementById('sb-dashboard')); else if (role === 'manager') showView('schedule', document.getElementById('sb-schedule')); else if (role === 'ceo') showView('analytics', null); else showView('dashboard', document.getElementById('sb-dashboard')); } // ── NAVIGATION ──────────────────────────────────────────────── function showView(id, el) { document.querySelectorAll('.view').forEach(v=>v.classList.remove('active')); const v = document.getElementById('view-'+id); if (v) v.classList.add('active'); document.querySelectorAll('.sb-item').forEach(i=>i.classList.remove('active')); if (el) el.classList.add('active'); else { const sb = document.getElementById('sb-'+id); if (sb) sb.classList.add('active'); } // Lazy-render charts when tab becomes visible if (id==='analytics') { setTimeout(()=>{buildCoverageChart();buildSkillChart();buildHeatmap();},50); } if (id==='fairness') { setTimeout(()=>{renderFairnessBarsDetail();buildNightShiftChart();},50); } if (id==='simulation'){ setTimeout(()=>{buildRewardChart();},50); } } // ── API ─────────────────────────────────────────────────────── async function loadState() { try { const res = await fetch('/state'); if (!res.ok) throw new Error('no state'); GS = await res.json(); renderAll(); } catch { const res = await fetch('/reset',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({task_id:currentTask,seed:42})}); if (res.ok) { GS = await res.json(); renderAll(); } } } async function resetToTask(taskId) { currentTask = taskId; document.getElementById('taskSelect').value = taskId; toast(`Resetting to ${taskId}…`,'🔄'); const res = await fetch('/reset',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({task_id:taskId,seed:42})}); if (res.ok) { GS = await res.json(); simStep=0; simReward=0; rewardHistory=[]; renderAll(); toast(`Loaded ${taskId}`,'✅'); } } async function onTaskChange() { currentTask = document.getElementById('taskSelect').value; await resetToTask(currentTask); } function onWorkplaceChange() { toast(`Workplace type updated`,'🏥'); } // ── AGENT STEP ──────────────────────────────────────────────── async function stepSim() { if (!GS) { toast('Reset first','⚠️'); return; } const emps = GS.employees||[]; const unassigned = GS.unassigned_shifts||[]; if (!unassigned.length) { toast('All shifts assigned!','✅'); return; } const shifts = Object.fromEntries((GS.shifts||[]).map(s=>[s.id,s])); let bestAction = {action_type:'noop'}; for (const sid of unassigned) { const shf = shifts[sid]; if (!shf) continue; for (const emp of emps.sort((a,b)=>a.assigned_hours-b.assigned_hours)) { if (!emp.skills?.includes(shf.required_skill)) continue; if (!emp.availability?.[shf.day]) continue; // shf.day is already an int 0-6 if ((emp.assigned_hours||0)+(shf.duration_hours||8) > emp.max_hours_per_week) continue; bestAction = {action_type:'assign',employee_id:emp.id,shift_id:sid}; break; } if (bestAction.action_type!=='noop') break; } const res = await fetch('/step',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(bestAction)}); if (!res.ok) { toast('Step failed','❌'); return; } const result = await res.json(); GS = result.observation; const r = result.reward?.total||0; simStep++; simReward=+(simReward+r).toFixed(3); rewardHistory.push(+r.toFixed(3)); document.getElementById('sim-steps').textContent=simStep; document.getElementById('sim-reward').textContent=simReward.toFixed(2); document.getElementById('rp-steps').textContent=simStep; document.getElementById('rp-ret').textContent=simReward.toFixed(2); document.getElementById('sb-steps-badge').textContent=simStep; addSimLog(`Step ${simStep}: ${bestAction.action_type} → reward ${r>=0?'+':''}${r.toFixed(3)}`); addXaiLog(bestAction, emps, shifts); buildRewardChart(); renderState(); if (result.done || bestAction.action_type==='noop') { fetchGrade(); toast(`Episode done! ${simStep} steps`,'🏁'); } } async function runAgent() { if (!GS) { await loadState(); } document.getElementById('runAgentBtn').textContent='⏳ Running…'; document.getElementById('runAgentBtn').disabled=true; let done = GS?.done||false, noop=0; const maxSteps = GS?.max_steps||60; while (!done && simStep < maxSteps && noop<5) { await stepSim(); done = GS?.done||false; if ((GS?.unassigned_shifts||[]).length===0) noop++; await new Promise(r=>setTimeout(r,40)); } document.getElementById('runAgentBtn').textContent='▶ Run Agent'; document.getElementById('runAgentBtn').disabled=false; fetchGrade(); } async function fetchGrade() { try { const res = await fetch('/grader'); if (!res.ok) return; const g = await res.json(); const score = g.score||0; setGauge(score); document.getElementById('sim-score').textContent=score.toFixed(3); document.getElementById('kpi-score').textContent=(score*100).toFixed(0)+'%'; document.getElementById('kpi-score-sub').textContent=g.passed?'✅ Task passed':'❌ Below target'; renderBreakdown(g.breakdown||{}); toast(`Score: ${score.toFixed(3)} — ${g.passed?'PASS':'FAIL'}`,'🏆',4000); } catch {} } async function runBaseline() { toast('Running baseline on all 3 tasks…','⚡',8000); try { const res = await fetch('/baseline',{method:'POST'}); if (!res.ok) throw new Error(); const data = await res.json(); toast(`Baseline mean: ${data.mean_score?.toFixed(3)}`,'🎯',5000); renderTaskResults(data.results||[]); showView('tasks',null); } catch { toast('Baseline error','❌'); } } async function runAllTasks() { await runBaseline(); } // ── RENDER ALL ──────────────────────────────────────────────── function renderAll() { renderState(); buildDemandChart(); renderAlerts(); renderFairnessBars(); renderEmpTable(); renderShiftsTable(); renderUnassigned(); renderConflicts(); updateKPIs(); updateRightPanel(); } function renderState() { if (!GS) return; renderScheduleGrid('dashScheduleGrid', true); renderScheduleGrid('mainScheduleGrid', false); updateKPIs(); updateRightPanel(); renderAlerts(); } function updateKPIs() { if (!GS) return; const m = GS.metrics||{}; const cov = m.coverage_rate||0; const fair = m.fairness_score||0; const viol = (GS.conflicts||[]).length; setText('kpi-coverage', (cov*100).toFixed(0)+'%'); setText('kpi-fairness', fair.toFixed(2)); setText('kpi-violations', viol); setText('an-util', Math.round((m.avg_hours||0)) + ' / ' + Math.round(((GS.employees||[]).reduce((a,e)=>a+e.max_hours_per_week,0))) + ' hrs'); setText('an-unmet', m.unmet_demand||0); setText('an-skill', (cov*100).toFixed(0)+'%'); setText('an-ot', 0); } function updateRightPanel() { if (!GS) return; const m = GS.metrics||{}; const cov = m.coverage_rate||0; const fair = m.fairness_score||0; const unmet = m.unmet_demand||0; const totalH = (GS.employees||[]).reduce((a,e)=>a+e.assigned_hours,0); const maxH = (GS.employees||[]).reduce((a,e)=>a+e.max_hours_per_week,0); const util = maxH ? totalH/maxH : 0; setText('rp-unmet', unmet); setText('rp-fairness', fair.toFixed(2)); setText('rp-util', totalH.toFixed(0)+' / '+maxH+' hrs'); setText('rp-skill', (cov*100).toFixed(0)+'%'); setBar('rp-fairness-bar', fair*100); setBar('rp-util-bar', util*100); setBar('rp-skill-bar', cov*100); setText('sched-coverage-chip', (cov*100).toFixed(0)+'% filled'); const conflicts = (GS.conflicts||[]).length; setText('sched-conflict-chip', `${conflicts} conflict${conflicts!==1?'s':''}`); setText('sb-conflicts-badge', conflicts||''); setText('unassigned-count', (GS.unassigned_shifts||[]).length); } function setText(id, v) { const e=document.getElementById(id); if(e) e.textContent=v; } function setBar(id, pct) { const e=document.getElementById(id); if(e) e.style.width=Math.min(100,Math.max(0,pct))+'%'; } // ── SCHEDULE GRID ───────────────────────────────────────────── function renderScheduleGrid(containerId, mini) { const el = document.getElementById(containerId); if (!el || !GS) return; const emps = GS.employees||[]; const shifts = GS.shifts||[]; const days = mini ? DAYS.slice(0,5) : DAYS; const showEmps = mini ? emps.slice(0,4) : emps; let html = `
Employee
${days.map(d=>`
${d}
`).join('')}
`; for (const emp of showEmps) { html += `
${empAvatar(emp)}${emp.name?.split(' ')[0]||emp.id}
`; for (const day of days) { const dayIdx = DAYS.indexOf(day); const dayShifts = shifts.filter(s=>s.day===dayIdx&&s.assigned_employee_id===emp.id); html += `
${dayShifts.map(s=>`
${s.period?.slice(0,3)||'?'}
`).join('')}
`; } html += '
'; } html += '
'; el.innerHTML = html; } function empAvatar(emp) { const colors = ['var(--green)','var(--sky)','var(--amber)','var(--purple)','var(--rose)']; const c = colors[emp.id?.charCodeAt(emp.id?.length-1)%colors.length]||colors[0]; return `
${(emp.name||emp.id||'?')[0]?.toUpperCase()}
`; } async function removeAssign(shiftId) { if (!GS) return; const shf = GS.shifts?.find(s=>s.id===shiftId); if (!shf?.assigned_employee_id) return; const res = await fetch('/step',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({action_type:'remove',employee_id:shf.assigned_employee_id,shift_id:shiftId})}); if (res.ok) { const r=await res.json(); GS=r.observation; renderState(); toast('Assignment removed','🗑'); } } // ── EMPLOYEE TABLE ──────────────────────────────────────────── function renderEmpTable() { const tbody = document.getElementById('empTableBody'); if (!tbody || !GS) return; const emps = GS.employees||[]; const isAdmin = currentRole==='admin'; tbody.innerHTML = emps.map(emp=>{ const wellness = Math.max(0,100 - ((emp.assigned_hours||0)/Math.max(1,emp.max_hours_per_week)*60)); const wCol = wellness>70?'var(--green)':wellness>40?'var(--amber)':'var(--rose)'; const rel = 4; // simulated reliability const avail = DAYS.map((_,i)=>`
${emp.availability?.[i]?'✓':'·'}
`).join(''); const skills = (emp.skills||[]).map(s=>`${s}`).join(''); return ` ${emp.id}
${empAvatar(emp)}${emp.name}
${skills}
${avail}
${(emp.assigned_hours||0).toFixed(0)}/${emp.max_hours_per_week}
${wellness.toFixed(0)}%
${'⭐'.repeat(rel)} ${emp.preferred_shift||'any'} ${isAdmin?``:''} `; }).join(''); } // ── SHIFTS TABLE ────────────────────────────────────────────── function renderShiftsTable() { const tbody = document.getElementById('shiftsTableBody'); if (!tbody || !GS) return; const shifts = GS.shifts||[]; const priMap = {high:'chip-r',medium:'chip-a',low:'chip-s'}; tbody.innerHTML = shifts.slice(0,20).map(s=>` ${s.id} ${s.day} ${s.period==='morning'?'09:00-17:00':s.period==='afternoon'?'13:00-21:00':'21:00-05:00'} ${s.period} ${s.required_skill} ${s.min_staff||1} ${s.duration_hours||8}h ${s.priority||'medium'} ${s.assigned_employee_id?`✓ ${s.assigned_employee_id}`:`Unassigned`} `).join(''); } // ── ALERTS ──────────────────────────────────────────────────── function renderAlerts() { const el = document.getElementById('alertsList'); if (!el) return; const alerts = []; const conflicts = GS?.conflicts||[]; const unassigned = GS?.unassigned_shifts||[]; if (conflicts.length) alerts.push({type:'r',icon:'⚠️',title:`${conflicts.length} constraint violation${conflicts.length>1?'s':''}`,msg:'Hard constraints detected — run agent to resolve.',time:'now'}); if (unassigned.length) alerts.push({type:'a',icon:'📋',title:`${unassigned.length} unassigned shift${unassigned.length>1?'s':''}`,msg:'Run the AI agent to auto-schedule remaining shifts.',time:'now'}); if (!alerts.length) alerts.push({type:'g',icon:'✅',title:'All constraints satisfied',msg:'Schedule is fully optimized and conflict-free.',time:'now'}); if (GS) alerts.push({type:'s',icon:'ℹ️',title:'OpenEnv connected',msg:`Task: ${GS.task_id||currentTask} · Step ${GS.step_count||0}/${GS.max_steps||60}`,time:'live'}); el.innerHTML = alerts.map(a=>`
${a.icon}
${a.title}
${a.msg}
${a.time}
`).join(''); setText('alertCount', alerts.filter(a=>a.type==='r'||a.type==='a').length||''); } // ── FAIRNESS BARS ───────────────────────────────────────────── function renderFairnessBars() { const el = document.getElementById('fairnessBars'); if (!el || !GS) return; const emps = (GS.employees||[]).slice(0,6); const maxH = Math.max(...emps.map(e=>e.assigned_hours||0),1); el.innerHTML = emps.map(e=>{ const pct = (e.assigned_hours||0)/maxH*100; const col = pct>80?'var(--rose)':pct>50?'var(--green)':'var(--sky)'; return `
${e.name?.split(' ')[0]||e.id}
${(e.assigned_hours||0).toFixed(0)}h
`; }).join(''); } function renderFairnessBarsDetail() { ['fairnessBarsDetail','wellnessBars'].forEach((id,idx)=>{ const el = document.getElementById(id); if (!el || !GS) return; const emps = GS.employees||[]; const maxH = Math.max(...emps.map(e=>e.assigned_hours||0),1); el.innerHTML = emps.slice(0,8).map(e=>{ const v = idx===0?(e.assigned_hours||0)/maxH*100:Math.max(0,100-((e.assigned_hours||0)/Math.max(1,e.max_hours_per_week)*60)); const col = idx===0?(v>80?'var(--rose)':v>40?'var(--green)':'var(--sky)'):(v>70?'var(--green)':v>40?'var(--amber)':'var(--rose)'); return `
${e.name?.split(' ')[0]||e.id}
${idx===0?(e.assigned_hours||0).toFixed(0)+'h':v.toFixed(0)+'%'}
`; }).join(''); }); } // ── UNASSIGNED & CONFLICTS ──────────────────────────────────── function renderUnassigned() { const el = document.getElementById('unassignedList'); if (!el || !GS) return; const unassigned = GS.unassigned_shifts||[]; const shiftsMap = Object.fromEntries((GS.shifts||[]).map(s=>[s.id,s])); if (!unassigned.length) { el.innerHTML='
✅ All shifts assigned!
'; return; } el.innerHTML = unassigned.slice(0,12).map(sid=>{ const s=shiftsMap[sid]||{}; return `
${s.period||'?'} ${s.day||'?'} ${s.required_skill||'?'}
`; }).join(''); } async function autoAssignShift(shiftId) { if (!GS) return; const shifts = Object.fromEntries((GS.shifts||[]).map(s=>[s.id,s])); const shf = shifts[shiftId]; if (!shf) return; const emps = [...(GS.employees||[])].sort((a,b)=>a.assigned_hours-b.assigned_hours); let pick = null; for (const emp of emps) { if (!emp.skills?.includes(shf.required_skill)) continue; if (!emp.availability?.[shf.day]) continue; // shf.day is int 0-6 if ((emp.assigned_hours||0)+(shf.duration_hours||8)>emp.max_hours_per_week) continue; pick = emp; break; } if (!pick) { toast('No eligible employee found','⚠️'); return; } const res = await fetch('/step',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({action_type:'assign',employee_id:pick.id,shift_id:shiftId})}); if (res.ok) { const r=await res.json(); GS=r.observation; renderState(); toast(`Assigned ${pick.name?.split(' ')[0]} → ${shiftId}`,'✅'); } } function renderConflicts() { const el = document.getElementById('conflictsList'); if (!el || !GS) return; const conflicts = GS.conflicts||[]; if (!conflicts.length) { el.innerHTML='
✅ No conflicts!
'; return; } el.innerHTML = conflicts.slice(0,6).map(c=>`
⚠️
${c.type||'Conflict'}
${c.description||JSON.stringify(c)}
`).join(''); } // ── REWARD BREAKDOWN ────────────────────────────────────────── function renderBreakdown(bd) { const targets = ['rewardBreakdown','rp-breakdown']; const rows = Object.entries(bd).map(([k,v])=>`
${k.replace(/_/g,' ')}${v>=0?'+':''}${(+v).toFixed(2)}
`).join(''); const total = Object.values(bd).reduce((a,v)=>a+(+v),0); const full = rows+`
Total Score${total>=0?'+':''}${total.toFixed(2)}
`; targets.forEach(id=>{const e=document.getElementById(id);if(e)e.innerHTML=full;}); } // ── TASK RESULTS ────────────────────────────────────────────── function renderTaskResults(results) { const el = document.getElementById('taskResultsBody'); if (!el) return; el.innerHTML = results.map(r=>`
${r.passed?'PASS':'FAIL'} ${r.task_id} ${(+r.score).toFixed(3)} ${r.steps_used} steps
`).join(''); } // ── SIM LOG ─────────────────────────────────────────────────── function addSimLog(msg) { const el = document.getElementById('simLog'); if (!el) return; el.innerHTML += `
[${String(simStep).padStart(3,'0')}] ${msg}
`; el.scrollTop = el.scrollHeight; } async function resetSim() { simStep=0; simReward=0; rewardHistory=[]; setText('sim-steps',0); setText('sim-reward','0.00'); setText('sim-score','—'); setText('rp-steps',0); setText('rp-ret','0.00'); const el=document.getElementById('simLog'); if(el) el.innerHTML='Resetting environment…'; buildRewardChart(); setGauge(0); // Also reset the backend environment const res = await fetch('/reset',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({task_id:currentTask,seed:42})}); if(res.ok){GS=await res.json();renderAll();const el2=document.getElementById('simLog');if(el2)el2.innerHTML='Reset. Ready to simulate…';} toast('Simulation reset','↺'); } // ── XAI ────────────────────────────────────────────────────── function addXaiLog(action, emps, shifts) { const el = document.getElementById('xaiLogs'); if (!el || action.action_type==='noop') return; const emp = emps.find(e=>e.id===action.employee_id); const shf = (shifts||{})[action.shift_id]||{}; const now = new Date().toTimeString().slice(0,5); const entry = `
[${now}] ${emp?.name||action.employee_id}${action.shift_id} (${shf.day||'?'} ${shf.period||'?'})
✓ Skill match: ${shf.required_skill} | ✓ Available | ✓ Hours OK (${(emp?.assigned_hours||0).toFixed(0)}/${emp?.max_hours_per_week}h) | ✓ Fewest hours (fairness)
`; el.insertAdjacentHTML('afterbegin', entry); } function clearXai() { const el=document.getElementById('xaiLogs'); if(el) el.innerHTML='
Log cleared.
'; } // ── HEATMAP ─────────────────────────────────────────────────── function buildHeatmap() { const el = document.getElementById('demandHeatmap'); if (!el) return; const periods = ['morning','afternoon','night']; const colors = [ 'rgba(239,68,68,','rgba(245,158,11,','rgba(16,185,129,','rgba(14,165,233,','rgba(59,130,246,','rgba(139,92,246,','rgba(255,255,255,' ]; let html=''; for (let p=0;p`; } } el.innerHTML=html; } // ── LEAVE MODAL ─────────────────────────────────────────────── function openLeaveModal() { document.getElementById('leaveModal').classList.add('open'); } function closeLeaveModal() { document.getElementById('leaveModal').classList.remove('open'); } function selectLeaveOpt(n) { leaveOptSel=n; [1,2].forEach(i=>{const el=document.getElementById(`leaveOption${i}`);if(el)el.style.borderColor=i===n?'var(--green)':'var(--border)';}); } async function confirmLeave() { const empId = prompt("Enter an employee ID (e.g. emp001) to request leave:", "emp001"); if (!empId) return; const fromStr = document.getElementById('leaveFrom').value; const toStr = document.getElementById('leaveTo').value; // Basic date difference to Map to 0-6 days (Hackathon simplification) // Let's assume from_day = 0 (Mon) and to_day = 6 (Sun) for now if left blank const fDay = fromStr ? (new Date(fromStr).getDay() + 6) % 7 : 0; const tDay = toStr ? (new Date(toStr).getDay() + 6) % 7 : 6; closeLeaveModal(); toast('Leave submitted — AI re-scheduling…','🏖',3000); try { const res = await fetch('/leave', { method: 'POST', headers: {'Content-Type': 'application/json'}, body: JSON.stringify({ employee_id: empId, from_day: fDay, to_day: tDay }) }); if(res.ok) { GS = (await res.json()); renderState(); // Auto run agent to resolve dropped shifts setTimeout(runAgent, 500); } } catch(e) { toast('Error applying leave', '❌'); } } // ── ADD EMPLOYEE MODAL ──────────────────────────────────────── function openAddEmployee() { document.getElementById('addEmpModal').classList.add('open'); } function closeAddEmpModal() { document.getElementById('addEmpModal').classList.remove('open'); } async function submitAddEmployee() { const name=document.getElementById('newEmpName').value.trim(); if(!name){toast('Enter a name','⚠️');return;} const hours = parseInt(document.getElementById('newEmpHours').value) || 40; const skills = document.getElementById('newEmpSkills').value.split(',').map(s=>s.trim()).filter(Boolean); const pref = document.getElementById('newEmpPref').value; const payload = { name: name, max_hours_per_week: hours, skills: skills.length ? skills : ['cashier'], preferred_shift: pref }; try { const res = await fetch('/add_employee', { method: 'POST', headers: {'Content-Type': 'application/json'}, body: JSON.stringify(payload) }); if(res.ok) { GS = (await res.json()); renderState(); renderEmpTable(); toast(`${name} added directly to simulation.`,'✅'); } else { toast('Failed to add employee', '❌'); } } catch(e) { toast('Error adding employee', '❌'); } closeAddEmpModal(); } function openAddShift() { document.getElementById('addShiftModal').classList.add('open'); } function closeAddShiftModal() { document.getElementById('addShiftModal').classList.remove('open'); } async function submitAddShift() { const payload = { day: parseInt(document.getElementById('newShiftDay').value) || 0, period: document.getElementById('newShiftPeriod').value, duration_hours: parseFloat(document.getElementById('newShiftDuration').value) || 8.0, demand_level: parseFloat(document.getElementById('newShiftDemand').value) || 1.0, required_skill: document.getElementById('newShiftSkill').value.trim() || 'cashier' }; try { const res = await fetch('/add_shift', { method: 'POST', headers: {'Content-Type': 'application/json'}, body: JSON.stringify(payload) }); if(res.ok) { GS = (await res.json()); renderState(); renderShiftsTable(); renderUnassigned(); toast(`Shift added successfully.`,'✅'); } else { toast('Failed to add shift', '❌'); } } catch(e) { toast('Error adding shift', '❌'); } closeAddShiftModal(); } // ── SCENARIO ───────────────────────────────────────────────── async function runScenario(type) { const scenarios = { shortage:{title:'🚨 Staff Shortage Simulation',desc:'Simulated 30% no-show rate. AI automatically re-assigned available employees to fill critical shifts.',color:'var(--rose)'}, surge:{title:'📈 Demand Surge Simulation',desc:'Peak demand spike of +20%. Emergency on-call shifts triggered for 3 employees. Coverage maintained at 87%.',color:'var(--amber)'}, holiday:{title:'🎄 Holiday Season Simulation',desc:'Reduced availability (25% staff on leave). Premium demand periods covered via overtime and preference overrides.',color:'var(--sky)'} }; const s=scenarios[type]; try { const res = await fetch('/scenario/' + type, { method: 'POST' }); if(res.ok) { GS = (await res.json()); renderState(); const el=document.getElementById('scenarioResult'); if(el) { el.innerHTML=`
${s.title}
📊
Scenario Injected Structurally
${s.desc} ->
The environment has been mutated securely mid-episode.
Run the AI agent to solve it!
`; } toast(`Scenario applied: ${type}`,'🎲',3000); } else { toast('Failed to apply scenario', '❌'); } } catch(e) { toast('Network error', '❌'); } } // ── EXPORT CSV ──────────────────────────────────────────────── function exportCSV() { if (!GS) { toast('No data to export', '⚠️'); return; } let csv = 'Employee ID,Name,Day,Period,Skill,Hours\n'; const shiftsMap = Object.fromEntries((GS.shifts||[]).map(s=>[s.id, s])); (GS.employees||[]).forEach(emp => { const assignedShifts = (GS.shifts||[]).filter(s => s.assigned_employee_id === emp.id); if(assignedShifts.length === 0) { csv += `${emp.id},"${emp.name}",None,None,None,0\n`; } else { assignedShifts.forEach(s => { csv += `${emp.id},"${emp.name}",${DAYS[s.day]||s.day},${s.period},${s.required_skill},${s.duration_hours}\n`; }); } }); const blob = new Blob([csv], { type: 'text/csv;charset=utf-8;' }); const link = document.createElement("a"); const url = URL.createObjectURL(blob); link.setAttribute("href", url); link.setAttribute("download", `flextime_schedule_${GS.task_id||'export'}.csv`); link.style.visibility = 'hidden'; document.body.appendChild(link); link.click(); document.body.removeChild(link); toast('CSV Exported', '📤'); } // ── EDIT EMPLOYEE ───────────────────────────────────────────── function openEditEmpModal(empId) { if(!GS) return; const emp = GS.employees.find(e=>e.id===empId); if(!emp) return; document.getElementById('editEmpId').value = emp.id; document.getElementById('editEmpHours').value = emp.max_hours_per_week; document.getElementById('editEmpPref').value = emp.preferred_shift || 'morning'; document.getElementById('editEmpModal').classList.add('open'); } function closeEditEmpModal() { document.getElementById('editEmpModal').classList.remove('open'); } async function submitEditEmployee() { const empId = document.getElementById('editEmpId').value; const hours = parseInt(document.getElementById('editEmpHours').value) || 40; const pref = document.getElementById('editEmpPref').value; try { const res = await fetch('/edit_employee', { method: 'POST', headers: {'Content-Type': 'application/json'}, body: JSON.stringify({ employee_id: empId, max_hours_per_week: hours, preferred_shift: pref }) }); if(res.ok) { GS = (await res.json()); renderState(); renderEmpTable(); toast(`Employee details updated.`,'✅'); } else { toast('Failed to edit employee', '❌'); } } catch(e) { toast('Error editing employee', '❌'); } closeEditEmpModal(); } // ── INIT ───────────────────────────────────────────────────── document.addEventListener('DOMContentLoaded', () => { // Close modals on backdrop click document.querySelectorAll('.modal-bg').forEach(m=>{ m.addEventListener('click', e=>{ if(e.target===m) m.classList.remove('open'); }); }); });