360° SCD Hub
🔄 Verifying your session…

Sickle Cell Foundation of Arizona

2026 Project Board · Force for Health Network

Checking whether you're logged into 360scdhub.org

Blossom Where You Are Planted
BWYAPL Organization Structure "Blossom Where You Are Planted" — SCFAZ hierarchy with Community Leader deployment targets by community · source: Program Implementation Model (©scfaz.org 2026)
`; } let _lastReport = { text:'', ext:'md', format:'pdf', data:null, from:'', to:'' }; function generateReport() { const scope = document.getElementById('rScope').value; const from = document.getElementById('rFrom').value; const to = document.getElementById('rTo').value; const format = document.getElementById('rFormat').value; const data = buildReportData(scope, from, to); _lastReport.data = data; _lastReport.from = from; _lastReport.to = to; _lastReport.format = format; let content; if (format === 'markdown') { content = formatMarkdown(data, from, to); _lastReport.ext = 'md'; } else if (format === 'text') { content = formatText(data, from, to); _lastReport.ext = 'txt'; } else if (format === 'html') { content = formatHTML(data, from, to); _lastReport.ext = 'html'; } else if (format === 'docx') { content = formatDocHTML(data, from, to); _lastReport.ext = 'doc'; } else { content = formatMarkdown(data, from, to); _lastReport.ext = 'pdf'; } // PDF uses markdown as preview _lastReport.text = content; // Preview let preview; if (format === 'pdf') { preview = '📄 PDF preview — click Download to generate a branded PDF with FFH header, orange accent bar, navy section headings, and page footer.\n\n' + formatMarkdown(data, from, to); } else if (format === 'docx') { preview = '📝 Word preview — click Download to get an editable .doc file (opens in Word / Pages / Google Docs).\n\n' + formatMarkdown(data, from, to); } else if (format === 'html') { preview = content.slice(0, 3000) + (content.length > 3000 ? '\n… (truncated preview)' : ''); } else { preview = content; } document.getElementById('reportPreview').textContent = preview; } // ====== WORD (.doc) FORMAT ====== // Wraps HTML in Word-compatible envelope; Word/Pages/Google Docs all open this as editable. function formatDocHTML(data, from, to) { const fromLabel = new Date(from).toLocaleDateString('en-US', {month:'long', day:'numeric', year:'numeric'}); const toLabel = new Date(to).toLocaleDateString('en-US', {month:'long', day:'numeric', year:'numeric'}); const totalContract = data.reduce((s, d) => s + d.fin.contract, 0); const totalPaid = data.reduce((s, d) => s + d.fin.inPaid, 0); const totalOut = data.reduce((s, d) => s + d.fin.inOutstanding, 0); const totalTasks = data.reduce((s, d) => s + d.tasks.length, 0); const wordStyles = ``; let body = ''; data.forEach(d => { body += `

${d.board.title}

Status: ${d.status.label}   |   Budget: ${d.board.budget}   |   Timeline: ${d.board.timeline}

${d.board.subtitle}

Payment Schedule

`; d.board.payments.forEach(p => { const statusCls = p.status === 'paid' ? 'paid' : 'pending'; body += ``; }); body += `
PaymentAmountDueStatus
${p.label}${p.type === 'out' ? ' (OUT)' : ''}$${p.amount.toLocaleString()}${p.due}${p.status}
`; if (d.tasks.length > 0) { body += `

Tasks in Range (${d.tasks.length})

`; } else { body += `

No tasks in this date range.

`; } }); return ` SCFA Status Report${wordStyles}
The Force for Health × Sickle Cell Foundation of Arizona

SCFA 2026 Project Status Report

Reporting Period: ${fromLabel}  —  ${toLabel}

Generated: ${new Date().toLocaleString()}   |   Prepared by: Lucy Howell, CEO

Executive Summary

Active Boards${data.length}Tasks in Range${totalTasks}
Contract Value$${totalContract.toLocaleString()}Collected
Outstanding$${totalOut.toLocaleString()}Collection %${totalContract > 0 ? Math.round((totalPaid/totalContract)*100) : 0}%
${body} `; } // ====== PDF GENERATION (jsPDF) ====== function generatePDF(data, from, to) { if (!window.jspdf || !window.jspdf.jsPDF) { toast('PDF library not loaded — try again in a moment', 'error'); return null; } const { jsPDF } = window.jspdf; const doc = new jsPDF({ unit:'pt', format:'letter' }); const pageW = doc.internal.pageSize.getWidth(); const pageH = doc.internal.pageSize.getHeight(); const margin = 50; let y = margin; const fromLabel = new Date(from).toLocaleDateString('en-US', {month:'long', day:'numeric', year:'numeric'}); const toLabel = new Date(to).toLocaleDateString('en-US', {month:'long', day:'numeric', year:'numeric'}); // FFH brand colors const navy = [15, 32, 68]; const orange = [232, 69, 10]; const mid = [107, 122, 153]; const light = [226, 232, 240]; const green = [22, 163, 74]; function checkPageBreak(spaceNeeded) { if (y + spaceNeeded > pageH - 60) { drawFooter(); doc.addPage(); y = margin; drawHeader(true); } } function drawHeader(isContinuation) { // Orange accent bar doc.setFillColor(...orange); doc.rect(0, 0, pageW, 6, 'F'); // Brand mark (text-based since we can't embed real PNG from here) doc.setFont('helvetica', 'bold'); doc.setFontSize(9); doc.setTextColor(...orange); doc.text('THE FORCE FOR HEALTH', margin, 28); doc.setTextColor(...navy); doc.text(' × SCFA', margin + 130, 28); doc.setFont('helvetica', 'normal'); doc.setFontSize(8); doc.setTextColor(...mid); doc.text('2026 Project Status Report', pageW - margin, 28, { align:'right' }); // Underline doc.setDrawColor(...light); doc.setLineWidth(0.5); doc.line(margin, 36, pageW - margin, 36); if (isContinuation) y = 55; } function drawFooter() { const pageNum = doc.internal.getCurrentPageInfo().pageNumber; doc.setDrawColor(...orange); doc.setLineWidth(1.5); doc.line(margin, pageH - 40, pageW - margin, pageH - 40); doc.setFont('helvetica', 'normal'); doc.setFontSize(8); doc.setTextColor(...mid); doc.text('Confidential · FFH × SCFA Partnership Year 3', margin, pageH - 25); doc.text('lucy@theforceforhealth.com', pageW / 2, pageH - 25, { align:'center' }); doc.text('Page ' + pageNum, pageW - margin, pageH - 25, { align:'right' }); } // COVER HEADER drawHeader(false); y = 80; doc.setFont('helvetica', 'bold'); doc.setFontSize(22); doc.setTextColor(...navy); doc.text('SCFA 2026 Project', margin, y); y += 26; doc.text('Status Report', margin, y); y += 18; doc.setDrawColor(...orange); doc.setLineWidth(3); doc.line(margin, y, margin + 80, y); y += 20; doc.setFont('helvetica', 'normal'); doc.setFontSize(11); doc.setTextColor(...mid); doc.text('Reporting Period:', margin, y); doc.setFont('helvetica', 'bold'); doc.setTextColor(...navy); doc.text(`${fromLabel} — ${toLabel}`, margin + 100, y); y += 16; doc.setFont('helvetica', 'normal'); doc.setTextColor(...mid); doc.text('Generated:', margin, y); doc.setFont('helvetica', 'bold'); doc.setTextColor(...navy); doc.text(new Date().toLocaleString(), margin + 100, y); y += 16; doc.setFont('helvetica', 'normal'); doc.setTextColor(...mid); doc.text('Prepared by:', margin, y); doc.setFont('helvetica', 'bold'); doc.setTextColor(...navy); doc.text('Lucy Howell, CEO · The Force for Health Network', margin + 100, y); y += 30; // EXECUTIVE SUMMARY const totalContract = data.reduce((s, d) => s + d.fin.contract, 0); const totalPaid = data.reduce((s, d) => s + d.fin.inPaid, 0); const totalOut = data.reduce((s, d) => s + d.fin.inOutstanding, 0); const totalTasks = data.reduce((s, d) => s + d.tasks.length, 0); const pct = totalContract > 0 ? Math.round((totalPaid/totalContract)*100) : 0; doc.setFillColor(244, 246, 250); doc.rect(margin, y, pageW - margin*2, 90, 'F'); doc.setDrawColor(...orange); doc.setLineWidth(2); doc.line(margin, y, margin, y + 90); doc.setFont('helvetica', 'bold'); doc.setFontSize(11); doc.setTextColor(...orange); doc.text('EXECUTIVE SUMMARY', margin + 12, y + 20); doc.setFont('helvetica', 'normal'); doc.setFontSize(10); doc.setTextColor(...navy); const col1X = margin + 12; const col2X = margin + (pageW - margin*2) / 2; doc.text(`Active boards: ${data.length}`, col1X, y + 40); doc.text(`Tasks in range: ${totalTasks}`, col2X, y + 40); doc.text(`Contract value: $${totalContract.toLocaleString()}`, col1X, y + 56); doc.setTextColor(...green); doc.text(`Collected: $${totalPaid.toLocaleString()} (${pct}%)`, col2X, y + 56); doc.setTextColor(...navy); doc.text(`Outstanding: $${totalOut.toLocaleString()}`, col1X, y + 72); y += 110; // PER-BOARD SECTIONS data.forEach(d => { checkPageBreak(80); doc.setFont('helvetica', 'bold'); doc.setFontSize(14); doc.setTextColor(...orange); doc.text(d.board.title, margin, y); y += 14; doc.setFont('helvetica', 'normal'); doc.setFontSize(9); doc.setTextColor(...mid); const subtitle = doc.splitTextToSize(d.board.subtitle, pageW - margin*2); doc.text(subtitle, margin, y); y += subtitle.length * 11 + 6; // Status pill + budget + timeline inline doc.setFontSize(9); doc.setTextColor(...navy); const statusLine = `${d.status.label} | Budget: ${d.board.budget} | Timeline: ${d.board.timeline}`; doc.text(statusLine, margin, y); y += 20; // Payment table checkPageBreak(60); doc.setFont('helvetica', 'bold'); doc.setFontSize(10); doc.setTextColor(...navy); doc.text('Payments', margin, y); y += 14; doc.setFillColor(...navy); doc.rect(margin, y, pageW - margin*2, 18, 'F'); doc.setFont('helvetica', 'bold'); doc.setFontSize(8); doc.setTextColor(255, 255, 255); doc.text('PAYMENT', margin + 6, y + 12); doc.text('AMOUNT', margin + 260, y + 12); doc.text('DUE', margin + 340, y + 12); doc.text('STATUS', margin + 410, y + 12); y += 18; doc.setFont('helvetica', 'normal'); doc.setFontSize(9); d.board.payments.forEach(p => { checkPageBreak(20); doc.setTextColor(...navy); doc.text(p.label + (p.type === 'out' ? ' (OUT)' : ''), margin + 6, y + 12, { maxWidth: 245 }); doc.text('$' + p.amount.toLocaleString(), margin + 260, y + 12); doc.text(p.due, margin + 340, y + 12); if (p.status === 'paid') doc.setTextColor(...green); else doc.setTextColor(...orange); doc.setFont('helvetica', 'bold'); doc.text(p.status.toUpperCase(), margin + 410, y + 12); doc.setFont('helvetica', 'normal'); doc.setDrawColor(...light); doc.setLineWidth(0.3); doc.line(margin, y + 18, pageW - margin, y + 18); y += 18; }); y += 10; // Tasks if (d.tasks.length > 0) { checkPageBreak(40); doc.setFont('helvetica', 'bold'); doc.setFontSize(10); doc.setTextColor(...navy); doc.text(`Tasks in Range (${d.tasks.length})`, margin, y); y += 14; doc.setFont('helvetica', 'normal'); doc.setFontSize(9); d.tasks.forEach(t => { checkPageBreak(14); doc.setTextColor(...navy); const bullet = t.stage === 'done' ? '✓' : '•'; const line = `${bullet} ${t.title} — ${t.owner} · ${t.due} · ${t.priority}`; const wrapped = doc.splitTextToSize(line, pageW - margin*2 - 10); doc.text(wrapped, margin + 4, y); y += wrapped.length * 11; }); y += 8; } y += 12; }); drawFooter(); return doc; } function downloadReport() { if (!_lastReport.data) { toast('Generate a report first', 'error'); return; } const stamp = new Date().toISOString().slice(0,10); const baseName = `SCFA-Status-Report-${stamp}`; if (_lastReport.format === 'pdf') { const doc = generatePDF(_lastReport.data, _lastReport.from, _lastReport.to); if (doc) { doc.save(baseName + '.pdf'); toast('PDF downloaded — FFH-branded', 'success'); } return; } if (_lastReport.format === 'docx') { const content = formatDocHTML(_lastReport.data, _lastReport.from, _lastReport.to); const blob = new Blob(['\ufeff', content], { type: 'application/msword' }); const a = document.createElement('a'); a.href = URL.createObjectURL(blob); a.download = baseName + '.doc'; a.click(); toast('Word doc downloaded — open in Word or Pages to edit', 'success'); return; } // Existing formats (md, txt, html) const mimeMap = { html:'text/html', md:'text/markdown', txt:'text/plain' }; const blob = new Blob([_lastReport.text], { type: mimeMap[_lastReport.ext] || 'text/plain' }); const a = document.createElement('a'); a.href = URL.createObjectURL(blob); a.download = `${baseName}.${_lastReport.ext}`; a.click(); toast('Report downloaded', 'success'); } function copyReport() { if (!_lastReport.text) { toast('Generate a report first', 'error'); return; } navigator.clipboard.writeText(_lastReport.text).then( () => toast('Report copied to clipboard', 'success'), () => toast('Copy failed — try downloading', 'error') ); } function exportAllBoards() { // Quick helper to download a portfolio JSON backup const payload = { exportedAt: new Date().toISOString(), boards: BOARDS.map(b => ({ id:b.id, title:b.title, budget:b.budget, payments:b.payments, tasks:b.tasks, speculative:b.speculative || false })), docs: DOCS }; const blob = new Blob([JSON.stringify(payload, null, 2)], { type:'application/json' }); const a = document.createElement('a'); a.href = URL.createObjectURL(blob); a.download = `SCFA-Portfolio-Backup-${new Date().toISOString().slice(0,10)}.json`; a.click(); toast('Portfolio backup exported', 'success'); } // ====== RENDER TABS (with status ribbons + edit pencil) ====== function renderTabs() { const container = document.getElementById('boardTabs'); const shortNames = {1:'360° SCD Hub Enhancement', 2:'Dashboard Development', 3:'Nurse Ed / Smart Snack', 4:'Website Redesign'}; let html = BOARDS.map(b => { const st = boardStatus(b); const isSpec = b.speculative ? 'speculative' : ''; const isActive = currentTab == b.id ? 'active' : ''; return ``; }).join(''); html += ``; container.innerHTML = html; // Wire clicks (but not on the pencil — pencil has its own onclick) container.querySelectorAll('.board-tab').forEach(tab => { tab.addEventListener('click', (e) => { if (e.target.closest('.tab-edit-pencil')) return; // pencil handles itself currentTab = tab.dataset.board; renderAll(); }); }); } // ====== BOARD STATUS QUICK-EDIT MENU ====== // Status is computed from payment.status — so changing the status here // just updates the underlying payment rows in the right pattern. function openStatusMenu(event, boardId) { event.stopPropagation(); closeStatusMenu(); const b = BOARDS.find(x => x.id === boardId); if (!b) return; const current = boardStatus(b).key; const isPitch = !!b.speculative; const items = isPitch ? [ { key:'pitch', label:'PITCH', desc:'Speculative — not yet contracted' } ] : [ { key:'signed', label:'SIGNED · AWAITING ACTIVATION', desc:'All incoming payments pending' }, { key:'activated', label:'● ACTIVATED', desc:'First (activation) payment marked paid' }, { key:'complete', label:'✓ COMPLETE', desc:'All incoming payments marked paid' } ]; const menu = document.createElement('div'); menu.className = 'status-menu'; menu.id = 'statusMenu'; menu.innerHTML = `
Set Part ${b.num} status
${items.map(i => ` `).join('')}
💡 Status flips are stored on payment rows. Click the payment schedule below to edit dates / amounts.
`; document.body.appendChild(menu); // Position menu under the pencil const rect = event.target.getBoundingClientRect(); const menuW = menu.offsetWidth; const left = Math.min(rect.left, window.innerWidth - menuW - 12); menu.style.left = left + 'px'; menu.style.top = (rect.bottom + 6) + 'px'; // Close on outside click / Esc setTimeout(() => { document.addEventListener('click', closeStatusMenu, { once:true }); document.addEventListener('keydown', escClose, { once:true }); }, 0); } function escClose(e) { if (e.key === 'Escape') closeStatusMenu(); } function closeStatusMenu() { const m = document.getElementById('statusMenu'); if (m) m.remove(); } // ====== PAYMENTS QUICK-EDIT POPOVER ====== // Click any pencil on the financial parts of a deliverable card → opens // a list of all payments for that contract. Each row is clickable to // edit, and there's an "+ Add Payment" CTA at the bottom. function openPaymentsPopover(event, boardId) { event.stopPropagation(); closePaymentsPopover(); closeStatusMenu(); const b = BOARDS.find(x => x.id === boardId); if (!b) return; const rowsHtml = b.payments.map((p, idx) => { const cls = `pop-stat ${p.status || 'pending'}`; const dirIcon = p.type === 'out' ? '💸' : '💰'; const label = (p.type === 'out' ? '[OUT] ' : '') + (p.label || 'Payment'); return `
${dirIcon} ${label}
Due: ${p.due || '—'}
${fmtMoney(p.amount || 0)} ${(p.status || 'pending').toUpperCase()} ✏️
`; }).join(''); const totalIn = b.payments.filter(p => p.type !== 'out').reduce((s, p) => s + (p.amount || 0), 0); const totalOut = b.payments.filter(p => p.type === 'out').reduce((s, p) => s + (p.amount || 0), 0); const pop = document.createElement('div'); pop.className = 'payments-popover'; pop.id = 'paymentsPopover'; pop.innerHTML = `
Part ${b.num} · Payment Schedule In: ${fmtMoney(totalIn)}${totalOut ? ` · Out: ${fmtMoney(totalOut)}` : ''}
${rowsHtml || '
No payments scheduled yet.
'}
+ Add Payment
💡 Click any row to edit amount, due date, or status. Status changes here flip the contract activation pill above.
`; document.body.appendChild(pop); // Position under the pencil const rect = event.target.getBoundingClientRect(); const popW = pop.offsetWidth; const popH = pop.offsetHeight; let left = Math.min(rect.left, window.innerWidth - popW - 12); if (left < 8) left = 8; let top = rect.bottom + 6; if (top + popH > window.innerHeight - 12) top = Math.max(12, rect.top - popH - 6); pop.style.left = left + 'px'; pop.style.top = top + 'px'; setTimeout(() => { document.addEventListener('click', closePaymentsPopover, { once:true }); document.addEventListener('keydown', escClosePayments, { once:true }); }, 0); } function escClosePayments(e) { if (e.key === 'Escape') closePaymentsPopover(); } function closePaymentsPopover() { const p = document.getElementById('paymentsPopover'); if (p) p.remove(); } function setBoardStatus(boardId, statusKey) { closeStatusMenu(); const b = BOARDS.find(x => x.id === boardId); if (!b) return; const inIndices = b.payments.map((p,i) => p.type !== 'out' ? i : -1).filter(i => i >= 0); if (statusKey === 'signed') { inIndices.forEach(i => b.payments[i].status = 'pending'); } else if (statusKey === 'activated') { inIndices.forEach((i, idx) => { b.payments[i].status = idx === 0 ? 'paid' : 'pending'; }); } else if (statusKey === 'complete') { inIndices.forEach(i => b.payments[i].status = 'paid'); } else if (statusKey === 'pitch') { b.speculative = true; inIndices.forEach(i => b.payments[i].status = 'pending'); } savePaymentsToStorage(); renderAll(); toast(`Part ${b.num} status updated → ${boardStatus(b).label}`, 'success'); } // ====== RENDER GROUP PANEL ====== function renderGroup() { const wrap = document.getElementById('groupMembers'); const avatarStack = TEAM.slice(0, 4).map(m => `
${m.initials}
` ).join('') + (TEAM.length > 4 ? `
+${TEAM.length - 4}
` : ''); const popoverItems = TEAM.map(m => `
${m.initials}
${m.name}
${m.role}
${m.email ? `` : ''} ${m.profile ? `` : ''}
`).join(''); wrap.innerHTML = `
${avatarStack}
👥 Team · ${TEAM.length} members · hover to browse
${popoverItems}
`; const grid = document.getElementById('membersGrid'); grid.innerHTML = TEAM.map(m => `
${m.initials}
${m.name}
${m.role}
`).join(''); const docList = document.getElementById('docList'); document.getElementById('docCount').textContent = `${DOCS.length} files`; if (DOCS.length === 0) { docList.innerHTML = '
No documents yet. Click "Add Document" above.
'; } else { docList.innerHTML = DOCS.map((d, i) => `
${iconForType(d.type)}
${d.name}
${d.cat || d.type.toUpperCase()}
`).join(''); } } function iconForType(t) { if (t === 'pdf') return '📄'; if (t === 'docx') return '📝'; if (t === 'xlsx') return '📊'; if (t === 'pptx') return '📊'; if (t === 'image') return '🖼️'; if (t === 'folder') return '📁'; return '🔗'; } function detectType(filename) { const ext = filename.split('.').pop().toLowerCase(); if (ext === 'pdf') return 'pdf'; if (['doc','docx'].includes(ext)) return 'docx'; if (['xls','xlsx','csv'].includes(ext)) return 'xlsx'; if (['ppt','pptx'].includes(ext)) return 'pptx'; if (['png','jpg','jpeg','gif','webp','svg'].includes(ext)) return 'image'; return 'other'; } function formatSize(bytes) { if (bytes < 1024) return bytes + ' B'; if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + ' KB'; return (bytes / (1024 * 1024)).toFixed(2) + ' MB'; } // ====== RENDER SUMMARY ====== function renderSummary(filterId) { const boards = filterId === 'all' ? BOARDS : BOARDS.filter(b => b.id == filterId); const fin = computeFinance(boards); const taskCount = boards.reduce((s, b) => s + b.tasks.length, 0); const isAll = filterId === 'all'; const isSpec = !isAll && boards[0].speculative; const label = isAll ? 'Total Contract Value' : (isSpec ? `Board ${boards[0].num} · Proposed Value` : `Board ${boards[0].num} · Contract`); const timeline = isAll ? 'Mar–Aug 2026' : boards[0].timeline; const timelineLabel = isAll ? 'Overall Timeline' : 'Project Timeline'; const contractVal = isSpec ? fin.inTotal : fin.contract; const container = document.getElementById('summaryRow'); container.innerHTML = `
${label}
${fmtMoney(contractVal)}${isSpec ? ' proposed' : ''}
${isAll ? `${boards.length} projects in portfolio` : boards[0].title}
Payment Status (In)
${fmtMoney(fin.inPaid)} / ${fmtMoney(fin.inTotal)}
${fin.pct}% collected · ${fmtMoney(fin.inOutstanding)} outstanding
${fin.outTotal > 0 ? `
↓ Out: ${fmtMoney(fin.outPaid)}Net: ${fmtMoney(fin.net)}
` : ''}
Tasks
${taskCount}
${isAll ? 'across all boards' : 'in this project'}
${timelineLabel}
${timeline}
${isAll ? 'Contract signed Mar 24, 2026' : boards[0].activationDate}
`; } // ====== VIEW RENDERS ====== function renderKanban(board) { const stageCounts = {backlog:0,todo:0,progress:0,review:0,done:0}; board.tasks.forEach(t => stageCounts[t.stage]++); return '
' + STAGES.map(st => `
${st.label} ${stageCounts[st.key]}
${board.tasks.filter(t => t.stage === st.key).map(t => renderTask(t)).join('')}
`).join('') + '
'; } function renderList(board) { let tasks = [...board.tasks]; if (sortField) { tasks.sort((a, b) => { const va = a[sortField] || ''; const vb = b[sortField] || ''; return (sortAsc ? 1 : -1) * (va > vb ? 1 : va < vb ? -1 : 0); }); } return `
${tasks.map(t => ` `).join('')}
Task ${sortField==='title'?(sortAsc?'▲':'▼'):''} Stage Priority Label Owner Due Hrs Cost
${t.title} ${t.stage} ${t.priority} ${t.label} ${t.owner[0]} ${t.owner} ${t.due} ${t.hrs || '—'} ${t.cost ? '$' + t.cost.toLocaleString() : '—'}
`; } function renderCalendar(board) { const year = calMonth.getFullYear(); const month = calMonth.getMonth(); const monthName = calMonth.toLocaleDateString('en-US', {month:'long', year:'numeric'}); const firstDay = new Date(year, month, 1); const lastDay = new Date(year, month + 1, 0); const startDow = firstDay.getDay(); const daysInMonth = lastDay.getDate(); const today = new Date(2026, 3, 21); // Today: April 21, 2026 // Map tasks by date (attempts to parse "May 5" or "Apr 28" etc.) const monthNames = ['Jan','Feb','Mar','Apr','May','Jun','Jul','Aug','Sep','Oct','Nov','Dec']; const tasksByDate = {}; board.tasks.forEach(t => { const m = t.due.match(/(\w{3})\s+(\d+)/); if (m) { const monthIdx = monthNames.indexOf(m[1]); const day = parseInt(m[2]); if (monthIdx === month) { if (!tasksByDate[day]) tasksByDate[day] = []; tasksByDate[day].push(t); } } }); let cells = ''; // Previous month padding for (let i = 0; i < startDow; i++) { cells += '
'; } // Days for (let d = 1; d <= daysInMonth; d++) { const isToday = year === today.getFullYear() && month === today.getMonth() && d === today.getDate(); const dayTasks = tasksByDate[d] || []; cells += `
${d}
${dayTasks.map(t => `${t.title.length > 22 ? t.title.slice(0,22)+'…' : t.title}`).join('')}
`; } return `
📅 ${monthName}
Sun
Mon
Tue
Wed
Thu
Fri
Sat
${cells}
`; } function sortList(field) { if (sortField === field) { sortAsc = !sortAsc; } else { sortField = field; sortAsc = true; } renderAll(); } function navCal(delta) { calMonth = new Date(calMonth.getFullYear(), calMonth.getMonth() + delta, 1); renderAll(); } function setView(view) { currentView = view; renderAll(); } // ====== RENDER BOARD ====== function renderBoard(board) { const viewHTML = currentView === 'list' ? renderList(board) : currentView === 'calendar' ? renderCalendar(board) : renderKanban(board); const inPayments = board.payments.filter(p => p.type !== 'out'); const outPayments = board.payments.filter(p => p.type === 'out'); const inPaid = inPayments.filter(p => p.status === 'paid').reduce((s, p) => s + p.amount, 0); const inTotal = inPayments.reduce((s, p) => s + p.amount, 0); const outPaid = outPayments.filter(p => p.status === 'paid').reduce((s, p) => s + p.amount, 0); const outTotal = outPayments.reduce((s, p) => s + p.amount, 0); const boardPct = inTotal ? Math.round((inPaid / inTotal) * 100) : 0; const specClass = board.speculative ? 'speculative' : ''; return `

${board.num}${board.title}${board.speculative ? ' SPECULATIVE PITCH' : ''}

${board.subtitle}
💰 ${board.budget} ⏱️ ${board.timeline} 📋 ${board.tasks.length} tasks 📅 ${board.activationDate}
${renderTimelineStrip(board)}

💵 Payment Schedule · ${fmtMoney(inPaid)} of ${fmtMoney(inTotal)} collected (${boardPct}%)${outTotal > 0 ? ` · Out: ${fmtMoney(outPaid)} / ${fmtMoney(outTotal)}` : ''}

${board.payments.map((p, i) => `
${(p.type || 'in').toUpperCase()} ${p.label}
Due: ${p.due}
${p.type === 'out' ? '−' : ''}${fmtMoney(p.amount)}
${p.status === 'paid' ? '✓ Paid' : p.status === 'overdue' ? 'Overdue' : p.status === 'due-soon' ? 'Due Soon' : 'Pending'}
`).join('')}
💡 Click any row to edit · Changes save to your browser (localStorage key: scfa_payments_v1)
${viewHTML}
`; } // ====== PAYMENT CRUD ====== const STORAGE_KEY = 'scfa_payments_v1'; function savePaymentsToStorage() { const snapshot = {}; BOARDS.forEach(b => { snapshot[b.id] = b.payments; }); try { localStorage.setItem(STORAGE_KEY, JSON.stringify(snapshot)); } catch(e) {} } function loadPaymentsFromStorage() { try { const raw = localStorage.getItem(STORAGE_KEY); if (!raw) return; const snapshot = JSON.parse(raw); BOARDS.forEach(b => { if (snapshot[b.id] && Array.isArray(snapshot[b.id])) { b.payments = snapshot[b.id]; } }); } catch(e) {} } let modalState = { boardId: null, paymentIdx: null, type: 'in' }; function openModal(boardId, paymentIdx, defaultType) { modalState = { boardId, paymentIdx, type: defaultType || 'in' }; const isEdit = paymentIdx !== null && paymentIdx !== undefined; document.getElementById('modalTitle').textContent = isEdit ? 'Edit Payment' : 'Add Payment'; document.getElementById('btnDelete').style.display = isEdit ? 'inline-block' : 'none'; if (isEdit) { const p = BOARDS.find(b => b.id === boardId).payments[paymentIdx]; document.getElementById('pLabel').value = p.label; document.getElementById('pAmount').value = p.amount; document.getElementById('pDue').value = p.due; document.getElementById('pStatus').value = p.status; modalState.type = p.type || 'in'; } else { document.getElementById('pLabel').value = ''; document.getElementById('pAmount').value = ''; document.getElementById('pDue').value = ''; document.getElementById('pStatus').value = 'pending'; } updateTypeToggle(); document.getElementById('paymentModal').classList.add('open'); setTimeout(() => document.getElementById('pLabel').focus(), 50); } function closeModal() { document.getElementById('paymentModal').classList.remove('open'); } function updateTypeToggle() { document.querySelectorAll('.type-btn').forEach(btn => { btn.classList.toggle('active', btn.dataset.type === modalState.type); }); } function savePayment() { const board = BOARDS.find(b => b.id === modalState.boardId); const label = document.getElementById('pLabel').value.trim(); const amount = parseFloat(document.getElementById('pAmount').value); const due = document.getElementById('pDue').value.trim(); const status = document.getElementById('pStatus').value; if (!label || isNaN(amount) || !due) { if (typeof toast === 'function') toast('Label, amount, and due date are required', 'error'); else alert('Please fill in label, amount, and due date.'); return; } const payment = { label, amount, due, status, type: modalState.type }; const isEdit = modalState.paymentIdx !== null && modalState.paymentIdx !== undefined; if (isEdit) { board.payments[modalState.paymentIdx] = payment; } else { board.payments.push(payment); } savePaymentsToStorage(); closeModal(); rerender(); if (typeof toast === 'function') toast(isEdit ? 'Payment updated' : 'Payment added', 'success'); } function deletePayment() { if (!confirm('Delete this payment?')) return; const board = BOARDS.find(b => b.id === modalState.boardId); board.payments.splice(modalState.paymentIdx, 1); savePaymentsToStorage(); closeModal(); rerender(); if (typeof toast === 'function') toast('Payment deleted', 'success'); } function rerender() { // Unified re-render (tabs + summary + boards + group) renderAll(); } function wireModalHandlers() { // Type toggle handlers document.querySelectorAll('.type-btn').forEach(btn => { btn.addEventListener('click', e => { e.preventDefault(); modalState.type = btn.dataset.type; updateTypeToggle(); }); }); // Backdrop click to close const pm = document.getElementById('paymentModal'); if (pm) pm.addEventListener('click', e => { if (e.target.id === 'paymentModal') closeModal(); }); const dm = document.getElementById('docModal'); if (dm) dm.addEventListener('click', e => { if (e.target.id === 'docModal') closeDocModal(); }); // Report modal backdrop + ESC const rm = document.getElementById('reportModal'); if (rm) rm.addEventListener('click', e => { if (e.target.id === 'reportModal') closeReportModal(); }); const tm = document.getElementById('taskModal'); if (tm) tm.addEventListener('click', e => { if (e.target.id === 'taskModal') closeTaskModal(); }); const tlm = document.getElementById('timelineModal'); if (tlm) tlm.addEventListener('click', e => { if (e.target.id === 'timelineModal') closeTimelineModal(); }); const om = document.getElementById('orgModal'); if (om) om.addEventListener('click', e => { if (e.target.id === 'orgModal') closeOrgModal(); }); const am = document.getElementById('accessModal'); if (am) am.addEventListener('click', e => { if (e.target.id === 'accessModal') closeAccessModal(); }); // ESC closes any modal document.addEventListener('keydown', e => { if (e.key === 'Escape') { closeModal(); closeDocModal(); closeReportModal(); closeTaskModal(); closeTimelineModal(); closeOrgModal(); closeAccessModal(); } }); } function renderTask(t) { const labelClass = t.label.replace(/\s/g, ''); const taskId = getTaskId(t); return `
⋮⋮
${t.title}
${t.label} ${t.owner[0]} 📅 ${t.due} ${t.hrs ? `⏱ ${t.hrs}h` : ''} ${t.cost ? `$${t.cost.toLocaleString()}` : ''}
`; } // ====== KANBAN DRAG & DROP ====== let dragState = { taskId:null, dragging:false, movedDuringDrag:false }; function onTaskClick(e, taskId) { if (dragState.movedDuringDrag) { dragState.movedDuringDrag = false; return; } openTaskModal(taskId); } function onTaskDragStart(e, taskId) { dragState.taskId = taskId; dragState.dragging = true; dragState.movedDuringDrag = true; e.dataTransfer.effectAllowed = 'move'; e.dataTransfer.setData('text/plain', taskId); e.target.classList.add('dragging'); } function onTaskDragEnd(e) { e.target.classList.remove('dragging'); document.querySelectorAll('.column.drag-over').forEach(c => c.classList.remove('drag-over')); dragState.dragging = false; // Small delay to prevent click firing right after drop setTimeout(() => { dragState.movedDuringDrag = false; }, 50); } function onColumnDragOver(e, stage) { e.preventDefault(); e.dataTransfer.dropEffect = 'move'; e.currentTarget.classList.add('drag-over'); } function onColumnDragLeave(e) { e.currentTarget.classList.remove('drag-over'); } function onColumnDrop(e, newStage) { e.preventDefault(); e.currentTarget.classList.remove('drag-over'); const taskId = e.dataTransfer.getData('text/plain'); if (!taskId) return; const found = findTaskById(taskId); if (!found) return; if (found.task.stage === newStage) return; // no change found.task.stage = newStage; saveTasksToStorage(); renderAll(); toast('Moved to ' + STAGES.find(s => s.key === newStage).label, 'success'); } function getTaskId(t) { // Stable ID from title hash (or use .id if we assign one) if (t._id) return t._id; t._id = 't_' + Math.abs(hashStr(t.title)).toString(36) + '_' + (Math.floor(Math.random()*1000)); return t._id; } function hashStr(s) { let h = 0; for (let i = 0; i < s.length; i++) { h = ((h<<5) - h) + s.charCodeAt(i); h |= 0; } return h; } function findTaskById(id) { for (const b of BOARDS) { for (const t of b.tasks) { if (getTaskId(t) === id) return { board: b, task: t }; } } return null; } function renderBoards(filterId) { const container = document.getElementById('boardsContainer'); const boardsToShow = filterId === 'all' ? BOARDS : BOARDS.filter(b => b.id == filterId); container.innerHTML = boardsToShow.map(renderBoard).join(''); } // ====== TASK CRUD ====== const TASKS_KEY = 'scfa_tasks_v1'; let editingTaskId = null; function saveTasksToStorage() { // Save as boardId → [tasks] so we can restore per board const snapshot = {}; BOARDS.forEach(b => { snapshot[b.id] = b.tasks; }); try { localStorage.setItem(TASKS_KEY, JSON.stringify(snapshot)); } catch(e) { toast('Storage full', 'error'); } } function loadTasksFromStorage() { try { const raw = localStorage.getItem(TASKS_KEY); if (!raw) return; const snapshot = JSON.parse(raw); BOARDS.forEach(b => { if (snapshot[b.id] && Array.isArray(snapshot[b.id])) { b.tasks = snapshot[b.id]; } }); } catch(e) {} } function openTaskModal(taskId) { const found = findTaskById(taskId); if (!found) return; editingTaskId = taskId; const t = found.task; document.getElementById('taskModalTitle').textContent = 'Edit Task · ' + found.board.title; document.getElementById('tTitle').value = t.title; document.getElementById('tStage').value = t.stage; document.getElementById('tPriority').value = t.priority; document.getElementById('tOwner').value = t.owner; document.getElementById('tLabel').value = t.label; document.getElementById('tDue').value = t.due; document.getElementById('tHrs').value = t.hrs || ''; document.getElementById('tCost').value = t.cost || ''; document.getElementById('taskModal').classList.add('open'); } function closeTaskModal() { document.getElementById('taskModal').classList.remove('open'); } function saveTask() { const found = findTaskById(editingTaskId); if (!found) return; const t = found.task; const title = document.getElementById('tTitle').value.trim(); if (!title) { toast('Title required', 'error'); return; } t.title = title; t.stage = document.getElementById('tStage').value; t.priority = document.getElementById('tPriority').value; t.owner = document.getElementById('tOwner').value; t.label = document.getElementById('tLabel').value; t.due = document.getElementById('tDue').value.trim(); t.hrs = parseInt(document.getElementById('tHrs').value) || 0; t.cost = parseInt(document.getElementById('tCost').value) || 0; saveTasksToStorage(); closeTaskModal(); renderAll(); toast('Task updated', 'success'); } function deleteTask() { if (!confirm('Delete this task?')) return; const found = findTaskById(editingTaskId); if (!found) return; found.board.tasks = found.board.tasks.filter(t => getTaskId(t) !== editingTaskId); saveTasksToStorage(); closeTaskModal(); renderAll(); toast('Task deleted', 'success'); } // ====== UNIFIED RENDER ====== function renderAll() { renderTabs(); renderSummary(currentTab); renderDeliverablesRecap(); renderBoards(currentTab); // Show BWYAPL Org Structure only on the Enhancements (Part 1) tab — it maps to the Community Leader Engagement deliverable const orgPanel = document.getElementById('orgPanel'); if (orgPanel) { orgPanel.style.display = (currentTab === '1') ? '' : 'none'; } } // ====== PROJECT DELIVERABLES RECAP ====== // Pulls every task with cost > 0 from each board and presents them as // per-contract deliverable cards. This is the single source of truth // for "what does this contract actually buy" — front-and-center for SCFA. // Filters by `currentTab` so the recap stays bound to the selector above. function renderDeliverablesRecap() { const container = document.getElementById('deliverablesRecap'); if (!container) return; // Honor the board-tab selector — single Part shows just that contract, // "View All Boards" shows the full portfolio. const isAll = (currentTab === 'all'); const visibleBoards = isAll ? BOARDS : BOARDS.filter(b => String(b.id) === String(currentTab)); if (visibleBoards.length === 0) { container.innerHTML = ''; return; } // Totals scoped to whatever is currently visible (matches the selector) const totals = computeFinance(visibleBoards); const allDeliverables = visibleBoards.reduce((sum, b) => sum + (b.tasks || []).filter(t => t.cost > 0).length, 0); // Set CSS class so a single-card view doesn't stretch awkwardly wide container.classList.toggle('single-card', visibleBoards.length === 1); const cards = visibleBoards.map(b => { const st = boardStatus(b); const isSpec = !!b.speculative; const lineItems = (b.tasks || []).filter(t => t.cost > 0); const lineTotal = lineItems.reduce((s, t) => s + (t.cost || 0), 0); const collected = b.payments.filter(p => p.type !== 'out' && p.status === 'paid') .reduce((s, p) => s + p.amount, 0); const cardClass = `deliv-card ${isSpec ? 'speculative' : ''} ${st.key}`.trim(); const linesHtml = lineItems.length ? lineItems.map(t => `
${t.title} ${fmtMoney(t.cost)}
`).join('') : `
Line items pending — see proposal for breakdown
`; return `
Part ${b.num} ${isSpec ? '· Pitch' : '· Contract'}
${b.title}
${b.budget}
${st.label}
${b.timeline} 📅 ${b.activationDate.replace(/^Target:?\s*/i, '')} 📋 ${lineItems.length} deliverable${lineItems.length===1?'':'s'}
📦 Deliverables · ${fmtMoney(lineTotal)}${lineTotal !== b.budgetNum && lineItems.length ? ` (${fmtMoney(b.budgetNum - lineTotal)} bundled)` : ''}
${linesHtml}
`; }).join(''); // Header reflects the current selector scope const scopeLabel = isAll ? `Project Deliverables RecapPer-contract breakdown · what each Part actually buys` : `Part ${visibleBoards[0].num} Deliverables${visibleBoards[0].title} · scoped to selector above`; const totalLabel = isAll ? 'Total Portfolio' : 'Contract Total'; container.innerHTML = `
📦 ${scopeLabel}
${totalLabel}${fmtMoney(totals.contract)} Collected${fmtMoney(totals.inPaid)} Outstanding${fmtMoney(totals.inOutstanding)} Deliverables${allDeliverables}
${cards}
${!isAll ? `
Showing Part ${visibleBoards[0].num} only · click View All Boards above to see the full portfolio recap
` : ''} `; } // ====== DOCUMENT CRUD ====== const DOCS_KEY = 'scfa_docs_v1'; const MAX_FILE_BYTES = 2 * 1024 * 1024; // 2MB per file const MAX_BATCH_BYTES = 5 * 1024 * 1024; // 5MB per batch let editingDocIdx = null; let stagedFiles = []; // [{ file, name, size, type, dataUrl, skipped, reason, folderName }] function saveDocsToStorage() { try { localStorage.setItem(DOCS_KEY, JSON.stringify(DOCS)); } catch(e) { toast('Storage full — delete older docs or use links', 'error'); } } function loadDocsFromStorage() { try { const raw = localStorage.getItem(DOCS_KEY); if (raw) DOCS = JSON.parse(raw); } catch(e) {} } function resetDocForm() { stagedFiles = []; document.getElementById('dName').value = ''; document.getElementById('dUrl').value = ''; document.getElementById('dType').value = 'pdf'; document.getElementById('dCat').value = ''; document.getElementById('stagedFiles').style.display = 'none'; document.getElementById('stagedFiles').innerHTML = ''; document.getElementById('dFile').value = ''; document.getElementById('dFolder').value = ''; } function openDocModal() { editingDocIdx = null; document.getElementById('docModalTitle').textContent = 'Add Document'; document.getElementById('docBtnDelete').style.display = 'none'; document.getElementById('uploadSection').style.display = ''; document.getElementById('orDivider').style.display = ''; resetDocForm(); document.getElementById('docModal').classList.add('open'); setTimeout(() => { const el = document.getElementById('dName'); if (el) el.focus(); }, 50); } function editDoc(idx) { editingDocIdx = idx; const d = DOCS[idx]; document.getElementById('docModalTitle').textContent = 'Edit Document'; document.getElementById('docBtnDelete').style.display = 'inline-block'; // Hide upload section when editing (can't swap a file easily) document.getElementById('uploadSection').style.display = 'none'; document.getElementById('orDivider').style.display = 'none'; resetDocForm(); document.getElementById('dName').value = d.name; document.getElementById('dUrl').value = d.url.startsWith('data:') ? '(embedded file — edit metadata only)' : d.url; document.getElementById('dType').value = d.type; document.getElementById('dCat').value = d.cat || ''; document.getElementById('docModal').classList.add('open'); } function closeDocModal() { document.getElementById('docModal').classList.remove('open'); stagedFiles = []; } function renderStagedFiles() { const container = document.getElementById('stagedFiles'); if (stagedFiles.length === 0) { container.style.display = 'none'; return; } container.style.display = 'block'; container.innerHTML = stagedFiles.map((f, i) => `
${iconForType(f.type)} ${f.folderName ? '📁 ' + f.folderName + ' / ' : ''}${f.name} ${f.skipped ? '⚠ ' + f.reason : formatSize(f.size)}
`).join(''); // Auto-fill name field with first file or folder name if empty const nameInput = document.getElementById('dName'); if (!nameInput.value) { const firstFolder = stagedFiles.find(f => f.folderName); if (firstFolder) { nameInput.value = firstFolder.folderName + ' (folder · ' + stagedFiles.length + ' files)'; document.getElementById('dType').value = 'folder'; document.getElementById('dCat').value = 'Folder upload'; } else if (stagedFiles.length === 1) { nameInput.value = stagedFiles[0].name.replace(/\.[^.]+$/, ''); document.getElementById('dType').value = stagedFiles[0].type; } else { nameInput.value = stagedFiles.length + ' files'; } } } function removeStaged(idx) { stagedFiles.splice(idx, 1); renderStagedFiles(); } async function handleFiles(fileList, fromFolder = false) { const files = Array.from(fileList); let batchBytes = stagedFiles.reduce((s, f) => s + (f.skipped ? 0 : f.size), 0); let folderName = null; if (fromFolder && files.length > 0 && files[0].webkitRelativePath) { folderName = files[0].webkitRelativePath.split('/')[0]; } for (const file of files) { const staged = { file, name: file.name, size: file.size, type: detectType(file.name), folderName: fromFolder ? folderName : null }; if (file.size > MAX_FILE_BYTES) { staged.skipped = true; staged.reason = 'Too large (>2MB)'; stagedFiles.push(staged); continue; } if (batchBytes + file.size > MAX_BATCH_BYTES) { staged.skipped = true; staged.reason = 'Batch cap reached'; stagedFiles.push(staged); continue; } // Read as data URL try { staged.dataUrl = await new Promise((resolve, reject) => { const r = new FileReader(); r.onload = e => resolve(e.target.result); r.onerror = reject; r.readAsDataURL(file); }); batchBytes += file.size; } catch (e) { staged.skipped = true; staged.reason = 'Read failed'; } stagedFiles.push(staged); } renderStagedFiles(); const skipped = stagedFiles.filter(f => f.skipped).length; const added = stagedFiles.filter(f => !f.skipped).length; if (skipped > 0) toast(`${added} added, ${skipped} skipped (size limits)`, skipped > added ? 'error' : 'success'); else toast(added + ' file' + (added === 1 ? '' : 's') + ' staged', 'success'); } function saveDoc() { const name = document.getElementById('dName').value.trim(); const url = document.getElementById('dUrl').value.trim(); const type = document.getElementById('dType').value; const cat = document.getElementById('dCat').value.trim(); // EDIT mode — just update metadata if (editingDocIdx !== null) { if (!name) { toast('Name is required', 'error'); return; } DOCS[editingDocIdx] = { ...DOCS[editingDocIdx], name, type, cat, // Only replace url if user typed a real new one (not the embedded placeholder) ...(url && !url.startsWith('(embedded') ? { url } : {}) }; saveDocsToStorage(); closeDocModal(); renderGroup(); toast('Document updated', 'success'); return; } // ADD mode — staged files first, then link if no files const validStaged = stagedFiles.filter(f => !f.skipped && f.dataUrl); if (validStaged.length > 0) { // If folder upload, create one folder "parent" doc + each file as child const folderName = validStaged.find(f => f.folderName)?.folderName; if (folderName) { // Add one folder-entry doc listing all files DOCS.push({ id: 'd' + Date.now(), name: name || `${folderName} (folder · ${validStaged.length} files)`, url: '#folder-' + Date.now(), type: 'folder', cat: cat || 'Folder upload', folderContents: validStaged.map(f => ({ name: f.name, type: f.type, size: f.size, dataUrl: f.dataUrl })) }); } else { // Individual file docs validStaged.forEach((f, i) => { DOCS.push({ id: 'd' + Date.now() + '_' + i, name: validStaged.length === 1 ? (name || f.name.replace(/\.[^.]+$/, '')) : f.name.replace(/\.[^.]+$/, ''), url: f.dataUrl, type: f.type, cat: cat || '', size: f.size }); }); } saveDocsToStorage(); closeDocModal(); renderGroup(); toast(validStaged.length + ' document' + (validStaged.length === 1 ? '' : 's') + ' saved', 'success'); return; } // Link mode if (!name || !url) { toast('Add a file, or provide a name + link', 'error'); return; } DOCS.push({ id: 'd' + Date.now(), name, url, type, cat }); saveDocsToStorage(); closeDocModal(); renderGroup(); toast('Document added', 'success'); } function deleteDoc() { if (!confirm('Delete this document?')) return; DOCS.splice(editingDocIdx, 1); saveDocsToStorage(); closeDocModal(); renderGroup(); toast('Document removed', 'success'); } function openDocLink(idx) { const d = DOCS[idx]; // Folder with embedded contents → show a list if (d.type === 'folder' && d.folderContents) { showFolderContents(d); return; } // Data URL (embedded file) → convert to blob + open if (d.url && d.url.startsWith('data:')) { try { const [header, base64] = d.url.split(','); const mime = (header.match(/data:([^;]+)/) || [])[1] || 'application/octet-stream'; const bytes = atob(base64); const arr = new Uint8Array(bytes.length); for (let i = 0; i < bytes.length; i++) arr[i] = bytes.charCodeAt(i); const blob = new Blob([arr], { type: mime }); const blobUrl = URL.createObjectURL(blob); window.open(blobUrl, '_blank'); } catch (e) { toast('Could not open file', 'error'); } return; } // Regular link if (d.url && (d.url.startsWith('http') || d.url.startsWith('computer:///'))) { window.open(d.url, '_blank'); } else { toast('Invalid link', 'error'); } } function showFolderContents(folderDoc) { // Quick inline preview listing const listing = folderDoc.folderContents.map((f, i) => `• ${f.name} (${formatSize(f.size)})` ).join('\n'); alert(`📁 ${folderDoc.name}\n\n${listing}\n\nClick individual files by re-uploading them separately, or store this folder in cloud storage (Google Drive, Dropbox) and use a link instead.`); } // ====== DROPZONE WIRING ====== function wireDropzone() { const zone = document.getElementById('docDropzone'); const fileInput = document.getElementById('dFile'); const folderInput = document.getElementById('dFolder'); if (!zone || !fileInput || !folderInput) return; fileInput.addEventListener('change', e => { if (e.target.files.length) handleFiles(e.target.files, false); }); folderInput.addEventListener('change', e => { if (e.target.files.length) handleFiles(e.target.files, true); }); zone.addEventListener('dragover', e => { e.preventDefault(); zone.classList.add('drag-over'); }); zone.addEventListener('dragleave', () => zone.classList.remove('drag-over')); zone.addEventListener('drop', async e => { e.preventDefault(); zone.classList.remove('drag-over'); const items = e.dataTransfer.items; if (items && items[0] && items[0].webkitGetAsEntry) { // Could be folder const files = []; const tasks = []; for (let i = 0; i < items.length; i++) { const entry = items[i].webkitGetAsEntry && items[i].webkitGetAsEntry(); if (entry) tasks.push(traverseEntry(entry, files)); } await Promise.all(tasks); const isFolder = Array.from(items).some(it => { const ent = it.webkitGetAsEntry && it.webkitGetAsEntry(); return ent && ent.isDirectory; }); handleFiles(files, isFolder); } else if (e.dataTransfer.files.length) { handleFiles(e.dataTransfer.files, false); } }); } async function traverseEntry(entry, files, path = '') { if (entry.isFile) { await new Promise(resolve => { entry.file(file => { // Attach synthetic webkitRelativePath for folder grouping Object.defineProperty(file, 'webkitRelativePath', { value: path + file.name, writable: false }); files.push(file); resolve(); }); }); } else if (entry.isDirectory) { const reader = entry.createReader(); await new Promise(resolve => { reader.readEntries(async entries => { for (const e of entries) { await traverseEntry(e, files, path + entry.name + '/'); } resolve(); }); }); } } // ====== TOAST ====== let toastTimer = null; function toast(msg, type = 'success') { const el = document.getElementById('toast'); el.textContent = msg; el.className = 'toast show ' + type; clearTimeout(toastTimer); toastTimer = setTimeout(() => { el.classList.remove('show'); }, 2400); } // (payment toast is fired inline from savePayment/deletePayment below) // ====== ACCESS MANAGEMENT ====== // Demo-level access — for real HIPAA-grade auth wire Supabase Auth (free tier, no PHI = no BAA). const AUTH_KEY = 'scfa_auth_ok'; const ACCESS_KEY = 'scfa_access_v1'; const BOARD_URL_KEY = 'scfa_board_url_v1'; // Default seeded access codes (migrated into managed list on first load) const DEFAULT_ACCESS = [ { code:'scfa2026', name:'SCFA Team (general)', email:'', added:'2026-04-21' }, { code:'moses', name:'Moses Akpan', email:'moses@sicklecellfoundationaz.org', added:'2026-04-21' }, { code:'ffhdemo', name:'FFH Demo Access', email:'', added:'2026-04-21' }, { code:'bloomaz', name:'Bloom AZ (generic)', email:'', added:'2026-04-21' } ]; let ACCESS_LIST = []; let currentInviteIdx = null; function loadAccess() { try { const raw = localStorage.getItem(ACCESS_KEY); if (raw) { ACCESS_LIST = JSON.parse(raw); return; } } catch(e) {} ACCESS_LIST = [...DEFAULT_ACCESS]; saveAccess(); } function saveAccess() { try { localStorage.setItem(ACCESS_KEY, JSON.stringify(ACCESS_LIST)); } catch(e) {} } function getBoardUrl() { try { return localStorage.getItem(BOARD_URL_KEY) || ''; } catch(e) { return ''; } } function saveBoardUrl() { const v = document.getElementById('boardUrl').value.trim(); try { localStorage.setItem(BOARD_URL_KEY, v); } catch(e) {} toast('Board URL saved', 'success'); renderAccessTable(); } function submitAuth() { const val = document.getElementById('authInput').value.trim().toLowerCase(); const errEl = document.getElementById('authError'); if (!val) { errEl.textContent = 'Enter your access code.'; return; } const match = ACCESS_LIST.find(a => a.code.toLowerCase() === val); if (match) { errEl.textContent = ''; passAuth(); } else { errEl.textContent = 'Invalid code. Ask Lucy for your code.'; } } // ====== ACCESS MODAL ====== function openAccessModal() { document.getElementById('boardUrl').value = getBoardUrl(); renderAccessTable(); closeInvitePreview(); document.getElementById('accessModal').classList.add('open'); } function closeAccessModal() { document.getElementById('accessModal').classList.remove('open'); } function generateCode() { const adj = ['bloom','rose','force','hope','bright','strong','vital','kind']; const noun = ['az','path','team','hub','path','care','flow','move']; return adj[Math.floor(Math.random()*adj.length)] + '-' + noun[Math.floor(Math.random()*noun.length)] + '-' + Math.floor(Math.random()*900 + 100); } function addAccess() { const name = document.getElementById('newName').value.trim(); const email = document.getElementById('newEmail').value.trim(); let code = document.getElementById('newCode').value.trim().toLowerCase(); if (!name) { toast('Name required', 'error'); return; } if (!code) code = generateCode(); // Dedupe if (ACCESS_LIST.some(a => a.code.toLowerCase() === code)) { toast('That code already exists — pick another', 'error'); return; } ACCESS_LIST.push({ code, name, email, added: new Date().toISOString().slice(0,10) }); saveAccess(); document.getElementById('newName').value = ''; document.getElementById('newEmail').value = ''; document.getElementById('newCode').value = ''; renderAccessTable(); toast(`Added ${name} with code: ${code}`, 'success'); } function deleteAccess(idx) { const a = ACCESS_LIST[idx]; if (!confirm(`Revoke access for ${a.name} (code: ${a.code})?`)) return; ACCESS_LIST.splice(idx, 1); saveAccess(); renderAccessTable(); toast('Access revoked', 'success'); } function regenerateCode(idx) { if (!confirm(`Generate a new code for ${ACCESS_LIST[idx].name}? The old code will stop working immediately.`)) return; ACCESS_LIST[idx].code = generateCode(); saveAccess(); renderAccessTable(); toast('New code generated', 'success'); } function showInvite(idx) { currentInviteIdx = idx; const a = ACCESS_LIST[idx]; const url = getBoardUrl() || '[paste URL here once deployed]'; const invite = `Subject: Access to SCFA 2026 Project Board Hi ${a.name.split(' ')[0]}, You now have access to the SCFA 2026 Project Board — our live tracker for the Force for Health × SCFA partnership projects (360° SCD Hub Enhancement, Dashboard Development, Banner Nurse Education, and Website Redesign). Board URL: ${url} Your access code: ${a.code} What you'll see inside: • All 4 project tabs with live timeline + progress bars • Payment schedule and budget tracking • Shared team documents (proposals, specs, strategy docs) • BWYAPL Organization Structure — full SCFAZ hierarchy with capacity tracking • Status report generator for date-range updates This page contains NO patient data (PHI) — it's project management, timelines, and financials only. We designed it that way so no BAA is required for hosting. Please let me know if you have questions or can't log in. — Lucy Howell CEO, The Force for Health Network lucy@theforceforhealth.com`; document.getElementById('invitePreview').textContent = invite; document.getElementById('invitePreviewWrap').style.display = 'block'; document.getElementById('invitePreviewWrap').scrollIntoView({ behavior:'smooth', block:'end' }); } function closeInvitePreview() { document.getElementById('invitePreviewWrap').style.display = 'none'; currentInviteIdx = null; } function copyInvite() { if (currentInviteIdx === null) return; const text = document.getElementById('invitePreview').textContent; navigator.clipboard.writeText(text).then( () => toast('Invite copied — paste into email', 'success'), () => toast('Copy failed', 'error') ); } function copyCode(idx) { const a = ACCESS_LIST[idx]; navigator.clipboard.writeText(a.code).then( () => toast(`Code "${a.code}" copied`, 'success'), () => toast('Copy failed', 'error') ); } function renderAccessTable() { const body = document.getElementById('accessTableBody'); if (ACCESS_LIST.length === 0) { body.innerHTML = 'No access codes yet. Add one above.'; return; } body.innerHTML = ACCESS_LIST.map((a, i) => ` ${a.name} ${a.code} ${a.email || '—'} ${a.added || '—'}
`).join(''); } function passAuth() { sessionStorage.setItem(AUTH_KEY, '1'); document.getElementById('authGate').classList.add('hidden'); document.body.style.overflow = ''; } function showLoginPrompt(reason) { const eyebrow = document.getElementById('authEyebrow'); const msg = document.getElementById('authMessage'); const link = document.getElementById('authLoginLink'); if (eyebrow) eyebrow.textContent = '🔐 Please log in'; if (msg) { msg.innerHTML = `You need to be logged into 360scdhub.org to view the SCFA Project Board.${reason ? '
'+reason+'' : ''}`; } if (link) link.style.display = 'inline-block'; document.body.style.overflow = 'hidden'; } /** * Auth check now: * 1. If page is being viewed locally (file://) OR on localhost → DEV mode, auto-pass. * 2. If parent page (WP template) set window.SCFA_TRACKER_API → trust it, auto-pass. * 3. Otherwise hit /wp-json/scfa/v1/me with credentials → if 200, pass; if 401, show login prompt. * * Replaces the old access-code system. The access-code admin tooling below is preserved * for now but no longer enforced — Lucy can ignore it or wire it for a future invite flow. */ function checkAuth() { // Bypass already-passed in this tab if (sessionStorage.getItem(AUTH_KEY) === '1') { document.getElementById('authGate').classList.add('hidden'); return true; } // DEV MODE: local file:// or localhost — let it through so Lucy can preview without WP const proto = window.location.protocol; const host = window.location.hostname; if (proto === 'file:' || host === 'localhost' || host === '127.0.0.1' || host === '') { console.info('[SCFA Project Board] Dev mode (local preview) — auth gate bypassed.'); passAuth(); return true; } // PARENT WP TEMPLATE set the API config — we're embedded in an authenticated WP page if (window.SCFA_TRACKER_API && window.SCFA_TRACKER_API.user_id) { passAuth(); return true; } // Iframe — try reading from parent try { if (window.parent && window.parent !== window && window.parent.SCFA_TRACKER_API && window.parent.SCFA_TRACKER_API.user_id) { window.SCFA_TRACKER_API = window.parent.SCFA_TRACKER_API; passAuth(); return true; } } catch(e) { /* cross-origin parent */ } // Production fallback: hit the REST whoami endpoint // (Works because WP cookie auth + nonce-less reads can still identify the user) fetch('/wp-json/scfa/v1/me', { credentials: 'include' }) .then(r => r.ok ? r.json() : Promise.reject(r.status)) .then(user => { if (user && user.user_id) { passAuth(); } else { showLoginPrompt('Session check returned no user.'); } }) .catch(err => { const reason = err === 401 ? 'You are not logged in.' : (typeof err === 'number' ? `Server returned ${err}.` : 'Could not reach the login service.'); showLoginPrompt(reason); }); document.body.style.overflow = 'hidden'; return false; } // ════════════════════════════════════════════════════════════════════ // SUPABASE SYNC — pushes the SCFA boards + tasks defined above into // shared pm_boards / pm_tasks tables so this project shows up live in // FFH-Project-Manager and the Tech Command Center. Idempotent: matches // boards by name and tasks by title, so re-running won't duplicate. // ════════════════════════════════════════════════════════════════════ const SCFA_BOARD_TAG = 'scfa-2026'; // metadata tag so we can filter to SCFA in TCC async function syncBoardsToSupabase() { if (!window.FFH_SUPA) { console.log('[SCFA Sync] FFH_SUPA not loaded — using localStorage only'); updateSyncIndicator('localStorage', 'Supabase client not loaded — using browser storage'); return; } await FFH_SUPA.ready(); if (!FFH_SUPA.status.connected) { console.log('[SCFA Sync] Supabase not configured — staying on localStorage'); updateSyncIndicator('localStorage', 'Supabase not configured — using browser storage'); return; } if (FFH_SUPA.status.readonly) { console.log('[SCFA Sync] Supabase readonly mode — skipping write'); updateSyncIndicator('readonly', 'Supabase connected (readonly preview)'); return; } updateSyncIndicator('syncing', 'Syncing boards + tasks to Supabase…'); let boardsCreated = 0, tasksCreated = 0, tasksSkipped = 0; try { for (const b of BOARDS) { // findOrCreate returns existing board if name matches, else creates const board = await FFH_SUPA.boards.findOrCreate(b.title, { description: b.subtitle || '' }); if (!board) continue; if (board.created_at && (Date.now() - new Date(board.created_at).getTime()) < 10000) { boardsCreated++; } // Pull existing tasks once, then only create the missing ones const existing = await FFH_SUPA.tasks.list({ boardId: board.id }); const existingTitles = new Set((existing || []).map(t => (t.title || '').trim().toLowerCase())); for (const t of (b.tasks || [])) { const titleKey = (t.title || '').trim().toLowerCase(); if (existingTitles.has(titleKey)) { tasksSkipped++; continue; } try { await FFH_SUPA.tasks.create({ title: t.title, board_id: board.id, stage: t.stage || 'backlog', priority: t.priority || 'medium', labels: t.label ? [t.label, SCFA_BOARD_TAG] : [SCFA_BOARD_TAG], assignees: t.owner ? [t.owner] : [], due: t.due || null, estimateHrs: t.hrs || null }); tasksCreated++; } catch (e) { console.warn('[SCFA Sync] task create failed for', t.title, e.message); } } } const msg = `Synced · ${boardsCreated} new boards · ${tasksCreated} new tasks · ${tasksSkipped} already in sync`; console.log('[SCFA Sync]', msg); updateSyncIndicator('synced', msg); } catch (e) { console.error('[SCFA Sync] failed:', e); updateSyncIndicator('error', 'Sync error — see console'); } } function updateSyncIndicator(state, message) { const el = document.getElementById('syncIndicator'); if (!el) return; const colors = { synced: { bg: '#d1fae5', fg: '#065f46', dot: '#10b981', icon: '☁️' }, syncing: { bg: '#dbeafe', fg: '#1e40af', dot: '#3b82f6', icon: '⏳' }, localStorage: { bg: '#fef3c7', fg: '#92400e', dot: '#f59e0b', icon: '💾' }, readonly: { bg: '#e0e7ff', fg: '#4338ca', dot: '#6366f1', icon: '👁' }, error: { bg: '#fee2e2', fg: '#991b1b', dot: '#dc2626', icon: '⚠️' } }; const c = colors[state] || colors.localStorage; el.style.background = c.bg; el.style.color = c.fg; el.innerHTML = `${c.icon} ${message}`; el.title = message; } // ====== INIT ====== function init() { loadAccess(); // must be first — auth check depends on it checkAuth(); loadPaymentsFromStorage(); loadDocsFromStorage(); loadTasksFromStorage(); loadTimelinesFromStorage(); loadOrgFromStorage(); wireModalHandlers(); wireDropzone(); renderClipboard(); renderGroup(); renderOrg(); renderAll(); // Kick off Supabase sync in the background — non-blocking so the UI // renders instantly from localStorage and Supabase fills in shortly after. setTimeout(() => { syncBoardsToSupabase().catch(e => console.warn(e)); }, 250); } if (document.readyState === 'loading') { document.addEventListener('DOMContentLoaded', init); } else { init(); }