Command Center
`); w.document.close(); setTimeout(()=>w.print(), 800); } function copyReport() { const el = document.getElementById('report-content'); const range = document.createRange(); range.selectNode(el); window.getSelection().removeAllRanges(); window.getSelection().addRange(range); document.execCommand('copy'); window.getSelection().removeAllRanges(); alert('Rapport gekopieerd naar klembord!'); } // ══════════════════════════════════════════════════════════════════ // PROGRAMMA MANAGER — Sub-projecten, WBS, Milestones, MVP's // ══════════════════════════════════════════════════════════════════ // ── Default sub-projects ────────────────────────────────────────── function DEF_SUBPROJECTS() { const t = today(); return [ { id:'sp_erp', naam:'Oracle ERP Cloud Implementatie', beschrijving:'Core ERP implementatie inclusief Finance, HRM en Supply Chain modules', pm:'IT Lead', budget:4200000, actuals:320000, status:'active', kleur:'#2d5a27', startdatum: t, einddatum: addD(t, 180), rag:'GEEL', wbs: [ // Epics → Stories → Tasks hiërarchie { id:'e1', type:'epic', naam:'Fundament & Architectuur', status:'done', volgorde:1, children:[ { id:'e1s1', type:'story', naam:'Technische architectuur ontwerpen', status:'done', eigenaar:'IT Lead', deadline:addD(t,-20), punt:5, children:[ {id:'e1s1t1',type:'task',naam:'Architectuur document opstellen',status:'done',eigenaar:'IT Lead',deadline:addD(t,-25),uren:16}, {id:'e1s1t2',type:'task',naam:'Infrastructure sizing bepalen',status:'done',eigenaar:'IT Lead',deadline:addD(t,-20),uren:8}, ]}, { id:'e1s2', type:'story', naam:'Ontwikkelomgeving inrichten', status:'done', eigenaar:'IT Lead', deadline:addD(t,-15), punt:3, children:[ {id:'e1s2t1',type:'task',naam:'Oracle Cloud tenant aanmaken',status:'done',eigenaar:'IT Lead',deadline:addD(t,-18),uren:4}, {id:'e1s2t2',type:'task',naam:'DevOps pipeline configureren',status:'done',eigenaar:'IT Lead',deadline:addD(t,-15),uren:12}, ]}, ]}, { id:'e2', type:'epic', naam:'Data Migratie', status:'active', volgorde:2, children:[ { id:'e2s1', type:'story', naam:'Data mapping & cleansing', status:'active', eigenaar:'Consultant A', deadline:addD(t,21), punt:8, children:[ {id:'e2s1t1',type:'task',naam:'Legacy data analyse',status:'done',eigenaar:'Consultant A',deadline:addD(t,-5),uren:24}, {id:'e2s1t2',type:'task',naam:'Data mapping document',status:'active',eigenaar:'Consultant A',deadline:addD(t,7),uren:32}, {id:'e2s1t3',type:'task',naam:'Cleansing scripts schrijven',status:'todo',eigenaar:'IT Lead',deadline:addD(t,14),uren:40}, ]}, { id:'e2s2', type:'story', naam:'Migratie dry-run', status:'todo', eigenaar:'IT Lead', deadline:addD(t,45), punt:5, children:[ {id:'e2s2t1',type:'task',naam:'Dry-run 1 uitvoeren',status:'todo',eigenaar:'IT Lead',deadline:addD(t,35),uren:16}, {id:'e2s2t2',type:'task',naam:'Resultaten valideren',status:'todo',eigenaar:'Consultant A',deadline:addD(t,42),uren:8}, ]}, ]}, { id:'e3', type:'epic', naam:'Finance Module', status:'todo', volgorde:3, children:[ { id:'e3s1', type:'story', naam:'GL configuratie', status:'todo', eigenaar:'Consultant B', deadline:addD(t,60), punt:8, children:[ {id:'e3s1t1',type:'task',naam:'Rekeningschema inrichten',status:'todo',eigenaar:'Consultant B',deadline:addD(t,55),uren:24}, {id:'e3s1t2',type:'task',naam:'Kostenplaatsen configureren',status:'todo',eigenaar:'Consultant B',deadline:addD(t,60),uren:16}, ]}, ]}, ], milestones:[ {id:'spm1', naam:'Architectuur Akkoord', datum:addD(t,-15), status:'done'}, {id:'spm2', naam:'Data Mapping Gereed', datum:addD(t,21), status:'active'}, {id:'spm3', naam:'Finance Module Live', datum:addD(t,75), status:'planned'}, {id:'spm4', naam:'UAT Gestart', datum:addD(t,120), status:'planned'}, {id:'spm5', naam:'Go-Live ERP', datum:addD(t,180), status:'planned'}, ], mvps:[ {id:'mvp1', naam:'MVP 1 — Finance Basisfunctionaliteit', datum:addD(t,90), status:'planned', beschrijving:'GL, AP, AR en basic reporting operationeel', features:['Grootboekboekingen','Crediteuren beheer','Debiteuren beheer','Standaard rapportages']}, {id:'mvp2', naam:'MVP 2 — Volledige Finance Suite', datum:addD(t,150), status:'planned', beschrijving:'Budgettering, consolidatie en geavanceerde analytics', features:['Budgetmodule','Consolidatie','Treasury','BI dashboards']}, ] }, { id:'sp_hrm', naam:'HRM Transformatie', beschrijving:'Volledige HR digitalisering incl. Oracle HCM en change management', pm:'HR Lead', budget:1800000, actuals:95000, status:'active', kleur:'#1a3d5c', startdatum: addD(t,14), einddatum: addD(t,200), rag:'GROEN', wbs:[ { id:'h1', type:'epic', naam:'Organisatiestructuur', status:'active', volgorde:1, children:[ { id:'h1s1', type:'story', naam:'Org-chart in Oracle HCM', status:'active', eigenaar:'HR Lead', deadline:addD(t,28), punt:5, children:[ {id:'h1s1t1',type:'task',naam:'Org-structuur valideren',status:'done',eigenaar:'HR Lead',deadline:addD(t,7),uren:8}, {id:'h1s1t2',type:'task',naam:'HCM configuratie',status:'active',eigenaar:'HR Lead',deadline:addD(t,21),uren:20}, ]}, ]}, { id:'h2', type:'epic', naam:'Payroll Integratie', status:'todo', volgorde:2, children:[ { id:'h2s1', type:'story', naam:'Salarisverwerking koppeling', status:'todo', eigenaar:'IT Lead', deadline:addD(t,90), punt:8, children:[ {id:'h2s1t1',type:'task',naam:'API koppeling bouwen',status:'todo',eigenaar:'IT Lead',deadline:addD(t,80),uren:40}, {id:'h2s1t2',type:'task',naam:'Testrun salarisverwerking',status:'todo',eigenaar:'HR Lead',deadline:addD(t,88),uren:16}, ]}, ]}, ], milestones:[ {id:'hm1', naam:'HCM Tenant Ingericht', datum:addD(t,14), status:'planned'}, {id:'hm2', naam:'Org-structuur Akkoord', datum:addD(t,35), status:'planned'}, {id:'hm3', naam:'Payroll Go-Live', datum:addD(t,200), status:'planned'}, ], mvps:[ {id:'hmvp1', naam:'MVP 1 — Core HR', datum:addD(t,100), status:'planned', beschrijving:'Personeelsregistratie en basisfunctionaliteit', features:['Personeelsdossiers','Contractbeheer','Verlofregistratie']}, ] }, { id:'sp_bi', naam:'Data & BI Platform', beschrijving:'Enterprise datawarehouse en business intelligence op Oracle Analytics Cloud', pm:'Consultant A', budget:950000, actuals:0, status:'planned', kleur:'#7c69a9', startdatum: addD(t,60), einddatum: addD(t,240), rag:'GROEN', wbs:[ { id:'b1', type:'epic', naam:'Data Architectuur', status:'todo', volgorde:1, children:[ { id:'b1s1', type:'story', naam:'DWH ontwerp', status:'todo', eigenaar:'Consultant A', deadline:addD(t,90), punt:13, children:[ {id:'b1s1t1',type:'task',naam:'Datamodel ontwerpen',status:'todo',eigenaar:'Consultant A',deadline:addD(t,85),uren:32}, ]}, ]}, ], milestones:[ {id:'bm1', naam:'BI Platform Kickoff', datum:addD(t,60), status:'planned'}, {id:'bm2', naam:'DWH Operationeel', datum:addD(t,180), status:'planned'}, ], mvps:[ {id:'bmvp1', naam:'MVP 1 — Finance Dashboards', datum:addD(t,200), status:'planned', beschrijving:'Real-time finance rapportages en KPI dashboards', features:['P&L dashboard','Cashflow monitor','Budget vs Actuals']}, ] } ]; } // ── State helpers ──────────────────────────────────────────────── let activeSP = null; // id of selected sub-project let wbsExpanded = {}; // track expanded/collapsed nodes let activeWbsTab = 'wbs'; // 'wbs' | 'milestones' | 'mvps' | 'summary' function getSP(id) { return (S.subprojects||[]).find(p=>p.id===id); } function flatWbs(nodes, depth=0) { const out = []; (nodes||[]).forEach(n => { out.push({...n, depth, children:undefined}); if (wbsExpanded[n.id] !== false && n.children?.length) { out.push(...flatWbs(n.children, depth+1)); } }); return out; } function allWbsNodes(nodes) { const out = []; (nodes||[]).forEach(n => { out.push(n); if (n.children?.length) out.push(...allWbsNodes(n.children)); }); return out; } function wbsStats(sp) { const all = allWbsNodes(sp.wbs||[]); const tasks = all.filter(n=>n.type==='task'); const done = tasks.filter(n=>n.status==='done').length; const active = tasks.filter(n=>n.status==='active').length; const total = tasks.length; const pct = total ? Math.round(done/total*100) : 0; const totalHours = tasks.reduce((s,t)=>s+(t.uren||0),0); const doneHours = tasks.filter(n=>n.status==='done').reduce((s,t)=>s+(t.uren||0),0); return {done, active, total, pct, totalHours, doneHours}; } function spRagColor(rag) { if (rag==='ROOD') return {c:'#cc0000',bg:'#ffe0e0',br:'#ff9999'}; if (rag==='GEEL') return {c:'#8a6400',bg:'#fff8e1',br:'#ffe082'}; return {c:C.green3, bg:'#f0f9e0', br:'#c5e08a'}; } function statusBadge(s, small=false) { const map = { done: {bg:'#f0f9e0',c:C.green3, br:'#c5e08a', lbl:'Gereed'}, active: {bg:'#e8f0fe',c:'#1a3d5c', br:'#90caf9', lbl:'Actief'}, todo: {bg:'#f0f1f0',c:'#555', br:'#ccc', lbl:'Te doen'}, planned:{bg:'#f3f0f9',c:C.purple, br:'#c5b8e0', lbl:'Gepland'}, blocked:{bg:'#ffe8e9',c:C.pink, br:'#ffbcbe', lbl:'Geblokkeerd'}, }; const m = map[s] || map.todo; const sz = small ? 'font-size:10px;padding:1px 7px' : 'font-size:11px;padding:2px 9px'; return `${m.lbl}`; } function typeIcon(type) { return {epic:'◈', story:'◇', task:'◻'}[type] || '◻'; } function typeColor(type) { return {epic: C.darkgreen, story: '#1a3d5c', task: C.t2}[type] || C.t2; } // ── PROGRAM OVERVIEW ──────────────────────────────────────────── function vProgram() { const sps = S.subprojects || []; const p = S.project; // Program-level KPIs const totalBudget = sps.reduce((s,sp)=>s+sp.budget,0); const totalActuals = sps.reduce((s,sp)=>s+sp.actuals,0); const allMs = sps.flatMap(sp=>sp.milestones||[]); const allMvps = sps.flatMap(sp=>sp.mvps||[]); const allTasks = sps.flatMap(sp=>allWbsNodes(sp.wbs||[]).filter(n=>n.type==='task')); const doneTasks = allTasks.filter(n=>n.status==='done').length; const redSPs = sps.filter(sp=>sp.rag==='ROOD').length; const kpis = `
${[ ['Sub-projecten', sps.length, sps.filter(s=>s.status==='active').length+' actief', C.darkgreen], ['Programma Budget', fmtM(totalBudget), `${Math.round(totalActuals/Math.max(totalBudget,1)*100)}% verbruikt`, totalActuals/Math.max(totalBudget,1)>.9?C.pink:C.darkgreen], ['Taken totaal', `${doneTasks}/${allTasks.length}`, `${Math.round(doneTasks/Math.max(allTasks.length,1)*100)}% gereed`, C.green], ['Mijlpalen', `${allMs.filter(m=>m.status==='done').length}/${allMs.length}`, `${allMvps.filter(m=>m.status==='released').length} MVP's released`, C.purple], ['RAG Kritisch', redSPs, redSPs>0?`${redSPs} project${redSPs>1?'en':''} op ROOD`:'Geen kritische projecten', redSPs>0?C.pink:C.green], ].map(([l,v,sub,c])=>`
${l}
${v}
${sub}
`).join('')}
`; // Sub-project cards grid const spCards = sps.map(sp => { const stats = wbsStats(sp); const rag = spRagColor(sp.rag||'GROEN'); const bp = sp.budget>0 ? Math.round(sp.actuals/sp.budget*100) : 0; const daysLeft = dBet(today(), sp.einddatum); const upMs = (sp.milestones||[]).filter(m=>m.status!=='done').slice(0,2); const upMvps = (sp.mvps||[]).filter(m=>m.status!=='released').slice(0,1); return `
${esc(sp.naam)}
${esc(sp.beschrijving||'')}
● ${sp.rag||'GROEN'}
${avatarEl(sp.pm, 26)}
Project Manager
${esc(sp.pm||'—')}
📅 ${sp.startdatum}
${daysLeft}d resterend
WBS Voortgang ${stats.done}/${stats.total} taken (${stats.pct}%)
${(sp.wbs||[]).map(epic=>{ const epicTasks = allWbsNodes([epic]).filter(n=>n.type==='task'); const epicDone = epicTasks.filter(n=>n.status==='done').length; const pct2 = epicTasks.length ? Math.round(epicDone/epicTasks.length*100) : 0; const sc = epic.status==='done'?C.green3:epic.status==='active'?sp.kleur:C.t3; return `
${esc(epic.naam.slice(0,20))}${epic.naam.length>20?'…':''}
${pct2}%
`; }).join('')}
BUDGET
${bp}% · ${fmtM(sp.actuals)} / ${fmtM(sp.budget)}
VOLGENDE MIJLPAAL
${upMs[0]?esc(upMs[0].naam):'—'}
${upMs[0]?`
${upMs[0].datum}
`:''}
${upMvps.length?`
VOLGENDE MVP
${esc(upMvps[0].naam)}
${upMvps[0].datum}
`:''}
Details & WBS →
`; }).join(''); // Program timeline const programTl = buildProgramTimeline(sps); return `
Programma Overzicht
${esc(p.naam)} · ${sps.length} sub-projecten · ${allTasks.length} activiteiten totaal
${kpis}
${spCards||'
Nog geen sub-projecten. Voeg een sub-project toe om te beginnen.
'}
Programma Tijdlijn
${programTl}
`; } function buildProgramTimeline(sps) { if (!sps.length) return '
Geen sub-projecten
'; const allDates = sps.flatMap(sp=>[sp.startdatum, sp.einddatum]).map(d=>new Date(d)); const minD = new Date(Math.min(...allDates)); const maxD = new Date(Math.max(...allDates)); const totalD = Math.max((maxD-minD)/864e5, 1); const W = 680, lbl = 180, pad = 10, rowH = 36; const toX = d => lbl + (dBet(minD.toISOString().split('T')[0], d)/totalD)*(W-lbl-pad); const todayX = Math.max(lbl, Math.min(W-pad, toX(today()))); const svgH = sps.length * rowH + 32; // Month markers const months = []; const cur = new Date(minD); cur.setDate(1); while (cur <= maxD) { months.push({d: new Date(cur), x: toX(cur.toISOString().split('T')[0])}); cur.setMonth(cur.getMonth()+1); } return `
${months.map(m=>` ${m.d.toLocaleDateString('nl-NL',{month:'short',year:'2-digit'})} `).join('')} Nu ${sps.map((sp, i) => { const y = i*rowH + 24; const x1 = Math.max(lbl+2, toX(sp.startdatum)); const x2 = Math.min(W-pad, toX(sp.einddatum)); const barW = Math.max(x2-x1, 6); const stats = wbsStats(sp); const doneW = barW * stats.pct/100; const rag = spRagColor(sp.rag||'GROEN'); return ` ${esc(sp.naam.slice(0,22))}${sp.naam.length>22?'…':''} ${stats.pct}% ${(sp.milestones||[]).map(m=>{ const mx = toX(m.datum); if (mx < lbl || mx > W-pad) return ''; const mc = m.status==='done'?C.green:'white'; return `${esc(m.naam)} — ${m.datum}`; }).join('')} ${(sp.mvps||[]).map(m=>{ const mx = toX(m.datum); if (mx < lbl || mx > W-pad) return ''; return ` MVP: ${esc(m.naam)} — ${m.datum}`; }).join('')} ${sp.rag||'GRN'} `; }).join('')} Voortgang Mijlpaal MVP
`; } // ── ADD SUB-PROJECT FORM ──────────────────────────────────────── function showAddSP() { document.getElementById('add-sp-form').innerHTML = `
Nieuw Sub-project toevoegen
Projectnaam
Beschrijving
Project Manager
${['Andre Keizer','IT Lead','Consultant A','Consultant B','HR Lead'].map(n=>`
Budget (€)
RAG Status
Startdatum
Einddatum
Kleur
${[C.darkgreen,'#1a3d5c',C.purple,C.orange,'#8b1a6b','#1a5c5c'].map(kl=>`
`).join('')}
`; selSPC(C.darkgreen); } function selSPC(c) { document.getElementById('nsp-c').value = c; document.querySelectorAll('[data-spc]').forEach(e => { e.style.border = e.dataset.spc===c ? '3px solid #111' : '3px solid transparent'; }); } function addSP() { const n = document.getElementById('nsp-n').value.trim(); if(!n) return; if (!S.subprojects) S.subprojects = []; S.subprojects.push({ id: 'sp_'+uid(), naam: n, beschrijving: document.getElementById('nsp-b').value, pm: document.getElementById('nsp-pm').value, budget: +document.getElementById('nsp-bud').value, actuals: 0, rag: document.getElementById('nsp-rag').value, kleur: document.getElementById('nsp-c').value, startdatum: document.getElementById('nsp-sd').value, einddatum: document.getElementById('nsp-ed').value, status: 'planned', wbs:[], milestones:[], mvps:[] }); save(); renderAll(); } // ── SUB-PROJECT DETAIL ────────────────────────────────────────── function vProgramDetail() { const sp = getSP(activeSP); if (!sp) return `
Sub-project niet gevonden.
`; const stats = wbsStats(sp); const rag = spRagColor(sp.rag||'GROEN'); const bp = sp.budget>0 ? Math.round(sp.actuals/sp.budget*100) : 0; // Header const header = `
${esc(sp.naam)}
${esc(sp.beschrijving||'')}
${avatarEl(sp.pm, 28)} ${esc(sp.pm||'—')} ● ${sp.rag||'GROEN'} 📅 ${sp.startdatum} → ${sp.einddatum}
WBS
${stats.pct}%
${stats.done}/${stats.total}
Budget
${bp}%
${fmtM(sp.actuals)}
Uren
${stats.doneHours}
/${stats.totalHours}u
`; // Tabs const tabs = `
${[['wbs','◈ Work Breakdown Structure'],['milestones','◆ Mijlpalen'],['mvps','⬡ MVP\'s'],['summary','≡ Samenvatting']].map(([id,lbl])=>` `).join('')}
`; let body = ''; if (activeWbsTab==='wbs') body = vWBS(sp); if (activeWbsTab==='milestones') body = vSPMilestones(sp); if (activeWbsTab==='mvps') body = vSPMvps(sp); if (activeWbsTab==='summary') body = vSPSummary(sp); return header + tabs + body; } // ── WBS VIEW ──────────────────────────────────────────────────── function vWBS(sp) { const flat = flatWbs(sp.wbs||[]); const wbsRows = flat.map(node => { const indent = node.depth * 22; const hasChildren = ['epic','story'].includes(node.type); const expanded = wbsExpanded[node.id] !== false; const tc = typeColor(node.type); const ov = node.deadline && new Date(node.deadline)${node.punt}pt` : ''; return `
${hasChildren ? `` : '
'} ${typeIcon(node.type)} ${esc(node.naam)} ${puntBadge}
${statusBadge(node.status, true)}
${node.eigenaar ? avatarEl(node.eigenaar, 20) : ''} ${esc(node.eigenaar||'—')}
${node.deadline||'—'} ${node.uren?node.uren+'u':node.type==='task'?'—':''}
${['todo','active','done'].map(s=>``).join('')}
`; }).join(''); return `
Work Breakdown Structure
◈ Epic  ·  ◇ Story (User Story)  ·  ◻ Task (Activiteit)
${wbsRows}
Naam Status Eigenaar Deadline Uren Wijzig
${(sp.wbs||[]).map(epic=>` `).join('')}
`; } function showAddWbs(spId, type, parentId) { const sp = getSP(spId); const parentOptions = type==='task' ? (sp.wbs||[]).flatMap(e=>e.children||[]).map(s=>``).join('') : (sp.wbs||[]).map(e=>``).join(''); const sel = parentId ? ` data-preselect="${parentId}"` : ''; document.getElementById('wbs-add-form').innerHTML = `
${typeIcon(type)} ${type==='epic'?'Nieuw Epic':type==='story'?'Nieuwe Story (User Story)':'Nieuwe Task (Activiteit)'} toevoegen
Naam
${type!=='epic'?`
Bovenliggend ${type==='task'?'Story':'Epic'}
`:''}
Eigenaar
${['Andre Keizer','IT Lead','Consultant A','Consultant B','HR Lead'].map(n=>`
Deadline
${type==='story'?`
Story Points
${[1,2,3,5,8,13,21].map(n=>``).join('')}
`:''} ${type==='task'?`
Geschatte uren
`:''}
`; if (parentId) { const el=document.getElementById('nw-par'); if(el) el.value=parentId; } selPts(5); } function selPts(n) { const el = document.getElementById('nw-pt'); if(el) el.value=n; document.querySelectorAll('[data-pt]').forEach(e => { e.style.background = parseInt(e.dataset.pt)===n ? C.darkgreen : 'white'; e.style.color = parseInt(e.dataset.pt)===n ? 'white' : C.t0; e.style.borderColor = parseInt(e.dataset.pt)===n ? C.darkgreen : 'var(--border)'; }); } function addWbsNode(spId, type) { const n = document.getElementById('nw-n').value.trim(); if(!n) return; const sp = getSP(spId); if(!sp) return; const newNode = { id: type[0]+uid(), type, naam:n, eigenaar: document.getElementById('nw-e')?.value||'', deadline: document.getElementById('nw-d')?.value||'', status:'todo', punt: type==='story' ? parseInt(document.getElementById('nw-pt')?.value||5) : undefined, uren: type==='task' ? parseInt(document.getElementById('nw-u')?.value||8) : undefined, children: type!=='task' ? [] : undefined }; if (type==='epic') { newNode.volgorde = (sp.wbs||[]).length+1; sp.wbs = [...(sp.wbs||[]), newNode]; } else { const parId = document.getElementById('nw-par')?.value; const parent = allWbsNodes(sp.wbs||[]).find(x=>x.id===parId); if (parent) { if(!parent.children) parent.children=[]; parent.children.push(newNode); } else sp.wbs.push(newNode); } wbsExpanded[newNode.id] = true; save(); render(); } function setWbsStatus(spId, nodeId, status) { const sp = getSP(spId); if(!sp) return; const node = allWbsNodes(sp.wbs||[]).find(n=>n.id===nodeId); if (node) node.status = status; save(); render(); } function delWbsNode(spId, nodeId) { if(!confirm('Verwijderen?')) return; const sp = getSP(spId); if(!sp) return; function removeFrom(nodes) { return (nodes||[]).filter(n=>n.id!==nodeId).map(n=>({...n,children:removeFrom(n.children)})); } sp.wbs = removeFrom(sp.wbs); save(); render(); } function expandAllWbs(spId) { const sp = getSP(spId); if(!sp) return; allWbsNodes(sp.wbs||[]).forEach(n=>wbsExpanded[n.id]=true); render(); } function collapseAllWbs(spId) { const sp = getSP(spId); if(!sp) return; (sp.wbs||[]).forEach(n=>wbsExpanded[n.id]=false); render(); } function setSPRag(id, rag) { const sp = getSP(id); if(sp) sp.rag=rag; save(); render(); } // ── MILESTONES ────────────────────────────────────────────────── function vSPMilestones(sp) { const ms = [...(sp.milestones||[])].sort((a,b)=>new Date(a.datum)-new Date(b.datum)); const done = ms.filter(m=>m.status==='done').length; return `
${done}/${ms.length} mijlpalen bereikt
${buildSPTimeline(sp)}
${ms.map(m=>{ const past = new Date(m.datum)
${m.status==='done'?'✓':'◆'}
${esc(m.naam)}
📅 ${m.datum} ${ov?`— ${Math.abs(dBet(today(),m.datum))}d verlaat`:''}
${m.beschrijving?`
${esc(m.beschrijving)}
`:''}
${['planned','active','done'].map(s=>``).join('')}
`; }).join('')}
`; } function buildSPTimeline(sp) { const ms = [...(sp.milestones||[])].sort((a,b)=>new Date(a.datum)-new Date(b.datum)); if(!ms.length) return ''; const W=640, pad=40; const dates = [sp.startdatum, ...ms.map(m=>m.datum), sp.einddatum]; const minD = dates.reduce((a,b)=>aa>b?a:b); const total = Math.max(dBet(minD, maxD), 1); const toX = d => pad + (dBet(minD,d)/total)*(W-2*pad); const todayX = Math.max(pad, Math.min(W-pad, toX(today()))); return ` Nu ${ms.map(m=>{ const x = toX(m.datum); const c = m.status==='done'?C.green:m.status==='active'?sp.kleur:C.t2; return ` ${m.status==='done'?``:''} ${esc(m.naam.slice(0,14))}${m.naam.length>14?'…':''} ${m.datum}`; }).join('')} `; } function showAddSPM(spId) { document.getElementById('spm-add-form').innerHTML = `
Mijlpaalnaam
Datum
Beschrijving
`; } function addSPM(spId) { const n=document.getElementById('nm-sn').value.trim(); if(!n) return; const sp=getSP(spId); if(!sp) return; if(!sp.milestones) sp.milestones=[]; sp.milestones.push({id:'m'+uid(),naam:n,datum:document.getElementById('nm-sd').value,beschrijving:document.getElementById('nm-sb').value,status:'planned'}); save(); render(); } function setSPMStatus(spId,mId,s){const sp=getSP(spId);if(!sp)return;sp.milestones=(sp.milestones||[]).map(m=>m.id===mId?{...m,status:s}:m);save();render();} function delSPM(spId,mId){if(!confirm('Verwijderen?'))return;const sp=getSP(spId);if(!sp)return;sp.milestones=(sp.milestones||[]).filter(m=>m.id!==mId);save();render();} // ── MVP VIEW ──────────────────────────────────────────────────── function vSPMvps(sp) { const mvps = sp.mvps||[]; return `
MVP's — Minimum Viable Products
Incrementele waarde-oplevering per MVP release
${mvps.map((mvp,i)=>{ const sc = {released:C.green,active:sp.kleur,planned:C.purple}[mvp.status]||C.purple; const past = new Date(mvp.datum)
MVP ${i+1}
${esc(mvp.naam)}
${mvp.status||'planned'}

${esc(mvp.beschrijving||'')}

Opgeleverde Features
${(mvp.features||[]).map(f=>`
${esc(f)}
`).join('')}
📅 ${mvp.datum}
${['planned','active','released'].map(s=>``).join('')}
`; }).join('')||'
Nog geen MVP\'s gedefinieerd.
'} `; } function showAddMvp(spId) { document.getElementById('mvp-add-form').innerHTML = `
Nieuw MVP definiëren
MVP naam
Releasedatum
Beschrijving / Waarde propositie
Features (één per regel)
`; } function addMvp(spId){ const n=document.getElementById('nmv-n').value.trim();if(!n)return; const sp=getSP(spId);if(!sp)return; if(!sp.mvps)sp.mvps=[]; const features=document.getElementById('nmv-f').value.split('\n').map(s=>s.trim()).filter(Boolean); sp.mvps.push({id:'mvp'+uid(),naam:n,datum:document.getElementById('nmv-d').value,beschrijving:document.getElementById('nmv-b').value,features,status:'planned'}); save();render(); } function setMvpStatus(spId,mId,s){const sp=getSP(spId);if(!sp)return;sp.mvps=(sp.mvps||[]).map(m=>m.id===mId?{...m,status:s}:m);save();render();} function delMvp(spId,mId){if(!confirm('MVP verwijderen?'))return;const sp=getSP(spId);if(!sp)return;sp.mvps=(sp.mvps||[]).filter(m=>m.id!==mId);save();render();} // ── SUMMARY ──────────────────────────────────────────────────── function vSPSummary(sp) { const stats = wbsStats(sp); const epics = sp.wbs||[]; const rag = spRagColor(sp.rag||'GROEN'); const bp = sp.budget>0?Math.round(sp.actuals/sp.budget*100):0; const daysLeft = dBet(today(), sp.einddatum); return `
Projectstatus
${[ ['Naam', esc(sp.naam)], ['Project Manager', `${avatarEl(sp.pm,18)} ${esc(sp.pm||'—')}`], ['RAG', `● ${sp.rag||'GROEN'}`], ['Looptijd', `${sp.startdatum} → ${sp.einddatum}`], ['Dagen resterend', `${daysLeft}`], ['Status', statusBadge(sp.status||'planned')], ].map(([k,v])=>``).join('')}
${k}${v}
Voortgang samenvatting
${[ ['WBS Taken', `${stats.done}/${stats.total}`, `${stats.pct}%`, sp.kleur], ['Uren', `${stats.doneHours}u`, `/${stats.totalHours}u`, C.blue], ['Budget', `${bp}%`, `${fmtM(sp.actuals)}`, bp>90?C.pink:sp.kleur], ['Mijlpalen', `${(sp.milestones||[]).filter(m=>m.status==='done').length}/${(sp.milestones||[]).length}`, 'bereikt', C.purple], ['MVP\'s', `${(sp.mvps||[]).filter(m=>m.status==='released').length}/${(sp.mvps||[]).length}`, 'released', C.purple], ].map(([l,v,sub,c])=>`
${l}
${v}
${sub}
`).join('')}
Epic Breakdown
${epics.map(epic=>{ const et=allWbsNodes([epic]).filter(n=>n.type==='task'); const ed=et.filter(n=>n.status==='done').length; const pct2=et.length?Math.round(ed/et.length*100):0; const stories=(epic.children||[]).length; return `
${typeIcon('epic')} ${esc(epic.naam)}
${statusBadge(epic.status, true)} ${stories} stories · ${et.length} taken ${pct2}%
`; }).join('')}
`; } // ══════════════════════════════════════════ // RENDER // ══════════════════════════════════════════ // ══════════════════════════════════════════ // FINANCIEEL BEHEER MODULE // ══════════════════════════════════════════ // Default kostenregels per categorie function DEF_FINANCE() { return { kostenregels: [ {id:'f1',naam:'ERP-kern Oracle Fusion Cloud',categorie:'Licenties',type:'opex',budget:5700000,actuals:0,etc:5700000}, {id:'f2',naam:'ERP-HRM Oracle HCM',categorie:'Licenties',type:'opex',budget:695000,actuals:5808,etc:689192}, {id:'f3',naam:'Rooster UP InPlanning',categorie:'Licenties',type:'opex',budget:460000,actuals:0,etc:460000}, {id:'f4',naam:'Overige leveranciers OIC iPaaS',categorie:'Overig',type:'opex',budget:1540000,actuals:36948,etc:1503052}, {id:'f5',naam:'Algemeen PM consultancy implementatie',categorie:'Personeel',type:'opex',budget:16407000,actuals:1948796,etc:14458204}, {id:'f6',naam:'Overige programmakosten',categorie:'Overig',type:'opex',budget:50000,actuals:0,etc:50000}, {id:'f7',naam:'Onvoorzien programma-reserve',categorie:'Reserve',type:'opex',budget:3480000,actuals:0,etc:3480000}, {id:'c1',naam:'Eigen interfaces maatwerk ERP-kern',categorie:'Maatwerk',type:'capex',budget:1500000,actuals:0,etc:1500000}, {id:'c2',naam:'HR-koppelingen eigen IP',categorie:'Integraties',type:'capex',budget:300000,actuals:0,etc:300000}, {id:'c3',naam:'Rooster-integratie eigen IP',categorie:'Integraties',type:'capex',budget:400000,actuals:0,etc:400000}, {id:'c4',naam:'iPaaS eigen integratielaag OIC',categorie:'Infrastructuur',type:'capex',budget:2200000,actuals:36948,etc:2163052}, {id:'c5',naam:'DM-tooling eigen beheer UMCU',categorie:'Technisch',type:'capex',budget:800000,actuals:150000,etc:650000} ], periodes: ['2025','2026','2027'], activePeriode: '2026', }; } let activeFinanceTab = 'dashboard'; // 'dashboard' | 'regels' | 'deelprojecten' function saveFinance() { save(); } function fmtK(n) { if (!n && n !== 0) return '—'; if (Math.abs(n) >= 1000000) return '€' + (n/1000000).toFixed(2) + 'M'; if (Math.abs(n) >= 1000) return '€' + (n/1000).toFixed(0) + 'K'; return '€' + n.toLocaleString('nl-NL'); } function pct(a, b) { return b > 0 ? Math.round(a/b*100) : 0; } // ── MAIN VIEW ───────────────────────────────────────────────── function vFinance() { if (!S.finance) S.finance = DEF_FINANCE(); const fin = S.finance; const tabs = `
${[['dashboard','📊 Dashboard'],['regels','📋 Kostenregels'],['deelprojecten','⊞ Deelprojecten']].map(([id,lbl])=>` `).join('')}
`; let body = ''; if (activeFinanceTab==='dashboard') body = vFinDashboard(fin); if (activeFinanceTab==='regels') body = vFinRegels(fin); if (activeFinanceTab==='deelprojecten') body = vFinDeelprojecten(fin); return `
Financieel Beheer
Budget · Realisatie · Estimate to Completion · CapEx / OpEx
Periode:
${tabs}${body}`; } // ── DASHBOARD ───────────────────────────────────────────────── function vFinDashboard(fin) { const regels = fin.kostenregels||[]; const capex = regels.filter(r=>r.type==='capex'); const opex = regels.filter(r=>r.type==='opex'); const totBudget = regels.reduce((s,r)=>s+(r.budget||0),0); const totActuals = regels.reduce((s,r)=>s+(r.actuals||0),0); const totETC = regels.reduce((s,r)=>s+(r.etc||0),0); const totEAC = totActuals + totETC; // Estimate At Completion const totVar = totBudget - totEAC; // Variance const capBudget = capex.reduce((s,r)=>s+(r.budget||0),0); const capActuals = capex.reduce((s,r)=>s+(r.actuals||0),0); const capETC = capex.reduce((s,r)=>s+(r.etc||0),0); const opBudget = opex.reduce((s,r)=>s+(r.budget||0),0); const opActuals = opex.reduce((s,r)=>s+(r.actuals||0),0); const opETC = opex.reduce((s,r)=>s+(r.etc||0),0); const bp = pct(totActuals, totBudget); const eacPct = pct(totEAC, totBudget); const varColor = totVar >= 0 ? C.green3 : C.pink; // KPI cards const kpis = `
${[ ['Totaal Budget', fmtK(totBudget), 'Goedgekeurd budget', C.darkgreen], ['Realisatie (Actuals)', fmtK(totActuals), `${bp}% van budget verbruikt`, bp>90?C.pink:bp>75?C.yellow:C.green3], ['ETC (resterend werk)', fmtK(totETC), 'Estimate to Completion', C.purple], ['EAC (eindprognose)', fmtK(totEAC), `${eacPct>100?'⚠️ '+Math.round(eacPct-100)+'% OVER budget':eacPct+'% van budget'}`, eacPct>100?C.pink:C.green3], ].map(([l,v,sub,c])=>`
${l}
${v}
${sub}
`).join('')}
`; // Variance banner const varBanner = `
${totVar>=0?'✅':'⚠️'}
Budget Variance: ${fmtK(Math.abs(totVar))} ${totVar>=0?'onder':'BOVEN'} budget
EAC ${fmtK(totEAC)} vs. goedgekeurd budget ${fmtK(totBudget)}
Burn rate
${totActuals&&S.project?.startdatum?'€'+Math.round(totActuals/Math.max(dBet(S.project.startdatum,today()),1)).toLocaleString('nl-NL')+'/dag':'—'}
`; // CapEx vs OpEx split const splitCard = `
${[ ['CapEx — Investeringen', capBudget, capActuals, capex.reduce((s,r)=>s+(r.etc||0),0), C.darkgreen, '#f0f9e0', capex], ['OpEx — Exploitatie', opBudget, opActuals, opex.reduce((s,r)=>s+(r.etc||0),0), C.purple, '#f3f0f9', opex], ].map(([titel, budget, actuals, etc, color, bg, items])=>{ const eac2 = actuals + etc; const bp2 = pct(actuals, budget); const catSums = {}; items.forEach(r => { catSums[r.categorie] = (catSums[r.categorie]||0) + (r.budget||0); }); return `
${titel}
${[['Budget',fmtK(budget)],['Actuals',fmtK(actuals)],['EAC',fmtK(eac2)]].map(([l,v])=>`
${l}
${v}
`).join('')}
${bp2}% van budget verbruikt
${Object.entries(catSums).map(([cat,bedrag])=>`
${cat} ${fmtK(bedrag)}
`).join('')}
`; }).join('')}
`; // Waterfall chart (SVG) const waterfall = buildWaterfall(totBudget, totActuals, totETC, totVar); return kpis + varBanner + splitCard + waterfall; } function buildWaterfall(budget, actuals, etc, variance) { const W=640, H=180, pad=50, barW=70, gap=40; const maxVal = Math.max(budget, actuals+etc) * 1.15; const toH = v => Math.round((v/maxVal)*(H-30)); const toY = v => H - toH(v) - 10; const bars = [ {lbl:'Budget', val:budget, c:C.darkgreen, y:toY(budget), h:toH(budget)}, {lbl:'Actuals', val:actuals, c:'#1a3d5c', y:toY(actuals), h:toH(actuals)}, {lbl:'ETC', val:etc, c:C.purple, y:toY(actuals+etc), h:toH(etc), yBase:toY(actuals)+toH(actuals)}, {lbl:'EAC', val:actuals+etc, c:variance>=0?C.green:'#cc0000', y:toY(actuals+etc), h:toH(actuals+etc)}, ]; return `
Financieel Overzicht — Waterfall
${bars.map((b,i)=>{ const x = pad + i*(barW+gap); const isETC = b.lbl==='ETC'; return ` ${fmtK(b.val)} ${b.lbl}`; }).join('')}
`; } // ── KOSTENREGELS ────────────────────────────────────────────── function vFinRegels(fin) { const regels = fin.kostenregels||[]; const CATS = ['Licenties','Personeel','Infrastructuur','Technisch','Organisatie','Beheer','Overig']; const rows = regels.map((r,idx)=>{ const eac = (r.actuals||0) + (r.etc||0); const var2 = (r.budget||0) - eac; const bp2 = pct(r.actuals||0, r.budget||1); const varColor = var2 >= 0 ? C.green3 : C.pink; const typeColor = r.type==='capex' ? C.darkgreen : C.purple; const typeBg = r.type==='capex' ? '#f0f9e0' : '#f3f0f9'; return `
${esc(r.naam)}
${r.type?.toUpperCase()} ${esc(r.categorie||'—')}
${r.toelichting?`
${esc(r.toelichting)}
`:''}
${fmtK(eac)} ${var2>=0?'+':''}${fmtK(var2)} `; }).join(''); // Group totals const capTotal = regels.filter(r=>r.type==='capex'); const opTotal = regels.filter(r=>r.type==='opex'); const subtotalRow = (label, items, color) => { const b = items.reduce((s,r)=>s+(r.budget||0),0); const a = items.reduce((s,r)=>s+(r.actuals||0),0); const e = items.reduce((s,r)=>s+(r.etc||0),0); const eac = a+e; return ` ${label} Subtotaal ${fmtK(b)} ${fmtK(a)} ${fmtK(e)} ${fmtK(eac)} ${fmtK(b-eac)} `; }; return `
${regels.length} kostenregels · Klik op een bedrag om het direct te bewerken
${regels.filter(r=>r.type==='capex').length?` ${rows.split('').filter((_,i)=>ir.type==='capex').length).join('')} ${subtotalRow('CapEx', capTotal, C.darkgreen)}`:''} ${regels.filter(r=>r.type==='opex').length?` ${rows.split('').filter((_,i)=>i>=regels.filter(r=>r.type==='capex').length).join('')} ${subtotalRow('OpEx', opTotal, C.purple)}`:''}
Kostenregel Budget (€) Actuals (€) ETC (€) EAC (€) Variance Type
◈ CapEx — Eenmalige investeringen
◈ OpEx — Doorlopende exploitatiekosten
`; } // ── DEELPROJECTEN ───────────────────────────────────────────── function vFinDeelprojecten(fin) { const sps = S.subprojects||[]; if (!sps.length) return '
Geen sub-projecten gevonden. Maak eerst sub-projecten aan in de Programma Manager.
'; // Ensure each sp has finance data sps.forEach(sp => { if (!sp.finCapexBudget) sp.finCapexBudget = 0; if (!sp.finCapexActuals) sp.finCapexActuals = 0; if (!sp.finCapexETC) sp.finCapexETC = 0; if (!sp.finOpexBudget) sp.finOpexBudget = 0; if (!sp.finOpexActuals) sp.finOpexActuals = 0; if (!sp.finOpexETC) sp.finOpexETC = 0; }); const totalBudget = sps.reduce((s,sp)=>s+(sp.finCapexBudget||0)+(sp.finOpexBudget||0),0); const totalActuals = sps.reduce((s,sp)=>s+(sp.finCapexActuals||0)+(sp.finOpexActuals||0),0); const totalETC = sps.reduce((s,sp)=>s+(sp.finCapexETC||0)+(sp.finOpexETC||0),0); const totalEAC = totalActuals + totalETC; const summaryRow = `
${[ ['Totaal Programma Budget', fmtK(totalBudget), C.darkgreen], ['Totaal Actuals', fmtK(totalActuals), '#1a3d5c'], ['Totaal ETC', fmtK(totalETC), C.purple], ['Totaal EAC (prognose)', fmtK(totalEAC), totalEAC>totalBudget?C.pink:C.green3], ].map(([l,v,c])=>`
${l}
${v}
`).join('')}
`; const spCards = sps.map((sp,idx)=>{ const capBudget = sp.finCapexBudget||0; const capActuals = sp.finCapexActuals||0; const capETC = sp.finCapexETC||0; const opBudget = sp.finOpexBudget||0; const opActuals = sp.finOpexActuals||0; const opETC = sp.finOpexETC||0; const totBud = capBudget + opBudget; const totAct = capActuals + opActuals; const totETC2 = capETC + opETC; const eac = totAct + totETC2; const variance = totBud - eac; const bp2 = pct(totAct, totBud||1); return `
${esc(sp.naam)}
PM: ${esc(sp.pm||'—')}
${variance>=0?'✓ Binnen budget':'⚠ Over budget'}
${[ ['CapEx — Investering', 'finCapex', capBudget, capActuals, capETC, C.darkgreen, '#f0f9e0'], ['OpEx — Exploitatie', 'finOpex', opBudget, opActuals, opETC, C.purple, '#f3f0f9'], ].map(([titel, prefix, bud, act, etc2, color, bg])=>`
${titel}
${[['Budget','Budget',bud],['Actuals','Actuals',act],['ETC','ETC',etc2]].map(([lbl,field,val])=>`
${lbl}
`).join('')}
EAC: ${fmtK(act+etc2)} Var: ${fmtK(bud-(act+etc2))}
`).join('')}
Totaal voortgang ${fmtK(totAct)} / ${fmtK(totBud)} (${bp2}%)
EAC eindprognose: ${fmtK(eac)} Variance: ${variance>=0?'+':''}${fmtK(variance)}
`; }).join(''); return summaryRow + spCards; } // ── CRUD ────────────────────────────────────────────────────── function updateFinRegel(idx, field, value) { if (!S.finance?.kostenregels?.[idx]) return; S.finance.kostenregels[idx][field] = value; saveFinance(); // Live update without full re-render } function updateSPFinance(idx, field, value) { if (!S.subprojects?.[idx]) return; S.subprojects[idx][field] = value; save(); } function delFinRegel(idx) { if (!confirm('Kostenregel verwijderen?')) return; S.finance.kostenregels.splice(idx, 1); saveFinance(); render(); } function showAddFinRegel() { const CATS = ['Licenties','Personeel','Infrastructuur','Technisch','Organisatie','Beheer','Overig']; document.getElementById('fin-add-form').innerHTML = `
Nieuwe kostenregel toevoegen
Omschrijving
Type
Categorie
Budget (€)
Actuals (€)
ETC (€)
Toelichting
`; } function addFinRegel() { const naam = document.getElementById('nfr-naam')?.value.trim(); if (!naam) return; if (!S.finance) S.finance = DEF_FINANCE(); if (!S.finance.kostenregels) S.finance.kostenregels = []; S.finance.kostenregels.push({ id: 'f'+uid(), naam, type: document.getElementById('nfr-type')?.value||'capex', categorie: document.getElementById('nfr-cat')?.value||'Overig', budget: +document.getElementById('nfr-budget')?.value||0, actuals: +document.getElementById('nfr-actuals')?.value||0, etc: +document.getElementById('nfr-etc')?.value||0, toelichting: document.getElementById('nfr-toel')?.value||'', periode: S.finance.activePeriode||'2026', }); saveFinance(); render(); } // ══════════════════════════════════════════════════════════════ // PROGRAM CHARTER // ══════════════════════════════════════════════════════════════ function vProgramCharter() { const p = S.project; const pc = S.programCharter || {}; const sps = S.subprojects || []; const fin = S.finance; const totBudget = fin?.kostenregels?.reduce((s,r)=>s+(r.budget||0),0) || p.budget || 0; const totActuals = fin?.kostenregels?.reduce((s,r)=>s+(r.actuals||0),0) || p.actuals || 0; const lbl = t2 => `
${t2}
`; const inp = (key, val, ph='') => ``; const ta = (key, val, ph='', rows=3) => ``; return `
Program Charter
Formele opdrachtverlening en kaders voor het programma
i P
${esc(p.naam||'Programmanaam')}
Program Charter · ${today()}
Opdrachtgever
${esc(p.opdrachtgever||'—')}
1. Kerngegevens
${lbl('Programmanaam')}${inp('naam', p.naam, 'Naam van het programma')}
${lbl('Opdrachtgever')}${inp('opdrachtgever', p.opdrachtgever)}
${lbl('Program Director')}${inp('manager', p.manager)}
${lbl('Startdatum')}
${lbl('Go-Live datum')}
${lbl('Looptijd')}
${p.startdatum&&p.golive?Math.round(dBet(p.startdatum,p.golive)/7)+' weken':'—'}
2. Doel & Scope
${lbl('Programmadoelstelling')}${ta('doelstelling', pc.doelstelling, 'Wat is het hoofddoel van dit programma?', 3)}
${lbl('Aanleiding / probleemstelling')}${ta('aanleiding', pc.aanleiding, 'Waarom is dit programma noodzakelijk?', 3)}
${lbl('In scope')}${ta('inScope', pc.inScope, 'Wat valt WEL binnen de scope...', 4)}
${lbl('Buiten scope')}${ta('buitenScope', pc.buitenScope, 'Wat valt NIET binnen de scope...', 4)}
3. Beoogde Resultaten & Baten
${lbl('Kwantitatieve baten')}${ta('batenKwantitatief', pc.batenKwantitatief, 'Meetbare baten: kostenbesparing, efficiëntie...', 4)}
${lbl('Kwalitatieve baten')}${ta('batenKwalitatief', pc.batenKwalitatief, 'Niet-meetbare baten: kwaliteit, tevredenheid...', 4)}
${lbl('Succescriteria')}${ta('succescriteria', pc.succescriteria, 'Wanneer is het programma succesvol?', 3)}
${lbl('KPIs')}${ta('kpis', pc.kpis, 'Meetbare indicatoren voor succes...', 3)}
4. Financiële Kaders
${[ ['Totaal Budget', fmtK(totBudget), '#2d5a27', '#f0f9e0'], ['Actuals t/m nu', fmtK(totActuals), '#1a3d5c', '#e4eef5'], ['Reservering risico', fmtK((pc.risicoReservering||0)), '#8a6400', '#fff8e1'], ['Netto budget', fmtK(totBudget - (pc.risicoReservering||0)), '#2d5a27', '#f0f9e0'], ].map(([l,v,c,bg])=>`
${l}
${v}
`).join('')}
${lbl('Risicoreservering (€)')}
${lbl('Financiële autoriteit')}${inp('financieleAutoriteit', pc.financieleAutoriteit, 'Wie keurt uitgaven goed boven welk bedrag?')}
5. Governance & Organisatie
${lbl('Stuurgroep samenstelling')}${ta('stuurgroep', pc.stuurgroep, 'Leden van de stuurgroep en hun rol...', 4)}
${lbl('Escalatiepad')}${ta('escalatiepad', pc.escalatiepad, 'Hoe worden issues geëscaleerd?', 4)}
${lbl('Vergaderstructuur')}${ta('vergaderstructuur', pc.vergaderstructuur, 'Stuurgroep maandelijks, werkgroep wekelijks...', 3)}
${lbl('Rapportagelijnen')}${ta('rapportagelijnen', pc.rapportagelijnen, 'Wie rapporteert aan wie, hoe frequent?', 3)}
${sps.length ? `
6. Sub-projecten (${sps.length})
${sps.map(sp=>{ const rag = spRagColor(sp.rag||'GROEN'); return ``; }).join('')}
Sub-projectProject ManagerStartEindeBudgetRAG
${esc(sp.naam)} ${esc(sp.pm||'—')} ${sp.startdatum||'—'} ${sp.einddatum||'—'} ${fmtK(sp.budget||0)} ● ${sp.rag||'GROEN'}
` : ''}
7. Risico's & Aannames
${lbl('Kritische risicos')}${ta('kritischeRisicos', pc.kritischeRisicos, 'Top 3-5 risicos voor dit programma...', 4)}
${lbl('Aannames en randvoorwaarden')}${ta('aannames', pc.aannames, 'Welke aannames liggen ten grondslag aan dit plan?', 4)}
8. Accordering
${[ ['Opdrachtgever', p.opdrachtgever], ['Program Director', p.manager], ['ITsPeople Lead', S.project?.consultant||'Consultant'] ].map(([rol,naam])=>`
${rol}
${esc(naam||'—')}
Handtekening & datum
`).join('')}
`; } function updateCharter(key, value) { if (!S.programCharter) S.programCharter = {}; S.programCharter[key] = value; save(); } function updateProject(key, value) { S.project[key] = value; save(); shell(); } function printCharter() { const content = document.getElementById('charter-doc')?.innerHTML || ''; const w = window.open('', '_blank'); w.document.write(`Program Charter ${content} `); w.document.close(); w.print(); } // ══════════════════════════════════════════════════════════════ // PROJECT MODEL — klein project beheer // ══════════════════════════════════════════════════════════════ const PROJECT_TYPES = { oracle: { naam: 'Oracle Implementatie', icon: '☁️', kleur: '#c74a00', duur: '8-16 weken', beschrijving: 'Oracle Cloud module implementatie (Finance, HRM, SCM etc.)', fasen: ['Configuratie & Setup', 'Testen (SIT/UAT)', 'Training & Enablement', 'Go-Live', 'Nazorg'], checklist: [ {fase:'Configuratie & Setup', items:[ {id:'or1', naam:'Requirements document definitief', verplicht:true}, {id:'or2', naam:'Oracle tenant ingericht', verplicht:true}, {id:'or3', naam:'Configuratie documentatie', verplicht:true}, {id:'or4', naam:'Data mapping gereed', verplicht:true}, {id:'or5', naam:'Integratieontwerp goedgekeurd', verplicht:false}, ]}, {fase:'Testen (SIT/UAT)', items:[ {id:'or6', naam:'Testplan opgesteld', verplicht:true}, {id:'or7', naam:'Testscenarios gedocumenteerd', verplicht:true}, {id:'or8', naam:'SIT uitgevoerd & goedgekeurd', verplicht:true}, {id:'or9', naam:'UAT uitgevoerd & getekend', verplicht:true}, {id:'or10', naam:'Bevindingen afgehandeld', verplicht:true}, ]}, {fase:'Training & Enablement', items:[ {id:'or11', naam:'Trainingsplan opgesteld', verplicht:true}, {id:'or12', naam:'Trainingsmateriaal gereed', verplicht:true}, {id:'or13', naam:'Eindgebruikers getraind', verplicht:true}, {id:'or14', naam:'Beheerders getraind', verplicht:false}, ]}, {fase:'Go-Live', items:[ {id:'or15', naam:'Go/No-Go besluit gedocumenteerd', verplicht:true}, {id:'or16', naam:'Go-Live checklist doorlopen', verplicht:true}, {id:'or17', naam:'Productie cutover uitgevoerd', verplicht:true}, {id:'or18', naam:'Hypercare plan actief', verplicht:true}, ]}, {fase:'Nazorg', items:[ {id:'or19', naam:'Overdracht naar beheer', verplicht:true}, {id:'or20', naam:'Lessons learned gedocumenteerd', verplicht:false}, ]}, ], templates: ['Configuratiedocument','Testplan & testscenarios','Trainingsplan','Go-Live checklist','Hypercare plan','Overdrachtsrapport'], }, proces: { naam: 'Procesoptimalisatie', icon: '⚙️', kleur: '#7c69a9', duur: '4-12 weken', beschrijving: 'Analyse en herontwerp van bedrijfsprocessen', fasen: ['Analyse', 'Ontwerp', 'Pilot', 'Uitrol', 'Monitoring'], checklist: [ {fase:'Analyse', items:[ {id:'pr1', naam:'As-is procesanalyse', verplicht:true}, {id:'pr2', naam:'Knelpunten geïdentificeerd', verplicht:true}, {id:'pr3', naam:'Stakeholder interviews afgerond', verplicht:true}, ]}, {fase:'Ontwerp', items:[ {id:'pr4', naam:'To-be procesonwerp', verplicht:true}, {id:'pr5', naam:'BPMN flows gedocumenteerd', verplicht:true}, {id:'pr6', naam:'Ontwerp goedgekeurd door stakeholders', verplicht:true}, ]}, {fase:'Pilot', items:[ {id:'pr7', naam:'Pilotplan opgesteld', verplicht:true}, {id:'pr8', naam:'Pilot uitgevoerd', verplicht:true}, {id:'pr9', naam:'Pilot geëvalueerd', verplicht:true}, ]}, {fase:'Uitrol', items:[ {id:'pr10', naam:'Uitrolplan goedgekeurd', verplicht:true}, {id:'pr11', naam:'Medewerkers geïnformeerd', verplicht:true}, {id:'pr12', naam:'Nieuw proces live', verplicht:true}, ]}, {fase:'Monitoring', items:[ {id:'pr13', naam:'KPIs gedefinieerd en gemeten', verplicht:true}, {id:'pr14', naam:'Evaluatierapport', verplicht:false}, ]}, ], templates: ['As-is analyse rapport','BPMN procestemplate','Pilotplan','Uitrolplan','KPI monitoring dashboard'], }, data: { naam: 'Data & BI Project', icon: '📊', kleur: '#1a3d5c', duur: '8-20 weken', beschrijving: 'Datawarehouse, dashboards en business intelligence', fasen: ['Data Architectuur', 'Ontwikkeling', 'Validatie', 'Oplevering'], checklist: [ {fase:'Data Architectuur', items:[ {id:'da1', naam:'Datamodel ontworpen', verplicht:true}, {id:'da2', naam:'Bronnen geïnventariseerd', verplicht:true}, {id:'da3', naam:'Governance framework', verplicht:false}, ]}, {fase:'Ontwikkeling', items:[ {id:'da4', naam:'ETL pipelines gebouwd', verplicht:true}, {id:'da5', naam:'Dashboards ontwikkeld', verplicht:true}, {id:'da6', naam:'Technische documentatie', verplicht:true}, ]}, {fase:'Validatie', items:[ {id:'da7', naam:'Data kwaliteitscheck', verplicht:true}, {id:'da8', naam:'Gebruikersacceptatietest', verplicht:true}, ]}, {fase:'Oplevering', items:[ {id:'da9', naam:'Productie deployment', verplicht:true}, {id:'da10', naam:'Gebruikerstraining', verplicht:true}, {id:'da11', naam:'Beheerhandboek', verplicht:true}, ]}, ], templates: ['Data architectuurdocument','ETL specificatie','Dashboard ontwerp','Testrapport','Beheerhandboek'], }, change: { naam: 'Change Management', icon: '🔄', kleur: '#1a7a3a', duur: '6-16 weken', beschrijving: 'Organisatieverandering en adoptie trajecten', fasen: ['Impact Analyse', 'Interventies', 'Communicatie', 'Training', 'Borging'], checklist: [ {fase:'Impact Analyse', items:[ {id:'ch1', naam:'Stakeholder analyse', verplicht:true}, {id:'ch2', naam:'Change impact assessment', verplicht:true}, {id:'ch3', naam:'Weerstandsanalyse', verplicht:false}, ]}, {fase:'Communicatie', items:[ {id:'ch4', naam:'Communicatieplan', verplicht:true}, {id:'ch5', naam:'Kernboodschappen gedefinieerd', verplicht:true}, ]}, {fase:'Training', items:[ {id:'ch6', naam:'Trainingsbehoefteanalyse', verplicht:true}, {id:'ch7', naam:'Trainingsplan', verplicht:true}, {id:'ch8', naam:'Training uitgevoerd', verplicht:true}, ]}, {fase:'Borging', items:[ {id:'ch9', naam:'Adoptie gemeten', verplicht:true}, {id:'ch10', naam:'Evaluatie & lessons learned', verplicht:false}, ]}, ], templates: ['Stakeholder analyse','Change impact assessment','Communicatieplan','Trainingsplan','Adoptie monitoring'], }, audit: { naam: 'Audit & Compliance', icon: '🔍', kleur: '#8a4a00', duur: '4-8 weken', beschrijving: 'Audit, gap analyse en compliance trajecten', fasen: ['Voorbereiding', 'Uitvoering', 'Bevindingen', 'Remediatie'], checklist: [ {fase:'Voorbereiding', items:[ {id:'au1', naam:'Auditscope gedefinieerd', verplicht:true}, {id:'au2', naam:'Normenkader vastgesteld', verplicht:true}, {id:'au3', naam:'Auditplan goedgekeurd', verplicht:true}, ]}, {fase:'Uitvoering', items:[ {id:'au4', naam:'Documentenreview afgerond', verplicht:true}, {id:'au5', naam:'Interviews uitgevoerd', verplicht:true}, {id:'au6', naam:'Bevindingen gedocumenteerd', verplicht:true}, ]}, {fase:'Bevindingen', items:[ {id:'au7', naam:'Auditrapport concept', verplicht:true}, {id:'au8', naam:'Hoor en wederhoor afgerond', verplicht:true}, {id:'au9', naam:'Auditrapport definitief', verplicht:true}, ]}, {fase:'Remediatie', items:[ {id:'au10', naam:'Actieplan opgesteld', verplicht:true}, {id:'au11', naam:'Acties gemonitord', verplicht:true}, ]}, ], templates: ['Auditplan','Bevindingen register','Auditrapport','Actieplan remediatie'], }, }; // Default klein project state function DEF_PROJECT() { return { id: 'p' + uid(), naam: 'Nieuw Project', type: 'oracle', opdrachtgever: '', pm: '', start: today(), einde: addD(today(), 84), budget: 0, actuals: 0, etc: 0, status: 'active', rag: 'GROEN', doel: '', scope: '', risicos: '', aannames: '', checklist: {}, // {itemId: true/false} taken: [], // [{id,naam,eigenaar,start,due,status}] notities: '', }; } let activeProjectId = null; let activeProjectTab = 'charter'; // 'charter'|'checklist'|'taken'|'financien' // ── Project List view ───────────────────────────────────────── function vProjectCharter() { if (!S.projecten) S.projecten = []; const projs = S.projecten; // If no project selected or only one, show list + detail if (!activeProjectId && projs.length === 0) { return vProjectWelcome(); } if (!activeProjectId && projs.length > 0) { activeProjectId = projs[0].id; } const proj = projs.find(p=>p.id===activeProjectId) || projs[0]; if (!proj) return vProjectWelcome(); return vProjectDetail(proj, projs); } function vProjectWelcome() { const types = Object.entries(PROJECT_TYPES); return `
Project Model
Beheer kleinere projecten en implementaties
Nieuw project starten
Kies een projecttype om direct te starten met een kant-en-klare checklist en templates
${types.map(([key,pt])=>`
${pt.icon}
${pt.naam}
${pt.beschrijving}
⏱ ${pt.duur}
`).join('')}
`; } function vProjectDetail(proj, projs) { const pt = PROJECT_TYPES[proj.type] || PROJECT_TYPES.oracle; const rag = spRagColor(proj.rag||'GROEN'); const daysLeft = dBet(today(), proj.einde); const checkedItems = Object.values(proj.checklist||{}).filter(Boolean).length; const totalItems = pt.checklist.reduce((s,f)=>s+f.items.length, 0); const pct2 = totalItems ? Math.round(checkedItems/totalItems*100) : 0; const budget = proj.budget||0; const actuals = proj.actuals||0; const etc = proj.etc||0; const eac = actuals + etc; const bp2 = budget > 0 ? Math.round(actuals/budget*100) : 0; return `
Projecten (${projs.length})
${projs.map(p=>{ const pt2 = PROJECT_TYPES[p.type]||PROJECT_TYPES.oracle; const active = p.id===activeProjectId; return `
${pt2.icon} ${esc(p.naam.slice(0,22))}${p.naam.length>22?'…':''}
${pt2.naam} · ${p.rag||'GROEN'}
`; }).join('')}
${pt.icon}
${pt.naam} · ${esc(proj.opdrachtgever||'Opdrachtgever invullen')}
${[ ['Voortgang', pct2+'%', pct2+'% checklist', pt.kleur], ['Resterend', daysLeft+'d', proj.einde, daysLeft<14?C.pink:C.t2], ['Budget', fmtK(budget), bp2+'% verbruikt', bp2>90?C.pink:C.green3], ['EAC', fmtK(eac), eac>budget?'⚠ Boven budget':'Binnen budget', eac>budget?C.pink:C.green3], ].map(([l,v,sub,c])=>`
${l}
${v}
${sub}
`).join('')}
${[['charter','📋 Charter'],['checklist','✅ Checklist'],['taken','◻ Taken'],['financien','€ Financiën']].map(([id,lbl])=>` `).join('')}
${activeProjectTab==='charter' ? vProjCharter(proj, pt) : ''} ${activeProjectTab==='checklist' ? vProjChecklist(proj, pt) : ''} ${activeProjectTab==='taken' ? vProjTaken(proj, pt) : ''} ${activeProjectTab==='financien' ? vProjFinancien(proj, pt) : ''}
`; } // ── Charter tab ─────────────────────────────────────────────── function vProjCharter(proj, pt) { const lbl = t2 => `
${t2}
`; const inp = (field, val, ph='') => ``; const ta = (field, val, ph='', rows=3) => ``; return `
Project Basisgegevens
${lbl('Projectnaam')}${inp('naam', proj.naam)}
${lbl('Opdrachtgever')}${inp('opdrachtgever', proj.opdrachtgever)}
${lbl('Project Manager')}${inp('pm', proj.pm)}
${lbl('Start')}
${lbl('Einde')}
Projectkaders
${lbl('Projectdoel')}${ta('doel', proj.doel, 'Wat is het doel van dit project?', 3)}
${lbl('Scope')}${ta('scope', proj.scope, 'Wat valt binnen/buiten scope?', 3)}
${lbl('Risicos en aannames')}${ta('risicos', proj.risicos, 'Belangrijkste risicos...', 3)}
📋 Templates voor ${pt.naam}
${pt.templates.map(t2=>`
${t2}
`).join('')}
`; } // ── Checklist tab ───────────────────────────────────────────── function vProjChecklist(proj, pt) { const checklist = proj.checklist||{}; const allItems = pt.checklist.flatMap(f=>f.items); const done = allItems.filter(i=>checklist[i.id]).length; const pct3 = Math.round(done/allItems.length*100); return `
${done}/${allItems.length} afgerond (${pct3}%)
${pt.checklist.map(fase=>{ const faseDone = fase.items.filter(i=>checklist[i.id]).length; return `
${fase.fase}
${faseDone}/${fase.items.length}
${fase.items.map(item=>{ const checked = !!checklist[item.id]; return `
${esc(item.naam)} ${item.verplicht?`VERPLICHT`:''}
`; }).join('')}
`; }).join('')}`; } // ── Taken tab ───────────────────────────────────────────────── function vProjTaken(proj, pt) { const taken = proj.taken||[]; const done = taken.filter(t2=>t2.status==='done').length; return `
${done}/${taken.length} taken afgerond
${taken.length===0?`
Nog geen taken. Voeg de eerste taak toe.
`:''}
${taken.map((t2,idx)=>{ const ov = t2.due && new Date(t2.due)
${esc(t2.naam)}
${avatarEl(t2.eigenaar||'?',16)} ${esc(t2.eigenaar||'—')} 📅 ${t2.due||'—'} ${ov?'(verlaat)':''}
`; }).join('')} `; } // ── Financiën tab ───────────────────────────────────────────── function vProjFinancien(proj, pt) { const budget = proj.budget||0; const actuals = proj.actuals||0; const etc = proj.etc||0; const eac = actuals + etc; const variance = budget - eac; const bp2 = budget>0 ? Math.round(actuals/budget*100) : 0; return `
Financiële Invoer
${[ ['Budget (€)', 'budget', budget, 'Goedgekeurd projectbudget'], ['Actuals t/m nu (€)', 'actuals', actuals, 'Werkelijk uitgegeven'], ['ETC — Estimate to Completion (€)', 'etc', etc, 'Verwachte resterende kosten'], ].map(([l,field,val,hint])=>`
${l}
${hint}
`).join('')}
Financieel Overzicht
${[ ['Budget', fmtK(budget), C.darkgreen], ['Actuals', fmtK(actuals), '#1a3d5c'], ['ETC', fmtK(etc), pt.kleur], ['EAC (prognose)', fmtK(eac), eac>budget?C.pink:C.green3], ['Variance', (variance>=0?'+':'')+fmtK(variance), variance>=0?C.green3:C.pink], ].map(([l,v,c])=>`
${l} ${v}
`).join('')}
${bp2}% van budget verbruikt
${variance>=0?'✅ Binnen budget':'⚠️ Boven budget'}
EAC ${fmtK(eac)} ${variance>=0?'is '+fmtK(variance)+' onder':'is '+fmtK(Math.abs(variance))+' boven'} het budget van ${fmtK(budget)}
`; } // ── CRUD helpers ────────────────────────────────────────────── function createProject(type) { if (!S.projecten) S.projecten = []; const p = DEF_PROJECT(); p.type = type; p.naam = PROJECT_TYPES[type]?.naam + ' ' + (S.projecten.length+1); S.projecten.push(p); activeProjectId = p.id; activeProjectTab = 'charter'; save(); render(); } function updateProj(id, field, value) { const p = (S.projecten||[]).find(p=>p.id===id); if (p) { p[field] = value; save(); } } function delProject(id) { if (!confirm('Project verwijderen?')) return; S.projecten = (S.projecten||[]).filter(p=>p.id!==id); activeProjectId = S.projecten.length ? S.projecten[0].id : null; save(); render(); } function toggleProjCheck(projId, itemId, checked) { const p = (S.projecten||[]).find(p=>p.id===projId); if (!p) return; if (!p.checklist) p.checklist = {}; p.checklist[itemId] = checked; save(); render(); } function showAddProjTaak(projId) { document.getElementById('proj-taak-form').innerHTML = `
Taaknaam
Eigenaar
Due date
`; } function addProjTaak(projId) { const naam = document.getElementById('pt-naam')?.value.trim(); if (!naam) return; const p = (S.projecten||[]).find(p=>p.id===projId); if (!p) return; if (!p.taken) p.taken = []; p.taken.push({ id: uid(), naam, eigenaar: document.getElementById('pt-eig')?.value||'', due: document.getElementById('pt-due')?.value||'', status: 'todo' }); save(); render(); } function setProjTaakStatus(projId, idx, status) { const p = (S.projecten||[]).find(p=>p.id===projId); if (p?.taken?.[idx]) { p.taken[idx].status = status; save(); render(); } } function delProjTaak(projId, idx) { const p = (S.projecten||[]).find(p=>p.id===projId); if (p) { p.taken.splice(idx,1); save(); render(); } } function showProjectTypeSelect() { document.getElementById('proj-type-select').innerHTML = `
${Object.entries(PROJECT_TYPES).map(([key,pt])=>`
${pt.icon} ${pt.naam}
`).join('')}
`; } // ══════════════════════════════════════════════════════════════ // SUPABASE SYNC — Centrale data opslag & samenwerking // ══════════════════════════════════════════════════════════════ // SB_URL and SB_KEY already defined above in template module // ── Core API helper ─────────────────────────────────────────── async function sbApi(path, method='GET', body=null) { const opts = { method, headers: { 'apikey': SB_KEY, 'Authorization': 'Bearer ' + (_authToken || SB_KEY), 'Content-Type': 'application/json', 'Prefer': method==='POST' ? 'return=representation' : 'return=minimal' } }; if (body) opts.body = JSON.stringify(body); const r = await fetch(SB_URL + '/rest/v1/' + path, opts); if (!r.ok) { const err = await r.text(); if (typeof window._sbErrors !== 'undefined') window._sbErrors.push({path, time: new Date().toISOString()}); return null; } const text = await r.text(); return text ? JSON.parse(text) : null; } // ── Sync state indicator ────────────────────────────────────── let _syncStatus = 'idle'; // 'idle'|'syncing'|'ok'|'error' let _lastSync = null; function setSyncStatus(s) { _syncStatus = s; const el = document.getElementById('sync-indicator'); if (!el) return; const map = { idle: {t:'●', c:'rgba(255,255,255,.2)', tt:'Niet gesynchroniseerd'}, syncing: {t:'↻', c:'#efb922', tt:'Synchroniseren...'}, ok: {t:'✓', c:'#7dbf04', tt:'Gesynchroniseerd · ' + (new Date().toLocaleTimeString('nl-NL'))}, error: {t:'✗', c:'#ff757a', tt:'Sync fout — lokale opslag actief'}, }; const m = map[s] || map.idle; el.textContent = m.t; el.style.color = m.c; el.title = m.tt; } // ── PROGRAMMA sync ──────────────────────────────────────────── async function syncProgrammaToSB() { setSyncStatus('syncing'); const p = S.project; try { // Upsert programma const existing = await sbApi('ip_programmas?naam=eq.' + encodeURIComponent(p.naam) + '&limit=1'); let progId = existing?.[0]?.id; if (!progId) { const created = await sbApi('ip_programmas', 'POST', { naam: p.naam, opdrachtgever: p.opdrachtgever, program_director: p.manager, lead_consultant: p.consultant, fase: p.fase, startdatum: p.startdatum, golive: p.golive, budget: p.budget, actuals: p.actuals, rag: 'GROEN' }); progId = created?.[0]?.id; } else { await sbApi('ip_programmas?id=eq.' + progId, 'PATCH', { opdrachtgever: p.opdrachtgever, program_director: p.manager, fase: p.fase, startdatum: p.startdatum, golive: p.golive, budget: p.budget, actuals: p.actuals }); } if (!progId) { setSyncStatus('error'); return null; } // Store progId for other syncs S._sbProgId = progId; save(); // Sync charter if (S.programCharter) { const charterExists = await sbApi('ip_program_charter?programma_id=eq.' + progId + '&limit=1'); if (!charterExists?.length) { await sbApi('ip_program_charter', 'POST', { programma_id: progId, ...S.programCharter }); } else { await sbApi('ip_program_charter?programma_id=eq.' + progId, 'PATCH', { ...S.programCharter, bijgewerkt_op: new Date().toISOString() }); } } setSyncStatus('ok'); _lastSync = new Date(); return progId; } catch(e) { console.error('Sync error:', e); setSyncStatus('error'); return null; } } // ── TAKEN sync ──────────────────────────────────────────────── async function syncTakenToSB(progId) { if (!progId) return; try { const taken = S.tasks || []; for (const t of taken.slice(0, 50)) { // Max 50 per sync const existing = await sbApi('ip_taken?programma_id=eq.' + progId + '&titel=eq.' + encodeURIComponent(t.titel) + '&limit=1'); if (!existing?.length) { await sbApi('ip_taken', 'POST', { programma_id: progId, titel: t.titel, deliverable: t.deliverable, eigenaar: t.eigenaar, fase: t.fase, status: t.status, prioriteit: t.prio, domein: t.domein, deadline: t.deadline || null, deps: t.deps || [], subacties: t.subacties || [] }); } else { await sbApi('ip_taken?id=eq.' + existing[0].id, 'PATCH', { status: t.status, eigenaar: t.eigenaar, deadline: t.deadline || null, subacties: t.subacties || [], bijgewerkt_op: new Date().toISOString() }); } } } catch(e) { console.warn('[Sync] syncTakenToSB fout:', e); } } // ── RISICOS sync ────────────────────────────────────────────── async function syncRisicosToSB(progId) { if (!progId) return; try { const risicos = S.risks || []; for (const r of risicos) { const existing = await sbApi('ip_risicos?programma_id=eq.' + progId + '&titel=eq.' + encodeURIComponent(r.titel) + '&limit=1'); if (!existing?.length) { await sbApi('ip_risicos', 'POST', { programma_id: progId, titel: r.titel, beschrijving: r.beschrijving, categorie: r.categorie, kans: r.kans, impact: r.impact, eigenaar: r.eigenaar, status: r.status, mitigatie: r.mitigatie, deadline: r.deadline || null }); } } } catch(e) { console.warn('[Sync] syncRisicosToSB fout:', e); } } // ── HEALTH SCORES sync ──────────────────────────────────────── async function syncHealthToSB(progId) { if (!progId) return; try { const weken = S.weken || []; const lastWeek = weken[weken.length - 1]; if (!lastWeek) return; await sbApi('ip_health_scores', 'POST', { programma_id: progId, week_naam: lastWeek.naam, datum: lastWeek.datum, scores: lastWeek.scores || {}, score: lastWeek.score || 0 }); } catch(e) { console.warn('[Sync] syncHealthToSB fout:', e); } } // ── FINANCE sync ────────────────────────────────────────────── async function syncFinanceToSB(progId) { if (!progId || !S.finance?.kostenregels) return; try { for (const r of S.finance.kostenregels) { const existing = await sbApi('ip_kostenregels?programma_id=eq.' + progId + '&naam=eq.' + encodeURIComponent(r.naam) + '&limit=1'); if (!existing?.length) { await sbApi('ip_kostenregels', 'POST', { programma_id: progId, naam: r.naam, categorie: r.categorie, type: r.type, budget: r.budget, actuals: r.actuals, etc: r.etc, periode: r.periode, toelichting: r.toelichting }); } else { await sbApi('ip_kostenregels?id=eq.' + existing[0].id, 'PATCH', { actuals: r.actuals, etc: r.etc }); } } } catch(e) { console.warn('[Sync] syncFinanceToSB fout:', e); } } // ── PROJECTEN sync ──────────────────────────────────────────── async function syncProjectenToSB() { const projecten = S.projecten || []; try { for (const p of projecten) { const existing = await sbApi('ip_projecten?naam=eq.' + encodeURIComponent(p.naam) + '&limit=1'); if (!existing?.length) { await sbApi('ip_projecten', 'POST', { naam: p.naam, type: p.type, opdrachtgever: p.opdrachtgever, pm: p.pm, startdatum: p.start || null, einddatum: p.einde || null, budget: p.budget, actuals: p.actuals, etc: p.etc, status: p.status, rag: p.rag, doel: p.doel, scope: p.scope, checklist: p.checklist || {} }); } else { await sbApi('ip_projecten?id=eq.' + existing[0].id, 'PATCH', { actuals: p.actuals, etc: p.etc, status: p.status, rag: p.rag, checklist: p.checklist || {} }); } } } catch(e) { console.warn('[Sync] syncProjectenToSB fout:', e); } } // ── MASTER SYNC ─────────────────────────────────────────────── let _syncTimeout = null; async function syncAll() { setSyncStatus('syncing'); try { const progId = S._sbProgId || await syncProgrammaToSB(); if (progId) { await Promise.all([ syncTakenToSB(progId), syncRisicosToSB(progId), syncFinanceToSB(progId), syncHealthToSB(progId), ]); } if (appMode === 'project') { await syncProjectenToSB(); } setSyncStatus('ok'); } catch(e) { console.error('Master sync error:', e); setSyncStatus('error'); } } // Debounced auto-sync: sync 3 seconds after last save function debouncedSync() { clearTimeout(_syncTimeout); _syncTimeout = setTimeout(() => syncAll(), 3000); } // ── LOAD FROM SUPABASE ──────────────────────────────────────── async function loadFromSB() { setSyncStatus('syncing'); try { const progId = S._sbProgId; if (!progId) { setSyncStatus('idle'); return; } // Load taken const taken = await sbApi('ip_taken?programma_id=eq.' + progId + '&order=aangemaakt_op.asc&limit=200'); if (taken?.length) { S.tasks = taken.map(t => ({ id: t.id, titel: t.titel, deliverable: t.deliverable, eigenaar: t.eigenaar, fase: t.fase, status: t.status, prio: t.prioriteit, domein: t.domein, deadline: t.deadline, deps: t.deps || [], subacties: t.subacties || [] })); } // Load risicos const risicos = await sbApi('ip_risicos?programma_id=eq.' + progId + '&order=aangemaakt_op.asc'); if (risicos?.length) { S.risks = risicos.map(r => ({ id: r.id, titel: r.titel, beschrijving: r.beschrijving, categorie: r.categorie, kans: r.kans, impact: r.impact, eigenaar: r.eigenaar, status: r.status, mitigatie: r.mitigatie, deadline: r.deadline, created: r.aangemaakt_op?.split('T')[0] })); } // Load kostenregels const kosten = await sbApi('ip_kostenregels?programma_id=eq.' + progId + '&order=aangemaakt_op.asc'); if (kosten?.length) { if (!S.finance) S.finance = DEF_FINANCE(); S.finance.kostenregels = kosten.map(k => ({ id: k.id, naam: k.naam, categorie: k.categorie, type: k.type, budget: k.budget, actuals: k.actuals, etc: k.etc, periode: k.periode, toelichting: k.toelichting })); } save(); setSyncStatus('ok'); renderAll(); } catch(e) { console.error('Load error:', e); setSyncStatus('error'); } } // ── Patch save() to auto-sync ───────────────────────────────── const _origSave = save; // Override save to trigger debounced sync // ══════════════════════════════════════════════════════════════ // AUTHENTICATIE — Supabase Auth // ══════════════════════════════════════════════════════════════ let _sbClient = null; let _currentUser = null; function getSBClient() { if (!_sbClient && window.supabase) { _sbClient = window.supabase.createClient(SB_URL, SB_KEY, { auth: { autoRefreshToken: true, persistSession: true, detectSessionInUrl: false } }); } return _sbClient; } async function checkAuth() { return true; /* UMC bypass */ } async function _checkAuth_orig() { const client = getSBClient(); if (!client) { // Supabase SDK niet geladen — toon login scherm showLoginScreen(); return false; } try { const { data: { session }, error } = await client.auth.getSession(); if (error || !session) { showLoginScreen(); return false; } _currentUser = session.user; // Pas SB_KEY aan voor authenticated requests _authToken = session.access_token; return true; } catch(e) { showLoginScreen(); return false; } } let _authToken = SB_KEY; // fallback naar anon key async function doLogin() { const email = document.getElementById('login-email')?.value?.trim(); const password = document.getElementById('login-password')?.value; const errorEl = document.getElementById('login-error'); const btn = document.getElementById('login-btn'); // Basis validatie if (!email || !password) { if (errorEl) { errorEl.textContent = 'Vul e-mailadres en wachtwoord in.'; errorEl.style.display='block'; } return; } if (!email.includes('@')) { if (errorEl) { errorEl.textContent = 'Voer een geldig e-mailadres in.'; errorEl.style.display='block'; } return; } if (btn) { btn.textContent = 'Inloggen...'; btn.disabled = true; } if (errorEl) errorEl.style.display = 'none'; try { const client = getSBClient(); if (!client) throw new Error('Auth niet beschikbaar'); const { data, error } = await client.auth.signInWithPassword({ email, password }); if (error) { throw new Error('Onjuist e-mailadres of wachtwoord.'); } _currentUser = data.user; _authToken = data.session.access_token; // Verberg login, toon app hideLoginScreen(); bootApp(); } catch(e) { if (errorEl) { errorEl.textContent = e.message || 'Inloggen mislukt. Probeer opnieuw.'; errorEl.style.display='block'; } } finally { if (btn) { btn.textContent = 'Inloggen'; btn.disabled = false; } } } async function doLogout() { const client = getSBClient(); if (client) { try { await client.auth.signOut(); } catch(e) {} } _currentUser = null; _authToken = SB_KEY; localStorage.removeItem(SK_MODE); showLoginScreen(); // Reset app state document.getElementById('app-shell').style.display = 'none'; document.getElementById('startup-screen').style.display = 'none'; } function showLoginScreen() { const ls = document.getElementById('login-screen'); const ss = document.getElementById('startup-screen'); const shell = document.getElementById('app-shell'); if (ls) ls.style.display = 'flex'; if (ss) ss.style.display = 'none'; if (shell) shell.style.display = 'none'; } function hideLoginScreen() { const ls = document.getElementById('login-screen'); if (ls) ls.style.display = 'none'; } // Gebruik _authToken i.p.v. vaste SB_KEY voor API calls const PAGES={overview:vOverview,finance:vFinance,program_charter:vProgramCharter,project_charter:vProjectCharter,program:vProgram,program_detail:vProgramDetail,risks:vRisks,report:vReport,framework:vFramework,kanban:vKanban,deps:vDeps,sprints:vSprints,milestones:vMilestones,health:vHealth,settings:vSettings}; function render(){document.getElementById('content').innerHTML=(PAGES[view]||vOverview)()} function renderAll(){shell();render()} // ── STARTUP ─────────────────────────────────────────────────── let appMode = localStorage.getItem(SK_MODE) || null; function bootApp() { if (!appMode) { renderAll(); } else { renderAll(); } } // App initialisatie — eerst auth checken async function initApp() { const authed = await checkAuth(); if (authed) { // Update topbar met gebruikersnaam updateUserBadge(); bootApp(); } // Als niet authed toont checkAuth() het login scherm } function updateUserBadge() { if (!_currentUser) return; const email = _currentUser.email || ''; const naam = email.split('@')[0] || 'Gebruiker'; // Voeg logout knop toe aan sidebar footer const meta = document.getElementById('sb-meta'); if (meta && !document.getElementById('logout-btn')) { const logoutDiv = document.createElement('button'); logoutDiv.id = 'logout-btn'; logoutDiv.className = 'nb'; logoutDiv.style.cssText = 'margin-top:8px;border-top:1px solid rgba(255,255,255,.08);padding-top:12px;color:rgba(255,100,100,.7);font-size:11px'; logoutDiv.innerHTML = 'Uitloggen (' + esc(naam) + ')'; logoutDiv.onclick = doLogout; meta.parentElement.insertBefore(logoutDiv, meta); } } function showStartup() { document.getElementById('app-shell').style.display = 'none'; document.getElementById('startup-screen').style.display = 'flex'; } function selectMode(mode) { appMode = mode; localStorage.setItem(SK_MODE, mode); document.getElementById('startup-screen').style.display = 'none'; document.getElementById('app-shell').style.display = 'flex'; // Reset to correct default view per mode if (mode === 'project') { view = 'project_charter'; // Ensure project state exists if (!S.projecten) S.projecten = []; } else { view = 'overview'; } renderAll(); } function switchMode() { if (!confirm('Wisselen van model? Je huidige data blijft bewaard.')) return; appMode = null; localStorage.removeItem(SK_MODE); showStartup(); } function installSyncPatch(){} // stub initApp(); installSyncPatch();