// Remaining pages: Ideas, Components, Tickets, Help, Admin. Plus shared wizards/drawers.
// ── IDEAS LIST ───────────────────────────────────────────────────────
const IdeasPage = ({ goto, openCompose }) => {
const [filter, setFilter] = React.useState('all');
const items = APM_IDEAS.filter(i => filter === 'all' ? true : i.status === filter);
return (
} onClick={openCompose}>Log idea}
/>
{[
{ id: 'all', label: 'All', n: APM_IDEAS.length },
{ id: 'new', label: 'New', n: APM_IDEAS.filter(i => i.status === 'new').length },
{ id: 'promoted', label: 'Promoted', n: APM_IDEAS.filter(i => i.status === 'promoted').length },
{ id: 'merged', label: 'Merged', n: APM_IDEAS.filter(i => i.status === 'merged').length },
].map(f => {
const active = filter === f.id;
return (
setFilter(f.id)} style={{
padding: '6px 12px', borderRadius: 999,
background: active ? 'var(--apm-ink)' : 'transparent',
color: active ? 'var(--apm-bg-2)' : 'var(--apm-ink-2)',
border: '1px solid ' + (active ? 'var(--apm-ink)' : 'var(--apm-line-2)'),
fontFamily: 'var(--apm-font-body)', fontSize: 12.5, fontWeight: 500, cursor: 'pointer',
}}>{f.label} {f.n}
);
})}
{items.map(idea => (
goto('ideas', { id: idea.id })}>
{idea.title}
{idea.description}
{PERSON_BY_ID[idea.submitter].name}
· {idea.submitted}
{idea.tags?.map(t =>
{t} )}
))}
);
};
// ── IDEA DETAIL ──────────────────────────────────────────────────────
const IdeaDetail = ({ id, goto }) => {
const idea = APM_IDEAS.find(i => i.id === id) || APM_IDEAS[0];
const [pickerOpen, setPickerOpen] = React.useState(false);
return (
{idea.status}
· {idea.upvotes} upvotes
{PERSON_BY_ID[idea.submitter].name}
>}
action={ } onClick={() => setPickerOpen(true)}>Route this idea}
/>
Discussion · 4 replies
{[
{ who: 'p_priya', when: '2 days ago', text: 'I\'d be hesitant to overlap this with the Q-shape preview work — different patient outcomes. Worth its own project?' },
{ who: 'p_jenna', when: '1 day ago', text: 'If we ship summaries to patients we need consent scope for it. Let\'s scope that into the wizard before promoting.' },
{ who: 'p_dr_ito', when: '4 hours ago', text: '+1 to keeping it separate. I\'d like to see this on the FY27 deck.' },
].map((c, i) => (
{PERSON_BY_ID[c.who].name}
{c.when}
{c.text}
))}
{/* Routing picker */}
setPickerOpen(false)} width={680}>
Route idea
Where does “{idea.title}” go?
Pick one. The idea stays linked so we keep credit and trace.
{[
{ icon: 'projects', title: 'Promote to project', hint: 'Becomes a Phase-0 project with the wizard. Best for novel work.', accent: true },
{ icon: 'initiatives', title: 'Promote to initiative', hint: 'Strategic/big. Owner + sponsor required.' },
{ icon: 'flow', title: 'Add as follow-on to a project', hint: 'Attach to an in-flight project as a deferred item.' },
{ icon: 'link', title: 'Merge into existing idea', hint: 'When a duplicate slipped through.' },
{ icon: 'archive', title: 'Park / Reject', hint: 'Archive with a reason. Submitter is notified.' },
].map((o, i) => (
{ setPickerOpen(false); if (i === 0) goto('__new_project__', { from: id }); }}
style={{
display: 'flex', alignItems: 'center', gap: 14,
padding: 14, textAlign: 'left',
background: 'transparent',
border: '1px solid ' + (o.accent ? 'var(--apm-accent)' : 'var(--apm-line)'),
borderRadius: 'var(--apm-radius)', cursor: 'pointer',
fontFamily: 'var(--apm-font-body)',
}}
onMouseEnter={e => e.currentTarget.style.background = 'var(--apm-bg-sunken)'}
onMouseLeave={e => e.currentTarget.style.background = 'transparent'}>
))}
);
};
// ── COMPONENTS LIST + DETAIL ─────────────────────────────────────────
const ComponentsPage = ({ goto }) => (
Component
Owner
Stability
Version
Deps
Sensitivity
{APM_COMPONENTS.map((c,i,a) => (
goto('components', { id: c.id })} style={{
display: 'grid', gridTemplateColumns: '1.4fr 0.7fr 0.7fr 0.7fr 0.6fr 1fr',
padding: 'var(--apm-row-pad) var(--apm-card-pad)', alignItems: 'center', cursor: 'pointer',
borderBottom: i < a.length-1 ? '1px solid var(--apm-line)' : 'none',
}}
onMouseEnter={e => e.currentTarget.style.background = 'var(--apm-bg-sunken)'} onMouseLeave={e => e.currentTarget.style.background = 'transparent'}>
{PERSON_BY_ID[c.owner].name.split(' ')[0]}
{c.stability}
{c.version}
{c.dependents.length}
{c.sensitivity.map(s => )}
))}
);
const ComponentDetail = ({ id, goto, openChange }) => {
const c = COMP_BY_ID[id] || APM_COMPONENTS[0];
const changes = APM_CHANGES.filter(ch => ch.component === c.id);
return (
{c.stability}
{PERSON_BY_ID[c.owner].name}
{c.sensitivity.map(s => )}
>}
action={ } onClick={openChange}>Submit change}
/>
{changes.length === 0 ?
:
{changes.map(ch => (
{ch.breaking ? 'Breaking' : 'Compatible'}
{ch.status}
{ch.title}
{ch.description}
Impact: {ch.impact.length} dependent projects
))}
}
{c.dependents.map((pid, i, a) => (
goto('project', { id: pid })} style={{
padding: '10px 14px', cursor: 'pointer',
borderBottom: i < a.length-1 ? '1px solid var(--apm-line)' : 'none',
}}
onMouseEnter={e => e.currentTarget.style.background = 'var(--apm-bg-sunken)'} onMouseLeave={e => e.currentTarget.style.background = 'transparent'}>
{PROJ_BY_ID[pid].name}
{PROJ_BY_ID[pid].phase}
))}
);
};
// ── TICKETS ──────────────────────────────────────────────────────────
const TicketsPage = ({ goto, openId }) => {
const [open, setOpen] = React.useState(openId || null);
React.useEffect(() => { if (openId) setOpen(openId); }, [openId]);
return (
}>New ticket}
/>
Pri
Title
Project
Status
Assignee
Age
{APM_TICKETS.map((t,i,a) => (
setOpen(t.id)} style={{
display: 'grid', gridTemplateColumns: '60px 1fr 1fr 100px 100px 80px',
padding: 'var(--apm-row-pad) var(--apm-card-pad)', alignItems: 'center', gap: 12, cursor: 'pointer',
borderBottom: i < a.length-1 ? '1px solid var(--apm-line)' : 'none',
}}
onMouseEnter={e => e.currentTarget.style.background = 'var(--apm-bg-sunken)'} onMouseLeave={e => e.currentTarget.style.background = 'transparent'}>
{t.priority}
{t.title}
{t.id} · opened by {PERSON_BY_ID[t.from].name}
{PROJ_BY_ID[t.project].name}
{t.status}
{t.assignee ?
{PERSON_BY_ID[t.assignee].name.split(' ')[0]} :
— }
{t.age}
))}
{/* Ticket drawer */}
setOpen(null)} width={560}>
{open && (() => {
const t = APM_TICKETS.find(x => x.id === open) || APM_TICKETS[0];
return (
<>
{t.priority}
{t.id}
setOpen(null)} style={{ background: 'transparent', border: 'none', cursor: 'pointer', color: 'var(--apm-muted)' }}>
{t.title}
{PERSON_BY_ID[t.from].name} opened — “Several EU clinics getting 502 on bulk uploads. Started ~30min ago.”
System escalated to P1 due to clinic count threshold.
{t.assignee &&
{PERSON_BY_ID[t.assignee].name} took ownership.
}
Resolve
Re-assign
Convert to idea
>
);
})()}
);
};
// ── HELP BOARD ───────────────────────────────────────────────────────
const HelpPage = ({ goto, openCompose }) => (
} onClick={openCompose}>Ask a question}
/>
{APM_HELP.map(h => (
{h.title}
{PERSON_BY_ID[h.from].name}
· {h.age}
· topic: {h.topic}
{h.status}
{h.answers} {h.answers === 1 ? 'reply' : 'replies'}
{h.promoted && → {h.promoted} }
))}
);
// ── ADMIN ────────────────────────────────────────────────────────────
const AdminPage = () => (
{Object.entries(APM_ROLES).map(([k, r], i, a) => (
))}
{[
{ n: 'Clinical Apps', members: 14, projects: 4 },
{ n: 'Platform', members: 9, projects: 3 },
{ n: 'Regulatory', members: 6, projects: 3 },
{ n: 'Executive', members: 5, projects: 0 },
].map((g, i, a) => (
{g.n}
{g.members} members · {g.projects} projects
Manage
))}
{APM_SENSITIVITY.map(s => (
))}
);
// ── NEW PROJECT WIZARD (modal) ──────────────────────────────────────
const NewProjectWizard = ({ open, onClose, fromIdea }) => {
const [step, setStep] = React.useState(0);
const [interviewOpen, setInterviewOpen] = React.useState(false);
const idea = fromIdea ? APM_IDEAS.find(i => i.id === fromIdea) : null;
const [data, setData] = React.useState({
name: idea?.title || '', initiative: '', summary: idea?.description || '',
sensitivity: [], owner: '', target: '',
});
const applyFill = (filled) => {
setData(prev => ({
...prev,
name: filled.name || prev.name,
summary: filled.summary || prev.summary,
target: filled.target || prev.target,
sensitivity: filled.sensitivity || prev.sensitivity,
initiative: filled.initiative || prev.initiative,
}));
setInterviewOpen(false);
};
if (!open) return null;
return (
{/* Main wizard */}
{idea ? 'Promote idea to project' : 'New project'} · Step {step + 1} of 3
{step === 0 && 'What are we building?'}
{step === 1 && 'Who owns it, what does it touch?'}
{step === 2 && 'Confirm — this enters Phase 0 Intake'}
{!interviewOpen && (
setInterviewOpen(true)} style={{
display: 'flex', alignItems: 'center', gap: 6,
padding: '7px 12px', borderRadius: 8,
background: 'var(--apm-ink)', color: 'var(--apm-bg-2)',
border: 'none', cursor: 'pointer', fontSize: 12.5, fontWeight: 500,
fontFamily: 'var(--apm-font-body)', flexShrink: 0,
}}>
Ask Claude
)}
{step === 0 && (
Roll up to which initiative?
{APM_INITIATIVES.map(ini => (
setData({ ...data, initiative: ini.id })} style={{
display: 'flex', alignItems: 'center', gap: 12, padding: 10,
background: data.initiative === ini.id ? 'var(--apm-bg-sunken)' : 'transparent',
border: '1px solid ' + (data.initiative === ini.id ? 'var(--apm-ink)' : 'var(--apm-line)'),
borderRadius: 'var(--apm-radius)', cursor: 'pointer', textAlign: 'left',
fontFamily: 'var(--apm-font-body)',
}}>
{data.initiative === ini.id &&
}
{ini.name}
{ini.code}
))}
)}
{step === 1 && (
Project owner
{APM_PEOPLE.filter(p => p.role === 'owner' || p.role === 'manager').map(p => (
setData({ ...data, owner: p.id })} style={{
display: 'flex', alignItems: 'center', gap: 8, padding: 10,
background: data.owner === p.id ? 'var(--apm-bg-sunken)' : 'transparent',
border: '1px solid ' + (data.owner === p.id ? 'var(--apm-ink)' : 'var(--apm-line)'),
borderRadius: 'var(--apm-radius)', cursor: 'pointer', textAlign: 'left',
}}>
{p.name}
{APM_ROLES[p.role].label}
))}
Sensitivity (gates downstream depend on this)
{APM_SENSITIVITY.map(s => {
const on = data.sensitivity.includes(s.id);
return (
setData({ ...data, sensitivity: on ? data.sensitivity.filter(x => x !== s.id) : [...data.sensitivity, s.id] })} style={{
display: 'flex', alignItems: 'center', gap: 6, padding: '6px 12px',
background: on ? 'var(--apm-ink)' : 'transparent',
color: on ? 'var(--apm-bg-2)' : 'var(--apm-ink)',
border: '1px solid ' + (on ? 'var(--apm-ink)' : 'var(--apm-line-2)'),
borderRadius: 999, cursor: 'pointer', fontSize: 12.5, fontWeight: 500,
}}>{s.label}
);
})}
Target date
setData({ ...data, target: v })} placeholder="YYYY-MM-DD" prefix={
} />
)}
{step === 2 && (
Summary
{data.name || 'Untitled project'}
{data.summary || 'No summary yet.'}
Initiative
{INI_BY_ID[data.initiative]?.name || '—'}
Owner
{PERSON_BY_ID[data.owner]?.name || '—'}
Target
{data.target || '—'}
Sensitivity
{data.sensitivity.length ? data.sensitivity.map(s => ) : none }
On submit, this project enters Phase 0 — Intake . Stage approvers will be notified based on sensitivity.
)}
Cancel
{step > 0 && } onClick={() => setStep(step - 1)}>Back}
{step < 2 && setStep(step + 1)}>Continue }
{step === 2 && } onClick={onClose}>Submit & enter Intake}
{/* end main wizard col */}
{/* Claude interview panel */}
{interviewOpen && (
setInterviewOpen(false)}
/>
)}
{/* end flex row */}
);
};
// ── IDEA COMPOSE ─────────────────────────────────────────────────────
const IdeaCompose = ({ open, onClose }) => {
const [t, setT] = React.useState('');
const [d, setD] = React.useState('');
const [interviewOpen, setInterviewOpen] = React.useState(false);
const applyFill = (filled) => {
if (filled.title) setT(filled.title);
if (filled.description) setD(filled.description);
setInterviewOpen(false);
};
if (!open) return null;
return (
{/* Main form */}
{!interviewOpen && (
setInterviewOpen(true)} style={{
display: 'flex', alignItems: 'center', gap: 6,
padding: '7px 12px', borderRadius: 8,
background: 'var(--apm-ink)', color: 'var(--apm-bg-2)',
border: 'none', cursor: 'pointer', fontSize: 12.5, fontWeight: 500,
fontFamily: 'var(--apm-font-body)', flexShrink: 0,
}}>
Ask Claude
)}
Cancel
} onClick={onClose}>Submit idea
{/* Claude panel */}
{interviewOpen && (
setInterviewOpen(false)} />
)}
);
};
// ── CLAUDE INTERVIEW PANEL ──────────────────────────────────────────
// Reusable within any wizard modal. Asks a series of targeted questions
// via the claude.complete API, then returns structured data to fill the form.
const ClaudeInterview = ({ context, onFill, onClose }) => {
const systemPrompt = context === 'project'
? `You are helping a healthcare device company employee create a new project record in their portfolio management system. Ask them short, focused questions one at a time to gather: project name, one-sentence summary, which strategic initiative it supports (options: HearWell Cloud Migration, PEARL — AI Fit Coach, Patient Data Platform, Workforce Modernization), target date, and any regulatory sensitivities (PHI, PII, FDA/MDR, Source data, Fin/Internal). Be conversational and concise. After gathering enough info, output a JSON block like: {"name":"...","summary":"...","initiative":"ini_001","target":"YYYY-MM-DD","sensitivity":["phi"]}. Start by asking what the project is trying to accomplish.`
: `You are helping a healthcare device company employee log a new idea in their innovation backlog. Ask them short questions one at a time to understand: what the idea is, what problem it solves, who benefits, and what success looks like. Be warm and curious. After 3–4 exchanges, output a JSON block like: {"title":"...","description":"..."}. Start with: "What's the spark? Describe the idea in a sentence or two."`;
const [msgs, setMsgs] = React.useState([]);
const [input, setInput] = React.useState('');
const [loading, setLoading] = React.useState(false);
const [filledData, setFilledData] = React.useState(null);
const scrollRef = React.useRef(null);
// Kick off with Claude's opening question
React.useEffect(() => {
setLoading(true);
window.claude.complete({ messages: [{ role: 'user', content: 'Start the interview.' }], system: systemPrompt })
.then(text => {
setMsgs([{ who: 'claude', text }]);
setLoading(false);
})
.catch(() => {
setMsgs([{ who: 'claude', text: context === 'project' ? "What's this project trying to accomplish?" : "What's the spark? Describe the idea in a sentence or two." }]);
setLoading(false);
});
}, []);
React.useEffect(() => {
if (scrollRef.current) scrollRef.current.scrollTop = scrollRef.current.scrollHeight;
}, [msgs, loading]);
const send = async () => {
if (!input.trim() || loading) return;
const userMsg = input.trim();
setInput('');
const next = [...msgs, { who: 'me', text: userMsg }];
setMsgs(next);
setLoading(true);
try {
const history = next.map(m => ({ role: m.who === 'me' ? 'user' : 'assistant', content: m.text }));
const reply = await window.claude.complete({ messages: history, system: systemPrompt });
setMsgs([...next, { who: 'claude', text: reply }]);
// detect JSON block in reply
const match = reply.match(/\{[\s\S]*\}/);
if (match) {
try { setFilledData(JSON.parse(match[0])); } catch (_) {}
}
} catch (e) {
setMsgs([...next, { who: 'claude', text: 'Sorry, I hit an error. Keep going or fill the form manually.' }]);
}
setLoading(false);
};
return (
{/* Header */}
Claude
interviewing you to fill this form
{/* Messages */}
{msgs.map((m, i) => (
{m.who === 'claude' ? m.text.replace(/\{[\s\S]*\}/, '').trim() : m.text}
))}
{loading && (
)}
{filledData && (
Ready to fill the form
{JSON.stringify(filledData, null, 2)}
} onClick={() => onFill(filledData)}>
Apply to form
)}
{/* Input */}
e.key === 'Enter' && send()} />
}>Send
);
};
// ── CHAT DRAWER ──────────────────────────────────────────────────────
const ChatDrawer = ({ open, onClose, persona }) => {
const [msgs, setMsgs] = React.useState([
{ who: 'claude', text: `Hi ${persona.name.split(' ')[0]}. I can find projects, summarize gates, draft idea routing recommendations, or pull a stat. What do you need?` },
]);
const [v, setV] = React.useState('');
const [loading, setLoading] = React.useState(false);
const scrollRef = React.useRef(null);
React.useEffect(() => {
if (scrollRef.current) scrollRef.current.scrollTop = scrollRef.current.scrollHeight;
}, [msgs]);
const send = async () => {
if (!v.trim() || loading) return;
const userText = v.trim();
setV('');
const next = [...msgs, { who: 'me', text: userText }];
setMsgs(next);
setLoading(true);
try {
const history = next.map(m => ({ role: m.who === 'me' ? 'user' : 'assistant', content: m.text }));
const reply = await window.claude.complete({
messages: history,
system: 'You are a portfolio management assistant for a healthcare device company called Earlens. You have knowledge of their projects (PEARL Fit-Coach MVP, HearWell Cloud Migration, Patient Consent Service, etc.), initiatives, RA/QA gates, and team. Answer concisely and helpfully.',
});
setMsgs([...next, { who: 'claude', text: reply }]);
} catch {
setMsgs([...next, { who: 'claude', text: 'Sorry, I had trouble with that one. Try again?' }]);
}
setLoading(false);
};
return (
Claude
Your portfolio assistant · scoped to your access
{msgs.map((m, i) => (
))}
{loading && (
)}
{['What changed since yesterday?', 'Why is PEARL at-risk?', 'Show me my queue'].map(q => (
setV(q)} style={{
padding: '5px 10px', background: 'transparent',
border: '1px solid var(--apm-line-2)', borderRadius: 999,
fontSize: 12, color: 'var(--apm-muted)', cursor: 'pointer', fontFamily: 'var(--apm-font-body)',
}}>{q}
))}
e.key === 'Enter' && send()} />
}>Ask
);
};
// ── MESSAGES PAGE ────────────────────────────────────────────────────
const MessagesPage = ({ persona, goto }) => {
const myTickets = APM_TICKETS.filter(t => t.assignee === persona.id);
const gateItems = persona.role === 'owner'
? [
{ id: 'g1', type: 'gate', title: 'RA/QA gate response needed — PEARL Fit-Coach MVP', project: 'prj_pearl', priority: 'high', age: '2h', from: 'p_jenna' },
{ id: 'g2', type: 'gate', title: 'Security gate condition: adverse-event routing plan', project: 'prj_pearl', priority: 'high', age: '1d', from: 'p_sarah' },
{ id: 'g3', type: 'gate', title: 'Cost & Ops gate sign-off requested — HearWell v3', project: 'prj_hw3', priority: 'med', age: '3d', from: 'p_marisa' },
]
: persona.role === 'approver'
? [
{ id: 'g4', type: 'gate', title: 'Security gate review pending — Patient Consent Service', project: 'prj_consent', priority: 'high', age: '4h', from: 'p_priya' },
]
: [];
const permRequests = [
{ id: 'r1', type: 'perm', title: 'Access request: PHI projects read access', from: 'p_diego', age: '30 min', status: 'pending' },
{ id: 'r2', type: 'perm', title: 'Role request: Stage Approver (HIPAA/PII)', from: 'p_kira', age: '2h', status: 'pending' },
];
const allItems = [
...gateItems.map(i => ({ ...i, category: 'Gate review' })),
...myTickets.map(t => ({ id: t.id, type: 'ticket', title: t.title, priority: t.priority, age: t.age, from: t.from, category: 'Ticket', project: t.project })),
...permRequests.map(r => ({ ...r, category: 'Access request' })),
];
const categories = [...new Set(allItems.map(i => i.category))];
return (
{allItems.length === 0 && (
} title="You're all caught up" body="No pending gate reviews, tickets, or access requests." />
)}
{categories.map(cat => {
const items = allItems.filter(i => i.category === cat);
return (
{items.map(item => (
{
if (item.type === 'ticket') goto('tickets', { id: item.id });
else if (item.type === 'gate') goto('project', { id: item.project, tab: 'gates' });
}}>
{item.type === 'gate' && <>
Respond
Snooze
>}
{item.type === 'ticket' && Open }
{item.type === 'perm' && <>
Approve
Deny
>}
))}
);
})}
);
};
// ── USER PROFILE PAGE ────────────────────────────────────────────────
const UserProfilePage = ({ persona, setTweak, t }) => {
const PALETTES = [
{ id: 'cream', label: 'Cream', swatch: ['#f7f4ec', '#1d1b16', '#b5651d'] },
{ id: 'light', label: 'Light', swatch: ['#ffffff', '#111827', '#6366f1'] },
{ id: 'dark', label: 'Dark', swatch: ['#1a1917', '#f5f4f0', '#c084fc'] },
];
const DENSITIES = [
{ id: 'compact', label: 'Compact', desc: '36px rows, tighter spacing' },
{ id: 'regular', label: 'Regular', desc: '44px rows, comfortable' },
{ id: 'comfy', label: 'Comfy', desc: '52px rows, relaxed' },
];
return (
Active >}
/>
{/* Palette */}
{PALETTES.map(p => {
const active = (t?.theme || 'cream') === p.id;
return (
setTweak('theme', p.id)} style={{
display: 'flex', alignItems: 'center', gap: 12, padding: 14,
background: active ? 'var(--apm-bg-sunken)' : 'transparent',
border: '1px solid ' + (active ? 'var(--apm-ink)' : 'var(--apm-line)'),
borderRadius: 'var(--apm-radius-lg)', cursor: 'pointer',
textAlign: 'left', fontFamily: 'var(--apm-font-body)',
}}>
{/* swatch */}
{p.swatch.map((c, i) =>
)}
{active && }
);
})}
{/* Density */}
{DENSITIES.map(d => {
const active = (t?.density || 'regular') === d.id;
return (
setTweak('density', d.id)} style={{
display: 'flex', alignItems: 'center', gap: 12, padding: 14,
background: active ? 'var(--apm-bg-sunken)' : 'transparent',
border: '1px solid ' + (active ? 'var(--apm-ink)' : 'var(--apm-line)'),
borderRadius: 'var(--apm-radius-lg)', cursor: 'pointer',
textAlign: 'left', fontFamily: 'var(--apm-font-body)',
}}>
{active && }
);
})}
Role
{APM_ROLES[persona.role]?.label}
Email
{persona.id.replace('p_', '')}@earlens.com
Sign out
);
};
Object.assign(window, {
ClaudeInterview,
IdeasPage, IdeaDetail, ComponentsPage, ComponentDetail,
TicketsPage, HelpPage, AdminPage,
NewProjectWizard, IdeaCompose, ChatDrawer,
MessagesPage, UserProfilePage,
});