/
home
/
techb158
/
cosmic-risk.abdallabala.com
/
src
/
app
/
dashboard
/
/home/techb158/cosmic-risk.abdallabala.com/src/app/dashboard
mkdir
upload
Name
Size
Mode
Actions
layout.jsx
304
0644
edit
dl
rm
page.jsx
127836
0644
edit
dl
rm
Edit:
/home/techb158/cosmic-risk.abdallabala.com/src/app/dashboard/page.jsx
(127836B)
'use client'; import { useEffect, useState, useCallback, useMemo } from 'react'; import { useRouter, useSearchParams } from 'next/navigation'; import { api } from '../../lib/api-client'; import ErrorBoundary from '../../components/ErrorBoundary'; const ORG_TABS = ['Overview', 'Projects', 'Risks', 'Mitigations', 'Gate', 'Indicators', 'Experiments', 'Reports', 'Integrations', 'Audit', 'Members']; const PAGE_SIZE = 20; const DIMENSIONS = ['Organizational', 'Technical', 'Human', 'Governance', 'Legal', 'Ethical', 'Operational']; const LIFECYCLE_PHASES = ['Design', 'Development', 'Testing', 'Deployment']; const RISK_STATUSES = ['OPEN', 'IN_MITIGATION', 'ACCEPTED', 'CLOSED']; const MITIGATION_STATUSES = ['NOT_STARTED', 'IN_PROGRESS', 'DONE', 'REJECTED']; function useToast() { const [toasts, setToasts] = useState([]); const add = useCallback((message, type = 'success') => { const id = Date.now() + Math.random(); setToasts(prev => [...prev, { id, message, type }]); setTimeout(() => setToasts(prev => prev.filter(t => t.id !== id)), 3000); }, []); return { toasts, add }; } function ToastContainer({ toasts }) { if (!toasts.length) return null; return ( <div className="toast-container"> {toasts.map(t => ( <div key={t.id} className={`toast ${t.type}`}>{t.message}</div> ))} </div> ); } function SearchInput({ value, onChange, placeholder }) { return ( <div className="search-wrap"> <span className="search-icon">⌕</span> <input className="input" value={value} onChange={e => onChange(e.target.value)} placeholder={placeholder || 'Search...'} /> </div> ); } function sortData(list, sortKey, sortDir) { if (!sortKey) return list; return [...list].sort((a, b) => { const va = a[sortKey], vb = b[sortKey]; if (va == null) return 1; if (vb == null) return -1; const cmp = typeof va === 'number' ? va - vb : String(va).localeCompare(String(vb)); return sortDir === 'asc' ? cmp : -cmp; }); } function Badge({ children, className }) { return <span className={`badge ${className || ''}`}>{children}</span>; } function FormField({ label, children }) { return ( <div style={{ marginBottom: 10 }}> <label style={{ display: 'block', fontSize: 12, color: 'var(--muted)', marginBottom: 3 }}>{label}</label> {children} </div> ); } function InlineInput({ value, onChange, type = 'text', min, max, style: extraStyle }) { return <input className="input" type={type} value={value} onChange={e => onChange(e.target.value)} min={min} max={max} style={{ width: '100%', ...extraStyle }} />; } function InlineSelect({ value, onChange, options }) { const items = options || []; return ( <select className="input" value={value} onChange={e => onChange(e.target.value)} style={{ width: '100%' }}> <option value="">— Select —</option> {items.map(o => { const val = typeof o === 'object' ? o.value : o; const lbl = typeof o === 'object' ? o.label : o; return <option key={val} value={val}>{lbl}</option>; })} </select> ); } function ConfirmDelete({ label, onConfirm, onCancel, deleting }) { return ( <div className="modal-overlay" onClick={onCancel}> <div className="modal" onClick={e => e.stopPropagation()} style={{ maxWidth: 380 }}> <h3>Delete {label}?</h3> <p style={{ fontSize: 13, color: 'var(--muted)' }}>This cannot be undone.</p> <div className="modal-actions"> <button className="button secondary small" onClick={onCancel}>Cancel</button> <button className="button small" style={{ background: 'var(--danger)', color: '#fff' }} onClick={onConfirm} disabled={deleting}> {deleting ? 'Deleting...' : 'Delete'} </button> </div> </div> </div> ); } function OverviewTab({ project, dashboard }) { const panels = useMemo(() => { if (!dashboard) return []; const s = dashboard.summary || {}; return [ { label: 'Overall AI Risk Score', value: `${s.overallScore ?? '-'}/100`, cls: (s.overallScore || 0) >= 50 ? 'bad' : (s.overallScore || 0) >= 25 ? 'warn' : 'good', sub: `${s.riskLevel || '-'} residual exposure` }, { label: 'Deployment Gate', value: s.gateStatus ?? '-', cls: s.gateStatus === 'READY' ? 'good' : s.gateStatus === 'WARNING' ? 'warn' : 'bad', sub: dashboard.gate?.message || '' }, { label: 'Open Risks', value: s.openRisks ?? 0, cls: (s.openRisks || 0) > 10 ? 'bad' : (s.openRisks || 0) > 3 ? 'warn' : 'good', sub: `${s.totalRisks || 0} total risks tracked` }, { label: 'Mitigation Completion', value: `${s.mitigationCompletion ?? 0}%`, cls: (s.mitigationCompletion || 0) < 40 ? 'bad' : (s.mitigationCompletion || 0) < 70 ? 'warn' : 'good', sub: 'Average progress across active risks' }, { label: 'Critical Risks', value: s.criticalRisks ?? 0, cls: (s.criticalRisks || 0) > 0 ? 'bad' : 'good', sub: `${s.highRisks || 0} high risks also active` }, ]; }, [dashboard]); if (!dashboard) return <div className="empty-state"><p>Loading dashboard data...</p></div>; const s = dashboard.summary || {}; const triangle = dashboard.governanceTriangle || {}; const lifecycleReadiness = dashboard.lifecycleReadiness || []; const dimensionResidual = dashboard.dimensionResidual || []; const dims = dimensionResidual.length > 0 ? dimensionResidual : [ { name: 'Organizational', score: triangle.organizational || 0 }, { name: 'Technical', score: triangle.technical || 0 }, { name: 'Human', score: triangle.human || 0 }, ]; const getDim = (name) => { const d = dims.find(x => x.name.toLowerCase() === name.toLowerCase()); return d ? Math.round(d.score || 0) : 0; }; const orgScore = getDim('Organizational'); const techScore = getDim('Technical'); const humanScore = getDim('Human'); const triClass = (s) => s >= 75 ? 'blocked' : s >= 50 ? 'warning' : 'pass'; const topRisks = dashboard.topRisks || []; return ( <> <div className="page-header" style={{ marginBottom: 18 }}> <h1 style={{ fontSize: 20, margin: 0 }}>Integrated Measurement Framework for AI Project Risks</h1> <p className="muted" style={{ margin: '4px 0 0 0', fontSize: 13 }}> {project?.projectType || 'AI-Enabler'} · {project?.currentLifecyclePhase || 'Assessment'} · Assessment {new Date(dashboard.project?.assessmentDate || Date.now()).toISOString().split('T')[0]} </p> </div> {/* Summary Cards */} <div className="grid" style={{ gridTemplateColumns: 'repeat(auto-fit, minmax(180px, 1fr))', marginBottom: 18 }}> {panels.map(p => ( <div key={p.label} className="metric-card"> <h3>{p.label}</h3> <div className={`metric-value ${p.cls}`}>{p.value}</div> <div className="metric-sub">{p.sub}</div> </div> ))} </div> <div className="grid" style={{ gridTemplateColumns: '1fr 1fr', gap: 18, marginBottom: 18 }}> {/* AI Governance Triangle */} <div className="panel"> <div className="panel-title"> <div> <span className="section-number" style={{ fontSize: 11, fontWeight: 600, color: 'var(--muted)', textTransform: 'uppercase', letterSpacing: '0.04em' }}>6.2</span> <h2 style={{ margin: '2px 0 0', fontSize: 18 }}>AI Governance Triangle</h2> </div> <p className="muted" style={{ fontSize: 12, margin: '4px 0 0' }}>Organizational, technical, and human residual risk.</p> </div> <svg viewBox="0 0 420 330" className="triangle-svg" role="img" aria-label="AI Governance Triangle"> <polygon points="210,38 56,282 364,282" fill="#f8fbf8" stroke="#134e21" strokeWidth="2" /> <circle cx="210" cy="38" r="28" className={`triangle-node ${triClass(orgScore)}`} /> <circle cx="56" cy="282" r="28" className={`triangle-node ${triClass(techScore)}`} /> <circle cx="364" cy="282" r="28" className={`triangle-node ${triClass(humanScore)}`} /> <text x="210" y="42" textAnchor="middle" className="node-score">{orgScore}</text> <text x="56" y="286" textAnchor="middle" className="node-score">{techScore}</text> <text x="364" y="286" textAnchor="middle" className="node-score">{humanScore}</text> <text x="210" y="18" textAnchor="middle" className="node-label">Organizational</text> <text x="56" y="320" textAnchor="middle" className="node-label">Technical</text> <text x="364" y="320" textAnchor="middle" className="node-label">Human</text> <text x="210" y="166" textAnchor="middle" className="triangle-title">AI Risks</text> <text x="210" y="188" textAnchor="middle" className="triangle-subtitle">Governance · Metrology · Evidence</text> </svg> </div> {/* Dimension Scores */} <div className="panel"> <div className="panel-title"> <div> <span className="section-number" style={{ fontSize: 11, fontWeight: 600, color: 'var(--muted)', textTransform: 'uppercase', letterSpacing: '0.04em' }}>Risk dimensions</span> <h2 style={{ margin: '2px 0 0', fontSize: 18 }}>Dimension scores</h2> </div> <p className="muted" style={{ fontSize: 12, margin: '4px 0 0' }}>Normalized residual risk exposure by dashboard dimension.</p> </div> <div style={{ display: 'flex', flexDirection: 'column', gap: 18, paddingTop: 8 }}> {dims.map(d => { const score = Math.round(d.score || 0); const barColor = score >= 75 ? 'var(--blocked)' : score >= 50 ? 'var(--warn)' : 'var(--accent)'; return ( <div key={d.name}> <div style={{ display: 'flex', justifyContent: 'space-between', fontSize: 13, marginBottom: 4 }}> <span style={{ fontWeight: 600 }}>{d.name}</span> <strong>{score}/100</strong> </div> <div className="bar-wrap" style={{ height: 10 }}> <div className="bar-fill" style={{ width: `${Math.min(score, 100)}%`, background: barColor }} /> </div> </div> ); })} </div> </div> </div> {/* Lifecycle Readiness Map */} <div className="panel" style={{ marginBottom: 18 }}> <div className="panel-title"> <div> <span className="section-number" style={{ fontSize: 11, fontWeight: 600, color: 'var(--muted)', textTransform: 'uppercase', letterSpacing: '0.04em' }}>AI lifecycle</span> <h2 style={{ margin: '2px 0 0', fontSize: 18 }}>Lifecycle readiness map</h2> </div> <p className="muted" style={{ fontSize: 12, margin: '4px 0 0' }}>Dashboard backbone from needs identification through deployment and monitoring.</p> </div> <div style={{ display: 'flex', flexDirection: 'column', gap: 6, marginTop: 12 }}> {lifecycleReadiness.map((phase, i) => { const readinessColor = phase.readinessScore >= 70 ? 'var(--accent)' : phase.readinessScore >= 50 ? 'var(--warn)' : 'var(--danger)'; return ( <div key={phase.code || i} style={{ display: 'flex', alignItems: 'center', gap: 8, fontSize: 12 }}> <span style={{ fontWeight: 600, minWidth: 40, fontFamily: 'monospace', fontSize: 11 }}>{phase.code || `LC-${String(i + 1).padStart(2, '0')}`}</span> <span style={{ flex: 1 }}>{phase.name}</span> <span style={{ minWidth: 50, textAlign: 'right' }}>Readiness <strong style={{ color: readinessColor }}>{phase.readinessScore || 0}/100</strong></span> <span style={{ minWidth: 60, textAlign: 'right' }}>{phase.openRisks} open risks</span> <div className="bar-wrap" style={{ width: 80, flexShrink: 0 }}> <div className="bar-fill" style={{ width: `${Math.min(phase.readinessScore || 0, 100)}%`, background: readinessColor }} /> </div> </div> ); })} </div> </div> {/* Top Risks */} <div className="panel"> <h2>Top Risks</h2> <p className="muted" style={{ fontSize: 12, marginBottom: 12 }}>Highest residual risk exposure — Prioritized by current residual score after mitigation progress.</p> {topRisks.length === 0 ? ( <div className="empty-state"><p>No risks found</p></div> ) : ( <div className="table-wrap"> <table className="table"> <thead> <tr> <th>ID</th> <th>Risk</th> <th>Dimension</th> <th>Residual</th> <th>Level</th> <th>Owner</th> </tr> </thead> <tbody> {topRisks.map((risk, i) => { const levelCls = risk.residualSeverity === 'Critical' ? 'bad' : risk.residualSeverity === 'High' ? 'bad' : risk.residualSeverity === 'Moderate' ? 'warn' : 'good'; return ( <tr key={risk.id || i}> <td style={{ fontFamily: 'monospace', fontSize: 12 }}>{risk.id ? `R-${String(risk.id).slice(-4).toUpperCase()}` : `R-${String(i + 1).padStart(3, '0')}`}</td> <td><strong style={{ fontSize: 13 }}>{risk.title || risk.description || 'Untitled'}</strong></td> <td style={{ fontSize: 12 }}>{risk.dimension || '-'}</td> <td style={{ fontSize: 13, fontWeight: 600 }}>{risk.residualScore}/100</td> <td><span className={`metric-value ${levelCls}`} style={{ fontSize: 12, padding: '2px 6px' }}>{risk.residualSeverity || 'Unknown'}</span></td> <td style={{ fontSize: 12 }}>{risk.ownerDisplayName || 'Unassigned'}</td> </tr> ); })} </tbody> </table> </div> )} </div> </> ); } function RiskFormModal({ project, onClose, onSaved, toast }) { const [saving, setSaving] = useState(false); const [form, setForm] = useState({ title: '', description: '', dimension: '', domain: '', lifecyclePhase: '', probability: 50, impact: 50, detectability: 50, ownerDisplayName: '' }); const set = key => value => setForm(f => ({ ...f, [key]: value })); const handleSave = async () => { if (!form.title.trim()) { toast.add('Title is required', 'error'); return; } if (!form.dimension) { toast.add('Dimension is required', 'error'); return; } setSaving(true); try { await api.post(`/api/projects/${project.id}/risks`, form); toast.add('Risk created'); onSaved(); } catch (e) { toast.add(e.message || 'Failed to create risk', 'error'); } finally { setSaving(false); } }; return ( <div className="modal-overlay" onClick={onClose}> <div className="modal" onClick={e => e.stopPropagation()} style={{ maxWidth: 500 }}> <h2>New Risk</h2> <FormField label="Title *"><InlineInput value={form.title} onChange={set('title')} /></FormField> <FormField label="Description"><textarea className="input" value={form.description} onChange={e => set('description')(e.target.value)} rows={3} style={{ width: '100%', resize: 'vertical' }} /></FormField> <div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 8 }}> <FormField label="Dimension *"><InlineSelect value={form.dimension} onChange={set('dimension')} options={DIMENSIONS} /></FormField> <FormField label="Domain"><InlineInput value={form.domain} onChange={set('domain')} /></FormField> </div> <FormField label="Lifecycle Phase"><InlineSelect value={form.lifecyclePhase} onChange={set('lifecyclePhase')} options={LIFECYCLE_PHASES} /></FormField> <div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr 1fr', gap: 8 }}> <FormField label="Probability"><InlineInput type="number" min={0} max={100} value={form.probability} onChange={set('probability')} /></FormField> <FormField label="Impact"><InlineInput type="number" min={0} max={100} value={form.impact} onChange={set('impact')} /></FormField> <FormField label="Detectability"><InlineInput type="number" min={0} max={100} value={form.detectability} onChange={set('detectability')} /></FormField> </div> <FormField label="Owner"><InlineInput value={form.ownerDisplayName} onChange={set('ownerDisplayName')} /></FormField> <div className="modal-actions"> <button className="button secondary small" onClick={onClose}>Cancel</button> <button className="button small" onClick={handleSave} disabled={saving}>{saving ? 'Saving...' : 'Create Risk'}</button> </div> </div> </div> ); } function RisksTab({ project, dashboard, toast, onRefresh }) { const [search, setSearch] = useState(''); const [sortKey, setSortKey] = useState('normalizedScore'); const [sortDir, setSortDir] = useState('desc'); const [page, setPage] = useState(0); const [selectedRisk, setSelectedRisk] = useState(null); const [showCreate, setShowCreate] = useState(false); const [editing, setEditing] = useState(false); const [editForm, setEditForm] = useState({}); const [updating, setUpdating] = useState(null); const [confirmDelete, setConfirmDelete] = useState(null); const [deleting, setDeleting] = useState(false); const risks = useMemo(() => { if (!dashboard?.topRisks) return []; return sortData(dashboard.topRisks, sortKey, sortDir); }, [dashboard, sortKey, sortDir]); const filtered = useMemo(() => { if (!search) return risks; const q = search.toLowerCase(); return risks.filter(r => (r.title || '').toLowerCase().includes(q) || (r.dimension || '').toLowerCase().includes(q) || (r.ownerDisplayName || '').toLowerCase().includes(q)); }, [risks, search]); const paged = filtered.slice(0, (page + 1) * PAGE_SIZE); const handleSort = key => { if (sortKey === key) setSortDir(d => d === 'asc' ? 'desc' : 'asc'); else { setSortKey(key); setSortDir('asc'); } }; const openDetail = risk => { setSelectedRisk(risk); setEditForm({ title: risk.title || '', description: risk.description || '', dimension: risk.dimension || '', domain: risk.domain || '', lifecyclePhase: risk.lifecyclePhase || '', probability: risk.probability ?? 50, impact: risk.impact ?? 50, detectability: risk.detectability ?? 50, ownerDisplayName: risk.ownerDisplayName || '' }); setEditing(false); setConfirmDelete(null); }; const updateStatus = async (riskId, status) => { setUpdating(riskId); try { await api.patch(`/api/risks/${riskId}`, { status }); toast.add(`Risk status updated to ${status}`); setSelectedRisk(null); onRefresh(); } catch (e) { toast.add(e.message, 'error'); } finally { setUpdating(null); } }; const saveEdit = async () => { if (!editForm.title.trim()) { toast.add('Title is required', 'error'); return; } setUpdating('edit'); try { await api.patch(`/api/risks/${selectedRisk.id}`, editForm); toast.add('Risk updated'); setSelectedRisk(null); onRefresh(); } catch (e) { toast.add(e.message || 'Failed to update', 'error'); } finally { setUpdating(null); } }; const handleDelete = async () => { setDeleting(true); try { await api.del(`/api/risks/${confirmDelete.id}`); toast.add('Risk deleted'); setConfirmDelete(null); setSelectedRisk(null); onRefresh(); } catch (e) { toast.add(e.message || 'Failed to delete', 'error'); } finally { setDeleting(false); } }; if (!dashboard) return <div className="empty-state"><p>Loading...</p></div>; return ( <> <div style={{ display: 'flex', gap: 8, marginBottom: 12 }}> <SearchInput value={search} onChange={setSearch} placeholder="Search risks by title, dimension, owner..." /> <button className="button small" onClick={() => setShowCreate(true)}>+ New Risk</button> </div> <div className="table-wrap"> <table className="table"> <thead> <tr> {[{ key: 'id', label: 'ID' }, { key: 'title', label: 'Risk' }, { key: 'dimension', label: 'Dimension' }, { key: 'lifecyclePhase', label: 'Phase' }, { key: 'normalizedScore', label: 'Score' }, { key: 'residualScore', label: 'Residual' }, { key: 'status', label: 'Status' }, { key: 'ownerDisplayName', label: 'Owner' }].map(col => ( <th key={col.key} className="sortable" onClick={() => handleSort(col.key)}> {col.label} {sortKey === col.key ? (sortDir === 'asc' ? '▲' : '▼') : ''} </th> ))} </tr> </thead> <tbody> {paged.map(risk => ( <tr key={risk.id} onClick={() => openDetail(risk)}> <td style={{ fontFamily: 'monospace', fontSize: 12 }}>{risk.id?.slice(0, 8)}</td> <td><strong>{risk.title}</strong></td> <td><Badge className={risk.dimension?.toLowerCase()}>{risk.dimension}</Badge></td> <td style={{ fontSize: 12 }}>{risk.lifecyclePhase}</td> <td><strong>{risk.normalizedScore}</strong></td> <td><span style={{ color: risk.residualScore >= 50 ? 'var(--danger)' : risk.residualScore >= 25 ? 'var(--warn)' : 'var(--accent)' }}>{risk.residualScore}</span></td> <td><Badge className={risk.status?.toLowerCase().replace(/\s+/g, '-')}>{risk.status}</Badge></td> <td><span className="muted">{risk.ownerDisplayName || '-'}</span></td> </tr> ))} </tbody> </table> </div> {filtered.length > paged.length && ( <div style={{ textAlign: 'center', marginTop: 12 }}> <button className="button secondary small" onClick={() => setPage(p => p + 1)}>Show more ({filtered.length - paged.length} remaining)</button> </div> )} {!filtered.length && <div className="empty-state"><p>No risks match your search</p></div>} {showCreate && ( <RiskFormModal project={project} onClose={() => setShowCreate(false)} onSaved={() => { setShowCreate(false); onRefresh(); }} toast={toast} /> )} {selectedRisk && !confirmDelete && ( <div className="modal-overlay" onClick={() => setSelectedRisk(null)}> <div className="modal" onClick={e => e.stopPropagation()} style={{ maxWidth: 520 }}> <div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}> <h2 style={{ margin: 0 }}>{selectedRisk.title}</h2> <button className="button secondary small" onClick={() => setEditing(!editing)}>{editing ? 'View' : 'Edit'}</button> </div> {editing ? ( <> <div style={{ marginTop: 12 }}> <FormField label="Title *"><InlineInput value={editForm.title} onChange={v => setEditForm(f => ({ ...f, title: v }))} /></FormField> <FormField label="Description"><textarea className="input" value={editForm.description} onChange={e => setEditForm(f => ({ ...f, description: e.target.value }))} rows={2} style={{ width: '100%', resize: 'vertical' }} /></FormField> <div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 8 }}> <FormField label="Dimension"><InlineSelect value={editForm.dimension} onChange={v => setEditForm(f => ({ ...f, dimension: v }))} options={DIMENSIONS} /></FormField> <FormField label="Domain"><InlineInput value={editForm.domain} onChange={v => setEditForm(f => ({ ...f, domain: v }))} /></FormField> </div> <FormField label="Lifecycle Phase"><InlineSelect value={editForm.lifecyclePhase} onChange={v => setEditForm(f => ({ ...f, lifecyclePhase: v }))} options={LIFECYCLE_PHASES} /></FormField> <div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr 1fr', gap: 8 }}> <FormField label="Probability"><InlineInput type="number" min={0} max={100} value={editForm.probability} onChange={v => setEditForm(f => ({ ...f, probability: Number(v) }))} /></FormField> <FormField label="Impact"><InlineInput type="number" min={0} max={100} value={editForm.impact} onChange={v => setEditForm(f => ({ ...f, impact: Number(v) }))} /></FormField> <FormField label="Detectability"><InlineInput type="number" min={0} max={100} value={editForm.detectability} onChange={v => setEditForm(f => ({ ...f, detectability: Number(v) }))} /></FormField> </div> <FormField label="Owner"><InlineInput value={editForm.ownerDisplayName} onChange={v => setEditForm(f => ({ ...f, ownerDisplayName: v }))} /></FormField> </div> <div className="modal-actions"> <button className="button secondary small" onClick={() => setSelectedRisk(null)}>Cancel</button> <button className="button small" onClick={saveEdit} disabled={updating === 'edit'}>{updating === 'edit' ? 'Saving...' : 'Save Changes'}</button> <button className="button small" style={{ background: 'var(--danger)', color: '#fff', marginLeft: 'auto' }} onClick={() => setConfirmDelete(selectedRisk)}>Delete</button> </div> </> ) : ( <> <div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 12, fontSize: 13, marginTop: 12 }}> <div><span className="muted">Dimension</span><br/>{selectedRisk.dimension}</div> <div><span className="muted">Phase</span><br/>{selectedRisk.lifecyclePhase}</div> <div><span className="muted">Probability</span><br/>{selectedRisk.probability}</div> <div><span className="muted">Impact</span><br/>{selectedRisk.impact}</div> <div><span className="muted">Detectability</span><br/>{selectedRisk.detectability}</div> <div><span className="muted">Score</span><br/><strong>{selectedRisk.normalizedScore}</strong></div> <div><span className="muted">Residual</span><br/>{selectedRisk.residualScore}</div> <div><span className="muted">Owner</span><br/>{selectedRisk.ownerDisplayName || '-'}</div> </div> {selectedRisk.description && ( <div style={{ marginTop: 12, fontSize: 13 }}><span className="muted">Description</span><br/>{selectedRisk.description}</div> )} <div style={{ marginTop: 16 }}> <span className="muted" style={{ fontSize: 12 }}>Update status:</span> <div style={{ display: 'flex', gap: 6, marginTop: 6, flexWrap: 'wrap' }}> {RISK_STATUSES.map(s => ( <button key={s} className={`button small ${s === 'OPEN' ? 'secondary' : ''}`} onClick={() => updateStatus(selectedRisk.id, s)} disabled={updating === selectedRisk.id}> {s.replace('_', ' ')} </button> ))} </div> </div> <div className="modal-actions"> <button className="button secondary small" onClick={() => setSelectedRisk(null)}>Close</button> </div> </> )} </div> </div> )} {confirmDelete && ( <ConfirmDelete label="this risk" onConfirm={handleDelete} onCancel={() => setConfirmDelete(null)} deleting={deleting} /> )} </> ); } function MitigationFormModal({ project, risks, onClose, onSaved, toast }) { const [saving, setSaving] = useState(false); const [form, setForm] = useState({ riskId: '', title: '', description: '', status: 'NOT_STARTED', progressPercent: 0, effectivenessPercent: 0, dueDate: '', ownerDisplayName: '' }); const set = key => value => setForm(f => ({ ...f, [key]: value })); const handleSave = async () => { if (!form.title.trim()) { toast.add('Title is required', 'error'); return; } if (!form.riskId) { toast.add('Please select a risk', 'error'); return; } setSaving(true); try { const body = { ...form, progressPercent: Number(form.progressPercent), effectivenessPercent: Number(form.effectivenessPercent) }; if (body.dueDate) body.dueDate = new Date(body.dueDate).toISOString(); else delete body.dueDate; await api.post(`/api/risks/${form.riskId}/mitigations`, body); toast.add('Mitigation created'); onSaved(); } catch (e) { toast.add(e.message || 'Failed to create mitigation', 'error'); } finally { setSaving(false); } }; return ( <div className="modal-overlay" onClick={onClose}> <div className="modal" onClick={e => e.stopPropagation()} style={{ maxWidth: 500 }}> <h2>New Mitigation</h2> <FormField label="Risk *"><InlineSelect value={form.riskId} onChange={set('riskId')} options={(risks || []).map(r => ({ value: r.id, label: r.title }))} /></FormField> <FormField label="Title *"><InlineInput value={form.title} onChange={set('title')} /></FormField> <FormField label="Description"><textarea className="input" value={form.description} onChange={e => set('description')(e.target.value)} rows={2} style={{ width: '100%', resize: 'vertical' }} /></FormField> <FormField label="Status"><InlineSelect value={form.status} onChange={set('status')} options={MITIGATION_STATUSES} /></FormField> <div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 8 }}> <FormField label="Progress %"><InlineInput type="number" min={0} max={100} value={form.progressPercent} onChange={set('progressPercent')} /></FormField> <FormField label="Effectiveness %"><InlineInput type="number" min={0} max={100} value={form.effectivenessPercent} onChange={set('effectivenessPercent')} /></FormField> </div> <FormField label="Due Date"><InlineInput type="date" value={form.dueDate} onChange={set('dueDate')} /></FormField> <FormField label="Owner"><InlineInput value={form.ownerDisplayName} onChange={set('ownerDisplayName')} /></FormField> <div className="modal-actions"> <button className="button secondary small" onClick={onClose}>Cancel</button> <button className="button small" onClick={handleSave} disabled={saving}>{saving ? 'Saving...' : 'Create Mitigation'}</button> </div> </div> </div> ); } function MitigationsTab({ project, dashboard, toast, onRefresh }) { const [search, setSearch] = useState(''); const [sortKey, setSortKey] = useState('progressPercent'); const [sortDir, setSortDir] = useState('asc'); const [showCreate, setShowCreate] = useState(false); const [selectedMitigation, setSelectedMitigation] = useState(null); const [editing, setEditing] = useState(false); const [editForm, setEditForm] = useState({}); const [updating, setUpdating] = useState(null); const [confirmDelete, setConfirmDelete] = useState(null); const [deleting, setDeleting] = useState(false); const mitigations = useMemo(() => { if (!dashboard?.mitigations) return []; return sortData(dashboard.mitigations, sortKey, sortDir); }, [dashboard, sortKey, sortDir]); const risks = useMemo(() => { if (!dashboard?.topRisks) return []; return dashboard.topRisks; }, [dashboard]); const filtered = useMemo(() => { if (!search) return mitigations; const q = search.toLowerCase(); return mitigations.filter(m => (m.title || '').toLowerCase().includes(q) || (m.riskTitle || '').toLowerCase().includes(q)); }, [mitigations, search]); const handleSort = key => { if (sortKey === key) setSortDir(d => d === 'asc' ? 'desc' : 'asc'); else { setSortKey(key); setSortDir('asc'); } }; const openDetail = m => { setSelectedMitigation(m); setEditForm({ title: m.title || '', description: m.description || '', status: m.status || 'NOT_STARTED', progressPercent: m.progressPercent ?? 0, effectivenessPercent: m.effectivenessPercent ?? 0, dueDate: m.dueDate ? m.dueDate.slice(0, 10) : '', ownerDisplayName: m.ownerDisplayName || '' }); setEditing(true); setConfirmDelete(null); }; const saveEdit = async () => { if (!editForm.title.trim()) { toast.add('Title is required', 'error'); return; } setUpdating('edit'); try { const body = { ...editForm, progressPercent: Number(editForm.progressPercent), effectivenessPercent: Number(editForm.effectivenessPercent) }; if (body.dueDate) body.dueDate = new Date(body.dueDate).toISOString(); else body.dueDate = null; await api.patch(`/api/mitigations/${selectedMitigation.id}`, body); toast.add('Mitigation updated'); setSelectedMitigation(null); onRefresh(); } catch (e) { toast.add(e.message || 'Failed to update', 'error'); } finally { setUpdating(null); } }; const handleDelete = async () => { setDeleting(true); try { await api.del(`/api/mitigations/${confirmDelete.id}`); toast.add('Mitigation deleted'); setConfirmDelete(null); setSelectedMitigation(null); onRefresh(); } catch (e) { toast.add(e.message || 'Failed to delete', 'error'); } finally { setDeleting(false); } }; if (!dashboard) return <div className="empty-state"><p>Loading...</p></div>; return ( <> <div style={{ display: 'flex', gap: 8, marginBottom: 12 }}> <SearchInput value={search} onChange={setSearch} placeholder="Search mitigations..." /> <button className="button small" onClick={() => setShowCreate(true)}>+ New Mitigation</button> </div> <div className="table-wrap"> <table className="table"> <thead> <tr> {[{ key: 'title', label: 'Title' }, { key: 'riskTitle', label: 'Risk' }, { key: 'status', label: 'Status' }, { key: 'progressPercent', label: 'Progress' }, { key: 'effectivenessPercent', label: 'Effectiveness' }, { key: 'ownerDisplayName', label: 'Owner' }].map(col => ( <th key={col.key} className="sortable" onClick={() => handleSort(col.key)}> {col.label} {sortKey === col.key ? (sortDir === 'asc' ? '▲' : '▼') : ''} </th> ))} </tr> </thead> <tbody> {filtered.slice(0, 50).map(m => ( <tr key={m.id} onClick={() => openDetail(m)} style={{ cursor: 'pointer' }}> <td><strong>{m.title}</strong></td> <td><span className="muted">{m.riskTitle}</span></td> <td><Badge className={m.status?.toLowerCase().replace(/\s+/g, '-')}>{m.status}</Badge></td> <td> <div style={{ display: 'flex', alignItems: 'center', gap: 8 }}> <div className="bar-wrap" style={{ flex: 1, maxWidth: 80 }}> <div className="bar-fill" style={{ width: `${m.progressPercent || 0}%`, background: (m.progressPercent || 0) >= 80 ? 'var(--accent)' : (m.progressPercent || 0) >= 30 ? 'var(--warn)' : 'var(--danger)' }} /> </div> {m.progressPercent || 0}% </div> </td> <td>{m.effectivenessPercent || 0}%</td> <td><span className="muted">{m.ownerDisplayName || '-'}</span></td> </tr> ))} </tbody> </table> </div> {!filtered.length && <div className="empty-state"><p>No mitigations found</p></div>} {showCreate && ( <MitigationFormModal project={project} risks={risks} onClose={() => setShowCreate(false)} onSaved={() => { setShowCreate(false); onRefresh(); }} toast={toast} /> )} {selectedMitigation && !confirmDelete && ( <div className="modal-overlay" onClick={() => setSelectedMitigation(null)}> <div className="modal" onClick={e => e.stopPropagation()} style={{ maxWidth: 500 }}> <h2 style={{ margin: 0 }}>{editing ? 'Edit Mitigation' : selectedMitigation.title}</h2> <div style={{ marginTop: 12 }}> <FormField label="Title *"><InlineInput value={editForm.title} onChange={v => setEditForm(f => ({ ...f, title: v }))} /></FormField> <FormField label="Description"><textarea className="input" value={editForm.description} onChange={e => setEditForm(f => ({ ...f, description: e.target.value }))} rows={2} style={{ width: '100%', resize: 'vertical' }} /></FormField> <FormField label="Status"><InlineSelect value={editForm.status} onChange={v => setEditForm(f => ({ ...f, status: v }))} options={MITIGATION_STATUSES} /></FormField> <div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 8 }}> <FormField label="Progress %"><InlineInput type="number" min={0} max={100} value={editForm.progressPercent} onChange={v => setEditForm(f => ({ ...f, progressPercent: Number(v) }))} /></FormField> <FormField label="Effectiveness %"><InlineInput type="number" min={0} max={100} value={editForm.effectivenessPercent} onChange={v => setEditForm(f => ({ ...f, effectivenessPercent: Number(v) }))} /></FormField> </div> <FormField label="Due Date"><InlineInput type="date" value={editForm.dueDate} onChange={v => setEditForm(f => ({ ...f, dueDate: v }))} /></FormField> <FormField label="Owner"><InlineInput value={editForm.ownerDisplayName} onChange={v => setEditForm(f => ({ ...f, ownerDisplayName: v }))} /></FormField> </div> <div className="modal-actions"> <button className="button secondary small" onClick={() => setSelectedMitigation(null)}>Cancel</button> <button className="button small" onClick={saveEdit} disabled={updating === 'edit'}>{updating === 'edit' ? 'Saving...' : 'Save Changes'}</button> <button className="button small" style={{ background: 'var(--danger)', color: '#fff', marginLeft: 'auto' }} onClick={() => setConfirmDelete(selectedMitigation)}>Delete</button> </div> </div> </div> )} {confirmDelete && ( <ConfirmDelete label="this mitigation" onConfirm={handleDelete} onCancel={() => setConfirmDelete(null)} deleting={deleting} /> )} </> ); } function GateTab({ project, dashboard, toast, onRefresh }) { const [evaluating, setEvaluating] = useState(false); const [showDecision, setShowDecision] = useState(false); const [decisionForm, setDecisionForm] = useState({ decision: 'APPROVED', reviewerName: '', reason: '' }); const [savingDecision, setSavingDecision] = useState(false); const criteria = dashboard?.gate?.criteria || []; const gateStatus = dashboard?.summary?.gateStatus || 'Unknown'; const gateId = dashboard?.gate?.id; const gateCls = gateStatus === 'Ready' ? 'good' : gateStatus === 'Warning' ? 'warn' : 'bad'; const handleEvaluate = async () => { setEvaluating(true); try { await api.post(`/api/projects/${project.id}/gate/evaluate`, {}); toast.add('Gate evaluated'); onRefresh(); } catch (e) { toast.add(e.message || 'Evaluation failed', 'error'); } finally { setEvaluating(false); } }; const submitDecision = async () => { if (!decisionForm.reviewerName.trim()) { toast.add('Reviewer name is required', 'error'); return; } setSavingDecision(true); try { await api.post(`/api/gates/${gateId}/decisions`, decisionForm); toast.add('Decision submitted'); setShowDecision(false); onRefresh(); } catch (e) { toast.add(e.message || 'Failed to submit decision', 'error'); } finally { setSavingDecision(false); } }; if (!dashboard) return <div className="empty-state"><p>Loading...</p></div>; return ( <> <div className="dash-header"> <div> <h1>Deployment Gate</h1> <p className="muted">Gate evaluation for AI deployment readiness</p> </div> <div className="dash-actions"> <div className={`metric-value ${gateCls}`} style={{ fontSize: 28 }}>{gateStatus}</div> </div> </div> <div className="table-wrap"> <table className="table"> <thead> <tr> <th>Criterion</th> <th>Required</th> <th>Actual</th> <th>Status</th> </tr> </thead> <tbody> {criteria.map(c => ( <tr key={c.name}> <td><strong>{c.name}</strong></td> <td>{c.required}</td> <td>{c.actual}</td> <td><Badge className={c.status?.toLowerCase()}>{c.status}</Badge></td> </tr> ))} </tbody> </table> </div> {!criteria.length && <div className="empty-state"><p>No gate criteria evaluated yet</p></div>} <div style={{ marginTop: 16, display: 'flex', gap: 8, flexWrap: 'wrap' }}> <button className="button small" onClick={handleEvaluate} disabled={evaluating}>{evaluating ? 'Evaluating...' : 'Evaluate Gate'}</button> {gateId && <button className="button secondary small" onClick={() => setShowDecision(true)}>Add Decision</button>} <a href={`/api/projects/${project?.id}/gate/history`} className="button secondary small" target="_blank">History (API)</a> </div> {showDecision && ( <div className="modal-overlay" onClick={() => setShowDecision(false)}> <div className="modal" onClick={e => e.stopPropagation()} style={{ maxWidth: 400 }}> <h2>Gate Decision</h2> <FormField label="Decision"> <InlineSelect value={decisionForm.decision} onChange={v => setDecisionForm(f => ({ ...f, decision: v }))} options={['APPROVED', 'REJECTED', 'ACCEPTED', 'NEEDS_CHANGES']} /> </FormField> <FormField label="Reviewer Name *"><InlineInput value={decisionForm.reviewerName} onChange={v => setDecisionForm(f => ({ ...f, reviewerName: v }))} /></FormField> <FormField label="Reason"><textarea className="input" value={decisionForm.reason} onChange={e => setDecisionForm(f => ({ ...f, reason: e.target.value }))} rows={3} style={{ width: '100%', resize: 'vertical' }} /></FormField> <div className="modal-actions"> <button className="button secondary small" onClick={() => setShowDecision(false)}>Cancel</button> <button className="button small" onClick={submitDecision} disabled={savingDecision}>{savingDecision ? 'Saving...' : 'Submit Decision'}</button> </div> </div> </div> )} </> ); } function credentialExample(provider) { if (provider === 'TRELLO') return '{\n "apiKey": "...",\n "token": "...",\n "listId": "trello-list-id"\n}'; if (provider === 'JIRA') return '{\n "apiEmail": "you@example.com",\n "apiToken": "...",\n "baseUrl": "https://your-domain.atlassian.net",\n "projectKey": "COS"\n}'; if (provider === 'ASANA') return '{\n "accessToken": "...",\n "projectGid": "asana-project-gid"\n}'; return '{\n "accessToken": "...",\n "planId": "planner-plan-id",\n "bucketId": "planner-bucket-id"\n}'; } function IntegrationFormModal({ project, dashboard, onClose, onSaved, toast }) { const [saving, setSaving] = useState(false); const [mode, setMode] = useState('simulated'); const [form, setForm] = useState({ provider: 'TRELLO', workspaceName: '', externalProjectKey: '', baseUrl: '', authMode: 'API_KEY', credentialsText: '' }); const set = key => value => setForm(f => ({ ...f, [key]: value })); const wsId = dashboard?.workspace?.id || project?.workspaceId; const handleSave = async () => { if (!form.workspaceName.trim()) { toast.add('Workspace name is required', 'error'); return; } let credentials = undefined; if (mode === 'live') { if (!form.credentialsText.trim()) { toast.add('Credentials JSON is required for live mode', 'error'); return; } try { credentials = JSON.parse(form.credentialsText); } catch (_error) { toast.add('Credential JSON is invalid', 'error'); return; } } setSaving(true); try { await api.post(`/api/workspaces/${wsId}/integrations`, { provider: form.provider, workspaceName: form.workspaceName, externalProjectKey: form.externalProjectKey, baseUrl: form.baseUrl, authMode: form.authMode, syncDirection: 'COSMIC to PM', liveEnabled: mode === 'live', liveConfig: form.provider === 'MICROSOFT_PLANNER' && credentials?.bucketId ? { bucketId: credentials.bucketId } : {}, credentials }); toast.add(mode === 'live' ? 'Live integration created' : 'Simulated integration created'); onSaved(); } catch (e) { toast.add(e.message || 'Failed to create integration', 'error'); } finally { setSaving(false); } }; return ( <div className="modal-overlay" onClick={onClose}> <div className="modal" onClick={e => e.stopPropagation()} style={{ maxWidth: 640 }}> <h2>New Integration</h2> <div style={{ marginBottom: 14 }}> <label style={{ display: 'block', fontSize: 12, color: 'var(--muted)', marginBottom: 4 }}>Mode</label> <div style={{ display: 'flex', gap: 8 }}> <button className={`button ${mode === 'simulated' ? '' : 'secondary'} small`} onClick={() => setMode('simulated')}>Simulated (mapping only)</button> <button className={`button ${mode === 'live' ? '' : 'secondary'} small`} onClick={() => setMode('live')}>Live (real API calls)</button> </div> </div> <FormField label="Provider"> <InlineSelect value={form.provider} onChange={set('provider')} options={['TRELLO', 'JIRA', 'ASANA', 'MICROSOFT_PLANNER']} /> </FormField> <FormField label="Workspace Name *"><InlineInput value={form.workspaceName} onChange={set('workspaceName')} /></FormField> <FormField label="External project/list/plan ID"><InlineInput value={form.externalProjectKey} onChange={set('externalProjectKey')} /></FormField> {mode === 'live' && ( <> <FormField label="Base URL"><InlineInput value={form.baseUrl} onChange={set('baseUrl')} /></FormField> <FormField label="Auth Mode"> <InlineSelect value={form.authMode} onChange={set('authMode')} options={['API_KEY', 'OAUTH', 'BASIC']} /> </FormField> <FormField label="Credentials JSON"> <textarea value={form.credentialsText} onChange={e => set('credentialsText')(e.target.value)} placeholder={credentialExample(form.provider)} rows={8} style={{ width: '100%', border: '1px solid var(--border)', borderRadius: 8, padding: 10, fontFamily: 'monospace', fontSize: 12 }} /> </FormField> <p className="muted" style={{ fontSize: 12 }}>Credentials are encrypted via AES-256-GCM and stored in OAuthToken records.</p> </> )} {mode === 'simulated' && ( <p className="muted" style={{ fontSize: 12, marginTop: 4 }}>Simulated mode maps COSMIC risks to external work item records. No real API calls are made.</p> )} <div className="modal-actions"> <button className="button secondary small" onClick={onClose}>Cancel</button> <button className="button small" onClick={handleSave} disabled={saving}>{saving ? 'Saving...' : 'Create Integration'}</button> </div> </div> </div> ); } function IntegrationsTab({ project, dashboard, toast, onRefresh }) { const [showCreate, setShowCreate] = useState(false); const [busyId, setBusyId] = useState(null); const integrations = dashboard?.integrations || []; const risks = dashboard?.risks || []; const hasRisks = risks.length > 0; const runAction = async (integration, action) => { setBusyId(`${integration.id}:${action}`); try { const path = action === 'sync' ? `/api/integrations/${integration.id}/sync` : action === 'live-test' ? `/api/integrations/${integration.id}/live/test` : `/api/integrations/${integration.id}/live/sync`; const result = await api.post(path, {}); const summary = result?.syncRun?.summary || result?.account || `${action} completed`; toast.add(summary); onRefresh(); } catch (e) { toast.add(e.message || `${action} failed`, 'error'); } finally { setBusyId(null); } }; const stepClass = done => done ? { color: 'var(--green)', fontWeight: 700 } : { color: 'var(--muted)' }; return ( <> <div className="dash-header"> <div> <h1>Integrations</h1> <p className="muted">Connect COSMIC risks to Trello, Jira, Asana, or Microsoft Planner</p> </div> <div className="dash-actions"> <button className="button small" onClick={() => setShowCreate(true)}>+ New Integration</button> </div> </div> <div className="panel" style={{ padding: 16, marginBottom: 16 }}> <h3 style={{ margin: '0 0 10px', fontSize: 13, color: 'var(--muted)' }}>WORKFLOW</h3> <div style={{ display: 'flex', gap: 0, alignItems: 'flex-start' }}> {[ { label: '1. Create Project', done: !!project, detail: project?.name || '' }, { label: '2. Assess Risks', done: hasRisks, detail: hasRisks ? `${risks.length} risk${risks.length !== 1 ? 's' : ''}` : 'No risks yet' }, { label: '3. Configure Integration', done: integrations.length > 0, detail: integrations.length > 0 ? integrations.map(i => i.provider.replace('_', ' ')).join(', ') : 'Not configured' }, { label: '4. Sync Risks', done: integrations.some(i => (i.mappings || []).length > 0 || (i.syncRuns || []).length > 0), detail: integrations.some(i => (i.syncRuns || []).length > 0) ? 'Sync completed' : 'Not synced' } ].map((step, i) => ( <div key={step.label} style={{ flex: 1, textAlign: 'center', position: 'relative', padding: '0 8px' }}> <div style={{ width: 32, height: 32, borderRadius: '50%', background: step.done ? 'var(--green)' : 'var(--line)', color: step.done ? '#fff' : 'var(--muted)', display: 'flex', alignItems: 'center', justifyContent: 'center', fontSize: 14, fontWeight: 700, margin: '0 auto 6px' }}>{step.done ? '✓' : i + 1}</div> <div style={{ fontSize: 11, fontWeight: 600, ...stepClass(step.done) }}>{step.label}</div> <div style={{ fontSize: 10, color: 'var(--muted)', marginTop: 2, maxWidth: 140, margin: '2px auto 0', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{step.detail}</div> {i < 3 && <div style={{ position: 'absolute', top: 16, right: -8, color: 'var(--line)', fontSize: 10 }}>→</div>} </div> ))} </div> </div> <div className="grid" style={{ gridTemplateColumns: 'repeat(auto-fit, minmax(260px, 1fr))' }}> {['TRELLO', 'JIRA', 'ASANA', 'MICROSOFT_PLANNER'].map(provider => { const existing = integrations.find(i => i.provider === provider); const hasMappings = existing && (existing.mappings || []).length > 0; const lastSync = existing?.syncRuns?.[0]; return ( <div key={provider} className="panel" style={{ textAlign: 'center' }}> <div style={{ fontSize: 32, marginBottom: 8 }}> {provider === 'TRELLO' ? 'T' : provider === 'JIRA' ? 'J' : provider === 'ASANA' ? 'A' : 'P'} </div> <h3 style={{ margin: '0 0 4px' }}>{provider.replace('_', ' ')}</h3> <p className="muted" style={{ fontSize: 12, margin: '0 0 6px' }}> {existing ? existing.workspaceName : 'Not configured'} </p> {existing && ( <div style={{ fontSize: 11, margin: '0 0 6px' }}> <span className={`badge ${existing.liveEnabled ? 'badge-green' : ''}`}> {existing.liveEnabled ? 'Live API' : 'Simulated Mapping'} </span> {hasMappings && <span className="badge badge-green" style={{ marginLeft: 4 }}>Synced</span>} </div> )} {lastSync && <p className="muted" style={{ fontSize: 10, margin: '0 0 10px' }}>Last: {new Date(lastSync.createdAt).toLocaleDateString()}</p>} <div style={{ display: 'flex', gap: 6, justifyContent: 'center', flexWrap: 'wrap' }}> {existing ? ( <> <a href={`/integrations/${existing.id}`} className="button secondary small">Manage</a> {existing.liveEnabled ? ( <> <button className="button secondary small" onClick={() => runAction(existing, 'live-test')} disabled={busyId === `${existing.id}:live-test`}>Test Live</button> <button className="button small" onClick={() => runAction(existing, 'live-sync')} disabled={busyId === `${existing.id}:live-sync`}>Live Sync</button> </> ) : ( <button className="button small" onClick={() => runAction(existing, 'sync')} disabled={busyId === `${existing.id}:sync`}>Sync (Simulated)</button> )} </> ) : ( <button className="button secondary small" onClick={() => setShowCreate(true)}>Configure</button> )} </div> </div> ); })} </div> {showCreate && ( <IntegrationFormModal project={project} dashboard={dashboard} onClose={() => setShowCreate(false)} onSaved={() => { setShowCreate(false); onRefresh(); }} toast={toast} /> )} {!integrations.length && ( <div style={{ marginTop: 16, textAlign: 'center' }}> <p className="muted" style={{ fontSize: 13 }}>No integrations configured yet. Click "+ New Integration" to get started.</p> </div> )} </> ); } function AuditTab({ project, toast }) { const [events, setEvents] = useState([]); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); const load = useCallback(async () => { if (!project) return; setLoading(true); setError(null); try { const resp = await api.get(`/api/projects/${project.id}/audit`); setEvents(resp.auditEvents || []); } catch (e) { setError(e.message || 'Failed to load audit log'); } finally { setLoading(false); } }, [project]); useEffect(() => { load(); }, [load]); if (loading) return <div className="empty-state"><p>Loading audit log...</p></div>; if (error) return <div className="empty-state"><p>{error}</p></div>; if (!events.length) return <div className="empty-state"><p>No audit events recorded yet</p></div>; return ( <> <div className="dash-header"> <div> <h1>Audit Log</h1> <p className="muted">Project activity history</p> </div> <div className="dash-actions"> <button className="button secondary small" onClick={load}>Refresh</button> </div> </div> <div className="table-wrap"> <table className="table"> <thead> <tr> <th>Date</th> <th>Action</th> <th>Entity</th> <th>Actor</th> <th>Details</th> </tr> </thead> <tbody> {events.map(e => ( <tr key={e.id}> <td style={{ fontSize: 12, whiteSpace: 'nowrap' }}>{new Date(e.createdAt).toLocaleString()}</td> <td><Badge className={e.action?.toLowerCase().replace(/\s+/g, '-')}>{e.action}</Badge></td> <td style={{ fontSize: 12 }}>{e.entityType} <span className="muted">({e.entityId?.slice(0, 8)})</span></td> <td style={{ fontSize: 12 }}>{e.actorUserId || 'system'}</td> <td style={{ fontSize: 12, maxWidth: 300, overflow: 'hidden', textOverflow: 'ellipsis' }}> {e.afterJson ? JSON.stringify(e.afterJson).slice(0, 80) : '-'} </td> </tr> ))} </tbody> </table> </div> </> ); } function ProjectsTab({ projects, workspaceId, currentProjectId, onSwitch, onRefresh, toast }) { const [deleting, setDeleting] = useState(null); const [confirmDelete, setConfirmDelete] = useState(null); const [showNew, setShowNew] = useState(false); const handleDelete = async () => { setDeleting(true); try { await api.del(`/api/projects/${confirmDelete.id}`); toast.add(`Project "${confirmDelete.name}" deleted`); setConfirmDelete(null); onRefresh(); } catch (e) { toast.add(e.message || 'Failed to delete project', 'error'); } finally { setDeleting(false); } }; return ( <> <div className="dash-header"> <div> <h1>All Projects</h1> <p className="muted">{projects.length} project(s) in workspace</p> </div> <div className="dash-actions"> <button className="button small" onClick={() => setShowNew(true)}>+ New Project</button> </div> </div> <div className="table-wrap"> <table className="table"> <thead> <tr> <th>Name</th> <th>Status</th> <th>Type</th> <th>Created</th> <th>Actions</th> </tr> </thead> <tbody> {[...projects].sort((a, b) => new Date(b.createdAt) - new Date(a.createdAt)).map(p => ( <tr key={p.id} style={{ background: p.id === currentProjectId ? 'var(--surface-alt)' : '' }}> <td> <strong>{p.name}</strong> {p.id === currentProjectId ? <span className="muted" style={{ fontSize: 11, marginLeft: 6 }}>(current)</span> : ''} {p.description ? <div className="muted" style={{ fontSize: 11 }}>{p.description}</div> : ''} </td> <td><Badge className={p.status?.toLowerCase()}>{p.status}</Badge></td> <td style={{ fontSize: 12 }}>{p.projectType}</td> <td style={{ fontSize: 12 }}>{new Date(p.createdAt).toLocaleDateString()}</td> <td style={{ textAlign: 'right', whiteSpace: 'nowrap' }}> {p.id !== currentProjectId && ( <button className="button secondary small" style={{ marginRight: 6 }} onClick={() => onSwitch(p.id)}>Open</button> )} <button className="button small" style={{ background: 'var(--danger)', color: '#fff' }} onClick={() => setConfirmDelete(p)} disabled={deleting === p.id}> Delete </button> </td> </tr> ))} </tbody> </table> </div> {!projects.length && <div className="empty-state"><p>No projects yet. Create one to get started.</p></div>} {showNew && ( <NewProjectModal workspaceId={workspaceId} onClose={() => setShowNew(false)} onSaved={() => { setShowNew(false); onRefresh(); }} toast={toast} /> )} {confirmDelete && ( <div className="modal-overlay" onClick={() => setConfirmDelete(null)}> <div className="modal" onClick={e => e.stopPropagation()} style={{ maxWidth: 380 }}> <h3>Delete "{confirmDelete.name}"?</h3> <p style={{ fontSize: 13, color: 'var(--muted)' }}>This permanently deletes the project and all its risks, mitigations, and data.</p> <div className="modal-actions"> <button className="button secondary small" onClick={() => setConfirmDelete(null)}>Cancel</button> <button className="button small" style={{ background: 'var(--danger)', color: '#fff' }} onClick={handleDelete} disabled={deleting}> {deleting ? 'Deleting...' : 'Delete Project'} </button> </div> </div> </div> )} </> ); } // ---- Indicators Tab ---- function IndicatorsTab({ project, dashboard, toast, onRefresh }) { const [search, setSearch] = useState(''); const [sortKey, setSortKey] = useState('name'); const [sortDir, setSortDir] = useState('asc'); const [showCreate, setShowCreate] = useState(false); const [selected, setSelected] = useState(null); const [editing, setEditing] = useState(false); const [editForm, setEditForm] = useState({}); const [updating, setUpdating] = useState(null); const [confirmDelete, setConfirmDelete] = useState(null); const [deleting, setDeleting] = useState(false); const indicators = useMemo(() => { if (!dashboard?.indicators) return []; return sortData(dashboard.indicators, sortKey, sortDir); }, [dashboard, sortKey, sortDir]); const filtered = useMemo(() => { if (!search) return indicators; const q = search.toLowerCase(); return indicators.filter(i => (i.name || '').toLowerCase().includes(q) || (i.dimension || '').toLowerCase().includes(q)); }, [indicators, search]); const handleSort = key => { if (sortKey === key) setSortDir(d => d === 'asc' ? 'desc' : 'asc'); else { setSortKey(key); setSortDir('asc'); } }; const openDetail = item => { setSelected(item); setEditForm({ name: item.name || '', dimension: item.dimension || '', measurand: item.measurand || '', unit: item.unit || '', target: item.target || '', interpretationRule: item.interpretationRule || '' }); setEditing(false); setConfirmDelete(null); }; const saveEdit = async () => { if (!editForm.name.trim()) { toast.add('Name is required', 'error'); return; } if (!editForm.dimension) { toast.add('Dimension is required', 'error'); return; } setUpdating('edit'); try { await api.patch(`/api/projects/${project.id}/indicators/${selected.id}`, editForm); toast.add('Indicator updated'); setSelected(null); onRefresh(); } catch (e) { toast.add(e.message || 'Failed to update', 'error'); } finally { setUpdating(null); } }; const handleDelete = async () => { setDeleting(true); try { await api.del(`/api/projects/${project.id}/indicators/${confirmDelete.id}`); toast.add('Indicator deleted'); setConfirmDelete(null); setSelected(null); onRefresh(); } catch (e) { toast.add(e.message || 'Failed to delete', 'error'); } finally { setDeleting(false); } }; if (!dashboard) return <div className="empty-state"><p>Loading...</p></div>; return ( <> <div style={{ display: 'flex', gap: 8, marginBottom: 12 }}> <SearchInput value={search} onChange={setSearch} placeholder="Search indicators..." /> <button className="button small" onClick={() => setShowCreate(true)}>+ New Indicator</button> </div> <div className="table-wrap"> <table className="table"> <thead> <tr> {[{ key: 'name', label: 'Name' }, { key: 'dimension', label: 'Dimension' }, { key: 'measurand', label: 'Measurand' }, { key: 'unit', label: 'Unit' }, { key: 'target', label: 'Target' }].map(col => ( <th key={col.key} className="sortable" onClick={() => handleSort(col.key)}> {col.label} {sortKey === col.key ? (sortDir === 'asc' ? '▲' : '▼') : ''} </th> ))} </tr> </thead> <tbody> {filtered.map(item => ( <tr key={item.id} onClick={() => openDetail(item)} style={{ cursor: 'pointer' }}> <td><strong>{item.name}</strong></td> <td><Badge className={item.dimension?.toLowerCase()}>{item.dimension}</Badge></td> <td>{item.measurand || '-'}</td> <td>{item.unit || '-'}</td> <td><span className="muted">{item.target || '-'}</span></td> </tr> ))} </tbody> </table> </div> {!filtered.length && <div className="empty-state"><p>No indicators defined. Create one to track AI risk metrics.</p></div>} {showCreate && ( <IndicatorFormModal project={project} onClose={() => setShowCreate(false)} onSaved={() => { setShowCreate(false); onRefresh(); }} toast={toast} /> )} {selected && !confirmDelete && ( <div className="modal-overlay" onClick={() => setSelected(null)}> <div className="modal" onClick={e => e.stopPropagation()} style={{ maxWidth: 480 }}> <div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}> <h2 style={{ margin: 0 }}>{selected.name}</h2> <button className="button secondary small" onClick={() => setEditing(!editing)}>{editing ? 'View' : 'Edit'}</button> </div> {editing ? ( <> <div style={{ marginTop: 12 }}> <FormField label="Name *"><InlineInput value={editForm.name} onChange={v => setEditForm(f => ({ ...f, name: v }))} /></FormField> <FormField label="Dimension *"><InlineSelect value={editForm.dimension} onChange={v => setEditForm(f => ({ ...f, dimension: v }))} options={DIMENSIONS} /></FormField> <div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 8 }}> <FormField label="Measurand"><InlineInput value={editForm.measurand} onChange={v => setEditForm(f => ({ ...f, measurand: v }))} /></FormField> <FormField label="Unit"><InlineInput value={editForm.unit} onChange={v => setEditForm(f => ({ ...f, unit: v }))} /></FormField> </div> <FormField label="Target"><textarea className="input" value={editForm.target} onChange={e => setEditForm(f => ({ ...f, target: e.target.value }))} rows={2} style={{ width: '100%', resize: 'vertical' }} /></FormField> <FormField label="Interpretation Rule"><textarea className="input" value={editForm.interpretationRule} onChange={e => setEditForm(f => ({ ...f, interpretationRule: e.target.value }))} rows={2} style={{ width: '100%', resize: 'vertical' }} /></FormField> </div> <div className="modal-actions"> <button className="button secondary small" onClick={() => setSelected(null)}>Cancel</button> <button className="button small" onClick={saveEdit} disabled={updating === 'edit'}>{updating === 'edit' ? 'Saving...' : 'Save Changes'}</button> <button className="button small" style={{ background: 'var(--danger)', color: '#fff', marginLeft: 'auto' }} onClick={() => setConfirmDelete(selected)}>Delete</button> </div> </> ) : ( <> <div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 12, fontSize: 13, marginTop: 12 }}> <div><span className="muted">Dimension</span><br/>{selected.dimension}</div> <div><span className="muted">Measurand</span><br/>{selected.measurand || '-'}</div> <div><span className="muted">Unit</span><br/>{selected.unit || '-'}</div> <div><span className="muted">Target</span><br/>{selected.target || '-'}</div> </div> {selected.interpretationRule && <div style={{ marginTop: 12, fontSize: 13 }}><span className="muted">Interpretation Rule</span><br/>{selected.interpretationRule}</div>} <div className="modal-actions"> <button className="button secondary small" onClick={() => setSelected(null)}>Close</button> </div> </> )} </div> </div> )} {confirmDelete && <ConfirmDelete label="this indicator" onConfirm={handleDelete} onCancel={() => setConfirmDelete(null)} deleting={deleting} />} </> ); } function IndicatorFormModal({ project, onClose, onSaved, toast }) { const [saving, setSaving] = useState(false); const [form, setForm] = useState({ name: '', dimension: '', measurand: '', unit: '', target: '', interpretationRule: '' }); const set = key => value => setForm(f => ({ ...f, [key]: value })); const handleSave = async () => { if (!form.name.trim()) { toast.add('Name is required', 'error'); return; } if (!form.dimension) { toast.add('Dimension is required', 'error'); return; } setSaving(true); try { await api.post(`/api/projects/${project.id}/indicators`, form); toast.add('Indicator created'); onSaved(); } catch (e) { toast.add(e.message || 'Failed to create indicator', 'error'); } finally { setSaving(false); } }; return ( <div className="modal-overlay" onClick={onClose}> <div className="modal" onClick={e => e.stopPropagation()} style={{ maxWidth: 480 }}> <h2>New Indicator</h2> <FormField label="Name *"><InlineInput value={form.name} onChange={set('name')} /></FormField> <FormField label="Dimension *"><InlineSelect value={form.dimension} onChange={set('dimension')} options={DIMENSIONS} /></FormField> <div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 8 }}> <FormField label="Measurand"><InlineInput value={form.measurand} onChange={set('measurand')} /></FormField> <FormField label="Unit"><InlineInput value={form.unit} onChange={set('unit')} /></FormField> </div> <FormField label="Target"><textarea className="input" value={form.target} onChange={e => set('target')(e.target.value)} rows={2} style={{ width: '100%', resize: 'vertical' }} /></FormField> <FormField label="Interpretation Rule"><textarea className="input" value={form.interpretationRule} onChange={e => set('interpretationRule')(e.target.value)} rows={2} style={{ width: '100%', resize: 'vertical' }} /></FormField> <div className="modal-actions"> <button className="button secondary small" onClick={onClose}>Cancel</button> <button className="button small" onClick={handleSave} disabled={saving}>{saving ? 'Saving...' : 'Create Indicator'}</button> </div> </div> </div> ); } // ---- Experiments Tab ---- function ExperimentsTab({ project, dashboard, toast, onRefresh }) { const [search, setSearch] = useState(''); const [showCreate, setShowCreate] = useState(false); const [selected, setSelected] = useState(null); const [editing, setEditing] = useState(false); const [editForm, setEditForm] = useState({}); const [updating, setUpdating] = useState(null); const [confirmDelete, setConfirmDelete] = useState(null); const [deleting, setDeleting] = useState(false); const [showAddMetric, setShowAddMetric] = useState(null); const [metricForm, setMetricForm] = useState({ metricName: '', metricValue: '', thresholdValue: '', status: 'unknown' }); const experiments = useMemo(() => { if (!dashboard?.experiments) return []; return [...dashboard.experiments].sort((a, b) => new Date(b.createdAt) - new Date(a.createdAt)); }, [dashboard]); const filtered = useMemo(() => { if (!search) return experiments; const q = search.toLowerCase(); return experiments.filter(e => (e.name || '').toLowerCase().includes(q) || (e.modelName || '').toLowerCase().includes(q)); }, [experiments, search]); const openDetail = item => { setSelected(item); setEditForm({ name: item.name || '', modelName: item.modelName || '', selected: item.selected ?? false }); setEditing(false); setConfirmDelete(null); }; const saveEdit = async () => { if (!editForm.name.trim()) { toast.add('Name is required', 'error'); return; } setUpdating('edit'); try { await api.patch(`/api/projects/${project.id}/experiments/${selected.id}`, editForm); toast.add('Experiment updated'); setSelected(null); onRefresh(); } catch (e) { toast.add(e.message || 'Failed to update', 'error'); } finally { setUpdating(null); } }; const handleDelete = async () => { setDeleting(true); try { await api.del(`/api/projects/${project.id}/experiments/${confirmDelete.id}`); toast.add('Experiment deleted'); setConfirmDelete(null); setSelected(null); onRefresh(); } catch (e) { toast.add(e.message || 'Failed to delete', 'error'); } finally { setDeleting(false); } }; const addMetric = async (experimentId) => { if (!metricForm.metricName.trim() || metricForm.metricValue === '') { toast.add('Metric name and value required', 'error'); return; } setUpdating('metric'); try { await api.post(`/api/projects/${project.id}/experiments/${experimentId}/metrics`, { metricName: metricForm.metricName, metricValue: Number(metricForm.metricValue), thresholdValue: metricForm.thresholdValue ? Number(metricForm.thresholdValue) : null, status: metricForm.status }); toast.add('Metric added'); setShowAddMetric(null); setMetricForm({ metricName: '', metricValue: '', thresholdValue: '', status: 'unknown' }); onRefresh(); } catch (e) { toast.add(e.message || 'Failed to add metric', 'error'); } finally { setUpdating(null); } }; if (!dashboard) return <div className="empty-state"><p>Loading...</p></div>; return ( <> <div style={{ display: 'flex', gap: 8, marginBottom: 12 }}> <SearchInput value={search} onChange={setSearch} placeholder="Search experiments..." /> <button className="button small" onClick={() => setShowCreate(true)}>+ New Experiment</button> </div> <div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}> {filtered.map(exp => ( <div key={exp.id} className="panel" style={{ cursor: 'pointer' }} onClick={() => openDetail(exp)}> <div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}> <div> <strong>{exp.name}</strong> {exp.modelName && <span className="muted" style={{ marginLeft: 8, fontSize: 12 }}>Model: {exp.modelName}</span>} </div> <div> {exp.selected ? <Badge className="active">Selected</Badge> : <Badge>Draft</Badge>} </div> </div> {exp.metrics?.length > 0 && ( <div style={{ marginTop: 8, display: 'flex', gap: 12, fontSize: 12, flexWrap: 'wrap' }}> {exp.metrics.map(m => ( <span key={m.id} style={{ background: 'var(--line)', padding: '2px 8px', borderRadius: 4 }}> {m.metricName}: <strong>{m.metricValue}</strong> {m.thresholdValue != null && <span className="muted"> / {m.thresholdValue}</span>} </span> ))} </div> )} </div> ))} </div> {!filtered.length && <div className="empty-state"><p>No experiments yet. Create one to compare AI model performance.</p></div>} {showCreate && ( <ExperimentFormModal project={project} onClose={() => setShowCreate(false)} onSaved={() => { setShowCreate(false); onRefresh(); }} toast={toast} /> )} {selected && !confirmDelete && ( <div className="modal-overlay" onClick={() => setSelected(null)}> <div className="modal" onClick={e => e.stopPropagation()} style={{ maxWidth: 520 }}> <div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}> <h2 style={{ margin: 0 }}>{selected.name}</h2> <button className="button secondary small" onClick={() => setEditing(!editing)}>{editing ? 'View' : 'Edit'}</button> </div> {editing ? ( <> <div style={{ marginTop: 12 }}> <FormField label="Name *"><InlineInput value={editForm.name} onChange={v => setEditForm(f => ({ ...f, name: v }))} /></FormField> <FormField label="Model Name"><InlineInput value={editForm.modelName} onChange={v => setEditForm(f => ({ ...f, modelName: v }))} /></FormField> <FormField label="Status"> <label style={{ display: 'flex', alignItems: 'center', gap: 8, fontSize: 13 }}> <input type="checkbox" checked={editForm.selected} onChange={e => setEditForm(f => ({ ...f, selected: e.target.checked }))} /> Selected (active experiment) </label> </FormField> </div> <div className="modal-actions"> <button className="button secondary small" onClick={() => setSelected(null)}>Cancel</button> <button className="button small" onClick={saveEdit} disabled={updating === 'edit'}>{updating === 'edit' ? 'Saving...' : 'Save Changes'}</button> <button className="button small" style={{ background: 'var(--danger)', color: '#fff', marginLeft: 'auto' }} onClick={() => setConfirmDelete(selected)}>Delete</button> </div> </> ) : ( <> <div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 12, fontSize: 13, marginTop: 12 }}> <div><span className="muted">Model</span><br/>{selected.modelName || '-'}</div> <div><span className="muted">Status</span><br/>{selected.selected ? 'Selected (active)' : 'Draft'}</div> </div> <div style={{ marginTop: 16 }}> <div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 8 }}> <span className="muted" style={{ fontSize: 12 }}>Metrics</span> <button className="button secondary small" onClick={(e) => { e.stopPropagation(); setShowAddMetric(selected.id); }}>+ Add Metric</button> </div> {(!selected.metrics || selected.metrics.length === 0) && <p className="muted" style={{ fontSize: 12 }}>No metrics recorded yet.</p>} {selected.metrics?.map(m => ( <div key={m.id} style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', padding: '6px 0', borderBottom: '1px solid var(--line)', fontSize: 13 }}> <span><strong>{m.metricName}</strong>: {m.metricValue} {m.thresholdValue != null ? `/ ${m.thresholdValue}` : ''}</span> <span><Badge className={m.status?.toLowerCase()}>{m.status}</Badge></span> </div> ))} </div> <div className="modal-actions"> <button className="button secondary small" onClick={() => setSelected(null)}>Close</button> </div> </> )} </div> </div> )} {showAddMetric && ( <div className="modal-overlay" onClick={() => setShowAddMetric(null)}> <div className="modal" onClick={e => e.stopPropagation()} style={{ maxWidth: 400 }}> <h2>Add Metric</h2> <FormField label="Metric Name *"><InlineInput value={metricForm.metricName} onChange={v => setMetricForm(f => ({ ...f, metricName: v }))} /></FormField> <FormField label="Value *"><InlineInput type="number" value={metricForm.metricValue} onChange={v => setMetricForm(f => ({ ...f, metricValue: v }))} /></FormField> <FormField label="Threshold"><InlineInput type="number" value={metricForm.thresholdValue} onChange={v => setMetricForm(f => ({ ...f, thresholdValue: v }))} /></FormField> <FormField label="Status"><InlineSelect value={metricForm.status} onChange={v => setMetricForm(f => ({ ...f, status: v }))} options={['unknown', 'pass', 'warn', 'fail']} /></FormField> <div className="modal-actions"> <button className="button secondary small" onClick={() => setShowAddMetric(null)}>Cancel</button> <button className="button small" onClick={() => addMetric(showAddMetric)} disabled={updating === 'metric'}>{updating === 'metric' ? 'Adding...' : 'Add Metric'}</button> </div> </div> </div> )} {confirmDelete && <ConfirmDelete label="this experiment" onConfirm={handleDelete} onCancel={() => setConfirmDelete(null)} deleting={deleting} />} </> ); } function ExperimentFormModal({ project, onClose, onSaved, toast }) { const [saving, setSaving] = useState(false); const [form, setForm] = useState({ name: '', modelName: '', selected: false }); const set = key => value => setForm(f => ({ ...f, [key]: value })); const handleSave = async () => { if (!form.name.trim()) { toast.add('Name is required', 'error'); return; } setSaving(true); try { await api.post(`/api/projects/${project.id}/experiments`, form); toast.add('Experiment created'); onSaved(); } catch (e) { toast.add(e.message || 'Failed to create experiment', 'error'); } finally { setSaving(false); } }; return ( <div className="modal-overlay" onClick={onClose}> <div className="modal" onClick={e => e.stopPropagation()} style={{ maxWidth: 460 }}> <h2>New Experiment</h2> <FormField label="Name *"><InlineInput value={form.name} onChange={set('name')} /></FormField> <FormField label="Model Name"><InlineInput value={form.modelName} onChange={set('modelName')} /></FormField> <FormField label="Status"> <label style={{ display: 'flex', alignItems: 'center', gap: 8, fontSize: 13 }}> <input type="checkbox" checked={form.selected} onChange={e => set('selected')(e.target.checked)} /> Selected (active experiment) </label> </FormField> <div className="modal-actions"> <button className="button secondary small" onClick={onClose}>Cancel</button> <button className="button small" onClick={handleSave} disabled={saving}>{saving ? 'Saving...' : 'Create Experiment'}</button> </div> </div> </div> ); } // ---- Reports Tab ---- function ReportsTab({ project, toast }) { const [exports, setExports] = useState([]); const [schedules, setSchedules] = useState([]); const [loading, setLoading] = useState(true); const [generating, setGenerating] = useState(null); const [showSchedule, setShowSchedule] = useState(false); const [scheduleForm, setScheduleForm] = useState({ reportType: 'executive.html', format: 'html', cronExpr: '0 8 * * 1', recipients: '' }); const [savingSchedule, setSavingSchedule] = useState(false); const loadData = useCallback(async () => { if (!project) return; setLoading(true); try { const [exp, sch] = await Promise.all([ api.get(`/api/projects/${project.id}/report-exports`), api.get(`/api/projects/${project.id}/scheduled-reports`) ]); setExports(exp.exports || []); setSchedules(sch || []); } catch (e) { /* ignore */ } finally { setLoading(false); } }, [project]); useEffect(() => { loadData(); }, [loadData]); const generateReport = async (reportType) => { setGenerating(reportType); try { await api.post(`/api/projects/${project.id}/report-exports`, { reportType }); toast.add(`Report ${reportType} generated`); loadData(); } catch (e) { toast.add(e.message || 'Failed to generate report', 'error'); } finally { setGenerating(null); } }; const saveSchedule = async () => { setSavingSchedule(true); try { await api.post(`/api/projects/${project.id}/scheduled-reports`, { ...scheduleForm, recipients: scheduleForm.recipients ? scheduleForm.recipients.split(',').map(s => s.trim()).filter(Boolean) : [] }); toast.add('Scheduled report created'); setShowSchedule(false); loadData(); } catch (e) { toast.add(e.message || 'Failed to create schedule', 'error'); } finally { setSavingSchedule(false); } }; const deleteSchedule = async (id) => { try { await api.del(`/api/projects/${project.id}/scheduled-reports/${id}`); toast.add('Schedule deleted'); loadData(); } catch (e) { toast.add(e.message || 'Failed to delete schedule', 'error'); } }; const deleteExport = async (id) => { try { await api.del(`/api/projects/${project.id}/report-exports/${id}`); toast.add('Report export deleted'); loadData(); } catch (e) { toast.add(e.message || 'Failed to delete export', 'error'); } }; function cronToHuman(expr) { if (!expr) return ''; const parts = expr.trim().split(/\s+/); if (parts.length < 5) return expr; const [min, hour, dom, month, dow] = parts; const days = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat']; const months = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec']; let readable = ''; if (dow !== '*' && dom === '*' && month === '*') { const dayNames = dow.split(',').map(d => days[parseInt(d)] || d).join(','); readable = `${dayNames} `; } if (dom !== '*' && dow === '*' && month === '*') { readable = `Day ${dom} `; } if (month !== '*' && month !== '*') { // specific month } if (hour !== '*' && min !== '*') { const h = parseInt(hour); const m = parseInt(min); const ampm = h >= 12 ? 'PM' : 'AM'; const h12 = h === 0 ? 12 : h > 12 ? h - 12 : h; readable += `${h12}:${m.toString().padStart(2, '0')} ${ampm}`; } return readable.trim() || expr; } if (!project) return <div className="empty-state"><p>Select a project to view reports.</p></div>; if (loading) return <div className="empty-state"><p>Loading...</p></div>; const reportTypes = [ { id: 'executive.html', label: 'Executive Report (HTML)', icon: '◇' }, { id: 'risk-register.csv', label: 'Risk Register (CSV)', icon: '⊞' }, { id: 'full-export.csv', label: 'Full Export (CSV)', icon: '⇦' }, { id: 'executive.json', label: 'Executive Report (JSON)', icon: '⚙' }, ]; return ( <> <div className="dash-header"> <div> <h1>Reports</h1> <p className="muted">Generate and schedule AI risk reports</p> </div> <div className="dash-actions"> <button className="button secondary small" onClick={() => setShowSchedule(true)}>+ Schedule Report</button> </div> </div> <div className="grid" style={{ gridTemplateColumns: 'repeat(auto-fit, minmax(200px, 1fr))', marginBottom: 18 }}> {reportTypes.map(rt => ( <div key={rt.id} className="panel" style={{ textAlign: 'center' }}> <div style={{ fontSize: 28, marginBottom: 6 }}>{rt.icon}</div> <h3 style={{ margin: '0 0 4px', fontSize: 14 }}>{rt.label}</h3> <button className="button small" onClick={() => generateReport(rt.id)} disabled={generating === rt.id} style={{ marginTop: 8 }}> {generating === rt.id ? 'Generating...' : 'Generate'} </button> <a href={`/api/projects/${project.id}/reports/${rt.id}`} className="button secondary small" target="_blank" style={{ marginTop: 8 }}>Download</a> </div> ))} </div> <div className="panel" style={{ marginBottom: 12 }}> <h2>Generated Reports</h2> {exports.length === 0 ? ( <p className="muted" style={{ fontSize: 13, marginTop: 8 }}>No reports generated yet.</p> ) : ( <div className="table-wrap"> <table className="table"> <thead> <tr> <th>Type</th> <th>Format</th> <th>Status</th> <th>Generated</th> <th></th> </tr> </thead> <tbody> {exports.slice(0, 20).map(exp => ( <tr key={exp.id}> <td><strong>{exp.reportType}</strong></td> <td><Badge>{exp.format}</Badge></td> <td><Badge className={exp.status?.toLowerCase()}>{exp.status}</Badge></td> <td style={{ fontSize: 12 }}>{new Date(exp.createdAt).toLocaleString()}</td> <td> <a href={`/api/projects/${project.id}/reports/${exp.reportType}`} className="button secondary small" target="_blank" style={{ marginRight: 6 }}>View</a> <button className="button small" style={{ background: 'var(--danger)', color: '#fff' }} onClick={() => deleteExport(exp.id)}>Delete</button> </td> </tr> ))} </tbody> </table> </div> )} </div> <div className="panel"> <h2>Scheduled Reports</h2> {schedules.length === 0 ? ( <p className="muted" style={{ fontSize: 13, marginTop: 8 }}>No scheduled reports yet.</p> ) : ( <div className="table-wrap"> <table className="table"> <thead> <tr> <th>Type</th> <th>Format</th> <th>Cron</th> <th>Enabled</th> <th>Last Run</th> <th></th> </tr> </thead> <tbody> {schedules.map(s => ( <tr key={s.id}> <td><strong>{s.reportType}</strong></td> <td>{s.format}</td> <td style={{ fontFamily: 'monospace', fontSize: 12 }}> {cronToHuman(s.cronExpr) ? <><span style={{ color: 'var(--muted)' }}>{cronToHuman(s.cronExpr)}</span><br /><span style={{ fontSize: 10 }}>{s.cronExpr}</span></> : s.cronExpr} </td> <td>{s.enabled ? '✓' : '✗'}</td> <td style={{ fontSize: 12 }}>{s.lastRunAt ? new Date(s.lastRunAt).toLocaleString() : '-'}</td> <td> <button className="button small" style={{ background: 'var(--danger)', color: '#fff' }} onClick={() => deleteSchedule(s.id)}>Remove</button> </td> </tr> ))} </tbody> </table> </div> )} </div> {showSchedule && ( <div className="modal-overlay" onClick={() => setShowSchedule(false)}> <div className="modal" onClick={e => e.stopPropagation()} style={{ maxWidth: 440 }}> <h2>Schedule Report</h2> <FormField label="Report Type"> <InlineSelect value={scheduleForm.reportType} onChange={v => setScheduleForm(f => ({ ...f, reportType: v }))} options={['executive.html', 'risk-register.csv', 'full-export.csv', 'executive.json']} /> </FormField> <FormField label="Format"><InlineSelect value={scheduleForm.format} onChange={v => setScheduleForm(f => ({ ...f, format: v }))} options={['html', 'csv', 'json']} /></FormField> <FormField label="Cron Expression"><InlineInput value={scheduleForm.cronExpr} onChange={v => setScheduleForm(f => ({ ...f, cronExpr: v }))} /></FormField> <FormField label="Recipients (comma-separated emails)"><textarea className="input" value={scheduleForm.recipients} onChange={e => setScheduleForm(f => ({ ...f, recipients: e.target.value }))} rows={2} style={{ width: '100%', resize: 'vertical' }} /></FormField> <div className="modal-actions"> <button className="button secondary small" onClick={() => setShowSchedule(false)}>Cancel</button> <button className="button small" onClick={saveSchedule} disabled={savingSchedule}>{savingSchedule ? 'Saving...' : 'Create Schedule'}</button> </div> </div> </div> )} </> ); } function ProjectSettingsModal({ project, onClose, onSaved, toast }) { const [saving, setSaving] = useState(false); const [form, setForm] = useState({ name: project?.name || '', description: project?.description || '', subtitle: project?.subtitle || '', status: project?.status || 'Active' }); const handleSave = async () => { if (!form.name.trim()) { toast.add('Project name is required', 'error'); return; } setSaving(true); try { await api.patch(`/api/projects/${project.id}`, form); toast.add('Project updated'); onSaved(); } catch (e) { toast.add(e.message || 'Failed to update project', 'error'); } finally { setSaving(false); } }; return ( <div className="modal-overlay" onClick={onClose}> <div className="modal" onClick={e => e.stopPropagation()} style={{ maxWidth: 450 }}> <h2>Project Settings</h2> <FormField label="Name *"><InlineInput value={form.name} onChange={v => setForm(f => ({ ...f, name: v }))} /></FormField> <FormField label="Subtitle"><InlineInput value={form.subtitle} onChange={v => setForm(f => ({ ...f, subtitle: v }))} /></FormField> <FormField label="Description"><textarea className="input" value={form.description} onChange={e => setForm(f => ({ ...f, description: e.target.value }))} rows={3} style={{ width: '100%', resize: 'vertical' }} /></FormField> <FormField label="Status"> <InlineSelect value={form.status} onChange={v => setForm(f => ({ ...f, status: v }))} options={['Active', 'Archived', 'On Hold']} /> </FormField> <div className="modal-actions"> <button className="button secondary small" onClick={onClose}>Cancel</button> <button className="button small" onClick={handleSave} disabled={saving}>{saving ? 'Saving...' : 'Save Changes'}</button> </div> </div> </div> ); } function NewProjectModal({ workspaceId, onClose, onSaved, toast }) { const [saving, setSaving] = useState(false); const [form, setForm] = useState({ name: '', description: '', projectType: 'AI-Enabler' }); const handleSave = async () => { if (!form.name.trim()) { toast.add('Project name is required', 'error'); return; } setSaving(true); try { await api.post(`/api/workspaces/${workspaceId}/projects`, form); toast.add('Project created'); onSaved(); } catch (e) { toast.add(e.message || 'Failed to create project', 'error'); } finally { setSaving(false); } }; return ( <div className="modal-overlay" onClick={onClose}> <div className="modal" onClick={e => e.stopPropagation()} style={{ maxWidth: 450 }}> <h2>New Project</h2> <FormField label="Name *"><InlineInput value={form.name} onChange={v => setForm(f => ({ ...f, name: v }))} /></FormField> <FormField label="Description"><textarea className="input" value={form.description} onChange={e => setForm(f => ({ ...f, description: e.target.value }))} rows={3} style={{ width: '100%', resize: 'vertical' }} /></FormField> <FormField label="Project Type"><InlineSelect value={form.projectType} onChange={v => setForm(f => ({ ...f, projectType: v }))} options={['AI-Enabler', 'GENERATIVE_AI', 'ML-Pipeline', 'Automation']} /></FormField> <div className="modal-actions"> <button className="button secondary small" onClick={onClose}>Cancel</button> <button className="button small" onClick={handleSave} disabled={saving}>{saving ? 'Saving...' : 'Create Project'}</button> </div> </div> </div> ); } function ProjectManagerModal({ projects, workspaceId, currentProjectId, onClose, onSwitch, onSaved, toast }) { const [deleting, setDeleting] = useState(null); const [confirmDelete, setConfirmDelete] = useState(null); const [showNew, setShowNew] = useState(false); const handleDelete = async () => { setDeleting(true); try { await api.del(`/api/projects/${confirmDelete.id}`); toast.add(`Project "${confirmDelete.name}" deleted`); setConfirmDelete(null); onSaved(); } catch (e) { toast.add(e.message || 'Failed to delete project', 'error'); } finally { setDeleting(false); } }; return ( <div className="modal-overlay" onClick={onClose}> <div className="modal" onClick={e => e.stopPropagation()} style={{ maxWidth: 700, maxHeight: '80vh', overflow: 'auto' }}> <div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}> <h2 style={{ margin: 0 }}>All Projects</h2> <button className="button small" onClick={() => setShowNew(true)}>+ New Project</button> </div> <p className="muted" style={{ fontSize: 12, marginTop: 4 }}>{projects.length} project(s) in workspace</p> <div className="table-wrap" style={{ marginTop: 12 }}> <table className="table"> <thead> <tr> <th>Name</th> <th>Status</th> <th>Type</th> <th>Created</th> <th></th> </tr> </thead> <tbody> {[...projects].sort((a, b) => new Date(b.createdAt) - new Date(a.createdAt)).map(p => ( <tr key={p.id} style={{ background: p.id === currentProjectId ? 'var(--surface-alt)' : '' }}> <td><strong>{p.name}</strong>{p.id === currentProjectId ? <span className="muted" style={{ fontSize: 11, marginLeft: 6 }}>(current)</span> : ''}</td> <td><Badge className={p.status?.toLowerCase()}>{p.status}</Badge></td> <td style={{ fontSize: 12 }}>{p.projectType}</td> <td style={{ fontSize: 12 }}>{new Date(p.createdAt).toLocaleDateString()}</td> <td style={{ textAlign: 'right', whiteSpace: 'nowrap' }}> {p.id !== currentProjectId && ( <button className="button secondary small" style={{ marginRight: 6 }} onClick={() => { onSwitch(p.id); onClose(); }}>Open</button> )} <button className="button small" style={{ background: 'var(--danger)', color: '#fff' }} onClick={() => setConfirmDelete(p)} disabled={deleting === p.id}> ✕ </button> </td> </tr> ))} </tbody> </table> </div> {!projects.length && <div className="empty-state"><p>No projects yet</p></div>} <div className="modal-actions"> <button className="button secondary small" onClick={onClose}>Close</button> </div> </div> {confirmDelete && ( <div className="modal-overlay" onClick={() => setConfirmDelete(null)}> <div className="modal" onClick={e => e.stopPropagation()} style={{ maxWidth: 380 }}> <h3>Delete "{confirmDelete.name}"?</h3> <p style={{ fontSize: 13, color: 'var(--muted)' }}>This permanently deletes the project and all its risks, mitigations, and data.</p> <div className="modal-actions"> <button className="button secondary small" onClick={() => setConfirmDelete(null)}>Cancel</button> <button className="button small" style={{ background: 'var(--danger)', color: '#fff' }} onClick={handleDelete} disabled={deleting}> {deleting ? 'Deleting...' : 'Delete Project'} </button> </div> </div> </div> )} {showNew && ( <NewProjectModal workspaceId={workspaceId} onClose={() => setShowNew(false)} onSaved={() => { setShowNew(false); onSaved(); }} toast={toast} /> )} </div> ); } function MembersTab({ organizationId, toast, sessionUserId }) { const [members, setMembers] = useState([]); const [roles, setRoles] = useState([]); const [loading, setLoading] = useState(true); const [showInvite, setShowInvite] = useState(false); const [editingMember, setEditingMember] = useState(null); const [editRoleId, setEditRoleId] = useState(''); const loadMembers = useCallback(async () => { if (!organizationId) return; setLoading(true); try { const [data, rolesData] = await Promise.all([ api.get(`/api/organizations/${organizationId}/members`), api.get(`/api/roles`) ]); setMembers((data.members || []).filter(m => m.user?.platformRole === 'NONE' || !m.user?.platformRole)); setRoles(rolesData || []); } catch (e) { toast.add(e.message || 'Failed to load members', 'error'); } finally { setLoading(false); } }, [organizationId, toast]); useEffect(() => { loadMembers(); }, [loadMembers]); const startEdit = (m) => { setEditingMember(m); setEditRoleId(m.role?.id || ''); }; const saveRole = async () => { try { await api.patch(`/api/organizations/${organizationId}/members/${editingMember.id}`, { roleId: editRoleId }); toast.add('Role updated', 'success'); setEditingMember(null); loadMembers(); } catch (e) { toast.add(e.message || 'Failed to update role', 'error'); } }; const toggleStatus = async (m) => { const newStatus = m.status === 'ACTIVE' ? 'DISABLED' : 'ACTIVE'; try { await api.patch(`/api/organizations/${organizationId}/members/${m.id}`, { status: newStatus }); toast.add(`Member ${newStatus === 'ACTIVE' ? 'enabled' : 'disabled'}`, 'success'); loadMembers(); } catch (e) { toast.add(e.message || 'Failed to update member', 'error'); } }; const removeMember = async (m) => { if (!window.confirm(`Remove ${m.user?.email || 'this member'} from the organization?`)) return; try { await api.del(`/api/organizations/${organizationId}/members/${m.id}`); toast.add('Member removed', 'success'); loadMembers(); } catch (e) { toast.add(e.message || 'Failed to remove member', 'error'); } }; return ( <> <div className="dash-header"> <div> <h1>Members</h1> <p className="muted">{members.length} member(s) in organization</p> </div> <div className="dash-actions"> <button className="button small" onClick={() => setShowInvite(true)}>+ Invite Member</button> </div> </div> {loading ? ( <div className="shell-loading"><div className="spinner" /></div> ) : ( <div className="table-wrap"> <table className="table"> <thead> <tr> <th>Name</th> <th>Email</th> <th>Role</th> <th>Status</th> <th>Actions</th> </tr> </thead> <tbody> {members.map(m => ( <tr key={m.id}> <td><strong>{m.user?.displayName || m.user?.email}</strong></td> <td>{m.user?.email}</td> <td> {editingMember?.id === m.id ? ( <select className="input" value={editRoleId} onChange={e => setEditRoleId(e.target.value)} style={{ width: 'auto' }}> {roles.map(r => <option key={r.id} value={r.id}>{r.name}</option>)} </select> ) : ( <Badge>{m.role?.name || m.roleCode}</Badge> )} </td> <td><Badge className={m.status?.toLowerCase()}>{m.status}</Badge></td> <td> <div style={{ display: 'flex', gap: 4, flexWrap: 'wrap' }}> {editingMember?.id === m.id ? ( <> <button className="button primary small" onClick={saveRole}>Save</button> <button className="button secondary small" onClick={() => setEditingMember(null)}>Cancel</button> </> ) : ( <> <button className="button secondary small" onClick={() => startEdit(m)} title="Change role">Role</button> <button className="button secondary small" onClick={() => toggleStatus(m)} title={m.status === 'ACTIVE' ? 'Disable member' : 'Enable member'}> {m.status === 'ACTIVE' ? 'Disable' : 'Enable'} </button> <button className="button danger small" onClick={() => removeMember(m)} title="Remove from organization">Remove</button> </> )} </div> </td> </tr> ))} </tbody> </table> </div> )} {showInvite && ( <InviteFormModal organizationId={organizationId} onClose={() => setShowInvite(false)} onSaved={() => { setShowInvite(false); loadMembers(); }} toast={toast} /> )} {/* Small style for danger button */} <style>{`.button.danger { background: #fee2e2; color: #991b1b; border: 1px solid #fecaca; }.button.danger:hover { background: #fecaca; }`}</style> </> ); } function InviteFormModal({ organizationId, onClose, onSaved, toast }) { const [email, setEmail] = useState(''); const [roleCode, setRoleCode] = useState('viewer'); const [saving, setSaving] = useState(false); const [inviteLink, setInviteLink] = useState(''); const handleSave = async () => { if (!email.trim()) { toast.add('Email is required', 'error'); return; } setSaving(true); setInviteLink(''); try { const data = await api.post('/api/auth/invite', { organizationId, email: email.trim(), roleCode }); const baseUrl = window.location.origin; const link = `${baseUrl}/accept-invite?token=${data.token}`; setInviteLink(link); toast.add(`Invitation created for ${email}`); onSaved(); } catch (e) { toast.add(e.message || 'Failed to create invitation', 'error'); } finally { setSaving(false); } }; const copyLink = () => { navigator.clipboard.writeText(inviteLink).then(() => toast.add('Invite link copied')); }; return ( <div className="modal-overlay" onClick={onClose}> <div className="modal" onClick={e => e.stopPropagation()} style={{ maxWidth: 480 }}> <h2>Invite Member</h2> {inviteLink ? ( <> <p className="muted" style={{ marginBottom: 12 }}>Share this link with the user:</p> <div style={{ display: 'flex', gap: 8, alignItems: 'center' }}> <input type="text" readOnly value={inviteLink} className="input" style={{ flex: 1, fontSize: 13 }} /> <button className="button secondary small" onClick={copyLink}>Copy</button> </div> <div className="modal-actions" style={{ marginTop: 16 }}> <button className="button small" onClick={onClose}>Close</button> </div> </> ) : ( <> <FormField label="Email *"> <InlineInput value={email} onChange={setEmail} placeholder="colleague@company.com" /> </FormField> <FormField label="Role"> <InlineSelect value={roleCode} onChange={setRoleCode} options={['viewer', 'risk_owner', 'project_manager', 'admin']} /> </FormField> <div className="modal-actions"> <button className="button secondary small" onClick={onClose}>Cancel</button> <button className="button small" onClick={handleSave} disabled={saving}>{saving ? 'Sending...' : 'Send Invitation'}</button> </div> </> )} </div> </div> ); } function AdminTab({ platformRole, toast }) { const isSuper = platformRole === 'SUPER_ADMIN' || platformRole === 'SUPER_OWNER'; const isOwner = platformRole === 'SUPER_OWNER'; const [users, setUsers] = useState([]); const [orgs, setOrgs] = useState([]); const [admins, setAdmins] = useState([]); const [userQuery, setUserQuery] = useState(''); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); const [assigning, setAssigning] = useState(null); // {adminId} or null const loadData = useCallback(async () => { if (!isSuper) return; setLoading(true); setError(null); try { const [userResp, orgResp, adminResp] = await Promise.all([ api.get('/api/admin/users'), api.get('/api/admin/organizations'), api.get('/api/admin/super-admins') ]); setUsers(userResp.users || []); setOrgs(orgResp.organizations || []); setAdmins(adminResp.admins || []); } catch (e) { setError(e.message || 'Failed to load admin data'); } finally { setLoading(false); } }, [isSuper]); useEffect(() => { loadData(); }, [loadData]); const promoteToSuperAdmin = async (userId) => { const user = users.find(u => u.id === userId); if (!user) return; const name = prompt(`Promote "${user.displayName}" to Super Admin? Enter display name:`, user.displayName); if (!name) return; try { await api.post('/api/admin/super-admins', { email: user.email, displayName: name }); toast.add('Super Admin created'); loadData(); } catch (e) { toast.add(e.message || 'Failed', 'error'); } }; const demoteSuperAdmin = async (adminId) => { if (!window.confirm('Remove this Super Admin?')) return; try { await api.del(`/api/admin/super-admins/${adminId}`); toast.add('Super Admin removed'); loadData(); } catch (e) { toast.add(e.message || 'Failed', 'error'); } }; if (!isSuper) return <div className="empty-state"><p>Access denied. Super Admin or Super Owner role required.</p></div>; if (loading) return <div className="shell-loading"><div className="spinner" /></div>; return ( <> <div className="dash-header"> <div> <h1>Platform Admin</h1> <p className="muted">Manage users, organizations, and platform administrators</p> </div> </div> {/* Super Admins section (Super Owner only) */} {isOwner && ( <div className="panel" style={{ marginBottom: 18 }}> <h2>Super Admins</h2> <p className="muted" style={{ fontSize: 12, marginBottom: 12 }}>Platform administrators who can manage organizations they are assigned to. Only Super Owner can add/remove and manage org access.</p> {admins.filter(a => a.platformRole === 'SUPER_ADMIN').length === 0 ? ( <p className="muted" style={{ fontSize: 13 }}>No Super Admins yet. Promote users below.</p> ) : ( <div className="table-wrap"> <table className="table"> <thead> <tr><th>Name</th><th>Email</th><th>Role</th><th>Assigned Orgs</th><th>Actions</th></tr> </thead> <tbody> {admins.filter(a => a.platformRole === 'SUPER_ADMIN').map(a => ( <tr key={a.id}> <td><strong>{a.displayName}</strong></td> <td>{a.email}</td> <td><Badge>Super Admin</Badge></td> <td style={{ fontSize: 12 }}> {a.organizationAccess && a.organizationAccess.length > 0 ? ( <span>{a.organizationAccess.map(oa => oa.organization?.name || oa.organizationId).join(', ')}</span> ) : ( <span className="muted">None</span> )} <button className="button secondary small" style={{ marginLeft: 8 }} onClick={() => setAssigning(assigning === a.id ? null : a.id)}> {assigning === a.id ? 'Done' : 'Manage'} </button> </td> <td> <button className="button danger small" onClick={() => demoteSuperAdmin(a.id)}>Remove</button> </td> </tr> ))} </tbody> </table> </div> )} {assigning && ( <div style={{ marginTop: 12, padding: 12, background: '#f8f9fa', borderRadius: 6 }}> <h3 style={{ fontSize: 14, marginBottom: 8 }}>Assign Organization Access</h3> <select id="org-select" style={{ marginRight: 8, padding: '6px 10px', borderRadius: 4, border: '1px solid #ccc' }} defaultValue="" > <option value="" disabled>Select organization...</option> {orgs.filter(o => !admins.find(a => a.id === assigning)?.organizationAccess?.some(oa => oa.organizationId === o.id)).map(o => ( <option key={o.id} value={o.id}>{o.name}</option> ))} </select> <button className="button secondary small" onClick={async () => { const sel = document.getElementById('org-select'); if (!sel || !sel.value) { toast.add('Select an organization', 'error'); return; } try { await api.post('/api/admin/org-access', { userId: assigning, organizationId: sel.value }); toast.add('Organization access granted'); loadData(); } catch (e) { toast.add(e.message || 'Failed', 'error'); } }}>Grant Access</button> <div style={{ marginTop: 8 }}> <p className="muted" style={{ fontSize: 12, marginBottom: 4 }}>Currently assigned:</p> {admins.find(a => a.id === assigning)?.organizationAccess?.map(oa => ( <div key={oa.organizationId} style={{ display: 'inline-flex', alignItems: 'center', margin: '2px 4px 2px 0', padding: '2px 8px', background: '#e8f4e8', borderRadius: 12, fontSize: 12 }}> {oa.organization?.name || oa.organizationId} <button style={{ marginLeft: 4, background: 'none', border: 'none', cursor: 'pointer', color: '#c00', fontSize: 14, lineHeight: 1 }} onClick={async () => { if (!window.confirm('Remove organization access?')) return; try { await api.del(`/api/admin/org-access?userId=${assigning}&organizationId=${oa.organizationId}`); toast.add('Organization access revoked'); loadData(); } catch (e) { toast.add(e.message || 'Failed', 'error'); } }} >×</button> </div> )) || <span className="muted" style={{ fontSize: 12 }}>None</span>} </div> </div> )} </div> )} {/* Users */} <div className="panel" style={{ marginBottom: 18 }}> <h2>Users ({users.length})</h2> <div style={{ marginBottom: 12 }}> <SearchInput value={userQuery} onChange={setUserQuery} placeholder="Search users by name or email..." /> </div> <div className="table-wrap"> <table className="table"> <thead> <tr><th>Name</th><th>Email</th><th>Platform Role</th><th>Created</th><th>Actions</th></tr> </thead> <tbody> {users.filter(u => { if (!userQuery) return true; const q = userQuery.toLowerCase(); return u.email.toLowerCase().includes(q) || u.displayName.toLowerCase().includes(q); }).map(u => ( <tr key={u.id}> <td><strong>{u.displayName}</strong></td> <td>{u.email}</td> <td><Badge className={u.platformRole === 'SUPER_OWNER' ? 'owner' : u.platformRole === 'SUPER_ADMIN' ? 'admin' : ''}>{u.platformRole || 'NONE'}</Badge></td> <td style={{ fontSize: 12 }}>{new Date(u.createdAt).toLocaleDateString()}</td> <td> {u.platformRole === 'NONE' && isOwner && ( <button className="button secondary small" onClick={() => promoteToSuperAdmin(u.id)}>Promote to Super Admin</button> )} </td> </tr> ))} </tbody> </table> </div> </div> {/* Organizations */} <div className="panel"> <h2>All Organizations ({orgs.length})</h2> <div className="table-wrap"> <table className="table"> <thead> <tr><th>Name</th><th>Slug</th><th>Status</th><th>Members</th><th>Workspaces</th><th>Created</th></tr> </thead> <tbody> {orgs.map(o => ( <tr key={o.id}> <td><strong>{o.name}</strong></td> <td style={{ fontSize: 12 }}>{o.slug}</td> <td><Badge className={o.status?.toLowerCase()}>{o.status}</Badge></td> <td>{o._count?.memberships || 0}</td> <td>{o._count?.workspaces || 0}</td> <td style={{ fontSize: 12 }}>{new Date(o.createdAt).toLocaleDateString()}</td> </tr> ))} </tbody> </table> </div> {!orgs.length && <div className="empty-state"><p>No organizations found</p></div>} </div> </> ); } export default function DashboardPage() { const router = useRouter(); const searchParams = useSearchParams(); const [project, setProject] = useState(null); const [projects, setProjects] = useState([]); const [selectedProjectId, setSelectedProjectId] = useState(searchParams.get('projectId') || null); const [dashboard, setDashboard] = useState(null); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); const [retryCount, setRetryCount] = useState(0); const [showProjectSettings, setShowProjectSettings] = useState(false); const [showProjectManager, setShowProjectManager] = useState(false); const [workspaceId, setWorkspaceId] = useState(null); const [organizationId, setOrganizationId] = useState(null); const [sessionUserId, setSessionUserId] = useState(null); const [platformRole, setPlatformRole] = useState('NONE'); const isSuper = platformRole === 'SUPER_ADMIN' || platformRole === 'SUPER_OWNER'; const VISIBLE_TABS = isSuper ? ['Admin'] : ORG_TABS; const tab = VISIBLE_TABS.includes(searchParams.get('tab')) ? searchParams.get('tab') : VISIBLE_TABS[0]; const { toasts, add } = useToast(); const onRefresh = useCallback(() => setRetryCount(c => c + 1), []); const loadDashboard = useCallback(async (projectId) => { setLoading(true); setError(null); try { const session = await api.get('/api/auth/session'); if (!session?.authenticated) { setError('Not authenticated.'); setLoading(false); return; } setSessionUserId(session.user?.id || null); setPlatformRole(session.user?.platformRole || 'NONE'); const isSuperUser = session.user?.platformRole === 'SUPER_ADMIN' || session.user?.platformRole === 'SUPER_OWNER'; // Super users have no org membership — skip org data loading if (isSuperUser) { setLoading(false); return; } const membership = session.memberships?.[0]; if (!membership) { setError('No organization membership found.'); setLoading(false); return; } const orgId = membership.organizationId; setOrganizationId(orgId); const workspacesResp = await api.get(`/api/workspaces?organizationId=${orgId}`); const ws = workspacesResp.workspaces?.[0]; if (!ws) { setError('No workspace found — create one to get started.'); setLoading(false); return; } setWorkspaceId(ws.id); const projectsResp = await api.get(`/api/workspaces/${ws.id}/projects`); const allProjects = projectsResp.projects || []; if (!allProjects.length) { setError('No project found — create a project to get started.'); setLoading(false); return; } setProjects(allProjects); let proj = projectId ? allProjects.find(p => p.id === projectId) : null; if (!proj) proj = allProjects[0]; if (proj.id !== (searchParams.get('projectId') || null)) { const currentTab = searchParams.get('tab') || 'Overview'; router.replace(`/dashboard?tab=${currentTab}&projectId=${proj.id}`); } setProject(proj); const dashResp = await api.get(`/api/projects/${proj.id}/dashboard`); setDashboard(dashResp.dashboard); setLoading(false); } catch (e) { setError(e.message || 'Failed to load dashboard data'); setLoading(false); } // eslint-disable-next-line react-hooks/exhaustive-deps }, []); useEffect(() => { loadDashboard(selectedProjectId); }, [loadDashboard, retryCount, selectedProjectId]); const switchProject = pid => { setSelectedProjectId(pid); router.push(`/dashboard?tab=${tab}&projectId=${pid}`); }; const [showCreateOnError, setShowCreateOnError] = useState(false); if (loading && tab !== 'Integrations') return <div className="shell-loading"><div className="spinner" /></div>; if (error && tab !== 'Integrations') return ( <div className="empty-state"> <div className="empty-icon">!</div> <p>{error}</p> <div style={{ marginTop: 12, display: 'flex', gap: 8, justifyContent: 'center' }}> <button className="button secondary small" onClick={() => setRetryCount(c => c + 1)}>Retry</button> {error.includes('No project found') && workspaceId && ( <button className="button small" onClick={() => setShowCreateOnError(true)}>+ Create Project</button> )} </div> {showCreateOnError && workspaceId && ( <NewProjectModal workspaceId={workspaceId} onClose={() => setShowCreateOnError(false)} onSaved={() => { setShowCreateOnError(false); setRetryCount(c => c + 1); }} toast={{ add }} /> )} </div> ); return ( <> {!isSuper && ( <div className="dash-header"> <div style={{ display: 'flex', alignItems: 'center', gap: 10 }}> <div> <div style={{ display: 'flex', alignItems: 'center', gap: 6 }}> <select className="input" value={project?.id || ''} onChange={e => switchProject(e.target.value)} style={{ fontSize: 16, fontWeight: 700, width: 'auto', minWidth: 200, border: 'none', background: 'transparent', cursor: 'pointer' }}> {projects.map(p => <option key={p.id} value={p.id}>{p.name} {p.status !== 'Active' ? `(${p.status})` : ''}</option>)} </select> <button className="button secondary small" style={{ fontSize: 14, padding: '2px 8px' }} onClick={() => setShowProjectSettings(true)} title="Project settings">⚙</button> <button className="button secondary small" onClick={() => setShowProjectManager(true)}>Projects</button> </div> <p className="muted" style={{ margin: 0 }}>{project?.subtitle || project?.description || ''}</p> </div> </div> <div className="dash-actions"> <a href={`/api/projects/${project?.id}/reports/executive.html`} className="button secondary small" target="_blank">Report</a> <a href={`/api/projects/${project?.id}/reports/risk-register.csv`} className="button secondary small" target="_blank">CSV</a> </div> </div> )} <div className="tabs"> {VISIBLE_TABS.map(t => ( <button key={t} className={`tab ${tab === t ? 'active' : ''}`} onClick={() => router.push(`/dashboard?tab=${t}${project ? `&projectId=${project.id}` : ''}`)}>{t}</button> ))} </div> {tab === 'Overview' && !isSuper && <ErrorBoundary name="Overview" key="overview"><OverviewTab project={project} dashboard={dashboard} /></ErrorBoundary>} {tab === 'Projects' && !isSuper && <ErrorBoundary name="Projects" key="projects"><ProjectsTab projects={projects} workspaceId={workspaceId} currentProjectId={project?.id} onSwitch={switchProject} onRefresh={onRefresh} toast={{ add }} /></ErrorBoundary>} {tab === 'Risks' && !isSuper && <ErrorBoundary name="Risks" key="risks"><RisksTab project={project} dashboard={dashboard} toast={{ add }} onRefresh={onRefresh} /></ErrorBoundary>} {tab === 'Mitigations' && !isSuper && <ErrorBoundary name="Mitigations" key="mitigations"><MitigationsTab project={project} dashboard={dashboard} toast={{ add }} onRefresh={onRefresh} /></ErrorBoundary>} {tab === 'Gate' && !isSuper && <ErrorBoundary name="Gate" key="gate"><GateTab project={project} dashboard={dashboard} toast={{ add }} onRefresh={onRefresh} /></ErrorBoundary>} {tab === 'Indicators' && !isSuper && <ErrorBoundary name="Indicators" key="indicators"><IndicatorsTab project={project} dashboard={dashboard} toast={{ add }} onRefresh={onRefresh} /></ErrorBoundary>} {tab === 'Experiments' && !isSuper && <ErrorBoundary name="Experiments" key="experiments"><ExperimentsTab project={project} dashboard={dashboard} toast={{ add }} onRefresh={onRefresh} /></ErrorBoundary>} {tab === 'Reports' && !isSuper && <ErrorBoundary name="Reports" key="reports"><ReportsTab project={project} toast={{ add }} /></ErrorBoundary>} {tab === 'Integrations' && !isSuper && <ErrorBoundary name="Integrations" key="integrations"><IntegrationsTab project={project} dashboard={dashboard} toast={{ add }} onRefresh={onRefresh} /></ErrorBoundary>} {tab === 'Audit' && !isSuper && <ErrorBoundary name="Audit" key="audit"><AuditTab project={project} toast={{ add }} /></ErrorBoundary>} {tab === 'Members' && !isSuper && <ErrorBoundary name="Members" key="members"><MembersTab organizationId={organizationId} toast={{ add }} sessionUserId={sessionUserId} /></ErrorBoundary>} {tab === 'Admin' && <ErrorBoundary name="Admin" key="admin"><AdminTab platformRole={platformRole} toast={{ add }} /></ErrorBoundary>} {showProjectSettings && ( <ProjectSettingsModal project={project} onClose={() => setShowProjectSettings(false)} onSaved={() => { setShowProjectSettings(false); onRefresh(); }} toast={{ add }} /> )} {showProjectManager && ( <ProjectManagerModal projects={projects} workspaceId={workspaceId} currentProjectId={project?.id} onClose={() => setShowProjectManager(false)} onSwitch={switchProject} onSaved={() => { setShowProjectManager(false); onRefresh(); }} toast={{ add }} /> )} <ToastContainer toasts={toasts} /> </> ); }
Save
cmd:
run