Initial commit
This commit is contained in:
@@ -0,0 +1,449 @@
|
||||
import { useMemo, useState, type ReactNode } from 'react'
|
||||
import type {
|
||||
ArchitecturePreset,
|
||||
ArchitecturePresetDetail,
|
||||
InstalledPackageInfo,
|
||||
ChannelStatusInfo,
|
||||
ReorgProposalInfo,
|
||||
} from '../types/visual'
|
||||
import { PackageCard } from './PackageCard'
|
||||
import { CollapsibleSection } from './CollapsibleSection'
|
||||
|
||||
/* ── Inline SVG icon data-URIs (no external CDN) ────────────────── */
|
||||
const ICON = {
|
||||
search: "data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'%3E%3Cpath fill='%23888' d='M15.5 14h-.79l-.28-.27a6.5 6.5 0 1 0-.7.7l.27.28v.79l5 4.99L20.49 19l-4.99-5zm-6 0A4.5 4.5 0 1 1 14 9.5 4.5 4.5 0 0 1 9.5 14z'/%3E%3C/svg%3E",
|
||||
arch: "data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'%3E%3Cpath fill='%23888' d='M3 13h8V3H3v10zm0 8h8v-6H3v6zm10 0h8V11h-8v10zm0-18v6h8V3h-8z'/%3E%3C/svg%3E",
|
||||
packages: "data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'%3E%3Cpath fill='%23888' d='M20.54 5.23l-1.39-1.68C18.88 3.21 18.47 3 18 3H6c-.47 0-.88.21-1.16.55L3.46 5.23C3.17 5.57 3 6.02 3 6.5V19c0 1.1.9 2 2 2h14c1.1 0 2-.9 2-2V6.5c0-.48-.17-.93-.46-1.27zM12 17.5L6.5 12H10v-2h4v2h3.5L12 17.5zM5.12 5l.81-1h12l.94 1H5.12z'/%3E%3C/svg%3E",
|
||||
channels: "data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'%3E%3Cpath fill='%23888' d='M1 9l2 2c4.97-4.97 13.03-4.97 18 0l2-2C16.93 2.93 7.08 2.93 1 9zm8 8l3 3 3-3c-1.65-1.66-4.34-1.66-6 0zm-4-4l2 2c2.76-2.76 7.24-2.76 10 0l2-2C15.14 9.14 8.87 9.14 5 13z'/%3E%3C/svg%3E",
|
||||
reorg: "data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'%3E%3Cpath fill='%23888' d='M6.99 11L3 15l3.99 4v-3H14v-2H6.99v-3zM21 9l-3.99-4v3H10v2h7.01v3L21 9z'/%3E%3C/svg%3E",
|
||||
importPkg: "data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'%3E%3Cpath fill='%23888' d='M19 9h-4V3H9v6H5l7 7 7-7zM5 18v2h14v-2H5z'/%3E%3C/svg%3E",
|
||||
arrow: "data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'%3E%3Cpath fill='%23888' d='M8.59 16.59L13.17 12 8.59 7.41 10 6l6 6-6 6-1.41-1.41z'/%3E%3C/svg%3E",
|
||||
gateReview: "data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'%3E%3Cpath fill='%23f59e0b' d='M12 2L4 5v6c0 5.55 3.84 10.74 9 12 5.16-1.26 9-6.45 9-12V5l-9-3zm-2 16l-4-4 1.41-1.41L10 15.17l6.59-6.59L18 10l-8 8z'/%3E%3C/svg%3E",
|
||||
gateApproval: "data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'%3E%3Cpath fill='%2322c55e' d='M9 16.17L4.83 12l-1.42 1.41L9 19 21 7l-1.41-1.41L9 16.17z'/%3E%3C/svg%3E",
|
||||
gateHold: "data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'%3E%3Cpath fill='%23ef4444' d='M6 19h4V5H6v14zm8-14v14h4V5h-4z'/%3E%3C/svg%3E",
|
||||
}
|
||||
|
||||
const PATTERN_LABELS: Record<string, string> = {
|
||||
pipeline: 'Pipeline',
|
||||
hub_spoke: 'Hub & Spoke',
|
||||
review_loop: 'Review Loop',
|
||||
hierarchical: 'Hierarchical',
|
||||
flat: 'Flat Team',
|
||||
}
|
||||
|
||||
interface ArchitectureMarketplaceProps {
|
||||
presets: ArchitecturePreset[]
|
||||
installedIds: Set<string>
|
||||
previewData: ArchitecturePresetDetail | null
|
||||
applyingPresetId: string | null
|
||||
readOnly: boolean
|
||||
onPreview: (presetId: string) => void
|
||||
onApplyPreset: (presetId: string, strategy: string) => void
|
||||
onClearPreview: () => void
|
||||
installedPackages: InstalledPackageInfo[]
|
||||
channels: ChannelStatusInfo[]
|
||||
reorgProposals: ReorgProposalInfo[]
|
||||
isCustomMode: boolean
|
||||
onReorgDecide: (proposalId: string, approved: boolean, notes?: string) => void
|
||||
onMarketInstall: (path: string, strategy: string) => void
|
||||
onMarketUninstall: (packageId: string) => void
|
||||
}
|
||||
|
||||
export function ArchitectureMarketplace({
|
||||
presets, installedIds, previewData, applyingPresetId, readOnly,
|
||||
onPreview, onApplyPreset, onClearPreview,
|
||||
installedPackages, channels, reorgProposals, isCustomMode,
|
||||
onReorgDecide, onMarketInstall, onMarketUninstall,
|
||||
}: ArchitectureMarketplaceProps) {
|
||||
const [search, setSearch] = useState('')
|
||||
const [activeCategory, setActiveCategory] = useState<string | null>(null)
|
||||
const [activePattern, setActivePattern] = useState<string | null>(null)
|
||||
const [showImportForm, setShowImportForm] = useState(false)
|
||||
const [importPath, setImportPath] = useState('')
|
||||
const [uninstallingId, setUninstallingId] = useState<string | null>(null)
|
||||
|
||||
const categories = useMemo(() => {
|
||||
const cats = new Set<string>()
|
||||
for (const p of presets) { if (p.category) cats.add(p.category) }
|
||||
return Array.from(cats).sort()
|
||||
}, [presets])
|
||||
|
||||
const patterns = useMemo(() => {
|
||||
const pats = new Set<string>()
|
||||
for (const p of presets) { if (p.collaboration_pattern) pats.add(p.collaboration_pattern) }
|
||||
return Array.from(pats).sort()
|
||||
}, [presets])
|
||||
|
||||
const filteredPresets = useMemo(() => {
|
||||
let result = presets
|
||||
if (activeCategory) result = result.filter(p => p.category === activeCategory)
|
||||
if (activePattern) result = result.filter(p => p.collaboration_pattern === activePattern)
|
||||
if (search.trim()) {
|
||||
const q = search.trim().toLowerCase()
|
||||
result = result.filter(p =>
|
||||
p.name.toLowerCase().includes(q) ||
|
||||
p.description.toLowerCase().includes(q) ||
|
||||
p.tags.some(t => t.toLowerCase().includes(q)) ||
|
||||
(PATTERN_LABELS[p.collaboration_pattern] || '').toLowerCase().includes(q),
|
||||
)
|
||||
}
|
||||
return result
|
||||
}, [presets, activeCategory, activePattern, search])
|
||||
|
||||
const handleImport = () => {
|
||||
if (!importPath.trim()) return
|
||||
onMarketInstall(importPath.trim(), 'namespace')
|
||||
setShowImportForm(false)
|
||||
setImportPath('')
|
||||
}
|
||||
|
||||
const handleUninstall = (pkgId: string) => {
|
||||
if (!confirm('Uninstall this package? Roles and work-item templates from this package will be removed.')) return
|
||||
setUninstallingId(pkgId)
|
||||
onMarketUninstall(pkgId)
|
||||
setTimeout(() => setUninstallingId(null), 3000)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="mkt-container" data-testid="architecture-marketplace">
|
||||
{/* Toolbar */}
|
||||
<div className="mkt-toolbar">
|
||||
<div className="mkt-search-wrap">
|
||||
<img src={ICON.search} alt="" className="mkt-search-icon" />
|
||||
<input
|
||||
className="mkt-search"
|
||||
placeholder="Search architectures..."
|
||||
value={search}
|
||||
onChange={e => setSearch(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<span className="mkt-count">{filteredPresets.length} architectures</span>
|
||||
</div>
|
||||
|
||||
{/* Filter pills */}
|
||||
<div className="mkt-filters">
|
||||
{categories.length > 0 && (
|
||||
<div className="mkt-pill-row">
|
||||
<button className={`mkt-pill${!activeCategory ? ' active' : ''}`}
|
||||
onClick={() => setActiveCategory(null)}>All</button>
|
||||
{categories.map(cat => (
|
||||
<button key={cat} className={`mkt-pill${activeCategory === cat ? ' active' : ''}`}
|
||||
onClick={() => setActiveCategory(activeCategory === cat ? null : cat)}
|
||||
>{cat}</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{patterns.length > 0 && (
|
||||
<div className="mkt-pill-row">
|
||||
<button className={`mkt-pill mkt-pill-pattern${!activePattern ? ' active' : ''}`}
|
||||
onClick={() => setActivePattern(null)}>All Patterns</button>
|
||||
{patterns.map(pat => (
|
||||
<button key={pat} className={`mkt-pill mkt-pill-pattern${activePattern === pat ? ' active' : ''}`}
|
||||
onClick={() => setActivePattern(activePattern === pat ? null : pat)}
|
||||
>{PATTERN_LABELS[pat] || pat}</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Architecture Blueprints grid */}
|
||||
{filteredPresets.length > 0 ? (
|
||||
<div className="mkt-section">
|
||||
<div className="mkt-section-header">
|
||||
<img src={ICON.arch} alt="" className="mkt-section-icon" />
|
||||
<h3 className="mkt-section-title">Architecture Blueprints</h3>
|
||||
<span className="mkt-section-count">{filteredPresets.length}</span>
|
||||
</div>
|
||||
<div className="mkt-arch-grid">
|
||||
{filteredPresets.map(p => (
|
||||
<ArchCard key={p.id} preset={p}
|
||||
isInstalled={installedIds.has(p.id)}
|
||||
isApplying={applyingPresetId === p.id}
|
||||
readOnly={readOnly}
|
||||
onPreview={() => onPreview(p.id)}
|
||||
onApply={() => onApplyPreset(p.id, 'namespace')}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="mkt-empty"><p>No architectures match your search.</p></div>
|
||||
)}
|
||||
|
||||
{/* Installed Packages */}
|
||||
<CollapsibleSection icon={ICON.packages} title="Installed Packages" count={installedPackages.length}
|
||||
extra={isCustomMode ? (
|
||||
<button className="myorg-inline-btn" onClick={() => setShowImportForm(!showImportForm)}>
|
||||
<img src={ICON.importPkg} alt="" className="myorg-inline-icon" /> Import
|
||||
</button>
|
||||
) : undefined}>
|
||||
{isCustomMode && showImportForm && (
|
||||
<div className="myorg-form">
|
||||
<div className="oc-form-row">
|
||||
<label>Path</label>
|
||||
<input value={importPath} onChange={e => setImportPath(e.target.value)}
|
||||
placeholder="/path/to/package.opcpkg" />
|
||||
</div>
|
||||
<div className="oc-form-actions">
|
||||
<button className="oc-btn-primary" onClick={handleImport} disabled={!importPath.trim()}>Install</button>
|
||||
<button className="oc-btn-ghost" onClick={() => setShowImportForm(false)}>Cancel</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{installedPackages.length > 0 ? (
|
||||
<div className="pkg-grid">
|
||||
{installedPackages.map(pkg => (
|
||||
<PackageCard key={pkg.package_id} pkg={pkg}
|
||||
onUninstall={isCustomMode ? handleUninstall : undefined}
|
||||
uninstallingId={uninstallingId} />
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="myorg-empty-hint">No packages installed.</div>
|
||||
)}
|
||||
</CollapsibleSection>
|
||||
|
||||
{/* Channels & Connectors */}
|
||||
<CollapsibleSection icon={ICON.channels} title="Channels & Connectors" count={channels.length}>
|
||||
{channels.length > 0 ? (
|
||||
<div className="org-channels-grid">
|
||||
{channels.map(ch => (
|
||||
<div key={ch.name} className={`org-channel-card${ch.running ? ' org-ch-running' : ''}${!ch.enabled ? ' org-ch-disabled' : ''}`}>
|
||||
<div className="org-ch-header">
|
||||
<span className={`org-ch-dot${ch.running ? ' running' : ch.ready ? ' ready' : ch.configured ? ' configured' : ''}`} />
|
||||
<span className="org-ch-name">{ch.name}</span>
|
||||
</div>
|
||||
<div className="org-ch-status-row">
|
||||
{ch.enabled && <span className="org-ch-badge org-ch-enabled">enabled</span>}
|
||||
{ch.running && <span className="org-ch-badge org-ch-running-badge">running</span>}
|
||||
{!ch.enabled && <span className="org-ch-badge org-ch-off">disabled</span>}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="myorg-empty-hint">No channels configured.</div>
|
||||
)}
|
||||
</CollapsibleSection>
|
||||
|
||||
{/* Reorg Proposals */}
|
||||
{reorgProposals.length > 0 && (
|
||||
<CollapsibleSection icon={ICON.reorg} title="Reorg Proposals" count={reorgProposals.length}>
|
||||
<div className="org-reorg-list">
|
||||
{reorgProposals.map(p => {
|
||||
const isPending = p.status === 'proposed'
|
||||
return (
|
||||
<div key={p.proposal_id} className={`org-reorg-card org-reorg-${p.status}`}>
|
||||
<div className="org-reorg-header">
|
||||
<span className="org-reorg-title">{p.title || p.summary || 'Untitled'}</span>
|
||||
<span className="org-reorg-status">{p.status}</span>
|
||||
</div>
|
||||
{p.summary && <div className="org-reorg-summary">{p.summary}</div>}
|
||||
{isPending && isCustomMode && (
|
||||
<div className="org-reorg-actions">
|
||||
<button className="org-reorg-approve" onClick={() => onReorgDecide(p.proposal_id, true)}>Approve</button>
|
||||
<button className="org-reorg-deny" onClick={() => onReorgDecide(p.proposal_id, false)}>Deny</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</CollapsibleSection>
|
||||
)}
|
||||
|
||||
{/* Architecture preview modal */}
|
||||
{previewData && (
|
||||
<ArchPreviewModal
|
||||
data={previewData}
|
||||
isInstalled={installedIds.has(previewData.id)}
|
||||
isApplying={applyingPresetId === previewData.id}
|
||||
onApply={() => onApplyPreset(previewData.id, 'namespace')}
|
||||
onClose={onClearPreview}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/* ── Architecture Card ───────────────────────────────────────────────── */
|
||||
|
||||
function ArchCard({ preset: p, isInstalled, isApplying, readOnly, onPreview, onApply }: {
|
||||
preset: ArchitecturePreset
|
||||
isInstalled: boolean
|
||||
isApplying: boolean
|
||||
readOnly: boolean
|
||||
onPreview: () => void
|
||||
onApply: () => void
|
||||
}) {
|
||||
const patternLabel = PATTERN_LABELS[p.collaboration_pattern] || p.collaboration_pattern
|
||||
|
||||
return (
|
||||
<div className="mkt-arch-card" style={{ borderLeftColor: p.color || 'var(--accent)' }}
|
||||
onClick={onPreview}>
|
||||
<div className="mkt-arch-header">
|
||||
<span className="mkt-arch-emoji">{p.emoji}</span>
|
||||
<div className="mkt-arch-title-wrap">
|
||||
<span className="mkt-arch-name">{p.name}</span>
|
||||
<div className="mkt-arch-badges">
|
||||
<span className="mkt-arch-category">{p.category}</span>
|
||||
{p.collaboration_pattern && <span className="mkt-arch-pattern">{patternLabel}</span>}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{p.dag_summary && <div className="mkt-arch-dag-summary">{p.dag_summary}</div>}
|
||||
<div className="mkt-arch-desc">{p.description}</div>
|
||||
|
||||
<div className="mkt-arch-stats">
|
||||
<span className="mkt-arch-stat">{p.roles_count} roles</span>
|
||||
<span className="mkt-arch-stat">{p.work_item_templates_count} templates</span>
|
||||
{p.gates_count > 0 && <span className="mkt-arch-stat">{p.gates_count} checkpoints</span>}
|
||||
{p.team_size && <span className="mkt-arch-stat">{p.team_size} people</span>}
|
||||
</div>
|
||||
|
||||
{p.tags.length > 0 && (
|
||||
<div className="mkt-arch-tags">
|
||||
{p.tags.slice(0, 4).map(t => <span key={t} className="mkt-tag">{t}</span>)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="mkt-arch-actions">
|
||||
{isInstalled ? (
|
||||
<span className="mkt-installed-badge">Installed</span>
|
||||
) : !readOnly ? (
|
||||
<button className="mkt-btn mkt-btn-primary mkt-btn-sm"
|
||||
disabled={isApplying}
|
||||
onClick={e => { e.stopPropagation(); onApply() }}
|
||||
>{isApplying ? 'Applying...' : 'Use This'}</button>
|
||||
) : null}
|
||||
<button className="mkt-btn mkt-btn-ghost mkt-btn-sm"
|
||||
onClick={e => { e.stopPropagation(); onPreview() }}
|
||||
>Preview</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/* ── Architecture Preview Modal ──────────────────────────────────────── */
|
||||
|
||||
function ArchPreviewModal({ data, isInstalled, isApplying, onApply, onClose }: {
|
||||
data: ArchitecturePresetDetail
|
||||
isInstalled: boolean
|
||||
isApplying: boolean
|
||||
onApply: () => void
|
||||
onClose: () => void
|
||||
}) {
|
||||
return (
|
||||
<div className="mkt-modal-overlay" onClick={onClose}>
|
||||
<div className="mkt-modal" onClick={e => e.stopPropagation()}>
|
||||
<div className="mkt-modal-header" style={{ borderBottomColor: data.color || 'var(--border)' }}>
|
||||
<span className="mkt-modal-emoji">{data.emoji}</span>
|
||||
<div>
|
||||
<h2 className="mkt-modal-name">{data.name}</h2>
|
||||
<span className="mkt-modal-category">{data.category}</span>
|
||||
</div>
|
||||
<button className="mkt-modal-close" onClick={onClose}>×</button>
|
||||
</div>
|
||||
|
||||
<div className="mkt-modal-body">
|
||||
<p className="mkt-modal-desc">{data.description}</p>
|
||||
|
||||
{data.tags.length > 0 && (
|
||||
<div className="mkt-modal-section">
|
||||
<div className="mkt-modal-label">Tags</div>
|
||||
<div className="mkt-modal-tags">
|
||||
{data.tags.map(t => <span key={t} className="mkt-tag">{t}</span>)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="mkt-modal-section">
|
||||
<div className="mkt-modal-label">Roles ({data.roles.length})</div>
|
||||
<div className="mkt-role-list">
|
||||
{data.roles.map(r => (
|
||||
<div key={r.id} className="mkt-role-item">
|
||||
<div className="mkt-role-name">{r.name} <code>{r.id}</code></div>
|
||||
<div className="mkt-role-resp">{r.responsibility}</div>
|
||||
<div className="mkt-role-meta">
|
||||
reports to: <code>{r.reports_to}</code>
|
||||
{r.can_spawn && r.can_spawn.length > 0 && (
|
||||
<> · spawns: {r.can_spawn.map(s => <code key={s}>{s}</code>)}</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Work item templates */}
|
||||
<div className="mkt-modal-section">
|
||||
<div className="mkt-modal-label">Work item templates ({data.work_item_templates.length} templates)</div>
|
||||
<div className="mkt-dag-wrap">
|
||||
<ModalWorkItemTemplates templates={data.work_item_templates} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mkt-modal-footer">
|
||||
{isInstalled ? (
|
||||
<span className="mkt-installed-badge">Already Installed</span>
|
||||
) : (
|
||||
<button className="mkt-btn mkt-btn-primary" disabled={isApplying} onClick={onApply}>
|
||||
{isApplying ? 'Applying...' : 'Use This Architecture'}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function ModalWorkItemTemplates({ templates }: { templates: ArchitecturePresetDetail['work_item_templates'] }): ReactNode {
|
||||
type Group = { group: string | null; templates: typeof templates }
|
||||
const groups: Group[] = []
|
||||
let currentGroup: string | null = '__init__'
|
||||
let currentTemplates: typeof templates = []
|
||||
for (const template of templates) {
|
||||
if (template.parallel_group !== currentGroup) {
|
||||
if (currentTemplates.length > 0) groups.push({ group: currentGroup, templates: currentTemplates })
|
||||
currentGroup = template.parallel_group
|
||||
currentTemplates = [template]
|
||||
} else {
|
||||
currentTemplates.push(template)
|
||||
}
|
||||
}
|
||||
if (currentTemplates.length > 0) groups.push({ group: currentGroup, templates: currentTemplates })
|
||||
|
||||
return (
|
||||
<div className="org-dag">
|
||||
{groups.map((g, gi) => (
|
||||
<div key={gi} className="org-dag-group-wrap">
|
||||
{gi > 0 && <div className="org-dag-arrow"><img src={ICON.arrow} alt="→" className="org-dag-arrow-icon" /></div>}
|
||||
<div className={`org-dag-group${g.templates.length > 1 ? ' org-dag-parallel' : ''}`}>
|
||||
{g.templates.length > 1 && <div className="org-dag-parallel-label">parallel</div>}
|
||||
{g.templates.map(template => (
|
||||
<div key={template.id} className="org-dag-node">
|
||||
<div className="org-dag-node-header">
|
||||
<span className="org-dag-node-title">{template.title}</span>
|
||||
<span className="org-dag-node-role">{template.role_id}</span>
|
||||
</div>
|
||||
<div className="org-dag-node-id">{template.id}</div>
|
||||
{template.gate && (
|
||||
<div className={`org-dag-gate org-gate-${template.gate.type}`}>
|
||||
<img
|
||||
src={template.gate.type === 'review' ? ICON.gateReview : template.gate.type === 'approval' ? ICON.gateApproval : ICON.gateHold}
|
||||
alt="" className="org-gate-icon"
|
||||
/>
|
||||
<span>{template.gate.type}</span>
|
||||
{template.gate.reviewer_role && <span className="org-gate-reviewer">by {template.gate.reviewer_role}</span>}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
import { useState, type ReactNode } from 'react'
|
||||
|
||||
interface CollapsibleSectionProps {
|
||||
icon: string
|
||||
title: string
|
||||
count: number
|
||||
extra?: ReactNode
|
||||
children: ReactNode
|
||||
defaultExpanded?: boolean
|
||||
}
|
||||
|
||||
export function CollapsibleSection({ icon, title, count, extra, children, defaultExpanded = false }: CollapsibleSectionProps) {
|
||||
const [expanded, setExpanded] = useState(defaultExpanded)
|
||||
return (
|
||||
<div className="myorg-collapsible">
|
||||
<button className="myorg-collapsible-toggle" onClick={() => setExpanded(!expanded)}>
|
||||
<span className="myorg-toggle-icon">{expanded ? '\u25BE' : '\u25B8'}</span>
|
||||
<img src={icon} alt="" className="myorg-section-icon" />
|
||||
<span className="myorg-collapsible-title">{title}</span>
|
||||
<span className="myorg-collapsible-count">{count}</span>
|
||||
{extra && <span className="myorg-collapsible-extra" onClick={e => e.stopPropagation()}>{extra}</span>}
|
||||
</button>
|
||||
{expanded && <div className="myorg-collapsible-body">{children}</div>}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,191 @@
|
||||
import { useEffect, useRef, useState, type ChangeEvent } from 'react'
|
||||
|
||||
/* ── Inline SVG icon data-URIs ──────────────────────────────────── */
|
||||
const ICON = {
|
||||
download: "data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'%3E%3Cpath fill='%23888' d='M19 9h-4V3H9v6H5l7 7 7-7zM5 18v2h14v-2H5z'/%3E%3C/svg%3E",
|
||||
upload: "data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'%3E%3Cpath fill='%23888' d='M9 16h6v-6h4l-7-7-7 7h4zm-4 2h14v2H5z'/%3E%3C/svg%3E",
|
||||
check: "data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'%3E%3Cpath fill='%2322c55e' d='M9 16.17L4.83 12l-1.42 1.41L9 19 21 7l-1.41-1.41L9 16.17z'/%3E%3C/svg%3E",
|
||||
warn: "data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'%3E%3Cpath fill='%23ef4444' d='M1 21h22L12 2 1 21zm12-3h-2v-2h2v2zm0-4h-2v-4h2v4z'/%3E%3C/svg%3E",
|
||||
}
|
||||
|
||||
interface ConfigImportExportPanelProps {
|
||||
onExport: () => void
|
||||
onImport: (yaml: string, dryRun: boolean) => void
|
||||
configExportYaml?: string | null
|
||||
importPreview?: { roles_added: number; roles_removed: number; employees_changed: number } | null
|
||||
importError?: string | null
|
||||
}
|
||||
|
||||
export function ConfigImportExportPanel({
|
||||
onExport, onImport, configExportYaml, importPreview, importError,
|
||||
}: ConfigImportExportPanelProps) {
|
||||
const [yamlText, setYamlText] = useState('')
|
||||
const [fileName, setFileName] = useState<string | null>(null)
|
||||
const [dryRunDone, setDryRunDone] = useState(false)
|
||||
const exportPending = useRef(false)
|
||||
const lastExportedYaml = useRef<string | null>(null)
|
||||
|
||||
// Trigger browser download when server returns the exported YAML
|
||||
useEffect(() => {
|
||||
if (!exportPending.current) return
|
||||
if (!configExportYaml) return
|
||||
if (configExportYaml === lastExportedYaml.current) return
|
||||
lastExportedYaml.current = configExportYaml
|
||||
exportPending.current = false
|
||||
|
||||
const blob = new Blob([configExportYaml], { type: 'application/x-yaml' })
|
||||
const url = URL.createObjectURL(blob)
|
||||
const a = document.createElement('a')
|
||||
a.href = url
|
||||
const stamp = new Date().toISOString().replace(/[:.]/g, '-').slice(0, 19)
|
||||
a.download = `org_config_${stamp}.yaml`
|
||||
document.body.appendChild(a)
|
||||
a.click()
|
||||
document.body.removeChild(a)
|
||||
URL.revokeObjectURL(url)
|
||||
}, [configExportYaml])
|
||||
|
||||
// Reset dry-run state when user edits the YAML
|
||||
useEffect(() => {
|
||||
setDryRunDone(false)
|
||||
}, [yamlText])
|
||||
|
||||
// Flip dry-run state on successful preview (not on error)
|
||||
useEffect(() => {
|
||||
if (importPreview && !importError) setDryRunDone(true)
|
||||
}, [importPreview, importError])
|
||||
|
||||
const handleExport = () => {
|
||||
exportPending.current = true
|
||||
onExport()
|
||||
}
|
||||
|
||||
const handleFile = (e: ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0]
|
||||
if (!file) return
|
||||
setFileName(file.name)
|
||||
const reader = new FileReader()
|
||||
reader.onload = () => {
|
||||
const result = reader.result
|
||||
if (typeof result === 'string') setYamlText(result)
|
||||
}
|
||||
reader.readAsText(file)
|
||||
e.target.value = '' // allow re-selecting same file
|
||||
}
|
||||
|
||||
const handleDryRun = () => {
|
||||
if (!yamlText.trim()) return
|
||||
onImport(yamlText, true)
|
||||
}
|
||||
|
||||
const handleApply = () => {
|
||||
if (!dryRunDone || !yamlText.trim()) return
|
||||
if (!confirm('Apply this config? The current company architecture will be overwritten.')) return
|
||||
onImport(yamlText, false)
|
||||
setDryRunDone(false)
|
||||
setYamlText('')
|
||||
setFileName(null)
|
||||
}
|
||||
|
||||
const handleClear = () => {
|
||||
setYamlText('')
|
||||
setFileName(null)
|
||||
setDryRunDone(false)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="cfg-io-panel" data-testid="config-import-export-panel">
|
||||
<div className="cfg-io-header">
|
||||
<h3 className="cfg-io-title">Config Import / Export</h3>
|
||||
<p className="cfg-io-subtitle">Download the current company architecture as YAML, or upload one to replace it.</p>
|
||||
</div>
|
||||
|
||||
{/* Export */}
|
||||
<div className="cfg-io-section">
|
||||
<div className="cfg-io-section-header">
|
||||
<img src={ICON.download} alt="" className="cfg-io-section-icon" />
|
||||
<span className="cfg-io-section-title">Download current config</span>
|
||||
</div>
|
||||
<button className="cfg-io-btn cfg-io-btn-primary" onClick={handleExport}>
|
||||
<img src={ICON.download} alt="" className="cfg-io-btn-icon" /> Download YAML
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Import */}
|
||||
<div className="cfg-io-section">
|
||||
<div className="cfg-io-section-header">
|
||||
<img src={ICON.upload} alt="" className="cfg-io-section-icon" />
|
||||
<span className="cfg-io-section-title">Upload config</span>
|
||||
</div>
|
||||
|
||||
<div className="cfg-io-upload-row">
|
||||
<label className="cfg-io-file-label">
|
||||
<input type="file" accept=".yaml,.yml" onChange={handleFile} className="cfg-io-file-input" />
|
||||
<span className="cfg-io-file-btn">Choose file…</span>
|
||||
<span className="cfg-io-file-name">{fileName ?? 'no file selected'}</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<textarea
|
||||
className="cfg-io-textarea"
|
||||
placeholder="…or paste YAML here"
|
||||
value={yamlText}
|
||||
onChange={e => setYamlText(e.target.value)}
|
||||
spellCheck={false}
|
||||
rows={10}
|
||||
/>
|
||||
|
||||
<div className="cfg-io-actions">
|
||||
<button className="cfg-io-btn cfg-io-btn-ghost"
|
||||
onClick={handleDryRun}
|
||||
disabled={!yamlText.trim()}>
|
||||
Dry run
|
||||
</button>
|
||||
<button className="cfg-io-btn cfg-io-btn-primary"
|
||||
onClick={handleApply}
|
||||
disabled={!dryRunDone || !yamlText.trim()}>
|
||||
Apply
|
||||
</button>
|
||||
{yamlText && (
|
||||
<button className="cfg-io-btn cfg-io-btn-ghost" onClick={handleClear}>Clear</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Preview */}
|
||||
{importPreview && !importError && (
|
||||
<div className="cfg-io-preview">
|
||||
<img src={ICON.check} alt="" className="cfg-io-preview-icon" />
|
||||
<div className="cfg-io-preview-body">
|
||||
<div className="cfg-io-preview-title">Dry run OK — ready to apply</div>
|
||||
<div className="cfg-io-preview-stats">
|
||||
<span className="cfg-io-preview-stat">
|
||||
<span className="cfg-io-stat-label">Roles added</span>
|
||||
<span className="cfg-io-stat-value">{importPreview.roles_added}</span>
|
||||
</span>
|
||||
<span className="cfg-io-preview-stat">
|
||||
<span className="cfg-io-stat-label">Roles removed</span>
|
||||
<span className="cfg-io-stat-value">{importPreview.roles_removed}</span>
|
||||
</span>
|
||||
<span className="cfg-io-preview-stat">
|
||||
<span className="cfg-io-stat-label">Employees changed</span>
|
||||
<span className="cfg-io-stat-value">{importPreview.employees_changed}</span>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Error */}
|
||||
{importError && (
|
||||
<div className="cfg-io-error">
|
||||
<img src={ICON.warn} alt="" className="cfg-io-error-icon" />
|
||||
<div className="cfg-io-error-body">
|
||||
<div className="cfg-io-error-title">Validation failed</div>
|
||||
<pre className="cfg-io-error-text">{importError}</pre>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,179 @@
|
||||
import type { OrgRole, RuntimeFrontierSummary, RuntimeSeatInfo, RuntimeTeamInfo, RuntimeWorkItemInfo, RuntimePolicy } from '../types/visual'
|
||||
|
||||
interface DelegationStrategyPanelProps {
|
||||
roles: OrgRole[]
|
||||
runtimeTeams: RuntimeTeamInfo[]
|
||||
runtimeSeats: RuntimeSeatInfo[]
|
||||
workItems: RuntimeWorkItemInfo[]
|
||||
frontier: RuntimeFrontierSummary
|
||||
companyProfile: string
|
||||
runtimePolicy?: RuntimePolicy
|
||||
finalDeciderRoleId?: string | null
|
||||
topLevelRoleIds?: string[]
|
||||
readOnly?: boolean
|
||||
onUpdateOrgStrategy?: (data: { final_decider_role_id?: string | null }) => void
|
||||
onUpdateRuntimePolicy?: (policy: Record<string, any>) => void
|
||||
}
|
||||
|
||||
function adaptiveForWorkItem(item: RuntimeWorkItemInfo): Record<string, unknown> | undefined {
|
||||
if (item.adaptive && typeof item.adaptive === 'object') return item.adaptive
|
||||
const metadata = item.metadata
|
||||
if (metadata && typeof metadata === 'object' && metadata.adaptive && typeof metadata.adaptive === 'object') {
|
||||
return metadata.adaptive as Record<string, unknown>
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
function missingSignalsForWorkItem(item: RuntimeWorkItemInfo): string[] {
|
||||
const adaptive = adaptiveForWorkItem(item)
|
||||
const signals = Array.isArray(adaptive?.signals) ? adaptive.signals : []
|
||||
return signals
|
||||
.filter(signal => signal && typeof signal === 'object')
|
||||
.filter(signal => Boolean((signal as Record<string, unknown>).required ?? true) && !Boolean((signal as Record<string, unknown>).satisfied))
|
||||
.map(signal => String((signal as Record<string, unknown>).name ?? '').trim())
|
||||
.filter(Boolean)
|
||||
}
|
||||
|
||||
function gateOwnerForWorkItem(item: RuntimeWorkItemInfo): string {
|
||||
const adaptive = adaptiveForWorkItem(item)
|
||||
const stageProfile = adaptive?.work_item_profile
|
||||
if (stageProfile && typeof stageProfile === 'object') {
|
||||
return String((stageProfile as Record<string, unknown>).gate_owner_role_id ?? '').trim()
|
||||
}
|
||||
return ''
|
||||
}
|
||||
|
||||
function adaptiveConfidenceLabel(item: RuntimeWorkItemInfo): string {
|
||||
const adaptive = adaptiveForWorkItem(item)
|
||||
const confidence = typeof adaptive?.confidence === 'number' ? adaptive.confidence : undefined
|
||||
return typeof confidence === 'number' ? `${Math.round(confidence * 100)}%` : ''
|
||||
}
|
||||
|
||||
export function DelegationStrategyPanel({
|
||||
roles,
|
||||
runtimeTeams,
|
||||
runtimeSeats,
|
||||
workItems,
|
||||
frontier,
|
||||
companyProfile,
|
||||
runtimePolicy,
|
||||
finalDeciderRoleId,
|
||||
topLevelRoleIds,
|
||||
readOnly = false,
|
||||
onUpdateOrgStrategy,
|
||||
onUpdateRuntimePolicy,
|
||||
}: DelegationStrategyPanelProps) {
|
||||
const topLevel = roles.filter(r => (topLevelRoleIds ?? []).includes(r.role_id))
|
||||
const selectedFinalDecider = finalDeciderRoleId || (topLevel.length === 1 ? topLevel[0]?.role_id : '')
|
||||
const hasSelectionError = topLevel.length > 1 && !selectedFinalDecider
|
||||
const roleNameMap = new Map(roles.map(r => [r.role_id, r.name]))
|
||||
|
||||
return (
|
||||
<div className="wfe-container">
|
||||
<div className="wfe-header">
|
||||
<h3 className="wfe-title">Actor Runtime</h3>
|
||||
<span className="wfe-profile-badge">{companyProfile}</span>
|
||||
</div>
|
||||
|
||||
<div className="myorg-collapsible" style={{ margin: '0 0 8px' }}>
|
||||
<div className="myorg-collapsible-body" style={{ display: 'block' }}>
|
||||
<div className="oc-form-row">
|
||||
<label>Final decider</label>
|
||||
<select
|
||||
value={selectedFinalDecider}
|
||||
disabled={readOnly}
|
||||
onChange={e => {
|
||||
if (readOnly) return
|
||||
onUpdateOrgStrategy?.({ final_decider_role_id: e.target.value || null })
|
||||
}}
|
||||
>
|
||||
<option value="">{topLevel.length > 1 ? 'Select top-level role' : 'Auto-select only top-level role'}</option>
|
||||
{topLevel.map(role => (
|
||||
<option key={role.role_id} value={role.role_id}>{role.name}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div style={{ fontSize: 12, color: 'var(--text-secondary)' }}>
|
||||
Runtime wakeups, delegation, approvals, and recovery are seat-scoped.
|
||||
</div>
|
||||
{hasSelectionError && (
|
||||
<div className="org-toast org-toast--warn" style={{ margin: '0 0 8px' }}>
|
||||
Multiple top-level roles exist. Select one final decider before company execution can start.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{(runtimeTeams.length || runtimeSeats.length || workItems.length) ? (
|
||||
<div className="myorg-collapsible" style={{ margin: '0 0 8px' }}>
|
||||
<div className="myorg-collapsible-body" style={{ display: 'block' }}>
|
||||
<div className="oc-form-row">
|
||||
<label>Runtime</label>
|
||||
<div>
|
||||
{frontier.status || 'running'}
|
||||
{frontier.run_id ? ` (${frontier.run_id.slice(0, 8)})` : ''}
|
||||
</div>
|
||||
</div>
|
||||
<div className="oc-form-row">
|
||||
<label>Frontier</label>
|
||||
<div>
|
||||
{frontier.running_count ?? 0} running, {frontier.ready_count ?? 0} ready, {frontier.blocked_count ?? 0} blocked, {frontier.waiting_count ?? 0} waiting
|
||||
</div>
|
||||
</div>
|
||||
<div className="oc-form-row">
|
||||
<label>Teams</label>
|
||||
<div>{runtimeTeams.length}</div>
|
||||
</div>
|
||||
<div className="oc-form-row">
|
||||
<label>Seats</label>
|
||||
<div>{runtimeSeats.length}</div>
|
||||
</div>
|
||||
<div className="oc-form-row">
|
||||
<label>Work items</label>
|
||||
<div>{workItems.length}</div>
|
||||
</div>
|
||||
{workItems.length > 0 && (
|
||||
<div style={{ fontSize: 12, color: 'var(--text-secondary)' }}>
|
||||
{workItems.slice(0, 6).map(item => {
|
||||
const adaptive = adaptiveForWorkItem(item)
|
||||
const normalizedState = typeof adaptive?.normalized_state === 'string' ? adaptive.normalized_state : ''
|
||||
const blockedReason = typeof adaptive?.blocked_reason === 'string' ? adaptive.blocked_reason : (item.blocked_reason ?? '')
|
||||
const gateOwner = gateOwnerForWorkItem(item)
|
||||
const missingSignals = missingSignalsForWorkItem(item)
|
||||
const confidence = adaptiveConfidenceLabel(item)
|
||||
const summary = [
|
||||
blockedReason ? `waiting ${blockedReason}` : '',
|
||||
gateOwner ? `gate ${roleNameMap.get(gateOwner) ?? gateOwner}` : '',
|
||||
missingSignals.length ? `signals ${missingSignals.join(', ')}` : '',
|
||||
confidence ? `confidence ${confidence}` : '',
|
||||
normalizedState === 'invalidated' ? 'invalidated' : '',
|
||||
].filter(Boolean)
|
||||
return (
|
||||
<div key={item.work_item_id} style={{ marginBottom: 6 }}>
|
||||
<div>
|
||||
{roleNameMap.get(item.role_id) ?? item.role_id}: {item.title} [{item.phase}]
|
||||
{item.kanban_column && (
|
||||
<span style={{ opacity: 0.5 }}> · {item.kanban_column}</span>
|
||||
)}
|
||||
{item.batch_id && <span style={{ opacity: 0.5 }}> batch:{item.batch_id}</span>}
|
||||
{normalizedState && normalizedState !== item.phase && (
|
||||
<span style={{ opacity: 0.7 }}> state:{normalizedState}</span>
|
||||
)}
|
||||
</div>
|
||||
{summary.length > 0 && (
|
||||
<div style={{ marginLeft: 12, opacity: 0.85 }}>
|
||||
{summary.join(' • ')}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,136 @@
|
||||
import { useMemo, useState } from 'react'
|
||||
import type { TalentTemplate, OrgRole, HireTalentHandler } from '../types/visual'
|
||||
import { TalentCard } from './TalentCard'
|
||||
import { TalentDetailModal } from './TalentDetailModal'
|
||||
import { HireToRoleModal } from './HireToRoleModal'
|
||||
|
||||
/* ── Inline SVG icon data-URIs ──────────────────────────────────── */
|
||||
const ICON = {
|
||||
search: "data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'%3E%3Cpath fill='%23888' d='M15.5 14h-.79l-.28-.27a6.5 6.5 0 1 0-.7.7l.27.28v.79l5 4.99L20.49 19l-4.99-5zm-6 0A4.5 4.5 0 1 1 14 9.5 4.5 4.5 0 0 1 9.5 14z'/%3E%3C/svg%3E",
|
||||
talent: "data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'%3E%3Cpath fill='%23888' d='M12 12c2.21 0 4-1.79 4-4s-1.79-4-4-4-4 1.79-4 4 1.79 4 4 4zm0 2c-2.67 0-8 1.34-8 4v2h16v-2c0-2.66-5.33-4-8-4z'/%3E%3C/svg%3E",
|
||||
}
|
||||
|
||||
interface EmployeesMarketplaceProps {
|
||||
templates: TalentTemplate[]
|
||||
vacantRoles: OrgRole[]
|
||||
hiringTemplateId: string | null
|
||||
readOnly: boolean
|
||||
onHireTalent: HireTalentHandler
|
||||
}
|
||||
|
||||
export function EmployeesMarketplace({
|
||||
templates, vacantRoles, hiringTemplateId, readOnly, onHireTalent,
|
||||
}: EmployeesMarketplaceProps) {
|
||||
const [search, setSearch] = useState('')
|
||||
const [activeCategory, setActiveCategory] = useState<string | null>(null)
|
||||
const [detailTemplate, setDetailTemplate] = useState<TalentTemplate | null>(null)
|
||||
const [hireForTemplate, setHireForTemplate] = useState<TalentTemplate | null>(null)
|
||||
|
||||
const categories = useMemo(() => {
|
||||
const cats = new Set<string>()
|
||||
for (const t of templates) { if (t.category) cats.add(t.category) }
|
||||
return Array.from(cats).sort()
|
||||
}, [templates])
|
||||
|
||||
const filteredTemplates = useMemo(() => {
|
||||
let result = templates
|
||||
if (activeCategory) result = result.filter(t => t.category === activeCategory)
|
||||
if (search.trim()) {
|
||||
const q = search.trim().toLowerCase()
|
||||
result = result.filter(t =>
|
||||
t.name.toLowerCase().includes(q) ||
|
||||
t.description.toLowerCase().includes(q) ||
|
||||
t.domains.some(d => d.toLowerCase().includes(q)) ||
|
||||
t.tags.some(tag => tag.toLowerCase().includes(q)) ||
|
||||
(t.vibe ?? '').toLowerCase().includes(q),
|
||||
)
|
||||
}
|
||||
return result
|
||||
}, [templates, activeCategory, search])
|
||||
|
||||
const handleCardHire = (templateId: string) => {
|
||||
if (readOnly) return
|
||||
const template = templates.find(t => t.template_id === templateId)
|
||||
if (template) setHireForTemplate(template)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="mkt-container" data-testid="employees-marketplace">
|
||||
{/* Toolbar */}
|
||||
<div className="mkt-toolbar">
|
||||
<div className="mkt-search-wrap">
|
||||
<img src={ICON.search} alt="" className="mkt-search-icon" />
|
||||
<input
|
||||
className="mkt-search"
|
||||
placeholder="Search talent templates..."
|
||||
value={search}
|
||||
onChange={e => setSearch(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<span className="mkt-count">
|
||||
{filteredTemplates.length} employees
|
||||
{vacantRoles.length > 0 && <> · {vacantRoles.length} vacant role{vacantRoles.length === 1 ? '' : 's'}</>}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Category pills */}
|
||||
{categories.length > 0 && (
|
||||
<div className="mkt-filters">
|
||||
<div className="mkt-pill-row">
|
||||
<button className={`mkt-pill${!activeCategory ? ' active' : ''}`}
|
||||
onClick={() => setActiveCategory(null)}>All</button>
|
||||
{categories.map(cat => (
|
||||
<button key={cat} className={`mkt-pill${activeCategory === cat ? ' active' : ''}`}
|
||||
onClick={() => setActiveCategory(activeCategory === cat ? null : cat)}
|
||||
>{cat}</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Talent grid */}
|
||||
{filteredTemplates.length > 0 ? (
|
||||
<div className="mkt-section">
|
||||
<div className="mkt-section-header">
|
||||
<img src={ICON.talent} alt="" className="mkt-section-icon" />
|
||||
<h3 className="mkt-section-title">Talent Templates</h3>
|
||||
<span className="mkt-section-count">{filteredTemplates.length}</span>
|
||||
</div>
|
||||
<div className="tm-grid">
|
||||
{filteredTemplates.map(t => (
|
||||
<TalentCard key={t.template_id} template={t}
|
||||
hiringId={hiringTemplateId}
|
||||
onHire={handleCardHire}
|
||||
onClick={setDetailTemplate}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="mkt-empty">
|
||||
<p>{templates.length === 0 ? 'No talent templates available.' : 'No employees match your search.'}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Detail modal */}
|
||||
{detailTemplate && (
|
||||
<TalentDetailModal
|
||||
template={detailTemplate}
|
||||
vacantRoles={vacantRoles}
|
||||
hiringId={hiringTemplateId}
|
||||
readOnly={readOnly}
|
||||
onHire={(tid, rid) => { if (!readOnly) { onHireTalent(tid, rid); setDetailTemplate(null) } }}
|
||||
onClose={() => setDetailTemplate(null)}
|
||||
/>
|
||||
)}
|
||||
|
||||
<HireToRoleModal
|
||||
open={hireForTemplate !== null}
|
||||
template={hireForTemplate}
|
||||
vacantRoles={vacantRoles}
|
||||
onConfirm={(tid, rid) => { onHireTalent(tid, rid); setHireForTemplate(null) }}
|
||||
onClose={() => setHireForTemplate(null)}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import type { OrgRole, TalentTemplate, RoleId, TemplateId } from '../types/visual'
|
||||
import { asRoleId, asTemplateId } from '../types/visual'
|
||||
|
||||
interface HireToRoleModalProps {
|
||||
open: boolean
|
||||
template: TalentTemplate | null
|
||||
vacantRoles: OrgRole[]
|
||||
onConfirm: (template: TemplateId, role: RoleId) => void
|
||||
onClose: () => void
|
||||
}
|
||||
|
||||
export function HireToRoleModal({
|
||||
open, template, vacantRoles, onConfirm, onClose,
|
||||
}: HireToRoleModalProps) {
|
||||
const [selectedRoleId, setSelectedRoleId] = useState<string>('')
|
||||
|
||||
useEffect(() => {
|
||||
if (open) setSelectedRoleId('')
|
||||
}, [open, template?.template_id])
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return
|
||||
const onKey = (event: KeyboardEvent) => {
|
||||
if (event.key === 'Escape') onClose()
|
||||
}
|
||||
window.addEventListener('keydown', onKey)
|
||||
return () => window.removeEventListener('keydown', onKey)
|
||||
}, [open, onClose])
|
||||
|
||||
if (!open || !template) return null
|
||||
|
||||
const noVacancies = vacantRoles.length === 0
|
||||
const canConfirm = !noVacancies && selectedRoleId !== ''
|
||||
|
||||
const handleConfirm = () => {
|
||||
if (!canConfirm) return
|
||||
onConfirm(asTemplateId(template.template_id), asRoleId(selectedRoleId))
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="htr-overlay" role="dialog" aria-modal="true" onMouseDown={onClose}>
|
||||
<div className="htr-modal" onMouseDown={event => event.stopPropagation()}>
|
||||
<header className="htr-header">
|
||||
<div>
|
||||
<h3 className="htr-title">Hire {template.name}</h3>
|
||||
<p className="htr-subtitle">Pick the role to fill with this employee.</p>
|
||||
</div>
|
||||
<button className="htr-close" type="button" onClick={onClose} aria-label="Close">x</button>
|
||||
</header>
|
||||
|
||||
<div className="htr-body">
|
||||
{noVacancies ? (
|
||||
<div className="htr-empty">
|
||||
<p className="htr-empty-title">No vacant roles.</p>
|
||||
<p className="htr-empty-hint">
|
||||
Add a role in the Team tab first, then come back to hire.
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="htr-role-list" role="listbox" aria-label="Vacant roles">
|
||||
{vacantRoles.map(role => {
|
||||
const selected = role.role_id === selectedRoleId
|
||||
return (
|
||||
<button
|
||||
key={role.role_id}
|
||||
type="button"
|
||||
role="option"
|
||||
aria-selected={selected}
|
||||
className={`htr-role-row${selected ? ' is-selected' : ''}`}
|
||||
onClick={() => setSelectedRoleId(role.role_id)}
|
||||
>
|
||||
<span className="htr-role-name">{role.name}</span>
|
||||
{role.responsibility && (
|
||||
<span className="htr-role-resp">{role.responsibility}</span>
|
||||
)}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<footer className="htr-footer">
|
||||
<button type="button" className="btn btn-ghost" onClick={onClose}>
|
||||
{noVacancies ? 'Close' : 'Cancel'}
|
||||
</button>
|
||||
{!noVacancies && (
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-primary"
|
||||
onClick={handleConfirm}
|
||||
disabled={!canConfirm}
|
||||
>
|
||||
Hire to selected role
|
||||
</button>
|
||||
)}
|
||||
</footer>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,257 @@
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import type { OrgCreateMemberInput, OrgSavedCreatePayload } from '../types/visual'
|
||||
|
||||
interface OrgCreateResult extends OrgSavedCreatePayload {
|
||||
nonce: number
|
||||
}
|
||||
|
||||
interface OrgCreateModalProps {
|
||||
open: boolean
|
||||
pending?: boolean
|
||||
result?: OrgCreateResult | null
|
||||
onClose: () => void
|
||||
onCreate: (organizationName: string, members: OrgCreateMemberInput[]) => void
|
||||
}
|
||||
|
||||
type MemberDraft = {
|
||||
name: string
|
||||
responsibility: string
|
||||
prompt: string
|
||||
reportsToIndex: number | null
|
||||
}
|
||||
|
||||
const INITIAL_MEMBERS: MemberDraft[] = [
|
||||
{ name: '', responsibility: '', prompt: '', reportsToIndex: null },
|
||||
{ name: '', responsibility: '', prompt: '', reportsToIndex: 0 },
|
||||
]
|
||||
|
||||
function slugLabel(value: string): string {
|
||||
return value.trim() || 'Member'
|
||||
}
|
||||
|
||||
export function OrgCreateModal({ open, pending, result, onClose, onCreate }: OrgCreateModalProps) {
|
||||
const [step, setStep] = useState(1)
|
||||
const [organizationName, setOrganizationName] = useState('')
|
||||
const [members, setMembers] = useState<MemberDraft[]>(INITIAL_MEMBERS)
|
||||
const [localError, setLocalError] = useState('')
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return
|
||||
setStep(1)
|
||||
setOrganizationName('')
|
||||
setMembers(INITIAL_MEMBERS)
|
||||
setLocalError('')
|
||||
}, [open])
|
||||
|
||||
useEffect(() => {
|
||||
if (!open || !result) return
|
||||
if (result.ok) {
|
||||
onClose()
|
||||
return
|
||||
}
|
||||
setLocalError(result.error || 'Failed to create organization')
|
||||
}, [open, result, onClose])
|
||||
|
||||
const organizationValid = organizationName.trim().length > 0
|
||||
const validMembers = useMemo(
|
||||
() => members.map((member, index) => ({ member, index })).filter(item => item.member.name.trim()),
|
||||
[members],
|
||||
)
|
||||
const originalIndexToCreateIndex = useMemo(
|
||||
() => new Map(validMembers.map((item, createIndex) => [item.index, createIndex])),
|
||||
[validMembers],
|
||||
)
|
||||
const membersValid = validMembers.length >= 2
|
||||
const canCreate = organizationValid && membersValid && !pending
|
||||
|
||||
const previewMembers = useMemo(
|
||||
() => validMembers.map(({ member, index }, createIndex) => {
|
||||
const mappedParent = member.reportsToIndex == null ? null : originalIndexToCreateIndex.get(member.reportsToIndex)
|
||||
return {
|
||||
...member,
|
||||
roleName: slugLabel(member.name),
|
||||
managerName: mappedParent != null && mappedParent < createIndex
|
||||
? slugLabel(validMembers[mappedParent]?.member.name || '')
|
||||
: 'Owner',
|
||||
index,
|
||||
}
|
||||
}),
|
||||
[originalIndexToCreateIndex, validMembers],
|
||||
)
|
||||
|
||||
if (!open) return null
|
||||
|
||||
const updateMember = (index: number, patch: Partial<MemberDraft>) => {
|
||||
setMembers(prev => prev.map((member, idx) => idx === index ? { ...member, ...patch } : member))
|
||||
}
|
||||
|
||||
const addMember = () => {
|
||||
setMembers(prev => [...prev, { name: '', responsibility: '', prompt: '', reportsToIndex: 0 }])
|
||||
}
|
||||
|
||||
const removeMember = (index: number) => {
|
||||
setMembers(prev => {
|
||||
const next = prev.filter((_, idx) => idx !== index)
|
||||
return next.map((member, idx) => ({
|
||||
...member,
|
||||
reportsToIndex: member.reportsToIndex == null
|
||||
? null
|
||||
: member.reportsToIndex >= index
|
||||
? Math.max(0, member.reportsToIndex - 1)
|
||||
: member.reportsToIndex,
|
||||
})).map((member, idx) => idx === 0 ? { ...member, reportsToIndex: null } : member)
|
||||
})
|
||||
}
|
||||
|
||||
const submit = () => {
|
||||
if (!canCreate) return
|
||||
setLocalError('')
|
||||
onCreate(
|
||||
organizationName.trim(),
|
||||
validMembers.map(({ member }, createIndex) => {
|
||||
const mappedParent = member.reportsToIndex == null ? null : originalIndexToCreateIndex.get(member.reportsToIndex)
|
||||
return {
|
||||
name: member.name.trim(),
|
||||
responsibility: member.responsibility.trim(),
|
||||
prompt: member.prompt.trim(),
|
||||
reports_to_index: mappedParent != null && mappedParent < createIndex
|
||||
? mappedParent
|
||||
: createIndex === 0 ? null : 0,
|
||||
}
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="org-create-backdrop" role="presentation" onMouseDown={onClose}>
|
||||
<div className="org-create-modal" role="dialog" aria-modal="true" aria-labelledby="org-create-title" onMouseDown={e => e.stopPropagation()}>
|
||||
<div className="org-create-header">
|
||||
<div>
|
||||
<span className="org-create-eyebrow">New organization</span>
|
||||
<h3 id="org-create-title" className="org-create-title">Create a saved org</h3>
|
||||
</div>
|
||||
<button type="button" className="org-create-close" onClick={onClose} aria-label="Close">x</button>
|
||||
</div>
|
||||
|
||||
<div className="org-create-steps" aria-label="Create organization steps">
|
||||
{[
|
||||
['1', 'Name'],
|
||||
['2', 'Members'],
|
||||
['3', 'Review'],
|
||||
].map(([id, label]) => (
|
||||
<span key={id} className={`org-create-step${step === Number(id) ? ' org-create-step--active' : step > Number(id) ? ' org-create-step--done' : ''}`}>
|
||||
<span>{id}</span>{label}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{step === 1 && (
|
||||
<div className="org-create-panel">
|
||||
<label className="org-create-field">
|
||||
<span>Organization name</span>
|
||||
<input
|
||||
value={organizationName}
|
||||
onChange={e => setOrganizationName(e.target.value)}
|
||||
placeholder="HKU Research Lab"
|
||||
autoFocus
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{step === 2 && (
|
||||
<div className="org-create-panel">
|
||||
<div className="org-create-member-list">
|
||||
{members.map((member, index) => (
|
||||
<div className="org-create-member-row" key={index}>
|
||||
<input
|
||||
value={member.name}
|
||||
onChange={e => updateMember(index, { name: e.target.value })}
|
||||
placeholder={index === 0 ? 'Lead role' : 'Member role'}
|
||||
/>
|
||||
<input
|
||||
value={member.responsibility}
|
||||
onChange={e => updateMember(index, { responsibility: e.target.value })}
|
||||
placeholder="Responsibility"
|
||||
/>
|
||||
<select
|
||||
value={member.reportsToIndex == null ? 'owner' : String(member.reportsToIndex)}
|
||||
onChange={e => updateMember(index, { reportsToIndex: e.target.value === 'owner' ? null : Number(e.target.value) })}
|
||||
disabled={index === 0}
|
||||
aria-label="Reports to"
|
||||
>
|
||||
<option value="owner">Owner</option>
|
||||
{members.slice(0, index).map((candidate, candidateIndex) => (
|
||||
<option key={candidateIndex} value={candidateIndex}>
|
||||
{slugLabel(candidate.name)}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<button
|
||||
type="button"
|
||||
className="org-create-icon-btn"
|
||||
onClick={() => removeMember(index)}
|
||||
disabled={members.length <= 2}
|
||||
title="Remove member"
|
||||
aria-label="Remove member"
|
||||
>
|
||||
-
|
||||
</button>
|
||||
<textarea
|
||||
value={member.prompt}
|
||||
onChange={e => updateMember(index, { prompt: e.target.value })}
|
||||
placeholder="Prompt optional"
|
||||
aria-label={`${index === 0 ? 'Lead role' : 'Member role'} prompt optional`}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<button type="button" className="org-create-add" onClick={addMember}>+ Add member</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{step === 3 && (
|
||||
<div className="org-create-panel">
|
||||
<div className="org-create-review">
|
||||
<div className="org-create-review-head">
|
||||
<span>{organizationName.trim()}</span>
|
||||
<b>{validMembers.length} members</b>
|
||||
</div>
|
||||
{previewMembers.map(member => (
|
||||
<div className="org-create-review-row" key={member.index}>
|
||||
<strong>{member.roleName}</strong>
|
||||
<span>
|
||||
{member.managerName}
|
||||
{member.prompt.trim() ? <em>Prompt</em> : null}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{localError && <div className="org-create-error">{localError}</div>}
|
||||
|
||||
<div className="org-create-actions">
|
||||
<button type="button" className="btn btn-ghost" onClick={step === 1 ? onClose : () => setStep(step - 1)}>
|
||||
{step === 1 ? 'Cancel' : 'Back'}
|
||||
</button>
|
||||
{step < 3 ? (
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-primary"
|
||||
onClick={() => setStep(step + 1)}
|
||||
disabled={step === 1 ? !organizationValid : !membersValid}
|
||||
>
|
||||
Next
|
||||
</button>
|
||||
) : (
|
||||
<button type="button" className="btn btn-primary" onClick={submit} disabled={!canCreate}>
|
||||
{pending ? 'Creating...' : 'Create organization'}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,128 @@
|
||||
/**
|
||||
* Structural regression test for OrgTab's 4-tab layout.
|
||||
*
|
||||
* Guards the sub-tab rename (flow → runtime, marketplace → architecture +
|
||||
* employees) and the three new marketplace panels. Reads OrgTab.tsx as
|
||||
* source text and asserts against it — the existing zero-framework test
|
||||
* convention (see runtimeOrg.test.ts, workItemSessions.test.ts) runs with
|
||||
* plain `tsx` and requires no vitest / jsdom / @testing-library install.
|
||||
*
|
||||
* Why source-scan instead of React render:
|
||||
* OrgTab.tsx imports './org.css'; Node can't load CSS without a vite
|
||||
* transform. A source-scan catches the primary regression concerns
|
||||
* (tab label rename, legacy label removal, panel import presence,
|
||||
* default active tab) without pulling in a test runtime.
|
||||
*
|
||||
* Run with:
|
||||
* tsx opc/plugins/office_ui/frontend_src/org/OrgTab.test.tsx
|
||||
*/
|
||||
import assert from 'node:assert/strict'
|
||||
import { readFileSync } from 'node:fs'
|
||||
import { dirname, join } from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
|
||||
const here = dirname(fileURLToPath(import.meta.url))
|
||||
const src = readFileSync(join(here, 'OrgTab.tsx'), 'utf8')
|
||||
const createModalSrc = readFileSync(join(here, 'OrgCreateModal.tsx'), 'utf8')
|
||||
const visualTypesSrc = readFileSync(join(here, '..', 'types', 'visual.ts'), 'utf8')
|
||||
const orgCssSrc = readFileSync(join(here, 'org.css'), 'utf8')
|
||||
|
||||
// ── 1. Four sub-tab labels declared ──
|
||||
for (const label of ['Team', 'Runtime', 'Architecture', 'Employees']) {
|
||||
assert.match(
|
||||
src,
|
||||
new RegExp(`label:\\s*['"]${label}['"]`),
|
||||
`OrgTab.tsx must declare tab label "${label}" (sub-tab rename regression)`,
|
||||
)
|
||||
}
|
||||
|
||||
// ── 2. Legacy labels removed ──
|
||||
assert.doesNotMatch(
|
||||
src,
|
||||
/label:\s*['"]Marketplace['"]/,
|
||||
'OrgTab.tsx must NOT declare legacy "Marketplace" sub-tab label',
|
||||
)
|
||||
assert.doesNotMatch(
|
||||
src,
|
||||
/label:\s*['"]Flow['"]/,
|
||||
'OrgTab.tsx must NOT declare legacy "Flow" sub-tab label',
|
||||
)
|
||||
|
||||
// ── 3. Active tabs typed against the new SubTab union ──
|
||||
assert.match(
|
||||
src,
|
||||
/type\s+SubTab\s*=\s*['"]team['"]\s*\|\s*['"]runtime['"]\s*\|\s*['"]architecture['"]\s*\|\s*['"]employees['"]/,
|
||||
'OrgTab.tsx must define SubTab = "team" | "runtime" | "architecture" | "employees"',
|
||||
)
|
||||
|
||||
// ── 4. The three marketplace panels are imported ──
|
||||
for (const comp of [
|
||||
'ArchitectureMarketplace',
|
||||
'EmployeesMarketplace',
|
||||
'ConfigImportExportPanel',
|
||||
]) {
|
||||
assert.match(
|
||||
src,
|
||||
new RegExp(`import\\s*\\{\\s*${comp}\\s*\\}\\s*from\\s*['"]\\./${comp}['"]`),
|
||||
`OrgTab.tsx must import ${comp} from './${comp}'`,
|
||||
)
|
||||
}
|
||||
|
||||
// ── 5. Default active tab is 'team' ──
|
||||
assert.match(
|
||||
src,
|
||||
/useState<SubTab>\(\s*['"]team['"]\s*\)/,
|
||||
'OrgTab.tsx must initialize activeTab state to "team"',
|
||||
)
|
||||
|
||||
// ── 6. data-testid wired on each marketplace panel root ──
|
||||
for (const [file, testId] of [
|
||||
['EmployeesMarketplace.tsx', 'employees-marketplace'],
|
||||
['ArchitectureMarketplace.tsx', 'architecture-marketplace'],
|
||||
['ConfigImportExportPanel.tsx', 'config-import-export-panel'],
|
||||
] as const) {
|
||||
const panelSrc = readFileSync(join(here, file), 'utf8')
|
||||
assert.match(
|
||||
panelSrc,
|
||||
new RegExp(`data-testid="${testId}"`),
|
||||
`${file} root must carry data-testid="${testId}"`,
|
||||
)
|
||||
}
|
||||
|
||||
// ── 7. Create-org prompt is optional and carried in the member payload ──
|
||||
assert.match(
|
||||
visualTypesSrc,
|
||||
/prompt\?:\s*string/,
|
||||
'OrgCreateMemberInput must allow an optional prompt field',
|
||||
)
|
||||
assert.match(
|
||||
createModalSrc,
|
||||
/<textarea[\s\S]+placeholder="Prompt optional"/,
|
||||
'OrgCreateModal must render an optional prompt textarea for each role',
|
||||
)
|
||||
assert.match(
|
||||
createModalSrc,
|
||||
/prompt:\s*member\.prompt\.trim\(\)/,
|
||||
'OrgCreateModal submit payload must trim and include each role prompt',
|
||||
)
|
||||
assert.doesNotMatch(
|
||||
createModalSrc,
|
||||
/membersValid[\s\S]{0,120}prompt/,
|
||||
'OrgCreateModal must not require prompt text for member validity',
|
||||
)
|
||||
|
||||
// ── 8. Native select option popovers must have readable themed colors ──
|
||||
assert.match(
|
||||
orgCssSrc,
|
||||
/\.org-switcher-select option\s*\{[\s\S]*background:\s*var\(--bg-elevated\);[\s\S]*color:\s*var\(--text\);/,
|
||||
'Organization select options must use explicit themed colors',
|
||||
)
|
||||
assert.match(
|
||||
orgCssSrc,
|
||||
/\.org-create-member-row select option\s*\{[\s\S]*background:\s*var\(--bg-elevated\);[\s\S]*color:\s*var\(--text\);/,
|
||||
'Create-org reports-to select options must use explicit themed colors',
|
||||
)
|
||||
|
||||
console.log(
|
||||
'OrgTab.test.tsx: OK (tabs, marketplace panels, create-org prompt, select option theme colors)',
|
||||
)
|
||||
@@ -0,0 +1,436 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import type {
|
||||
OrgInfoPayload,
|
||||
OrgCreateMemberInput,
|
||||
OrgSavedCreatePayload,
|
||||
SavedOrgSummary,
|
||||
TalentTemplate,
|
||||
EmployeeDetailPayload,
|
||||
ReorgProposalInfo,
|
||||
ArchitecturePreset,
|
||||
ArchitecturePresetDetail,
|
||||
HireTalentHandler,
|
||||
} from '../types/visual'
|
||||
import { TeamView } from './TeamView'
|
||||
import { DelegationStrategyPanel } from './DelegationStrategyPanel'
|
||||
import { ArchitectureMarketplace } from './ArchitectureMarketplace'
|
||||
import { EmployeesMarketplace } from './EmployeesMarketplace'
|
||||
import { ConfigImportExportPanel } from './ConfigImportExportPanel'
|
||||
import { OrgCreateModal } from './OrgCreateModal'
|
||||
import { getRuntimeOrgView } from '../lib/runtimeOrg'
|
||||
import './org.css'
|
||||
import './team.css'
|
||||
import './marketplace.css'
|
||||
import './config.css'
|
||||
import './structure.css'
|
||||
|
||||
/* ── Inline SVG icon data-URIs (no external CDN) ────────────────── */
|
||||
const TAB_ICON = {
|
||||
team: "data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'%3E%3Cpath fill='%23888' d='M16 11c1.66 0 2.99-1.34 2.99-3S17.66 5 16 5c-1.66 0-3 1.34-3 3s1.34 3 3 3zm-8 0c1.66 0 2.99-1.34 2.99-3S9.66 5 8 5C6.34 5 5 6.34 5 8s1.34 3 3 3zm0 2c-2.33 0-7 1.17-7 3.5V19h14v-2.5c0-2.33-4.67-3.5-7-3.5zm8 0c-.29 0-.62.02-.97.05 1.16.84 1.97 1.97 1.97 3.45V19h6v-2.5c0-2.33-4.67-3.5-7-3.5z'/%3E%3C/svg%3E",
|
||||
runtime: "data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'%3E%3Cpath fill='%23888' d='M13 2.05v2.02c3.95.49 7 3.85 7 7.93 0 3.21-1.81 6-4.72 7.72L13 17v5h5l-1.22-1.22C19.91 19.07 22 15.76 22 12c0-5.18-3.95-9.45-9-9.95zM11 2.05C5.95 2.55 2 6.82 2 12c0 3.76 2.09 7.07 5.22 8.78L6 22h5V2.05z'/%3E%3C/svg%3E",
|
||||
architecture: "data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'%3E%3Cpath fill='%23888' d='M3 13h8V3H3v10zm0 8h8v-6H3v6zm10 0h8V11h-8v10zm0-18v6h8V3h-8z'/%3E%3C/svg%3E",
|
||||
employees: "data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'%3E%3Cpath fill='%23888' d='M12 12c2.21 0 4-1.79 4-4s-1.79-4-4-4-4 1.79-4 4 1.79 4 4 4zm0 2c-2.67 0-8 1.34-8 4v2h16v-2c0-2.66-5.33-4-8-4z'/%3E%3C/svg%3E",
|
||||
}
|
||||
const STAT_ICON = {
|
||||
agents: "data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'%3E%3Cpath fill='%23888' d='M20 9V7c0-1.1-.9-2-2-2h-3c0-1.66-1.34-3-3-3S9 3.34 9 5H6c-1.1 0-2 .9-2 2v2c-1.66 0-3 1.34-3 3s1.34 3 3 3v4c0 1.1.9 2 2 2h12c1.1 0 2-.9 2-2v-4c1.66 0 3-1.34 3-3s-1.34-3-3-3z'/%3E%3C/svg%3E",
|
||||
active: "data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'%3E%3Cpath fill='%2322c55e' d='M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm-2 14.5v-9l6 4.5-6 4.5z'/%3E%3C/svg%3E",
|
||||
}
|
||||
const SECTION_ICON = {
|
||||
packages: "data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'%3E%3Cpath fill='%23888' d='M20.54 5.23l-1.39-1.68C18.88 3.21 18.47 3 18 3H6c-.47 0-.88.21-1.16.55L3.46 5.23C3.17 5.57 3 6.02 3 6.5V19c0 1.1.9 2 2 2h14c1.1 0 2-.9 2-2V6.5c0-.48-.17-.93-.46-1.27zM12 17.5L6.5 12H10v-2h4v2h3.5L12 17.5zM5.12 5l.81-1h12l.94 1H5.12z'/%3E%3C/svg%3E",
|
||||
channels: "data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'%3E%3Cpath fill='%23888' d='M1 9l2 2c4.97-4.97 13.03-4.97 18 0l2-2C16.93 2.93 7.08 2.93 1 9zm8 8l3 3 3-3c-1.65-1.66-4.34-1.66-6 0zm-4-4l2 2c2.76-2.76 7.24-2.76 10 0l2-2C15.14 9.14 8.87 9.14 5 13z'/%3E%3C/svg%3E",
|
||||
reorg: "data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'%3E%3Cpath fill='%23888' d='M6.99 11L3 15l3.99 4v-3H14v-2H6.99v-3zM21 9l-3.99-4v3H10v2h7.01v3L21 9z'/%3E%3C/svg%3E",
|
||||
importPkg: "data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'%3E%3Cpath fill='%23888' d='M19 9h-4V3H9v6H5l7 7 7-7zM5 18v2h14v-2H5z'/%3E%3C/svg%3E",
|
||||
}
|
||||
|
||||
type SubTab = 'team' | 'runtime' | 'architecture' | 'employees'
|
||||
|
||||
function humanizeOrgName(value?: string | null): string {
|
||||
const normalized = String(value ?? '').trim()
|
||||
if (!normalized) return ''
|
||||
return normalized
|
||||
.replace(/^org[_-]/i, '')
|
||||
.replace(/[_-]+/g, ' ')
|
||||
.replace(/\s+/g, ' ')
|
||||
.trim()
|
||||
.replace(/\b\w/g, char => char.toUpperCase())
|
||||
}
|
||||
|
||||
interface OrgTabProps {
|
||||
data: OrgInfoPayload | null
|
||||
/** role_id -> recruited names for the selected session (canvas display only). */
|
||||
sessionRecruitmentByRole?: Record<string, string[]> | null
|
||||
talents: TalentTemplate[]
|
||||
employeeDetail: EmployeeDetailPayload | null
|
||||
reorgProposals: ReorgProposalInfo[]
|
||||
isCustomMode?: boolean
|
||||
onRequestData: () => void
|
||||
onRequestTalents: () => void
|
||||
onRequestEmployeeDetail: (employeeId: string) => void
|
||||
onHireTalent: HireTalentHandler
|
||||
hiringTemplateId?: string | null
|
||||
onImportEmployee?: (employeeId: string) => void
|
||||
onRequestReorgList: () => void
|
||||
onReorgDecide: (proposalId: string, approved: boolean, notes?: string) => void
|
||||
// Market
|
||||
onMarketExport?: (data: { package_id: string; name: string; description: string; version: string }) => void
|
||||
onMarketInstall?: (path: string, strategy: string) => void
|
||||
onMarketUninstall?: (packageId: string) => void
|
||||
// Architecture gallery
|
||||
marketPresets?: ArchitecturePreset[]
|
||||
marketPreviewData?: ArchitecturePresetDetail | null
|
||||
onMarketBrowse?: () => void
|
||||
onMarketPreview?: (presetId: string) => void
|
||||
onMarketApplyPreset?: (presetId: string, strategy: string) => void
|
||||
onMarketClearPreview?: () => void
|
||||
// Config import/export
|
||||
onConfigExport?: () => void
|
||||
onConfigImport?: (yaml: string, dryRun: boolean) => void
|
||||
configExportYaml?: string | null
|
||||
configImportPreview?: { roles_added: number; roles_removed: number; employees_changed: number } | null
|
||||
configImportError?: string | null
|
||||
// Saved org architectures (named snapshots) — rendered in the Team tab toolbar
|
||||
onSavedOrgsList?: () => void
|
||||
onSavedOrgSaveAs?: (name: string, overwrite: boolean) => void
|
||||
onSavedOrgCreate?: (organizationName: string, members: OrgCreateMemberInput[]) => void
|
||||
onSavedOrgLoad?: (name: string) => void
|
||||
onSavedOrgDelete?: (name: string) => void
|
||||
savedOrgsList?: SavedOrgSummary[] | null
|
||||
activeSavedOrg?: string | null
|
||||
activeSavedOrgVersionAtLoad?: number | null
|
||||
orgCreatePending?: boolean
|
||||
orgCreateResult?: (OrgSavedCreatePayload & { nonce: number }) | null
|
||||
onSelectCorporate?: () => void
|
||||
// Org editing
|
||||
onAddRole?: (roleId: string, name: string, responsibility: string, reportsTo: string, icon?: string | null) => void
|
||||
onBulkAddRoles?: (roles: Array<{ role_id: string; name: string; responsibility: string; reports_to: string }>) => void
|
||||
onUpdateRole?: (roleId: string, updates: { name?: string; responsibility?: string; reports_to?: string; can_spawn?: string[]; icon?: string | null; execution_strategy?: string; preferred_external_agent?: string | null; prompt_refs?: string[] }) => void
|
||||
onDeleteRole?: (roleId: string) => void
|
||||
onUpdateOrgStrategy?: (data: { final_decider_role_id?: string | null }) => void
|
||||
onUpdateRuntimePolicy?: (policy: Record<string, any>) => void
|
||||
onResetArchitecture?: () => void
|
||||
}
|
||||
|
||||
export function OrgTab({
|
||||
data, sessionRecruitmentByRole, talents, employeeDetail, reorgProposals, isCustomMode,
|
||||
onRequestData, onRequestTalents, onRequestEmployeeDetail,
|
||||
onHireTalent, hiringTemplateId, onImportEmployee, onRequestReorgList, onReorgDecide,
|
||||
onMarketExport, onMarketInstall, onMarketUninstall,
|
||||
marketPresets, marketPreviewData, onMarketBrowse, onMarketPreview, onMarketApplyPreset, onMarketClearPreview,
|
||||
onAddRole, onBulkAddRoles, onUpdateRole, onDeleteRole, onUpdateOrgStrategy,
|
||||
onUpdateRuntimePolicy, onResetArchitecture,
|
||||
onConfigExport, onConfigImport, configExportYaml, configImportPreview, configImportError,
|
||||
onSavedOrgsList, onSavedOrgSaveAs, onSavedOrgCreate, onSavedOrgLoad, onSavedOrgDelete, savedOrgsList,
|
||||
activeSavedOrg, activeSavedOrgVersionAtLoad, orgCreatePending, orgCreateResult, onSelectCorporate,
|
||||
}: OrgTabProps) {
|
||||
const [activeTab, setActiveTab] = useState<SubTab>('team')
|
||||
const [createOpen, setCreateOpen] = useState(false)
|
||||
|
||||
const switchTab = useCallback((tab: SubTab) => {
|
||||
setActiveTab(tab)
|
||||
}, [])
|
||||
const [applyingPresetId, setApplyingPresetId] = useState<string | null>(null)
|
||||
const versionAtApply = useRef<number>(-1) // track org_version when apply starts
|
||||
const [toast, setToast] = useState<{ msg: string; type: 'info' | 'warn' } | null>(null)
|
||||
const toastTimer = useRef<ReturnType<typeof setTimeout>>(null)
|
||||
|
||||
const showToast = useCallback((msg: string, type: 'info' | 'warn' = 'info') => {
|
||||
setToast({ msg, type })
|
||||
if (toastTimer.current) clearTimeout(toastTimer.current)
|
||||
toastTimer.current = setTimeout(() => setToast(null), 3000)
|
||||
}, [])
|
||||
|
||||
useEffect(() => () => {
|
||||
if (toastTimer.current) clearTimeout(toastTimer.current)
|
||||
}, [])
|
||||
|
||||
const onRequestDataRef = useRef(onRequestData)
|
||||
const onRequestTalentsRef = useRef(onRequestTalents)
|
||||
const onRequestReorgListRef = useRef(onRequestReorgList)
|
||||
const onMarketBrowseRef = useRef(onMarketBrowse)
|
||||
const onSavedOrgsListRef = useRef(onSavedOrgsList)
|
||||
onRequestDataRef.current = onRequestData
|
||||
onRequestTalentsRef.current = onRequestTalents
|
||||
onRequestReorgListRef.current = onRequestReorgList
|
||||
onMarketBrowseRef.current = onMarketBrowse
|
||||
onSavedOrgsListRef.current = onSavedOrgsList
|
||||
|
||||
useEffect(() => {
|
||||
onRequestDataRef.current()
|
||||
onRequestTalentsRef.current()
|
||||
onRequestReorgListRef.current()
|
||||
onMarketBrowseRef.current?.()
|
||||
onSavedOrgsListRef.current?.()
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
if (!orgCreateResult || !orgCreateResult.ok) return
|
||||
setCreateOpen(false)
|
||||
setActiveTab('team')
|
||||
showToast(`Created ${orgCreateResult.organization_name || orgCreateResult.name} and saved automatically`)
|
||||
}, [orgCreateResult, showToast])
|
||||
|
||||
// In org mode: show only user-owned roles. In company mode: show all roles read-only.
|
||||
const allRoles = data?.roles ?? []
|
||||
const displayRoles = useMemo(() => isCustomMode ? allRoles.filter(r => !r.is_builtin) : allRoles, [allRoles, isCustomMode])
|
||||
const displayEmployees = useMemo(() => data?.employees ?? [], [data?.employees])
|
||||
const runtimeView = useMemo(() => getRuntimeOrgView(data), [data])
|
||||
const activeAgents = useMemo(() => displayEmployees.filter(e => e.linked_agent_id), [displayEmployees])
|
||||
const configuredOrgName = data?.organization_name?.trim()
|
||||
const activeOrgLabel = configuredOrgName || humanizeOrgName(activeSavedOrg) || (isCustomMode ? 'Custom org' : 'Corporate company')
|
||||
const activeOrgId = (isCustomMode ? (data?.organization_id || activeSavedOrg) : 'corporate') || ''
|
||||
const architectureKindLabel = isCustomMode ? 'Saved org' : 'Corporate'
|
||||
const architectureStateLabel = isCustomMode
|
||||
? activeSavedOrg ? 'Editable saved architecture' : 'Editable draft architecture'
|
||||
: 'Built-in read-only architecture'
|
||||
const runtimeStateLabel = runtimeView.frontier.status || runtimeView.projectRun?.status || runtimeView.projectRun?.lifecycle_status || 'ready'
|
||||
|
||||
// Roles that already have at least one non-placeholder employee.
|
||||
const filledRoleIds = useMemo(
|
||||
() => {
|
||||
const ids = new Set<string>()
|
||||
for (const employee of displayEmployees) {
|
||||
if (employee.is_default_employee) continue
|
||||
const roleIds = employee.role_ids?.length ? employee.role_ids : [employee.role_id]
|
||||
for (const roleId of roleIds) {
|
||||
if (roleId) ids.add(roleId)
|
||||
}
|
||||
}
|
||||
return ids
|
||||
},
|
||||
[displayEmployees],
|
||||
)
|
||||
const vacantRoles = useMemo(() => displayRoles.filter(r => !filledRoleIds.has(r.role_id)), [displayRoles, filledRoleIds])
|
||||
|
||||
const installedIds = useMemo(() => new Set((data?.installed_packages ?? []).map(p => p.package_id)), [data?.installed_packages])
|
||||
|
||||
// When applying a preset, wait for org_version to change (every config.save() increments it)
|
||||
const orgVersion = data?.org_version ?? 0
|
||||
useEffect(() => {
|
||||
if (applyingPresetId && versionAtApply.current >= 0 && orgVersion !== versionAtApply.current) {
|
||||
setApplyingPresetId(null)
|
||||
versionAtApply.current = -1
|
||||
setActiveTab('team')
|
||||
showToast('Architecture applied successfully')
|
||||
}
|
||||
}, [orgVersion, applyingPresetId, showToast])
|
||||
|
||||
const handleApplyPreset = (presetId: string, strategy: string) => {
|
||||
versionAtApply.current = orgVersion // snapshot current version
|
||||
setApplyingPresetId(presetId)
|
||||
onMarketApplyPreset?.(presetId, strategy)
|
||||
}
|
||||
|
||||
const handleOrgSelection = (value: string) => {
|
||||
if (value === 'corporate') {
|
||||
onSelectCorporate?.()
|
||||
return
|
||||
}
|
||||
if (value.startsWith('org:')) {
|
||||
const orgName = value.slice(4)
|
||||
if (orgName) onSavedOrgLoad?.(orgName)
|
||||
}
|
||||
}
|
||||
|
||||
if (!data) {
|
||||
return <div className="org-tab"><div className="org-loading">Loading organization data...</div></div>
|
||||
}
|
||||
|
||||
const installedPackages = data.installed_packages ?? []
|
||||
const savedOrgOptions = savedOrgsList ?? []
|
||||
const selectedOrgValue = isCustomMode && activeSavedOrg ? `org:${activeSavedOrg}` : 'corporate'
|
||||
|
||||
return (
|
||||
<div className="org-tab">
|
||||
<div className={`org-header${isCustomMode ? ' org-header--custom' : ' org-header--corporate'}`}>
|
||||
<div className="org-header-main">
|
||||
<div className="org-eyebrow">
|
||||
<span>Company</span>
|
||||
<span className="org-eyebrow-separator">/</span>
|
||||
<span>{architectureKindLabel}</span>
|
||||
</div>
|
||||
<div className="org-title-row">
|
||||
<h2 className="org-title">{activeOrgLabel}</h2>
|
||||
<span className="org-version-badge">v{data.org_version}</span>
|
||||
<span className={`org-state-badge${isCustomMode ? ' org-state-badge--editable' : ' org-state-badge--readonly'}`}>
|
||||
{architectureStateLabel}
|
||||
</span>
|
||||
</div>
|
||||
<div className="org-header-meta">
|
||||
<span className="org-meta-pill">{data.company_profile || (isCustomMode ? 'custom' : 'corporate')}</span>
|
||||
{activeOrgId && <code className="org-meta-code">{activeOrgId}</code>}
|
||||
<span className="org-meta-pill org-meta-pill--runtime">{runtimeStateLabel}</span>
|
||||
<span className="org-meta-pill org-meta-pill--saved">Auto-saved</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="org-control-panel">
|
||||
<label className="org-switcher">
|
||||
<span className="org-switcher-label">Organization</span>
|
||||
<span className="org-switcher-select-wrap">
|
||||
<select
|
||||
className="org-switcher-select"
|
||||
value={selectedOrgValue}
|
||||
onChange={e => handleOrgSelection(e.target.value)}
|
||||
onFocus={() => onSavedOrgsList?.()}
|
||||
onPointerDown={() => onSavedOrgsList?.()}
|
||||
aria-label="Organization"
|
||||
>
|
||||
<option value="corporate">Corporate</option>
|
||||
{savedOrgOptions.map(org => (
|
||||
<option key={org.name} value={`org:${org.name}`}>
|
||||
{(org.organization_name || org.name).trim() || org.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</span>
|
||||
</label>
|
||||
<button type="button" className="org-create-trigger" onClick={() => setCreateOpen(true)}>
|
||||
<span className="org-create-trigger-icon" aria-hidden>+</span>
|
||||
New organization
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="org-stats-strip">
|
||||
<span className="org-stat">
|
||||
<img src={STAT_ICON.agents} alt="" className="org-stat-icon" />
|
||||
<b>{displayRoles.length}</b> roles
|
||||
</span>
|
||||
<span className="org-stat">
|
||||
<img src={TAB_ICON.employees} alt="" className="org-stat-icon" />
|
||||
<b>{displayEmployees.length}</b> employees
|
||||
</span>
|
||||
<span className="org-stat">
|
||||
<img src={TAB_ICON.runtime} alt="" className="org-stat-icon" />
|
||||
<b>{runtimeView.runtimeTeams.length}</b> runtime teams
|
||||
</span>
|
||||
<span className="org-stat">
|
||||
<img src={STAT_ICON.active} alt="" className="org-stat-icon" />
|
||||
<b>{activeAgents.length}</b> active
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="org-subtabs">
|
||||
{([
|
||||
{ id: 'team' as SubTab, icon: TAB_ICON.team, label: 'Team', count: displayRoles.length },
|
||||
{ id: 'runtime' as SubTab, icon: TAB_ICON.runtime, label: 'Runtime', count: runtimeView.runtimeTeams.length },
|
||||
{ id: 'architecture' as SubTab, icon: TAB_ICON.architecture, label: 'Architecture', count: marketPresets?.length ?? 0 },
|
||||
{ id: 'employees' as SubTab, icon: TAB_ICON.employees, label: 'Employees', count: talents.length },
|
||||
]).map(tab => (
|
||||
<button key={tab.id}
|
||||
className={`org-subtab${activeTab === tab.id ? ' org-subtab--active' : ''}`}
|
||||
onClick={() => switchTab(tab.id)}>
|
||||
<img src={tab.icon} alt="" className="org-subtab-icon" />
|
||||
<span className="org-subtab-label">{tab.label}</span>
|
||||
<span className="org-subtab-count">{tab.count}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* ── Tab content ─────────────────────────────────── */}
|
||||
{/* Toast notification */}
|
||||
{toast && (
|
||||
<div className={`org-toast org-toast--${toast.type}`} onClick={() => setToast(null)}>
|
||||
{toast.msg}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="org-tab-content">
|
||||
|
||||
{/* Team tab */}
|
||||
{activeTab === 'team' && (
|
||||
<TeamView
|
||||
roles={displayRoles}
|
||||
employees={displayEmployees}
|
||||
sessionRecruitmentByRole={sessionRecruitmentByRole}
|
||||
isCustomMode={isCustomMode}
|
||||
onAddRole={onAddRole ?? (() => {})}
|
||||
onBulkAddRoles={onBulkAddRoles}
|
||||
onUpdateRole={onUpdateRole ?? (() => {})}
|
||||
onDeleteRole={onDeleteRole ?? (() => {})}
|
||||
onExport={onMarketExport ?? (() => {})}
|
||||
onImportEmployee={onImportEmployee}
|
||||
onResetArchitecture={onResetArchitecture}
|
||||
onSwitchToTab={(target) => setActiveTab(target)}
|
||||
savedOrgsList={savedOrgsList}
|
||||
activeSavedOrg={activeSavedOrg ?? null}
|
||||
currentOrgVersion={data?.org_version ?? 0}
|
||||
versionAtLoad={activeSavedOrgVersionAtLoad ?? null}
|
||||
onSavedOrgsList={onSavedOrgsList}
|
||||
onSavedOrgSaveAs={onSavedOrgSaveAs}
|
||||
onSavedOrgLoad={onSavedOrgLoad}
|
||||
onSavedOrgDelete={onSavedOrgDelete}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Runtime tab */}
|
||||
{activeTab === 'runtime' && (
|
||||
<DelegationStrategyPanel
|
||||
roles={displayRoles}
|
||||
runtimeTeams={runtimeView.runtimeTeams}
|
||||
runtimeSeats={runtimeView.runtimeSeats}
|
||||
workItems={runtimeView.workItems}
|
||||
frontier={runtimeView.frontier}
|
||||
companyProfile={data.company_profile}
|
||||
runtimePolicy={data.runtime_policy}
|
||||
finalDeciderRoleId={data.final_decider_role_id}
|
||||
topLevelRoleIds={data.top_level_role_ids}
|
||||
readOnly={!isCustomMode}
|
||||
onUpdateOrgStrategy={onUpdateOrgStrategy}
|
||||
onUpdateRuntimePolicy={onUpdateRuntimePolicy}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Architecture tab */}
|
||||
{activeTab === 'architecture' && (
|
||||
<>
|
||||
<ArchitectureMarketplace
|
||||
presets={marketPresets ?? []}
|
||||
installedIds={installedIds}
|
||||
previewData={marketPreviewData ?? null}
|
||||
applyingPresetId={applyingPresetId}
|
||||
readOnly={!isCustomMode}
|
||||
onPreview={onMarketPreview ?? (() => {})}
|
||||
onApplyPreset={handleApplyPreset}
|
||||
onClearPreview={onMarketClearPreview ?? (() => {})}
|
||||
installedPackages={installedPackages}
|
||||
channels={data.channels}
|
||||
reorgProposals={reorgProposals}
|
||||
isCustomMode={!!isCustomMode}
|
||||
onReorgDecide={onReorgDecide}
|
||||
onMarketInstall={(p, s) => onMarketInstall?.(p, s)}
|
||||
onMarketUninstall={(id) => onMarketUninstall?.(id)}
|
||||
/>
|
||||
{isCustomMode && (
|
||||
<ConfigImportExportPanel
|
||||
onExport={onConfigExport ?? (() => {})}
|
||||
onImport={onConfigImport ?? (() => {})}
|
||||
configExportYaml={configExportYaml ?? null}
|
||||
importPreview={configImportPreview ?? null}
|
||||
importError={configImportError ?? null}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Employees tab */}
|
||||
{activeTab === 'employees' && (
|
||||
<EmployeesMarketplace
|
||||
templates={talents}
|
||||
vacantRoles={vacantRoles}
|
||||
hiringTemplateId={hiringTemplateId ?? null}
|
||||
readOnly={!isCustomMode}
|
||||
onHireTalent={onHireTalent}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<OrgCreateModal
|
||||
open={createOpen}
|
||||
pending={orgCreatePending}
|
||||
result={orgCreateResult ?? null}
|
||||
onClose={() => setCreateOpen(false)}
|
||||
onCreate={(organizationName, members) => onSavedOrgCreate?.(organizationName, members)}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,378 @@
|
||||
/**
|
||||
* OrgVersionSwitcher — editor-toolbar "version picker" for the org.
|
||||
*
|
||||
* Conceptually equivalent to Figma's page switcher or VS Code's git-branch
|
||||
* indicator: a compact pill showing the active saved-org name (+ modified
|
||||
* indicator when the editor has changed since the last loaded snapshot),
|
||||
* plus a glass popover command-menu for search / load / save-as-copy
|
||||
* / delete.
|
||||
*
|
||||
* All visual tokens match the house dialect (refined-technical, dark, no
|
||||
* emoji, no purple gradients). Styles live in structure.css under `.sos-*`.
|
||||
*
|
||||
* Lives in the StructureEditor toolbar (Team tab, org mode only).
|
||||
*/
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
|
||||
interface SavedOrg {
|
||||
name: string
|
||||
organization_name?: string
|
||||
saved_at: number
|
||||
roles_count: number
|
||||
employees_count: number
|
||||
}
|
||||
|
||||
export interface OrgVersionSwitcherProps {
|
||||
savedOrgs: SavedOrg[] | null
|
||||
activeName: string | null
|
||||
isDirty: boolean
|
||||
onRefresh: () => void
|
||||
onSaveAs: (name: string, overwrite: boolean) => void
|
||||
onLoad: (name: string) => void
|
||||
onDelete: (name: string) => void
|
||||
}
|
||||
|
||||
const MAX_DISPLAY_NAME = 80
|
||||
|
||||
function formatRelativeTime(epochSeconds: number): string {
|
||||
const now = Date.now() / 1000
|
||||
const diff = Math.max(0, now - epochSeconds)
|
||||
if (diff < 60) return 'just now'
|
||||
if (diff < 3600) return `${Math.floor(diff / 60)}m ago`
|
||||
if (diff < 86400) return `${Math.floor(diff / 3600)}h ago`
|
||||
if (diff < 604800) return `${Math.floor(diff / 86400)}d ago`
|
||||
try {
|
||||
return new Date(epochSeconds * 1000).toLocaleDateString()
|
||||
} catch {
|
||||
return ''
|
||||
}
|
||||
}
|
||||
|
||||
function displayOrgName(org: SavedOrg): string {
|
||||
return (org.organization_name || org.name).trim() || org.name
|
||||
}
|
||||
|
||||
function isValidDisplayName(value: string): boolean {
|
||||
const trimmed = value.trim()
|
||||
return trimmed.length > 0
|
||||
&& trimmed.length <= MAX_DISPLAY_NAME
|
||||
&& !/[\\/]/.test(trimmed)
|
||||
&& !/[\u0000-\u001f]/.test(trimmed)
|
||||
}
|
||||
|
||||
function slugifyOrgDisplayName(value: string): string {
|
||||
const ascii = value.normalize('NFKD').replace(/[^\x00-\x7F]/g, '')
|
||||
const slug = ascii
|
||||
.toLowerCase()
|
||||
.trim()
|
||||
.replace(/\s+/g, '_')
|
||||
.replace(/[^a-z0-9_-]+/g, '_')
|
||||
.replace(/_+/g, '_')
|
||||
.replace(/^[_-]+|[_-]+$/g, '')
|
||||
return slug.slice(0, 64) || 'org'
|
||||
}
|
||||
|
||||
/* Inline SVG glyphs — match the house convention (no emoji, no font icons). */
|
||||
function LayersGlyph({ className }: { className?: string }) {
|
||||
return (
|
||||
<svg className={className} width="12" height="12" viewBox="0 0 16 16" fill="none" aria-hidden>
|
||||
<path d="M8 1.5L1.5 4.5L8 7.5L14.5 4.5L8 1.5Z" stroke="currentColor" strokeWidth="1.3" strokeLinejoin="round" />
|
||||
<path d="M2 8L8 10.8L14 8" stroke="currentColor" strokeWidth="1.3" strokeLinejoin="round" opacity="0.6" />
|
||||
<path d="M2 11.5L8 14.2L14 11.5" stroke="currentColor" strokeWidth="1.3" strokeLinejoin="round" opacity="0.35" />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
function Caret({ className }: { className?: string }) {
|
||||
return (
|
||||
<svg className={className} width="10" height="10" viewBox="0 0 12 12" fill="none" aria-hidden>
|
||||
<path d="M3 4.5L6 7.5L9 4.5" stroke="currentColor" strokeWidth="1.4" strokeLinecap="round" strokeLinejoin="round" />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
function SearchGlyph({ className }: { className?: string }) {
|
||||
return (
|
||||
<svg className={className} width="11" height="11" viewBox="0 0 12 12" fill="none" aria-hidden>
|
||||
<circle cx="5" cy="5" r="3.2" stroke="currentColor" strokeWidth="1.3" />
|
||||
<path d="M7.5 7.5L10 10" stroke="currentColor" strokeWidth="1.3" strokeLinecap="round" />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
export function OrgVersionSwitcher({
|
||||
savedOrgs, activeName, isDirty, onRefresh, onSaveAs, onLoad, onDelete,
|
||||
}: OrgVersionSwitcherProps) {
|
||||
const [open, setOpen] = useState(false)
|
||||
const [search, setSearch] = useState('')
|
||||
const [highlighted, setHighlighted] = useState(0)
|
||||
const [saveAsMode, setSaveAsMode] = useState(false)
|
||||
const [saveAsName, setSaveAsName] = useState('')
|
||||
const [confirmDelete, setConfirmDelete] = useState<string | null>(null)
|
||||
const [loadingName, setLoadingName] = useState<string | null>(null)
|
||||
const wrapperRef = useRef<HTMLDivElement>(null)
|
||||
const saveInputRef = useRef<HTMLInputElement>(null)
|
||||
|
||||
// Clear loading state once the list or activeName updates (proxy for ack).
|
||||
useEffect(() => { setLoadingName(null) }, [activeName, savedOrgs])
|
||||
|
||||
// Filter list by search.
|
||||
const filtered = (savedOrgs ?? []).filter(o =>
|
||||
!search.trim() || o.name.toLowerCase().includes(search.trim().toLowerCase()),
|
||||
)
|
||||
|
||||
// Refresh once on first open.
|
||||
const firstOpenRef = useRef(false)
|
||||
useEffect(() => {
|
||||
if (open && !firstOpenRef.current) {
|
||||
firstOpenRef.current = true
|
||||
onRefresh()
|
||||
}
|
||||
}, [open, onRefresh])
|
||||
|
||||
// Auto-focus save input when entering save-as mode.
|
||||
useEffect(() => {
|
||||
if (saveAsMode) saveInputRef.current?.focus()
|
||||
}, [saveAsMode])
|
||||
|
||||
// Clamp highlight when filtered list shrinks.
|
||||
useEffect(() => {
|
||||
setHighlighted(h => Math.min(h, Math.max(0, filtered.length - 1)))
|
||||
}, [filtered.length])
|
||||
|
||||
// Close on outside click.
|
||||
useEffect(() => {
|
||||
if (!open) return
|
||||
const onDocClick = (e: MouseEvent) => {
|
||||
if (wrapperRef.current && !wrapperRef.current.contains(e.target as Node)) {
|
||||
closePopover()
|
||||
}
|
||||
}
|
||||
document.addEventListener('mousedown', onDocClick)
|
||||
return () => document.removeEventListener('mousedown', onDocClick)
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [open])
|
||||
|
||||
const closePopover = useCallback(() => {
|
||||
setOpen(false)
|
||||
setSearch('')
|
||||
setHighlighted(0)
|
||||
setSaveAsMode(false)
|
||||
setSaveAsName('')
|
||||
setConfirmDelete(null)
|
||||
}, [])
|
||||
|
||||
const handleLoad = useCallback((name: string) => {
|
||||
if (name === activeName) {
|
||||
// Already active — no need to round-trip; just close the popover.
|
||||
closePopover()
|
||||
return
|
||||
}
|
||||
setLoadingName(name)
|
||||
onLoad(name)
|
||||
closePopover()
|
||||
}, [onLoad, closePopover, activeName])
|
||||
|
||||
const handleDelete = useCallback((name: string) => {
|
||||
onDelete(name)
|
||||
setConfirmDelete(null)
|
||||
}, [onDelete])
|
||||
|
||||
const saveAsTrimmed = saveAsName.trim()
|
||||
const saveAsValid = isValidDisplayName(saveAsTrimmed)
|
||||
const saveAsSlug = slugifyOrgDisplayName(saveAsTrimmed)
|
||||
const saveAsExists = (savedOrgs ?? []).some(o =>
|
||||
o.name === saveAsSlug || displayOrgName(o).toLowerCase() === saveAsTrimmed.toLowerCase(),
|
||||
)
|
||||
|
||||
const handleSaveAs = useCallback(() => {
|
||||
if (!saveAsValid) return
|
||||
onSaveAs(saveAsTrimmed, saveAsExists)
|
||||
setSaveAsMode(false)
|
||||
setSaveAsName('')
|
||||
closePopover()
|
||||
}, [saveAsValid, saveAsTrimmed, saveAsExists, onSaveAs, closePopover])
|
||||
|
||||
// Keyboard navigation inside the popover.
|
||||
const onKeyDown = (e: React.KeyboardEvent) => {
|
||||
if (e.key === 'Escape') {
|
||||
e.preventDefault()
|
||||
closePopover()
|
||||
return
|
||||
}
|
||||
if (saveAsMode) {
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault()
|
||||
handleSaveAs()
|
||||
}
|
||||
return
|
||||
}
|
||||
if (e.key === 'ArrowDown') {
|
||||
e.preventDefault()
|
||||
setHighlighted(h => Math.min(h + 1, Math.max(0, filtered.length - 1)))
|
||||
} else if (e.key === 'ArrowUp') {
|
||||
e.preventDefault()
|
||||
setHighlighted(h => Math.max(0, h - 1))
|
||||
} else if (e.key === 'Enter') {
|
||||
const target = filtered[highlighted]
|
||||
if (target) handleLoad(target.name)
|
||||
} else if ((e.key === 'Backspace' || e.key === 'Delete') && (e.metaKey || e.ctrlKey)) {
|
||||
const target = filtered[highlighted]
|
||||
if (target) {
|
||||
e.preventDefault()
|
||||
setConfirmDelete(target.name)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="sos-wrap" ref={wrapperRef}>
|
||||
<button
|
||||
type="button"
|
||||
className="sos-pill"
|
||||
data-open={open ? 'true' : 'false'}
|
||||
onClick={() => setOpen(o => !o)}
|
||||
aria-haspopup="listbox"
|
||||
aria-expanded={open}
|
||||
title={activeName ? `Active saved architecture: ${activeName}` : 'No saved architecture loaded'}
|
||||
>
|
||||
<LayersGlyph className="sos-pill-glyph" />
|
||||
{activeName ? (
|
||||
<span className="sos-pill-name">{activeName}</span>
|
||||
) : (
|
||||
<span className="sos-pill-name sos-pill-name--placeholder">draft</span>
|
||||
)}
|
||||
{isDirty && <span className="sos-pill-dirty" aria-label="Modified since opened" />}
|
||||
<Caret className="sos-pill-caret" />
|
||||
</button>
|
||||
|
||||
{open && (
|
||||
<div className="sos-popover" role="listbox" onKeyDown={onKeyDown} tabIndex={-1}>
|
||||
<div className="sos-search-row">
|
||||
<SearchGlyph className="sos-search-glyph" />
|
||||
<input
|
||||
type="text"
|
||||
className="sos-search-input"
|
||||
placeholder="Search architectures…"
|
||||
value={search}
|
||||
onChange={e => { setSearch(e.target.value); setHighlighted(0) }}
|
||||
autoFocus
|
||||
onKeyDown={onKeyDown}
|
||||
/>
|
||||
<kbd className="sos-search-hint">↑↓ ↵</kbd>
|
||||
</div>
|
||||
|
||||
<div className="sos-list">
|
||||
{savedOrgs === null ? (
|
||||
<div className="sos-empty">Loading…</div>
|
||||
) : filtered.length === 0 ? (
|
||||
<div className="sos-empty">
|
||||
{(savedOrgs ?? []).length === 0 ? 'No saved architectures.' : 'No matches.'}
|
||||
</div>
|
||||
) : (
|
||||
filtered.map((org, idx) => {
|
||||
const isActive = activeName === org.name
|
||||
const isHighlighted = highlighted === idx
|
||||
const title = displayOrgName(org)
|
||||
return (
|
||||
<div
|
||||
key={org.name}
|
||||
className={`sos-row${isActive ? ' sos-row--active' : ''}`}
|
||||
data-highlighted={isHighlighted ? 'true' : 'false'}
|
||||
onMouseEnter={() => setHighlighted(idx)}
|
||||
onClick={() => handleLoad(org.name)}
|
||||
role="option"
|
||||
aria-selected={isActive}
|
||||
>
|
||||
<div className="sos-row-meta">
|
||||
<span className="sos-row-name">{title}</span>
|
||||
<span className="sos-row-stats">
|
||||
{title !== org.name && `${org.name} · `}
|
||||
{org.roles_count} {org.roles_count === 1 ? 'role' : 'roles'}
|
||||
{' · '}
|
||||
{org.employees_count} {org.employees_count === 1 ? 'employee' : 'employees'}
|
||||
{' · '}
|
||||
{formatRelativeTime(org.saved_at)}
|
||||
</span>
|
||||
</div>
|
||||
{loadingName === org.name ? (
|
||||
<span className="sos-row-loading">loading…</span>
|
||||
) : isActive ? (
|
||||
<span className="sos-row-active-chip">active</span>
|
||||
) : confirmDelete === org.name ? (
|
||||
<button
|
||||
type="button"
|
||||
className="sos-row-delete sos-row-delete--confirm"
|
||||
onClick={e => { e.stopPropagation(); handleDelete(org.name) }}
|
||||
>
|
||||
confirm
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
className="sos-row-delete"
|
||||
onClick={e => { e.stopPropagation(); setConfirmDelete(org.name) }}
|
||||
title="Delete"
|
||||
>
|
||||
delete
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="sos-save-as">
|
||||
{saveAsMode ? (
|
||||
<div className="sos-save-as-form">
|
||||
<input
|
||||
ref={saveInputRef}
|
||||
type="text"
|
||||
className="sos-save-as-input"
|
||||
placeholder="Organization name"
|
||||
value={saveAsName}
|
||||
onChange={e => setSaveAsName(e.target.value)}
|
||||
onKeyDown={onKeyDown}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-primary btn-sm"
|
||||
onClick={handleSaveAs}
|
||||
disabled={!saveAsValid}
|
||||
>
|
||||
{saveAsExists ? 'Overwrite copy' : 'Save as copy'}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-ghost btn-sm"
|
||||
onClick={() => { setSaveAsMode(false); setSaveAsName('') }}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
className="sos-save-as-trigger"
|
||||
onClick={() => setSaveAsMode(true)}
|
||||
>
|
||||
+ Save as copy...
|
||||
</button>
|
||||
)}
|
||||
{saveAsMode && saveAsName && !saveAsValid && (
|
||||
<div className="sos-save-as-hint sos-save-as-hint--warn">
|
||||
Use up to 80 characters. Slashes are not allowed.
|
||||
</div>
|
||||
)}
|
||||
{saveAsMode && saveAsValid && saveAsExists && (
|
||||
<div className="sos-save-as-hint sos-save-as-hint--warn">
|
||||
Name exists - this will overwrite that copy.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
import type { InstalledPackageInfo } from '../types/visual'
|
||||
|
||||
interface PackageCardProps {
|
||||
pkg: InstalledPackageInfo
|
||||
onUninstall?: (packageId: string) => void
|
||||
uninstallingId?: string | null
|
||||
}
|
||||
|
||||
export function PackageCard({ pkg, onUninstall, uninstallingId }: PackageCardProps) {
|
||||
const isUninstalling = uninstallingId === pkg.package_id
|
||||
|
||||
return (
|
||||
<div className="pkg-card">
|
||||
<div className="pkg-card-header">
|
||||
<img
|
||||
src="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'%3E%3Cpath fill='%23888' d='M20.54 5.23l-1.39-1.68C18.88 3.21 18.47 3 18 3H6c-.47 0-.88.21-1.16.55L3.46 5.23C3.17 5.57 3 6.02 3 6.5V19c0 1.1.9 2 2 2h14c1.1 0 2-.9 2-2V6.5c0-.48-.17-.93-.46-1.27zM12 17.5L6.5 12H10v-2h4v2h3.5L12 17.5zM5.12 5l.81-1h12l.94 1H5.12z'/%3E%3C/svg%3E"
|
||||
alt="package"
|
||||
className="pkg-card-icon"
|
||||
/>
|
||||
<div className="pkg-card-title-wrap">
|
||||
<span className="pkg-card-name">{pkg.name || pkg.package_id}</span>
|
||||
<span className="pkg-card-version">v{pkg.version}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="pkg-card-stats">
|
||||
<span className="pkg-card-stat">
|
||||
<img src="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'%3E%3Cpath fill='%23888' d='M12 12c2.21 0 4-1.79 4-4s-1.79-4-4-4-4 1.79-4 4 1.79 4 4 4zm0 2c-2.67 0-8 1.34-8 4v2h16v-2c0-2.66-5.33-4-8-4z'/%3E%3C/svg%3E" alt="roles" className="pkg-stat-icon" />
|
||||
{pkg.role_ids.length} roles
|
||||
</span>
|
||||
<span className="pkg-card-stat">
|
||||
<img src="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'%3E%3Cpath fill='%23888' d='M19 3H5c-1.11 0-2 .9-2 2v14c0 1.1.89 2 2 2h14c1.1 0 2-.9 2-2V5c0-1.11-.9-2-2-2zm-5 14H7v-2h7v2zm3-4H7v-2h10v2zm0-4H7V7h10v2z'/%3E%3C/svg%3E" alt="templates" className="pkg-stat-icon" />
|
||||
{pkg.template_ids.length} templates
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{pkg.installed_at && (
|
||||
<div className="pkg-card-date">
|
||||
Installed {new Date(pkg.installed_at).toLocaleDateString()}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{onUninstall && (
|
||||
<div className="pkg-card-actions">
|
||||
<button
|
||||
className="pkg-btn pkg-btn-danger"
|
||||
disabled={isUninstalling}
|
||||
onClick={(e) => { e.stopPropagation(); onUninstall(pkg.package_id) }}
|
||||
>
|
||||
{isUninstalling ? 'Removing...' : 'Uninstall'}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,468 @@
|
||||
/**
|
||||
* RoleInspector — Figma-style floating property panel (D4).
|
||||
*
|
||||
* Renders on the right of the StructureEditor when a role is selected.
|
||||
* Six collapsible groups:
|
||||
* 1. Identity (expanded by default): name, responsibility, icon
|
||||
* 2. Hierarchy (expanded by default): reports_to, can_spawn
|
||||
* 3. Tools (collapsed): 22-item checklist grouped by prefix
|
||||
* 4. Prompts (collapsed): textarea (one prompt_ref per line)
|
||||
* 5. Runtime (collapsed): execution_strategy, preferred_external_agent
|
||||
* 6. Advanced (collapsed): role_type, skill_refs, artifact_contract_ref
|
||||
*
|
||||
* Edits are debounced 500ms and batched into a single onUpdateRole call per
|
||||
* quiescence window.
|
||||
*/
|
||||
import { useCallback, useEffect, useMemo, useRef, useState, type ReactNode } from 'react'
|
||||
import type { OrgRole, OrgEmployee } from '../types/visual'
|
||||
import { ROLE_ICON_KEYS, ROLE_ICONS, resolveRoleIcon, type RoleIconKey } from './roleIcons'
|
||||
|
||||
/** Tool union derived from company_runtime_profiles.py _CORPORATE_*_TOOLS. */
|
||||
const AVAILABLE_TOOLS = [
|
||||
'file_read', 'file_write', 'file_edit', 'file_search', 'list_dir',
|
||||
'shell_exec',
|
||||
'web_search', 'web_fetch',
|
||||
'todo_read', 'todo_write',
|
||||
'browser_navigate', 'browser_navigate_back', 'browser_snapshot',
|
||||
'browser_wait_for', 'browser_scroll', 'browser_click', 'browser_type',
|
||||
'browser_select_option', 'browser_take_screenshot',
|
||||
'browser_evaluate', 'browser_close',
|
||||
] as const
|
||||
|
||||
const TOOL_GROUPS: { label: string; prefix: string; tools: readonly string[] }[] = [
|
||||
{ label: 'Files', prefix: 'file_', tools: AVAILABLE_TOOLS.filter(t => t.startsWith('file_')) },
|
||||
{ label: 'Shell', prefix: 'shell_', tools: AVAILABLE_TOOLS.filter(t => t === 'shell_exec') },
|
||||
{ label: 'Web', prefix: 'web_', tools: AVAILABLE_TOOLS.filter(t => t.startsWith('web_')) },
|
||||
{ label: 'TODOs', prefix: 'todo_', tools: AVAILABLE_TOOLS.filter(t => t.startsWith('todo_')) },
|
||||
{ label: 'Browser', prefix: 'browser_', tools: AVAILABLE_TOOLS.filter(t => t.startsWith('browser_')) },
|
||||
]
|
||||
|
||||
const EXTERNAL_AGENTS = ['codex', 'cursor', 'claude_code', 'opencode'] as const
|
||||
const EXECUTION_STRATEGIES = [
|
||||
{ value: 'auto', label: 'Auto', hint: 'System picks native or external based on role config' },
|
||||
{ value: 'native', label: 'Native', hint: 'Run directly in-process via LLM' },
|
||||
{ value: 'external', label: 'External', hint: 'Delegate to an external agent (codex, cursor, etc.)' },
|
||||
] as const
|
||||
|
||||
/* ── Props ─────────────────────────────────────────────────────── */
|
||||
|
||||
interface RoleInspectorProps {
|
||||
role: OrgRole
|
||||
allRoles: OrgRole[]
|
||||
employees: OrgEmployee[]
|
||||
readOnly?: boolean
|
||||
onUpdateRole: (roleId: string, updates: RoleUpdatePatch) => void
|
||||
onDeleteRole: (roleId: string) => void
|
||||
onClose: () => void
|
||||
}
|
||||
|
||||
/** Matches the shape that App.tsx's onUpdateRole accepts; `tools` is forwarded
|
||||
* via the same path (backend RoleConfig Pydantic model accepts it). */
|
||||
export interface RoleUpdatePatch {
|
||||
name?: string
|
||||
responsibility?: string
|
||||
reports_to?: string
|
||||
can_spawn?: string[]
|
||||
icon?: string | null
|
||||
execution_strategy?: string
|
||||
preferred_external_agent?: string | null
|
||||
prompt_refs?: string[]
|
||||
tools?: string[]
|
||||
}
|
||||
|
||||
/* ── RoleInspector ─────────────────────────────────────────────── */
|
||||
|
||||
export function RoleInspector({
|
||||
role, allRoles, employees, readOnly,
|
||||
onUpdateRole, onDeleteRole, onClose,
|
||||
}: RoleInspectorProps) {
|
||||
const [name, setName] = useState(role.name)
|
||||
const [responsibility, setResponsibility] = useState(role.responsibility)
|
||||
const [reportsTo, setReportsTo] = useState(role.reports_to)
|
||||
const [iconKey, setIconKey] = useState<string | null>(role.icon ?? null)
|
||||
const [canSpawn, setCanSpawn] = useState<Set<string>>(() => new Set(role.can_spawn))
|
||||
const [tools, setTools] = useState<Set<string>>(() => new Set(role.tools))
|
||||
const [execStrategy, setExecStrategy] = useState<string>(
|
||||
role.runtime_policy?.execution_strategy ?? 'auto',
|
||||
)
|
||||
const [extAgent, setExtAgent] = useState<string | null>(role.preferred_external_agent ?? null)
|
||||
const [promptRefs, setPromptRefs] = useState<string>((role.prompt_refs ?? []).join('\n'))
|
||||
const [confirmDelete, setConfirmDelete] = useState(false)
|
||||
|
||||
// Reset local state when selected role changes
|
||||
const lastRoleIdRef = useRef(role.role_id)
|
||||
useEffect(() => {
|
||||
if (lastRoleIdRef.current === role.role_id) return
|
||||
lastRoleIdRef.current = role.role_id
|
||||
setName(role.name)
|
||||
setResponsibility(role.responsibility)
|
||||
setReportsTo(role.reports_to)
|
||||
setIconKey(role.icon ?? null)
|
||||
setCanSpawn(new Set(role.can_spawn))
|
||||
setTools(new Set(role.tools))
|
||||
setExecStrategy(role.runtime_policy?.execution_strategy ?? 'auto')
|
||||
setExtAgent(role.preferred_external_agent ?? null)
|
||||
setPromptRefs((role.prompt_refs ?? []).join('\n'))
|
||||
setConfirmDelete(false)
|
||||
}, [role])
|
||||
|
||||
/* ── Debounced save: batch fragments, fire once after 500ms quiescence ── */
|
||||
const dirtyRef = useRef<RoleUpdatePatch>({})
|
||||
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
||||
const roleIdRef = useRef(role.role_id)
|
||||
useEffect(() => { roleIdRef.current = role.role_id }, [role.role_id])
|
||||
|
||||
const flush = useCallback(() => {
|
||||
if (timerRef.current) { clearTimeout(timerRef.current); timerRef.current = null }
|
||||
const patch = dirtyRef.current
|
||||
dirtyRef.current = {}
|
||||
if (Object.keys(patch).length === 0) return
|
||||
onUpdateRole(roleIdRef.current, patch)
|
||||
}, [onUpdateRole])
|
||||
|
||||
const scheduleSave = useCallback((fragment: RoleUpdatePatch) => {
|
||||
if (readOnly) return
|
||||
dirtyRef.current = { ...dirtyRef.current, ...fragment }
|
||||
if (timerRef.current) clearTimeout(timerRef.current)
|
||||
timerRef.current = setTimeout(flush, 500)
|
||||
}, [flush, readOnly])
|
||||
|
||||
// Unmount flush — capture any pending edits so they are not lost
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (timerRef.current) { clearTimeout(timerRef.current); timerRef.current = null }
|
||||
const patch = dirtyRef.current
|
||||
if (Object.keys(patch).length > 0) {
|
||||
dirtyRef.current = {}
|
||||
onUpdateRole(roleIdRef.current, patch)
|
||||
}
|
||||
}
|
||||
}, [onUpdateRole])
|
||||
|
||||
/* ── Setters ───────────────────────────────────────────────── */
|
||||
const handleNameChange = (v: string) => { setName(v); scheduleSave({ name: v }) }
|
||||
const handleResponsibilityChange = (v: string) => { setResponsibility(v); scheduleSave({ responsibility: v }) }
|
||||
const handleReportsToChange = (v: string) => { setReportsTo(v); scheduleSave({ reports_to: v }) }
|
||||
const handleIconChange = (v: string | null) => { setIconKey(v); scheduleSave({ icon: v }) }
|
||||
const handleExecStrategyChange = (v: string) => { setExecStrategy(v); scheduleSave({ execution_strategy: v }) }
|
||||
const handleExtAgentChange = (v: string | null) => { setExtAgent(v); scheduleSave({ preferred_external_agent: v }) }
|
||||
const handlePromptRefsChange = (v: string) => {
|
||||
setPromptRefs(v)
|
||||
const lines = v.split('\n').map(s => s.trim()).filter(Boolean)
|
||||
scheduleSave({ prompt_refs: lines })
|
||||
}
|
||||
const toggleCanSpawn = (id: string) => {
|
||||
const next = new Set(canSpawn)
|
||||
if (next.has(id)) next.delete(id); else next.add(id)
|
||||
setCanSpawn(next)
|
||||
scheduleSave({ can_spawn: Array.from(next) })
|
||||
}
|
||||
const toggleTool = (toolName: string) => {
|
||||
const next = new Set(tools)
|
||||
if (next.has(toolName)) next.delete(toolName); else next.add(toolName)
|
||||
setTools(next)
|
||||
scheduleSave({ tools: Array.from(next) })
|
||||
}
|
||||
|
||||
/* ── Derived ───────────────────────────────────────────────── */
|
||||
const otherRoles = useMemo(
|
||||
() => allRoles.filter(r => r.role_id !== role.role_id),
|
||||
[allRoles, role.role_id],
|
||||
)
|
||||
const employeeCount = useMemo(
|
||||
() => employees.filter(e => (e.role_ids?.length ? e.role_ids : [e.role_id]).includes(role.role_id)).length,
|
||||
[employees, role.role_id],
|
||||
)
|
||||
const promptRefCount = useMemo(
|
||||
() => promptRefs.split('\n').filter(s => s.trim()).length,
|
||||
[promptRefs],
|
||||
)
|
||||
|
||||
/* ── Delete (2-step confirm) ──────────────────────────────── */
|
||||
const handleDelete = () => {
|
||||
if (!confirmDelete) { setConfirmDelete(true); return }
|
||||
flush()
|
||||
onDeleteRole(role.role_id)
|
||||
}
|
||||
|
||||
return (
|
||||
<aside className="ri-panel" aria-label={`Inspector for role ${role.name}`}>
|
||||
<header className="ri-panel-header">
|
||||
<img src={resolveRoleIcon(iconKey)} alt="" className="ri-panel-icon" />
|
||||
<div className="ri-panel-title-wrap">
|
||||
<h3 className="ri-panel-title">{name || '(unnamed)'}</h3>
|
||||
<code className="ri-panel-id">{role.role_id}</code>
|
||||
</div>
|
||||
<button className="btn btn-ghost btn-sm ri-panel-close" onClick={onClose} title="Close (Esc)">✕</button>
|
||||
</header>
|
||||
|
||||
<div className="ri-panel-body">
|
||||
<InspectorGroup title="Identity" defaultExpanded>
|
||||
<InspectorField label="Name">
|
||||
<input
|
||||
className="ri-text-input"
|
||||
value={name}
|
||||
onChange={e => handleNameChange(e.target.value)}
|
||||
disabled={readOnly}
|
||||
/>
|
||||
</InspectorField>
|
||||
<InspectorField label="Responsibility">
|
||||
<textarea
|
||||
className="ri-textarea"
|
||||
rows={3}
|
||||
value={responsibility}
|
||||
onChange={e => handleResponsibilityChange(e.target.value)}
|
||||
disabled={readOnly}
|
||||
/>
|
||||
</InspectorField>
|
||||
<InspectorField label="Icon">
|
||||
<IconPicker value={iconKey} onChange={handleIconChange} readOnly={readOnly} />
|
||||
</InspectorField>
|
||||
</InspectorGroup>
|
||||
|
||||
<InspectorGroup title="Hierarchy" defaultExpanded>
|
||||
<InspectorField label="Reports to">
|
||||
<select
|
||||
className="ri-select"
|
||||
value={reportsTo}
|
||||
onChange={e => handleReportsToChange(e.target.value)}
|
||||
disabled={readOnly}
|
||||
>
|
||||
<option value="owner">You (Owner)</option>
|
||||
{otherRoles.map(r => (
|
||||
<option key={r.role_id} value={r.role_id}>{r.name}</option>
|
||||
))}
|
||||
</select>
|
||||
</InspectorField>
|
||||
<InspectorField label="Can delegate to">
|
||||
<MultiSelect
|
||||
allIds={otherRoles.map(r => r.role_id)}
|
||||
labelFor={(id) => otherRoles.find(r => r.role_id === id)?.name ?? id}
|
||||
selected={canSpawn}
|
||||
onToggle={toggleCanSpawn}
|
||||
readOnly={readOnly}
|
||||
/>
|
||||
</InspectorField>
|
||||
<InspectorField label="Employees assigned">
|
||||
<span className="ri-meta-value">{employeeCount}</span>
|
||||
</InspectorField>
|
||||
</InspectorGroup>
|
||||
|
||||
<InspectorGroup title={`Tools (${tools.size})`}>
|
||||
<ToolChecklist tools={tools} onToggle={toggleTool} readOnly={readOnly} />
|
||||
</InspectorGroup>
|
||||
|
||||
<InspectorGroup title={`Prompts (${promptRefCount})`}>
|
||||
<InspectorField label="Prompt refs / inline">
|
||||
<textarea
|
||||
className="ri-textarea ri-textarea-mono"
|
||||
rows={5}
|
||||
placeholder="One prompt ref or inline instruction per line"
|
||||
value={promptRefs}
|
||||
onChange={e => handlePromptRefsChange(e.target.value)}
|
||||
disabled={readOnly}
|
||||
/>
|
||||
</InspectorField>
|
||||
</InspectorGroup>
|
||||
|
||||
<InspectorGroup title="Runtime policy">
|
||||
<InspectorField label="Execution strategy">
|
||||
<div className="ri-radio-group">
|
||||
{EXECUTION_STRATEGIES.map(opt => (
|
||||
<label key={opt.value} className="ri-radio" title={opt.hint}>
|
||||
<input
|
||||
type="radio"
|
||||
name={`exec-strategy-${role.role_id}`}
|
||||
value={opt.value}
|
||||
checked={execStrategy === opt.value}
|
||||
onChange={() => handleExecStrategyChange(opt.value)}
|
||||
disabled={readOnly}
|
||||
/>
|
||||
{opt.label}
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</InspectorField>
|
||||
{execStrategy === 'external' && (
|
||||
<InspectorField label="Preferred external agent">
|
||||
<select
|
||||
className="ri-select"
|
||||
value={extAgent ?? ''}
|
||||
onChange={e => handleExtAgentChange(e.target.value || null)}
|
||||
disabled={readOnly}
|
||||
>
|
||||
<option value="">(any)</option>
|
||||
{EXTERNAL_AGENTS.map(a => <option key={a} value={a}>{a}</option>)}
|
||||
</select>
|
||||
</InspectorField>
|
||||
)}
|
||||
</InspectorGroup>
|
||||
|
||||
<InspectorGroup title="Advanced">
|
||||
<InspectorField label="Role type">
|
||||
<span className="ri-meta-value">{role.role_type ?? 'worker'}</span>
|
||||
</InspectorField>
|
||||
<InspectorField label="Skills">
|
||||
<span className="ri-meta-value">
|
||||
{role.skill_refs && role.skill_refs.length > 0 ? role.skill_refs.join(', ') : '(none)'}
|
||||
</span>
|
||||
</InspectorField>
|
||||
<InspectorField label="Artifact contract">
|
||||
<span className="ri-meta-value">{role.artifact_contract_ref ?? '(none)'}</span>
|
||||
</InspectorField>
|
||||
</InspectorGroup>
|
||||
</div>
|
||||
|
||||
{!readOnly && (
|
||||
<footer className="ri-panel-footer">
|
||||
<button
|
||||
className={`btn btn-sm ${confirmDelete ? 'btn-danger' : 'btn-ghost'}`}
|
||||
onClick={handleDelete}
|
||||
onBlur={() => setConfirmDelete(false)}
|
||||
>
|
||||
{confirmDelete ? 'Confirm delete?' : 'Delete role'}
|
||||
</button>
|
||||
</footer>
|
||||
)}
|
||||
</aside>
|
||||
)
|
||||
}
|
||||
|
||||
/* ── Sub-components ────────────────────────────────────────────── */
|
||||
|
||||
function InspectorGroup({ title, defaultExpanded = false, children }: {
|
||||
title: string
|
||||
defaultExpanded?: boolean
|
||||
children: ReactNode
|
||||
}) {
|
||||
const [expanded, setExpanded] = useState(defaultExpanded)
|
||||
return (
|
||||
<section className={`ri-group${expanded ? ' is-expanded' : ''}`}>
|
||||
<button className="ri-group-header" onClick={() => setExpanded(e => !e)}>
|
||||
<span className="ri-group-caret">{expanded ? '▾' : '▸'}</span>
|
||||
<span className="ri-group-title">{title}</span>
|
||||
</button>
|
||||
{expanded && <div className="ri-group-body">{children}</div>}
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
function InspectorField({ label, children }: { label: string; children: ReactNode }) {
|
||||
return (
|
||||
<div className="ri-field">
|
||||
<label className="ri-field-label">{label}</label>
|
||||
<div className="ri-field-control">{children}</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function IconPicker({ value, onChange, readOnly }: {
|
||||
value: string | null
|
||||
onChange: (v: string | null) => void
|
||||
readOnly?: boolean
|
||||
}) {
|
||||
const [open, setOpen] = useState(false)
|
||||
return (
|
||||
<div className="ri-icon-picker">
|
||||
<button
|
||||
className="ri-icon-picker-trigger"
|
||||
onClick={() => !readOnly && setOpen(o => !o)}
|
||||
disabled={readOnly}
|
||||
>
|
||||
<img src={resolveRoleIcon(value)} alt="" className="ri-icon-picker-current" />
|
||||
<span className="ri-icon-picker-label">{value ?? 'generic'}</span>
|
||||
</button>
|
||||
{open && (
|
||||
<div className="ri-icon-picker-popover">
|
||||
<button
|
||||
className={`ri-icon-option${value === null ? ' is-active' : ''}`}
|
||||
onClick={() => { onChange(null); setOpen(false) }}
|
||||
title="Default icon"
|
||||
>
|
||||
<img src={ROLE_ICONS.generic} alt="" />
|
||||
</button>
|
||||
{(ROLE_ICON_KEYS as readonly RoleIconKey[]).filter(k => k !== 'generic').map(key => (
|
||||
<button
|
||||
key={key}
|
||||
className={`ri-icon-option${value === key ? ' is-active' : ''}`}
|
||||
onClick={() => { onChange(key); setOpen(false) }}
|
||||
title={key}
|
||||
>
|
||||
<img src={ROLE_ICONS[key]} alt={key} />
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function MultiSelect({ allIds, labelFor, selected, onToggle, readOnly }: {
|
||||
allIds: string[]
|
||||
labelFor: (id: string) => string
|
||||
selected: Set<string>
|
||||
onToggle: (id: string) => void
|
||||
readOnly?: boolean
|
||||
}) {
|
||||
const [open, setOpen] = useState(false)
|
||||
const available = allIds.filter(id => !selected.has(id))
|
||||
|
||||
return (
|
||||
<div className="ri-multiselect">
|
||||
<div className="ri-chips">
|
||||
{Array.from(selected).map(id => (
|
||||
<span key={id} className="ri-chip">
|
||||
{labelFor(id)}
|
||||
{!readOnly && (
|
||||
<button className="ri-chip-x" onClick={() => onToggle(id)} title="Remove">×</button>
|
||||
)}
|
||||
</span>
|
||||
))}
|
||||
{!readOnly && available.length > 0 && (
|
||||
<button className="ri-chip-add" onClick={() => setOpen(o => !o)}>+ Add</button>
|
||||
)}
|
||||
{selected.size === 0 && readOnly && <span className="ri-meta-value">(none)</span>}
|
||||
</div>
|
||||
{open && !readOnly && (
|
||||
<div className="ri-multiselect-popover">
|
||||
{available.map(id => (
|
||||
<button
|
||||
key={id}
|
||||
className="ri-multiselect-option"
|
||||
onClick={() => { onToggle(id); setOpen(false) }}
|
||||
>{labelFor(id)}</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function ToolChecklist({ tools, onToggle, readOnly }: {
|
||||
tools: Set<string>
|
||||
onToggle: (name: string) => void
|
||||
readOnly?: boolean
|
||||
}) {
|
||||
return (
|
||||
<div className="ri-toolcheck">
|
||||
{TOOL_GROUPS.map(g => (
|
||||
<fieldset key={g.prefix} className="ri-toolcheck-group">
|
||||
<legend className="ri-toolcheck-legend">{g.label}</legend>
|
||||
<div className="ri-toolcheck-items">
|
||||
{g.tools.map(t => (
|
||||
<label key={t} className="ri-toolcheck-item">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={tools.has(t)}
|
||||
onChange={() => onToggle(t)}
|
||||
disabled={readOnly}
|
||||
/>
|
||||
<span className="ri-toolcheck-name">{t.replace(g.prefix, '') || t}</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</fieldset>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,345 @@
|
||||
/**
|
||||
* RoleTable — Tanstack-table bulk editor for roles.
|
||||
*
|
||||
* Columns:
|
||||
* - checkbox (multi-select)
|
||||
* - icon (click to open IconPicker — deferred; currently read-only)
|
||||
* - name (inline editable via double-click)
|
||||
* - role_id (monospace, immutable)
|
||||
* - reports_to (dropdown)
|
||||
* - tools (count, clickable to open popover)
|
||||
* - agent (select)
|
||||
* - employees (count)
|
||||
* - actions (⋯ menu: Delete)
|
||||
*
|
||||
* Row click → selects the row in StructureEditor (opens Inspector).
|
||||
* Multi-select → bulk-edit bar at top ("Change agent for N roles", etc.)
|
||||
*/
|
||||
import { useMemo, useState, type ChangeEvent } from 'react'
|
||||
import {
|
||||
useReactTable, getCoreRowModel, getSortedRowModel, flexRender,
|
||||
type ColumnDef, type SortingState,
|
||||
} from '@tanstack/react-table'
|
||||
import type { OrgRole, OrgEmployee } from '../types/visual'
|
||||
import { resolveRoleIcon } from './roleIcons'
|
||||
|
||||
const EXTERNAL_AGENTS = ['codex', 'cursor', 'claude_code', 'opencode'] as const
|
||||
|
||||
interface RoleTableProps {
|
||||
roles: OrgRole[]
|
||||
employees: OrgEmployee[]
|
||||
selectedIds: string[]
|
||||
onSelectRow: (id: string) => void
|
||||
onUpdateRole: (roleId: string, updates: {
|
||||
name?: string
|
||||
reports_to?: string
|
||||
preferred_external_agent?: string | null
|
||||
}) => void
|
||||
onDeleteRole: (roleId: string) => void
|
||||
readOnly?: boolean
|
||||
}
|
||||
|
||||
interface TableRow {
|
||||
role_id: string
|
||||
name: string
|
||||
icon: string | null
|
||||
reports_to: string
|
||||
toolCount: number
|
||||
agent: string | null
|
||||
employeeCount: number
|
||||
}
|
||||
|
||||
/* ── RoleTable ───────────────────────────────────────────────── */
|
||||
|
||||
export function RoleTable({
|
||||
roles, employees, selectedIds, onSelectRow,
|
||||
onUpdateRole, onDeleteRole, readOnly,
|
||||
}: RoleTableProps) {
|
||||
const [sorting, setSorting] = useState<SortingState>([])
|
||||
const [rowSelection, setRowSelection] = useState<Record<string, boolean>>({})
|
||||
const [editingCell, setEditingCell] = useState<{ rowId: string; col: 'name' } | null>(null)
|
||||
const [nameBuffer, setNameBuffer] = useState('')
|
||||
|
||||
const data: TableRow[] = useMemo(() => {
|
||||
const countByRole = new Map<string, number>()
|
||||
for (const e of employees) {
|
||||
const roleIds = e.role_ids?.length ? e.role_ids : [e.role_id]
|
||||
for (const roleId of roleIds) {
|
||||
if (roleId) countByRole.set(roleId, (countByRole.get(roleId) ?? 0) + 1)
|
||||
}
|
||||
}
|
||||
return roles.map(r => ({
|
||||
role_id: r.role_id,
|
||||
name: r.name,
|
||||
icon: r.icon ?? null,
|
||||
reports_to: r.reports_to,
|
||||
toolCount: r.tools?.length ?? 0,
|
||||
agent: r.preferred_external_agent ?? null,
|
||||
employeeCount: countByRole.get(r.role_id) ?? 0,
|
||||
}))
|
||||
}, [roles, employees])
|
||||
|
||||
const reportsToOptions = useMemo(
|
||||
() => [{ id: 'owner', name: 'Owner' }, ...roles.map(r => ({ id: r.role_id, name: r.name }))],
|
||||
[roles],
|
||||
)
|
||||
|
||||
const commitName = (rowId: string) => {
|
||||
const trimmed = nameBuffer.trim()
|
||||
if (trimmed && trimmed !== roles.find(r => r.role_id === rowId)?.name) {
|
||||
onUpdateRole(rowId, { name: trimmed })
|
||||
}
|
||||
setEditingCell(null)
|
||||
setNameBuffer('')
|
||||
}
|
||||
|
||||
const columns: ColumnDef<TableRow>[] = useMemo(() => [
|
||||
{
|
||||
id: 'select',
|
||||
header: ({ table }) => (
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={table.getIsAllRowsSelected()}
|
||||
ref={el => { if (el) el.indeterminate = table.getIsSomeRowsSelected() && !table.getIsAllRowsSelected() }}
|
||||
onChange={table.getToggleAllRowsSelectedHandler()}
|
||||
disabled={readOnly}
|
||||
aria-label="Select all"
|
||||
/>
|
||||
),
|
||||
cell: ({ row }) => (
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={row.getIsSelected()}
|
||||
onChange={row.getToggleSelectedHandler()}
|
||||
disabled={readOnly}
|
||||
aria-label={`Select ${row.original.role_id}`}
|
||||
onClick={e => e.stopPropagation()}
|
||||
/>
|
||||
),
|
||||
size: 32,
|
||||
enableSorting: false,
|
||||
},
|
||||
{
|
||||
id: 'icon',
|
||||
header: '',
|
||||
cell: ({ row }) => (
|
||||
<img src={resolveRoleIcon(row.original.icon)} alt="" className="rt-cell-icon" />
|
||||
),
|
||||
size: 32,
|
||||
enableSorting: false,
|
||||
},
|
||||
{
|
||||
id: 'name',
|
||||
header: 'Name',
|
||||
accessorKey: 'name',
|
||||
cell: ({ row }) => {
|
||||
const r = row.original
|
||||
const isEditing = editingCell?.rowId === r.role_id && editingCell.col === 'name'
|
||||
if (isEditing) {
|
||||
return (
|
||||
<input
|
||||
autoFocus
|
||||
className="rt-inline-input"
|
||||
value={nameBuffer}
|
||||
onChange={e => setNameBuffer(e.target.value)}
|
||||
onBlur={() => commitName(r.role_id)}
|
||||
onKeyDown={e => {
|
||||
if (e.key === 'Enter') commitName(r.role_id)
|
||||
else if (e.key === 'Escape') { setEditingCell(null); setNameBuffer('') }
|
||||
}}
|
||||
onClick={e => e.stopPropagation()}
|
||||
/>
|
||||
)
|
||||
}
|
||||
return (
|
||||
<span
|
||||
className="rt-cell-name"
|
||||
onDoubleClick={e => {
|
||||
if (readOnly) return
|
||||
e.stopPropagation()
|
||||
setNameBuffer(r.name)
|
||||
setEditingCell({ rowId: r.role_id, col: 'name' })
|
||||
}}
|
||||
title="Double-click to edit"
|
||||
>
|
||||
{r.name}
|
||||
</span>
|
||||
)
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'role_id',
|
||||
header: 'ID',
|
||||
accessorKey: 'role_id',
|
||||
cell: ({ row }) => <code className="rt-cell-id">{row.original.role_id}</code>,
|
||||
},
|
||||
{
|
||||
id: 'reports_to',
|
||||
header: 'Reports to',
|
||||
accessorKey: 'reports_to',
|
||||
cell: ({ row }) => {
|
||||
const r = row.original
|
||||
return (
|
||||
<select
|
||||
className="rt-cell-select"
|
||||
value={r.reports_to}
|
||||
onChange={(e: ChangeEvent<HTMLSelectElement>) => onUpdateRole(r.role_id, { reports_to: e.target.value })}
|
||||
disabled={readOnly}
|
||||
onClick={e => e.stopPropagation()}
|
||||
>
|
||||
{reportsToOptions.filter(o => o.id !== r.role_id).map(o => (
|
||||
<option key={o.id} value={o.id}>{o.name}</option>
|
||||
))}
|
||||
</select>
|
||||
)
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'toolCount',
|
||||
header: 'Tools',
|
||||
accessorKey: 'toolCount',
|
||||
cell: ({ row }) => (
|
||||
<span className="rt-cell-count">{row.original.toolCount}</span>
|
||||
),
|
||||
size: 64,
|
||||
},
|
||||
{
|
||||
id: 'agent',
|
||||
header: 'Agent',
|
||||
accessorKey: 'agent',
|
||||
cell: ({ row }) => {
|
||||
const r = row.original
|
||||
return (
|
||||
<select
|
||||
className="rt-cell-select"
|
||||
value={r.agent ?? ''}
|
||||
onChange={(e: ChangeEvent<HTMLSelectElement>) => onUpdateRole(r.role_id, { preferred_external_agent: e.target.value || null })}
|
||||
disabled={readOnly}
|
||||
onClick={e => e.stopPropagation()}
|
||||
>
|
||||
<option value="">(auto)</option>
|
||||
{EXTERNAL_AGENTS.map(a => <option key={a} value={a}>{a}</option>)}
|
||||
</select>
|
||||
)
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'employeeCount',
|
||||
header: 'People',
|
||||
accessorKey: 'employeeCount',
|
||||
cell: ({ row }) => <span className="rt-cell-count">{row.original.employeeCount}</span>,
|
||||
size: 64,
|
||||
},
|
||||
{
|
||||
id: 'actions',
|
||||
header: '',
|
||||
cell: ({ row }) => (
|
||||
<button
|
||||
className="rt-cell-action"
|
||||
onClick={e => {
|
||||
e.stopPropagation()
|
||||
if (readOnly) return
|
||||
if (confirm(`Delete role "${row.original.name}"?`)) onDeleteRole(row.original.role_id)
|
||||
}}
|
||||
disabled={readOnly}
|
||||
title="Delete"
|
||||
>✕</button>
|
||||
),
|
||||
size: 40,
|
||||
enableSorting: false,
|
||||
},
|
||||
], [editingCell, nameBuffer, readOnly, reportsToOptions, onUpdateRole, onDeleteRole])
|
||||
|
||||
const table = useReactTable({
|
||||
data,
|
||||
columns,
|
||||
state: { sorting, rowSelection },
|
||||
onSortingChange: setSorting,
|
||||
onRowSelectionChange: setRowSelection,
|
||||
enableRowSelection: true,
|
||||
getRowId: (row) => row.role_id,
|
||||
getCoreRowModel: getCoreRowModel(),
|
||||
getSortedRowModel: getSortedRowModel(),
|
||||
})
|
||||
|
||||
const selectedCount = Object.values(rowSelection).filter(Boolean).length
|
||||
const selectedRoleIds = Object.keys(rowSelection).filter(id => rowSelection[id])
|
||||
|
||||
/* ── Bulk actions ────────────────────────────────────────── */
|
||||
const bulkSetAgent = (agent: string | null) => {
|
||||
if (readOnly) return
|
||||
selectedRoleIds.forEach(id => onUpdateRole(id, { preferred_external_agent: agent }))
|
||||
setRowSelection({})
|
||||
}
|
||||
const bulkDelete = () => {
|
||||
if (readOnly) return
|
||||
if (!confirm(`Delete ${selectedCount} roles? This cannot be undone.`)) return
|
||||
selectedRoleIds.forEach(id => onDeleteRole(id))
|
||||
setRowSelection({})
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="rt-container">
|
||||
{selectedCount > 0 && !readOnly && (
|
||||
<div className="rt-bulk-bar">
|
||||
<span className="rt-bulk-count">{selectedCount} selected</span>
|
||||
<select
|
||||
className="rt-bulk-select"
|
||||
defaultValue=""
|
||||
onChange={e => { const v = e.target.value; if (v) bulkSetAgent(v === '__auto__' ? null : v) }}
|
||||
>
|
||||
<option value="" disabled>Change agent…</option>
|
||||
<option value="__auto__">(auto)</option>
|
||||
{EXTERNAL_AGENTS.map(a => <option key={a} value={a}>{a}</option>)}
|
||||
</select>
|
||||
<button className="btn btn-danger btn-sm" onClick={bulkDelete}>Delete selected</button>
|
||||
<button className="btn btn-ghost btn-sm" onClick={() => setRowSelection({})}>Clear</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="rt-table-wrap">
|
||||
<table className="rt-table">
|
||||
<thead>
|
||||
{table.getHeaderGroups().map(hg => (
|
||||
<tr key={hg.id}>
|
||||
{hg.headers.map(h => (
|
||||
<th
|
||||
key={h.id}
|
||||
style={{ width: h.getSize() }}
|
||||
className={h.column.getCanSort() ? 'rt-th-sortable' : ''}
|
||||
onClick={h.column.getToggleSortingHandler()}
|
||||
>
|
||||
{flexRender(h.column.columnDef.header, h.getContext())}
|
||||
{h.column.getIsSorted() === 'asc' && <span className="rt-sort-caret"> ▲</span>}
|
||||
{h.column.getIsSorted() === 'desc' && <span className="rt-sort-caret"> ▼</span>}
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
))}
|
||||
</thead>
|
||||
<tbody>
|
||||
{table.getRowModel().rows.length === 0 && (
|
||||
<tr><td colSpan={9} className="rt-empty">No roles. Add one via the "+ Add role" button.</td></tr>
|
||||
)}
|
||||
{table.getRowModel().rows.map(row => {
|
||||
const isSelected = selectedIds.includes(row.original.role_id)
|
||||
return (
|
||||
<tr
|
||||
key={row.id}
|
||||
className={`rt-row${row.getIsSelected() ? ' rt-row-checked' : ''}${isSelected ? ' rt-row-active' : ''}`}
|
||||
onClick={() => onSelectRow(row.original.role_id)}
|
||||
>
|
||||
{row.getVisibleCells().map(cell => (
|
||||
<td key={cell.id} style={{ width: cell.column.getSize() }}>
|
||||
{flexRender(cell.column.columnDef.cell, cell.getContext())}
|
||||
</td>
|
||||
))}
|
||||
</tr>
|
||||
)
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,250 @@
|
||||
import { useMemo, useCallback, useState, useEffect, useRef, useImperativeHandle, forwardRef } from 'react'
|
||||
import { ReactFlow, Background, Controls, MiniMap, ReactFlowProvider, applyNodeChanges } from '@xyflow/react'
|
||||
import type { Node, Edge, NodeChange } from '@xyflow/react'
|
||||
import '@xyflow/react/dist/style.css'
|
||||
import type { OrgRole, OrgEmployee } from '../types/visual'
|
||||
import { StructureCanvasNode, type StructureCanvasNodeData } from './StructureCanvasNode'
|
||||
import { computeDagreLayout } from './dagreLayout'
|
||||
|
||||
const nodeTypes = { roleNode: StructureCanvasNode }
|
||||
|
||||
export interface StructureCanvasHandle {
|
||||
/** Re-run dagre and animate nodes to tidy positions. */
|
||||
autoLayout: () => void
|
||||
}
|
||||
|
||||
interface StructureCanvasProps {
|
||||
roles: OrgRole[]
|
||||
employees: OrgEmployee[]
|
||||
/**
|
||||
* role_id -> recruited names for the currently selected session. When
|
||||
* provided it takes precedence over the global `employees` for the node
|
||||
* subtitle, so the canvas reflects the selected session's hires. Null/absent
|
||||
* -> fall back to the global org employees.
|
||||
*/
|
||||
sessionRecruitmentByRole?: Record<string, string[]> | null
|
||||
selectedRoleId: string | null
|
||||
onSelectRole: (roleId: string | null) => void
|
||||
onReparent: (roleId: string, newParentId: string) => void
|
||||
readOnly?: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Constrained canvas for org structure.
|
||||
* Positions are managed internally. See D1 + D3 in the plan doc:
|
||||
* - dagre runs on mount, on role add/delete, and on explicit autoLayout() calls.
|
||||
* - Role-field updates do NOT reflow the graph.
|
||||
* - Reparenting reflows (a new parent->child edge would leave the graph stale).
|
||||
*/
|
||||
export const StructureCanvas = forwardRef<StructureCanvasHandle, StructureCanvasProps>(function StructureCanvas(props, ref) {
|
||||
return (
|
||||
<ReactFlowProvider>
|
||||
<StructureCanvasInner {...props} forwardedRef={ref} />
|
||||
</ReactFlowProvider>
|
||||
)
|
||||
})
|
||||
|
||||
function StructureCanvasInner({ roles, employees, sessionRecruitmentByRole, selectedRoleId, onSelectRole, onReparent, readOnly, forwardedRef }: StructureCanvasProps & { forwardedRef: React.ForwardedRef<StructureCanvasHandle> }) {
|
||||
const employeesByRole = useMemo(() => {
|
||||
const m = new Map<string, OrgEmployee[]>()
|
||||
for (const e of employees) {
|
||||
const roleIds = e.role_ids?.length ? e.role_ids : [e.role_id]
|
||||
for (const roleId of roleIds) {
|
||||
if (!roleId) continue
|
||||
const arr = m.get(roleId) ?? []
|
||||
arr.push(e)
|
||||
m.set(roleId, arr)
|
||||
}
|
||||
}
|
||||
return m
|
||||
}, [employees])
|
||||
|
||||
// Names of the actually recruited people per role.
|
||||
// - When the selected session carries a recruitment map, it is authoritative
|
||||
// (a role absent from it is unstaffed *for that session*).
|
||||
// - Otherwise fall back to the global org employees, excluding placeholder/
|
||||
// default employees (which carry the role name itself, not a real hire).
|
||||
const recruitedNamesByRole = useCallback(
|
||||
(roleId: string): string[] => {
|
||||
if (sessionRecruitmentByRole) return sessionRecruitmentByRole[roleId] ?? []
|
||||
return (employeesByRole.get(roleId) ?? [])
|
||||
.filter(e => !e.is_default_employee)
|
||||
.map(e => e.name)
|
||||
.filter(Boolean)
|
||||
},
|
||||
[employeesByRole, sessionRecruitmentByRole],
|
||||
)
|
||||
|
||||
// Layout invalidation key. Incrementing -> dagre re-runs.
|
||||
const [layoutVersion, setLayoutVersion] = useState(0)
|
||||
|
||||
// Re-layout automatically when the set of role IDs changes (add/delete).
|
||||
const roleIdsKey = useMemo(() => roles.map(r => r.role_id).sort().join('|'), [roles])
|
||||
const prevRoleIdsKeyRef = useRef(roleIdsKey)
|
||||
useEffect(() => {
|
||||
if (prevRoleIdsKeyRef.current !== roleIdsKey) {
|
||||
prevRoleIdsKeyRef.current = roleIdsKey
|
||||
setLayoutVersion(v => v + 1)
|
||||
}
|
||||
}, [roleIdsKey])
|
||||
|
||||
// Expose imperative autoLayout() to parent so the "Auto-layout" button can trigger it.
|
||||
useImperativeHandle(forwardedRef, () => ({
|
||||
autoLayout: () => setLayoutVersion(v => v + 1),
|
||||
}), [])
|
||||
|
||||
const [dropTargetId, setDropTargetId] = useState<string | null>(null)
|
||||
|
||||
// Free-form drag positions: a node the user has dragged sticks where it was
|
||||
// dropped (purely visual — never changes the org structure). Cleared whenever
|
||||
// dagre re-runs (autoLayout / role add-delete / reparent) so an explicit
|
||||
// "Auto-layout" tidies everything back into the hierarchy.
|
||||
const [manualPositions, setManualPositions] = useState<Record<string, { x: number; y: number }>>({})
|
||||
useEffect(() => { setManualPositions({}) }, [layoutVersion])
|
||||
|
||||
// Compute layout once per layoutVersion bump (NOT on every roles update).
|
||||
const laidOut = useMemo(() => {
|
||||
const ownerNode: Node<StructureCanvasNodeData> = {
|
||||
id: 'owner',
|
||||
type: 'roleNode',
|
||||
position: { x: 0, y: 0 },
|
||||
draggable: false,
|
||||
data: {
|
||||
roleId: 'owner', name: 'You (Owner)', responsibility: '',
|
||||
icon: null, employeeCount: 0, employeeNames: [],
|
||||
isOwner: true, isSelected: false, isDropTarget: false,
|
||||
},
|
||||
}
|
||||
const roleNodes: Node<StructureCanvasNodeData>[] = roles.map(r => ({
|
||||
id: r.role_id,
|
||||
type: 'roleNode',
|
||||
position: { x: 0, y: 0 },
|
||||
// Always draggable: in editable mode a drop onto another node reparents;
|
||||
// otherwise the drag is a free visual reposition (no structural change).
|
||||
draggable: true,
|
||||
data: {
|
||||
roleId: r.role_id, name: r.name, responsibility: r.responsibility,
|
||||
icon: r.icon ?? null, employeeCount: recruitedNamesByRole(r.role_id).length,
|
||||
employeeNames: recruitedNamesByRole(r.role_id),
|
||||
isOwner: false, isSelected: false, isDropTarget: false,
|
||||
},
|
||||
}))
|
||||
const all = [ownerNode, ...roleNodes]
|
||||
const edges: Edge[] = roles.map(r => ({
|
||||
id: `e-${r.reports_to}-${r.role_id}`,
|
||||
source: r.reports_to,
|
||||
target: r.role_id,
|
||||
type: 'smoothstep',
|
||||
}))
|
||||
const positioned = computeDagreLayout(all, edges)
|
||||
return { nodes: positioned, edges }
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps -- layoutVersion is the explicit reflow trigger
|
||||
}, [layoutVersion, readOnly])
|
||||
|
||||
// Live nodes: start from laid-out positions; overlay current role data
|
||||
// (name/icon/etc.) without re-running dagre.
|
||||
const liveNodes = useMemo(() => {
|
||||
return laidOut.nodes.map(n => {
|
||||
if (n.id === 'owner') return n
|
||||
const role = roles.find(r => r.role_id === n.id)
|
||||
if (!role) return n // node for a deleted role -- invariant: layoutVersion will bump and this clears
|
||||
const manual = manualPositions[n.id]
|
||||
return {
|
||||
...n,
|
||||
position: manual ?? n.position,
|
||||
draggable: true,
|
||||
data: {
|
||||
...n.data,
|
||||
name: role.name,
|
||||
responsibility: role.responsibility,
|
||||
icon: role.icon ?? null,
|
||||
employeeCount: recruitedNamesByRole(role.role_id).length,
|
||||
employeeNames: recruitedNamesByRole(role.role_id),
|
||||
isSelected: role.role_id === selectedRoleId,
|
||||
isDropTarget: role.role_id === dropTargetId,
|
||||
},
|
||||
}
|
||||
})
|
||||
}, [laidOut.nodes, roles, employeesByRole, recruitedNamesByRole, selectedRoleId, dropTargetId, readOnly, manualPositions])
|
||||
|
||||
const [stateNodes, setStateNodes] = useState(liveNodes)
|
||||
useEffect(() => { setStateNodes(liveNodes) }, [liveNodes])
|
||||
|
||||
const handleNodesChange = useCallback((changes: NodeChange[]) => {
|
||||
setStateNodes(curr => applyNodeChanges(changes, curr))
|
||||
}, [])
|
||||
|
||||
const handleNodeDrag = useCallback((_evt: any, node: Node) => {
|
||||
// Reparent drop-target highlighting only applies in editable mode. In
|
||||
// read-only mode the drag is a pure visual reposition -- no target.
|
||||
if (readOnly) return
|
||||
// Bounding-box hit test for drop target
|
||||
const W = 220, H = 80
|
||||
const pt = { x: node.position.x + W / 2, y: node.position.y + H / 2 }
|
||||
const hit = stateNodes.find(n => {
|
||||
if (n.id === node.id) return false
|
||||
return pt.x >= n.position.x && pt.x <= n.position.x + W &&
|
||||
pt.y >= n.position.y && pt.y <= n.position.y + H
|
||||
})
|
||||
setDropTargetId(hit?.id ?? null)
|
||||
}, [stateNodes, readOnly])
|
||||
|
||||
const handleNodeDragStop = useCallback((_evt: any, node: Node) => {
|
||||
const target = dropTargetId
|
||||
setDropTargetId(null)
|
||||
// Remember the dropped position so the node stays put (visual only).
|
||||
const keepPosition = () =>
|
||||
setManualPositions(prev => ({ ...prev, [node.id]: { x: node.position.x, y: node.position.y } }))
|
||||
// No valid reparent (read-only, no/own target, or a cycle) -> free reposition.
|
||||
if (readOnly || !target || target === node.id || isDescendant(roles, node.id, target)) {
|
||||
keepPosition()
|
||||
return
|
||||
}
|
||||
onReparent(node.id, target)
|
||||
// A reparent changes the edge structure -- reflow (also clears manual positions).
|
||||
setLayoutVersion(v => v + 1)
|
||||
}, [dropTargetId, roles, readOnly, onReparent])
|
||||
|
||||
const handleNodeClick = useCallback((_evt: any, node: Node) => {
|
||||
onSelectRole(node.id === 'owner' ? null : node.id)
|
||||
}, [onSelectRole])
|
||||
|
||||
// @xyflow/react v12's `.react-flow` CSS class does NOT set height/width.
|
||||
// Wrap in a sized div so the canvas has a definite box to render into —
|
||||
// this is the library's documented integration pattern for v12.
|
||||
return (
|
||||
<div style={{ width: '100%', height: '100%' }}>
|
||||
<ReactFlow
|
||||
nodes={stateNodes}
|
||||
edges={laidOut.edges}
|
||||
nodeTypes={nodeTypes}
|
||||
onNodesChange={handleNodesChange}
|
||||
onNodeDrag={handleNodeDrag}
|
||||
onNodeDragStop={handleNodeDragStop}
|
||||
onNodeClick={handleNodeClick}
|
||||
fitView
|
||||
fitViewOptions={{ padding: 0.32, minZoom: 0.45, maxZoom: 1.2 }}
|
||||
nodesConnectable={false}
|
||||
edgesFocusable={false}
|
||||
style={{ width: '100%', height: '100%' }}
|
||||
>
|
||||
<Background gap={28} size={1} color="rgba(240, 237, 232, 0.10)" />
|
||||
<Controls showInteractive={false} />
|
||||
<MiniMap pannable nodeStrokeWidth={0} maskColor="rgba(12, 17, 27, 0.6)" />
|
||||
</ReactFlow>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Note: no ReactFlow `proOptions.hideAttribution` used -- @xyflow/react v12 is MIT
|
||||
// with the attribution fully removed at the library level, so no workaround needed.
|
||||
|
||||
/** Helper: is `candidateDescendantId` a descendant of `rootId`? */
|
||||
function isDescendant(roles: OrgRole[], rootId: string, candidateDescendantId: string): boolean {
|
||||
const children = roles.filter(r => r.reports_to === rootId).map(r => r.role_id)
|
||||
for (const c of children) {
|
||||
if (c === candidateDescendantId) return true
|
||||
if (isDescendant(roles, c, candidateDescendantId)) return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
import { memo } from 'react'
|
||||
import { Handle, Position } from '@xyflow/react'
|
||||
import { resolveRoleIcon } from './roleIcons'
|
||||
|
||||
export interface StructureCanvasNodeData {
|
||||
[key: string]: unknown
|
||||
roleId: string
|
||||
name: string
|
||||
responsibility: string
|
||||
icon: string | null
|
||||
employeeCount: number
|
||||
/** Names of the actual recruited (non-placeholder) people staffed on this role. */
|
||||
employeeNames: string[]
|
||||
isOwner: boolean
|
||||
isSelected: boolean
|
||||
isDropTarget: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Canvas node — single-accent refined card.
|
||||
* Outer <div> always carries .oc-canvas-node (plus optional state
|
||||
* modifiers) — this is the E2E anchor. Icon is rendered via CSS
|
||||
* `mask-image` so its tint can track the active theme's --accent;
|
||||
* the card reads consistently under Paper, OpenOPC, etc.
|
||||
*/
|
||||
export const StructureCanvasNode = memo(function StructureCanvasNode({ data }: { data: StructureCanvasNodeData }) {
|
||||
const stateClass = [
|
||||
'oc-canvas-node',
|
||||
data.isOwner && 'is-owner',
|
||||
data.isSelected && 'is-selected',
|
||||
data.isDropTarget && 'is-drop-target',
|
||||
].filter(Boolean).join(' ')
|
||||
|
||||
const iconSrc = resolveRoleIcon(data.icon)
|
||||
|
||||
return (
|
||||
<div className={stateClass}>
|
||||
<Handle type="target" position={Position.Top} className="oc-canvas-handle" />
|
||||
<div className="oc-canvas-node-row">
|
||||
<div className="oc-canvas-node-chip">
|
||||
<span
|
||||
className="oc-canvas-node-chip-icon"
|
||||
style={{
|
||||
WebkitMaskImage: `url("${iconSrc}")`,
|
||||
maskImage: `url("${iconSrc}")`,
|
||||
}}
|
||||
aria-hidden
|
||||
/>
|
||||
</div>
|
||||
<div className="oc-canvas-node-text">
|
||||
<span className="oc-canvas-node-name">{data.name}</span>
|
||||
<div className="oc-canvas-node-meta">
|
||||
{data.employeeNames.length > 0 ? (
|
||||
<span
|
||||
className="oc-canvas-node-person"
|
||||
title={data.employeeNames.join(', ')}
|
||||
>
|
||||
{data.employeeNames[0]}
|
||||
{data.employeeNames.length > 1 ? ` +${data.employeeNames.length - 1}` : ''}
|
||||
</span>
|
||||
) : (
|
||||
<span className="oc-canvas-node-id">{data.roleId}</span>
|
||||
)}
|
||||
{data.employeeCount > 0 && (
|
||||
<span className="oc-canvas-node-badge">{data.employeeCount}</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<Handle type="source" position={Position.Bottom} className="oc-canvas-handle" />
|
||||
</div>
|
||||
)
|
||||
})
|
||||
@@ -0,0 +1,223 @@
|
||||
/**
|
||||
* StructureEditor — top-level wrapper for Team sub-tab's role editor.
|
||||
*
|
||||
* Owns:
|
||||
* - view mode (canvas | table)
|
||||
* - selection state (which role is open in Inspector)
|
||||
* - reparent handler (proxies to onUpdateRole)
|
||||
* - keyboard shortcuts: Escape (close Inspector), Delete (delete selected
|
||||
* role in Canvas mode), F (fit view to graph), ⌘D (duplicate selected)
|
||||
*/
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import type { OrgRole, OrgEmployee, SavedOrgSummary } from '../types/visual'
|
||||
import { OrgVersionSwitcher } from './OrgVersionSwitcher'
|
||||
import { RoleInspector, type RoleUpdatePatch } from './RoleInspector'
|
||||
import { RoleTable } from './RoleTable'
|
||||
import { StructureCanvas, type StructureCanvasHandle } from './StructureCanvas'
|
||||
|
||||
interface StructureEditorProps {
|
||||
roles: OrgRole[]
|
||||
employees: OrgEmployee[]
|
||||
/** role_id -> recruited names for the selected session (canvas display only). */
|
||||
sessionRecruitmentByRole?: Record<string, string[]> | null
|
||||
isCustomMode?: boolean
|
||||
onAddRole: (
|
||||
roleId: string,
|
||||
name: string,
|
||||
responsibility: string,
|
||||
reportsTo: string,
|
||||
icon?: string | null,
|
||||
) => void
|
||||
onUpdateRole: (roleId: string, updates: RoleUpdatePatch) => void
|
||||
onDeleteRole: (roleId: string) => void
|
||||
// Saved org architectures — render a version-switcher pill in the toolbar
|
||||
savedOrgsList?: SavedOrgSummary[] | null
|
||||
activeSavedOrg?: string | null
|
||||
currentOrgVersion?: number
|
||||
versionAtLoad?: number | null
|
||||
onSavedOrgsList?: () => void
|
||||
onSavedOrgSaveAs?: (name: string, overwrite: boolean) => void
|
||||
onSavedOrgLoad?: (name: string) => void
|
||||
onSavedOrgDelete?: (name: string) => void
|
||||
}
|
||||
|
||||
type EditorView = 'canvas' | 'table'
|
||||
|
||||
export function StructureEditor({
|
||||
roles, employees, sessionRecruitmentByRole, isCustomMode,
|
||||
onAddRole, onUpdateRole, onDeleteRole,
|
||||
savedOrgsList, activeSavedOrg, currentOrgVersion, versionAtLoad,
|
||||
onSavedOrgsList, onSavedOrgSaveAs, onSavedOrgLoad, onSavedOrgDelete,
|
||||
}: StructureEditorProps) {
|
||||
const [view, setView] = useState<EditorView>('canvas')
|
||||
const [selectedId, setSelectedId] = useState<string | null>(null)
|
||||
const canvasRef = useRef<StructureCanvasHandle>(null)
|
||||
|
||||
const selectedRole = useMemo(
|
||||
() => roles.find(r => r.role_id === selectedId) ?? null,
|
||||
[roles, selectedId],
|
||||
)
|
||||
|
||||
/** Drag-to-reparent comes from StructureCanvas and turns into a normal role update. */
|
||||
const handleReparent = useCallback((roleId: string, newParentId: string) => {
|
||||
if (!isCustomMode) return
|
||||
onUpdateRole(roleId, { reports_to: newParentId })
|
||||
}, [onUpdateRole, isCustomMode])
|
||||
|
||||
/** Toolbar "Auto-layout" — triggers dagre reflow via forwardRef on Canvas. */
|
||||
const handleAutoLayout = useCallback(() => {
|
||||
canvasRef.current?.autoLayout()
|
||||
}, [])
|
||||
|
||||
/**
|
||||
* Toolbar "+ Add role" — generates a unique placeholder ID + default label,
|
||||
* then selects the new role so Inspector opens ready-to-edit.
|
||||
*/
|
||||
const handleAddRoleQuick = useCallback(() => {
|
||||
if (!isCustomMode) return
|
||||
const existingIds = new Set(roles.map(r => r.role_id))
|
||||
let id = 'new_role'
|
||||
let suffix = 1
|
||||
while (existingIds.has(id)) { suffix += 1; id = `new_role_${suffix}` }
|
||||
onAddRole(id, 'New Role', '', 'owner', null)
|
||||
setSelectedId(id)
|
||||
}, [roles, onAddRole, isCustomMode])
|
||||
|
||||
/** Duplicate the currently-selected role (⌘D). */
|
||||
const handleDuplicateSelected = useCallback(() => {
|
||||
if (!isCustomMode || !selectedRole) return
|
||||
const existingIds = new Set(roles.map(r => r.role_id))
|
||||
let id = `${selectedRole.role_id}_copy`
|
||||
let suffix = 1
|
||||
while (existingIds.has(id)) { suffix += 1; id = `${selectedRole.role_id}_copy_${suffix}` }
|
||||
onAddRole(id, `${selectedRole.name} (copy)`, selectedRole.responsibility, selectedRole.reports_to, selectedRole.icon)
|
||||
setSelectedId(id)
|
||||
}, [roles, onAddRole, selectedRole, isCustomMode])
|
||||
|
||||
/**
|
||||
* Keyboard shortcuts (scoped to StructureEditor via a wrapper ref +
|
||||
* document-level listener that first checks whether the event originated
|
||||
* from inside the editor). Skips when user is typing in a form field.
|
||||
*/
|
||||
const rootRef = useRef<HTMLDivElement>(null)
|
||||
useEffect(() => {
|
||||
const handler = (e: KeyboardEvent) => {
|
||||
// Don't swallow shortcuts while user types in form fields.
|
||||
const t = e.target as HTMLElement | null
|
||||
if (t && (t.tagName === 'INPUT' || t.tagName === 'TEXTAREA' || t.tagName === 'SELECT' || t.isContentEditable)) return
|
||||
// Only react if the editor is on screen (event path touches our root).
|
||||
if (rootRef.current && !rootRef.current.contains(t)) return
|
||||
|
||||
if (e.key === 'Escape' && selectedId) {
|
||||
setSelectedId(null)
|
||||
e.preventDefault()
|
||||
return
|
||||
}
|
||||
if (e.key === 'Delete' && selectedId && isCustomMode) {
|
||||
onDeleteRole(selectedId)
|
||||
setSelectedId(null)
|
||||
e.preventDefault()
|
||||
return
|
||||
}
|
||||
if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === 'd' && selectedId && isCustomMode) {
|
||||
handleDuplicateSelected()
|
||||
e.preventDefault()
|
||||
return
|
||||
}
|
||||
if (e.key.toLowerCase() === 'f' && view === 'canvas') {
|
||||
handleAutoLayout()
|
||||
e.preventDefault()
|
||||
return
|
||||
}
|
||||
}
|
||||
window.addEventListener('keydown', handler)
|
||||
return () => window.removeEventListener('keydown', handler)
|
||||
}, [selectedId, isCustomMode, view, onDeleteRole, handleAutoLayout, handleDuplicateSelected])
|
||||
|
||||
return (
|
||||
<div className="se-container" ref={rootRef}>
|
||||
<div className="se-toolbar">
|
||||
<div className="se-view-switcher" role="tablist" aria-label="Editor view">
|
||||
<button
|
||||
role="tab"
|
||||
aria-selected={view === 'canvas'}
|
||||
className={`se-view-btn${view === 'canvas' ? ' is-active' : ''}`}
|
||||
onClick={() => setView('canvas')}
|
||||
>Canvas</button>
|
||||
<button
|
||||
role="tab"
|
||||
aria-selected={view === 'table'}
|
||||
className={`se-view-btn${view === 'table' ? ' is-active' : ''}`}
|
||||
onClick={() => setView('table')}
|
||||
>Table</button>
|
||||
</div>
|
||||
<div className={`se-toolbar-actions${isCustomMode ? '' : ' se-toolbar-actions--readonly'}`}>
|
||||
{isCustomMode ? (
|
||||
<div className="se-saved-org-control">
|
||||
<span className="se-toolbar-label">Saved org</span>
|
||||
<OrgVersionSwitcher
|
||||
savedOrgs={savedOrgsList ?? null}
|
||||
activeName={activeSavedOrg ?? null}
|
||||
isDirty={versionAtLoad != null && (currentOrgVersion ?? 0) !== versionAtLoad}
|
||||
onRefresh={onSavedOrgsList ?? (() => {})}
|
||||
onSaveAs={onSavedOrgSaveAs ?? (() => {})}
|
||||
onLoad={onSavedOrgLoad ?? (() => {})}
|
||||
onDelete={onSavedOrgDelete ?? (() => {})}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<span className="se-readonly-pill">Read-only corporate</span>
|
||||
)}
|
||||
<div className="se-toolbar-divider" aria-hidden />
|
||||
{view === 'canvas' && (
|
||||
<button className="btn btn-ghost btn-sm" onClick={handleAutoLayout}>
|
||||
Auto-layout
|
||||
</button>
|
||||
)}
|
||||
{isCustomMode && (
|
||||
<button className="btn btn-primary btn-sm" onClick={handleAddRoleQuick}>
|
||||
+ Add role
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="se-body">
|
||||
{view === 'canvas' ? (
|
||||
<StructureCanvas
|
||||
ref={canvasRef}
|
||||
roles={roles}
|
||||
employees={employees}
|
||||
sessionRecruitmentByRole={sessionRecruitmentByRole}
|
||||
selectedRoleId={selectedId}
|
||||
onSelectRole={setSelectedId}
|
||||
onReparent={handleReparent}
|
||||
readOnly={!isCustomMode}
|
||||
/>
|
||||
) : (
|
||||
<RoleTable
|
||||
roles={roles}
|
||||
employees={employees}
|
||||
selectedIds={selectedId ? [selectedId] : []}
|
||||
onSelectRow={(id) => setSelectedId(id)}
|
||||
onUpdateRole={onUpdateRole}
|
||||
onDeleteRole={onDeleteRole}
|
||||
readOnly={!isCustomMode}
|
||||
/>
|
||||
)}
|
||||
|
||||
{selectedRole && (
|
||||
<RoleInspector
|
||||
role={selectedRole}
|
||||
allRoles={roles}
|
||||
employees={employees}
|
||||
readOnly={!isCustomMode}
|
||||
onUpdateRole={onUpdateRole}
|
||||
onDeleteRole={(id) => { onDeleteRole(id); setSelectedId(null) }}
|
||||
onClose={() => setSelectedId(null)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
import type { TalentTemplate } from '../types/visual'
|
||||
|
||||
interface TalentCardProps {
|
||||
template: TalentTemplate
|
||||
hiringId?: string | null
|
||||
onHire: (templateId: string) => void
|
||||
onClick: (template: TalentTemplate) => void
|
||||
}
|
||||
|
||||
/** Derive a 2-letter monogram from a name. "Creative Director" → "CD". */
|
||||
function monogram(name: string): string {
|
||||
const words = name.trim().split(/\s+/).filter(Boolean)
|
||||
if (words.length === 0) return '?'
|
||||
if (words.length === 1) return words[0].slice(0, 2).toUpperCase()
|
||||
return (words[0][0] + words[words.length - 1][0]).toUpperCase()
|
||||
}
|
||||
|
||||
export function TalentCard({ template: t, hiringId, onHire, onClick }: TalentCardProps) {
|
||||
const isHiring = hiringId === t.template_id
|
||||
const avatarStyle = t.color
|
||||
? {
|
||||
background: `color-mix(in srgb, ${t.color} 18%, transparent)`,
|
||||
color: t.color,
|
||||
boxShadow: `inset 0 0 0 1px color-mix(in srgb, ${t.color} 28%, transparent)`,
|
||||
}
|
||||
: undefined
|
||||
|
||||
const chipPool: string[] = []
|
||||
const seen = new Set<string>()
|
||||
for (const c of [...t.tags, ...t.domains]) {
|
||||
if (!seen.has(c)) { seen.add(c); chipPool.push(c) }
|
||||
}
|
||||
const visibleChips = chipPool.slice(0, 3)
|
||||
const overflow = chipPool.length - visibleChips.length
|
||||
|
||||
return (
|
||||
<div className="tm-card" onClick={() => onClick(t)}>
|
||||
<div className="tm-card-head">
|
||||
<div className="tm-card-avatar" style={avatarStyle} aria-hidden>
|
||||
{t.emoji ? (
|
||||
<span className="tm-card-avatar-emoji">{t.emoji}</span>
|
||||
) : (
|
||||
<span className="tm-card-avatar-mono">{monogram(t.name)}</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="tm-card-head-text">
|
||||
<div className="tm-card-name-row">
|
||||
<span className="tm-card-name" title={t.name}>{t.name}</span>
|
||||
{t.preferred_external_agent && (
|
||||
<span
|
||||
className="tm-card-agent-badge"
|
||||
title={`Agent: ${t.preferred_external_agent}`}
|
||||
>
|
||||
{t.preferred_external_agent}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{t.category && <span className="tm-card-category">{t.category}</span>}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="tm-card-body">
|
||||
{t.vibe && <div className="tm-card-vibe">"{t.vibe}"</div>}
|
||||
{t.description && <div className="tm-card-desc">{t.description}</div>}
|
||||
</div>
|
||||
|
||||
{visibleChips.length > 0 && (
|
||||
<div className="tm-card-chips">
|
||||
{visibleChips.map(c => (
|
||||
<span key={c} className="tm-card-chip">{c}</span>
|
||||
))}
|
||||
{overflow > 0 && (
|
||||
<span className="tm-card-chip tm-card-chip-more">+{overflow}</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="tm-card-footer">
|
||||
<button
|
||||
className="tm-card-hire-btn"
|
||||
disabled={isHiring}
|
||||
onClick={(e) => { e.stopPropagation(); onHire(t.template_id) }}
|
||||
>
|
||||
{isHiring ? <><span className="spinner-inline" /> Hiring</> : 'Hire →'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import type { OrgRole, TalentTemplate, HireTalentHandler } from '../types/visual'
|
||||
import { asRoleId, asTemplateId } from '../types/visual'
|
||||
|
||||
interface TalentDetailModalProps {
|
||||
template: TalentTemplate
|
||||
vacantRoles: OrgRole[]
|
||||
hiringId?: string | null
|
||||
readOnly?: boolean
|
||||
onHire: HireTalentHandler
|
||||
onClose: () => void
|
||||
}
|
||||
|
||||
export function TalentDetailModal({
|
||||
template: t, vacantRoles, hiringId, readOnly, onHire, onClose,
|
||||
}: TalentDetailModalProps) {
|
||||
const [selectedRoleId, setSelectedRoleId] = useState<string>('')
|
||||
const isHiring = hiringId === t.template_id
|
||||
const noVacancies = vacantRoles.length === 0
|
||||
const canHire = !readOnly && !isHiring && !noVacancies && selectedRoleId !== ''
|
||||
|
||||
useEffect(() => {
|
||||
setSelectedRoleId('')
|
||||
}, [t.template_id])
|
||||
|
||||
const handleHire = () => {
|
||||
if (!canHire) return
|
||||
onHire(asTemplateId(t.template_id), asRoleId(selectedRoleId))
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="tm-detail-overlay" onClick={onClose}>
|
||||
<div className="tm-detail-modal" onClick={e => e.stopPropagation()}>
|
||||
{/* Header */}
|
||||
<div className="tm-detail-header" style={{ borderBottomColor: t.color || 'var(--border)' }}>
|
||||
{t.emoji && <span className="tm-detail-emoji">{t.emoji}</span>}
|
||||
<div>
|
||||
<h2 className="tm-detail-name">{t.name}</h2>
|
||||
<span className="tm-detail-category">{t.category}</span>
|
||||
</div>
|
||||
<button className="tm-detail-close" onClick={onClose}>×</button>
|
||||
</div>
|
||||
|
||||
{/* Body */}
|
||||
<div className="tm-detail-body">
|
||||
{t.vibe && (
|
||||
<blockquote className="tm-detail-vibe">"{t.vibe}"</blockquote>
|
||||
)}
|
||||
|
||||
{t.description && (
|
||||
<p className="tm-detail-desc">{t.description}</p>
|
||||
)}
|
||||
|
||||
{t.domains.length > 0 && (
|
||||
<div className="tm-detail-section">
|
||||
<div className="tm-detail-label">Domains</div>
|
||||
<div className="tm-detail-tags">
|
||||
{t.domains.map(d => <span key={d} className="org-domain-tag">{d}</span>)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{t.tags.length > 0 && (
|
||||
<div className="tm-detail-section">
|
||||
<div className="tm-detail-label">Tags</div>
|
||||
<div className="tm-detail-tags">
|
||||
{t.tags.map(tag => <span key={tag} className="org-tool-tag">{tag}</span>)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{t.preferred_external_agent && (
|
||||
<div className="tm-detail-section">
|
||||
<div className="tm-detail-label">Recommended Agent</div>
|
||||
<span className="tm-card-agent-badge">{t.preferred_external_agent}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!readOnly && (
|
||||
<div className="tm-detail-section">
|
||||
<div className="tm-detail-label">Hire into role</div>
|
||||
{noVacancies ? (
|
||||
<p className="tm-detail-vacancy-empty">
|
||||
No vacant roles. Create a role in the Team tab first.
|
||||
</p>
|
||||
) : (
|
||||
<div className="tm-detail-role-list" role="listbox" aria-label="Vacant roles">
|
||||
{vacantRoles.map(role => {
|
||||
const selected = role.role_id === selectedRoleId
|
||||
return (
|
||||
<button
|
||||
key={role.role_id}
|
||||
type="button"
|
||||
role="option"
|
||||
aria-selected={selected}
|
||||
className={`tm-detail-role-row${selected ? ' is-selected' : ''}`}
|
||||
onClick={() => setSelectedRoleId(role.role_id)}
|
||||
>
|
||||
<span className="tm-detail-role-name">{role.name}</span>
|
||||
{role.responsibility && (
|
||||
<span className="tm-detail-role-resp">{role.responsibility}</span>
|
||||
)}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Hire footer */}
|
||||
{!readOnly && (
|
||||
<div className="tm-detail-footer">
|
||||
<button
|
||||
className="tm-detail-hire-btn"
|
||||
disabled={!canHire}
|
||||
onClick={handleHire}
|
||||
>
|
||||
{isHiring ? <><span className="spinner-inline" /> Hiring...</> : 'Hire to selected role'}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,402 @@
|
||||
import { useEffect, useMemo, useRef, useState } from 'react'
|
||||
import type { OrgRole, OrgEmployee, SavedOrgSummary } from '../types/visual'
|
||||
import { StructureEditor } from './StructureEditor'
|
||||
import { resolveRoleIcon } from './roleIcons'
|
||||
|
||||
/* ── Inline SVG icon data-URIs (no external CDN — see P4.5 Phase 5) ─── */
|
||||
const ICON = {
|
||||
rocket: "data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'%3E%3Cpath fill='%23888' d='M13.13 22.19L11.5 18.36c3.07-1.39 5.51-3.94 6.69-7.07L22 13l-8.87 9.19zM5.64 12.5L2 10.87l9.19-8.87 1.63 3.81c-3.13 1.18-5.68 3.62-7.07 6.69zM14.54 9.46c-.78-.78-.78-2.05 0-2.83s2.05-.78 2.83 0 .78 2.05 0 2.83c-.79.78-2.05.78-2.83 0zM8 18c0 1.1-.9 2-2 2s-2-.9-2-2 .9-2 2-2 2 .9 2 2z'/%3E%3C/svg%3E",
|
||||
people: "data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'%3E%3Cpath fill='%23888' d='M16 11c1.66 0 2.99-1.34 2.99-3S17.66 5 16 5c-1.66 0-3 1.34-3 3s1.34 3 3 3zm-8 0c1.66 0 2.99-1.34 2.99-3S9.66 5 8 5C6.34 5 5 6.34 5 8s1.34 3 3 3zm0 2c-2.33 0-7 1.17-7 3.5V19h14v-2.5c0-2.33-4.67-3.5-7-3.5zm8 0c-.29 0-.62.02-.97.05 1.16.84 1.97 1.97 1.97 3.45V19h6v-2.5c0-2.33-4.67-3.5-7-3.5z'/%3E%3C/svg%3E",
|
||||
check: "data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'%3E%3Cpath fill='%23888' d='M9 16.17L4.83 12l-1.42 1.41L9 19 21 7l-1.41-1.41L9 16.17z'/%3E%3C/svg%3E",
|
||||
addPerson: "data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'%3E%3Cpath fill='%23888' d='M15 12c2.21 0 4-1.79 4-4s-1.79-4-4-4-4 1.79-4 4 1.79 4 4 4zm-9-2V7H4v3H1v2h3v3h2v-3h3v-2H6zm9 4c-2.67 0-8 1.34-8 4v2h16v-2c0-2.66-5.33-4-8-4z'/%3E%3C/svg%3E",
|
||||
trash: "data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'%3E%3Cpath fill='%23888' d='M6 19c0 1.1.9 2 2 2h8c1.1 0 2-.9 2-2V7H6v12zM19 4h-3.5l-1-1h-5l-1 1H5v2h14V4z'/%3E%3C/svg%3E",
|
||||
team: "data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'%3E%3Cpath fill='%23888' d='M12 12c2.21 0 4-1.79 4-4s-1.79-4-4-4-4 1.79-4 4 1.79 4 4 4zm0 2c-2.67 0-8 1.34-8 4v2h16v-2c0-2.66-5.33-4-8-4z'/%3E%3C/svg%3E",
|
||||
deploy: "data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'%3E%3Cpath fill='%2322c55e' d='M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm-2 14.5v-9l6 4.5-6 4.5z'/%3E%3C/svg%3E",
|
||||
person: "data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'%3E%3Cpath fill='%23888' d='M12 12c2.21 0 4-1.79 4-4s-1.79-4-4-4-4 1.79-4 4 1.79 4 4 4zm0 2c-2.67 0-8 1.34-8 4v2h16v-2c0-2.66-5.33-4-8-4z'/%3E%3C/svg%3E",
|
||||
}
|
||||
|
||||
/* ── Quick Start Wizard ────────────────────────────────────────── */
|
||||
|
||||
interface QuickStartProps {
|
||||
onComplete: (roles: Array<{ name: string; responsibility: string; reportsTo: string }>) => void
|
||||
onSwitchToTab: (target: 'employees' | 'architecture') => void
|
||||
}
|
||||
|
||||
function QuickStartWizard({ onComplete, onSwitchToTab }: QuickStartProps) {
|
||||
const [step, setStep] = useState(1)
|
||||
const [members, setMembers] = useState<Array<{ name: string; resp: string; parent: string }>>([
|
||||
{ name: '', resp: '', parent: 'owner' },
|
||||
])
|
||||
|
||||
const addMember = () => setMembers([...members, { name: '', resp: '', parent: 'owner' }])
|
||||
const updateMember = (i: number, field: string, val: string) => {
|
||||
const next = [...members]; (next[i] as any)[field] = val; setMembers(next)
|
||||
}
|
||||
const removeMember = (i: number) => {
|
||||
if (members.length <= 1) return
|
||||
setMembers(members.filter((_, idx) => idx !== i))
|
||||
}
|
||||
|
||||
const validMembers = members.filter(m => m.name.trim())
|
||||
const memberNames = validMembers.map(m => m.name.trim()).filter(Boolean)
|
||||
|
||||
const handleFinish = () => {
|
||||
onComplete(
|
||||
validMembers.map(m => ({ name: m.name.trim(), responsibility: m.resp.trim(), reportsTo: m.parent })),
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="qs-wizard">
|
||||
<div className="qs-header">
|
||||
<img src={ICON.rocket} alt="" className="qs-header-icon" />
|
||||
<div>
|
||||
<h3 className="qs-header-title">Build Your Team</h3>
|
||||
<p className="qs-header-sub">Create an org team in a few steps, or <button className="qs-link-btn" onClick={() => onSwitchToTab('architecture')}>use a template</button></p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="qs-progress">
|
||||
{[
|
||||
{ n: 1, icon: ICON.people, label: 'Team Members' },
|
||||
{ n: 2, icon: ICON.check, label: 'Preview' },
|
||||
].map(s => (
|
||||
<div key={s.n} className={`qs-step${step === s.n ? ' qs-step-active' : step > s.n ? ' qs-step-done' : ''}`}>
|
||||
<img src={s.icon} alt="" className="qs-step-icon" />
|
||||
<span>{s.label}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{step === 1 && (
|
||||
<div className="qs-panel">
|
||||
<h4>Who's on your team?</h4>
|
||||
<p className="qs-hint">Add each team member with their name and what they do.</p>
|
||||
<div className="qs-members">
|
||||
{members.map((m, i) => (
|
||||
<div key={i} className="qs-member-row">
|
||||
<input className="qs-member-name" value={m.name} placeholder="Role name (e.g. Engineer)"
|
||||
onChange={e => updateMember(i, 'name', e.target.value)} />
|
||||
<input className="qs-member-resp" value={m.resp} placeholder="What do they do?"
|
||||
onChange={e => updateMember(i, 'resp', e.target.value)} />
|
||||
<select className="qs-member-parent" value={m.parent}
|
||||
onChange={e => updateMember(i, 'parent', e.target.value)}>
|
||||
<option value="owner">Reports to you</option>
|
||||
{memberNames.filter(n => n !== m.name.trim()).map(n => (
|
||||
<option key={n} value={n}>{`Managed by ${n}`}</option>
|
||||
))}
|
||||
</select>
|
||||
{members.length > 1 && (
|
||||
<button className="qs-remove-btn" onClick={() => removeMember(i)} title="Remove">
|
||||
<img src={ICON.trash} alt="" className="qs-remove-icon" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<button className="qs-add-member" onClick={addMember}>
|
||||
<img src={ICON.addPerson} alt="" className="qs-add-icon" /> Add another member
|
||||
</button>
|
||||
<div className="qs-nav">
|
||||
<span />
|
||||
<button className="oc-btn-primary" onClick={() => setStep(2)} disabled={validMembers.length === 0}>
|
||||
Next: Preview →
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{step === 2 && (
|
||||
<div className="qs-panel">
|
||||
<h4>Your team at a glance</h4>
|
||||
<div className="qs-preview">
|
||||
<div className="qs-preview-section">
|
||||
<h5>Team ({validMembers.length} members)</h5>
|
||||
{validMembers.map((m, i) => (
|
||||
<div key={i} className="qs-preview-member">
|
||||
<strong>{m.name}</strong>
|
||||
{m.resp && <span className="qs-preview-resp"> — {m.resp}</span>}
|
||||
<span className="qs-preview-parent">
|
||||
{m.parent === 'owner' ? ' (reports to you)' : ` (managed by ${m.parent})`}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="qs-preview-section">
|
||||
<h5>Actor Runtime</h5>
|
||||
<p>Seat routing and delegation will be derived from your reporting structure and any team seats you configure later.</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="qs-nav">
|
||||
<button className="oc-btn-ghost" onClick={() => setStep(1)}>← Back</button>
|
||||
<button className="oc-btn-primary qs-create-btn" onClick={handleFinish}>Create Team</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/* ── TeamView ──────────────────────────────────────────────────── */
|
||||
|
||||
interface TeamViewProps {
|
||||
roles: OrgRole[]
|
||||
employees: OrgEmployee[]
|
||||
/** role_id -> recruited names for the selected session (canvas display only). */
|
||||
sessionRecruitmentByRole?: Record<string, string[]> | null
|
||||
isCustomMode?: boolean
|
||||
onAddRole: (roleId: string, name: string, responsibility: string, reportsTo: string, icon?: string | null) => void
|
||||
onBulkAddRoles?: (roles: Array<{ role_id: string; name: string; responsibility: string; reports_to: string }>) => void
|
||||
onUpdateRole: (roleId: string, updates: { name?: string; responsibility?: string; reports_to?: string; can_spawn?: string[]; icon?: string | null; execution_strategy?: string; preferred_external_agent?: string | null; prompt_refs?: string[] }) => void
|
||||
onDeleteRole: (roleId: string) => void
|
||||
onExport: (data: { package_id: string; name: string; description: string; version: string }) => void
|
||||
onImportEmployee?: (employeeId: string) => void
|
||||
onResetArchitecture?: () => void
|
||||
onSwitchToTab: (target: 'employees' | 'architecture') => void
|
||||
// Saved org architectures — passed to StructureEditor for toolbar pill
|
||||
savedOrgsList?: SavedOrgSummary[] | null
|
||||
activeSavedOrg?: string | null
|
||||
currentOrgVersion?: number
|
||||
versionAtLoad?: number | null
|
||||
onSavedOrgsList?: () => void
|
||||
onSavedOrgSaveAs?: (name: string, overwrite: boolean) => void
|
||||
onSavedOrgLoad?: (name: string) => void
|
||||
onSavedOrgDelete?: (name: string) => void
|
||||
}
|
||||
|
||||
export function TeamView({
|
||||
roles, employees, sessionRecruitmentByRole, isCustomMode,
|
||||
onAddRole, onBulkAddRoles, onUpdateRole, onDeleteRole, onExport,
|
||||
onImportEmployee,
|
||||
onResetArchitecture, onSwitchToTab,
|
||||
savedOrgsList, activeSavedOrg, currentOrgVersion, versionAtLoad,
|
||||
onSavedOrgsList, onSavedOrgSaveAs, onSavedOrgLoad, onSavedOrgDelete,
|
||||
}: TeamViewProps) {
|
||||
const [quickStartPending, setQuickStartPending] = useState(false)
|
||||
const [showExportForm, setShowExportForm] = useState(false)
|
||||
const [exportId, setExportId] = useState('')
|
||||
const [exportName, setExportName] = useState('')
|
||||
const [exportDesc, setExportDesc] = useState('')
|
||||
const [exportVersion, setExportVersion] = useState('1.0.0')
|
||||
|
||||
const empByRole = useMemo(() => {
|
||||
const m = new Map<string, OrgEmployee[]>()
|
||||
for (const e of employees) {
|
||||
const roleIds = e.role_ids?.length ? e.role_ids : [e.role_id]
|
||||
for (const roleId of roleIds) {
|
||||
if (!roleId) continue
|
||||
const list = m.get(roleId) || []; list.push(e); m.set(roleId, list)
|
||||
}
|
||||
}
|
||||
return m
|
||||
}, [employees])
|
||||
|
||||
const handleExport = () => {
|
||||
if (!exportId.trim() || !exportName.trim()) return
|
||||
onExport({ package_id: exportId.trim(), name: exportName.trim(), description: exportDesc, version: exportVersion })
|
||||
setShowExportForm(false); setExportId(''); setExportName(''); setExportDesc(''); setExportVersion('1.0.0')
|
||||
}
|
||||
|
||||
// When roles arrive after bulk add, clear the quick-start loading state.
|
||||
const quickStartTimer = useRef<ReturnType<typeof setTimeout>>(null)
|
||||
useEffect(() => {
|
||||
if (quickStartPending && roles.length > 0) {
|
||||
setQuickStartPending(false)
|
||||
if (quickStartTimer.current) { clearTimeout(quickStartTimer.current); quickStartTimer.current = null }
|
||||
}
|
||||
}, [roles.length, quickStartPending])
|
||||
// Timeout: if roles never arrive within 10s, reset to wizard
|
||||
useEffect(() => {
|
||||
if (quickStartPending) {
|
||||
quickStartTimer.current = setTimeout(() => setQuickStartPending(false), 10000)
|
||||
return () => { if (quickStartTimer.current) clearTimeout(quickStartTimer.current) }
|
||||
}
|
||||
}, [quickStartPending])
|
||||
|
||||
const handleQuickStart = (
|
||||
newRoles: Array<{ name: string; responsibility: string; reportsTo: string }>,
|
||||
) => {
|
||||
const nameToId = new Map<string, string>()
|
||||
const usedIds = new Set<string>()
|
||||
const bulkRoles: Array<{ role_id: string; name: string; responsibility: string; reports_to: string }> = []
|
||||
|
||||
for (const r of newRoles) {
|
||||
let id = r.name.toLowerCase().replace(/\s+/g, '-').replace(/[^a-z0-9-]/g, '').replace(/^-+|-+$/g, '')
|
||||
if (!id) continue // skip roles with empty ID (e.g. name was "!!!")
|
||||
// Deduplicate: append suffix if ID already used
|
||||
let finalId = id
|
||||
let suffix = 2
|
||||
while (usedIds.has(finalId)) { finalId = `${id}-${suffix++}` }
|
||||
usedIds.add(finalId)
|
||||
nameToId.set(r.name, finalId)
|
||||
const parentId = r.reportsTo === 'owner' ? 'owner' : (nameToId.get(r.reportsTo) || 'owner')
|
||||
bulkRoles.push({ role_id: finalId, name: r.name, responsibility: r.responsibility, reports_to: parentId })
|
||||
}
|
||||
|
||||
if (bulkRoles.length === 0) return // all roles had invalid names
|
||||
|
||||
if (onBulkAddRoles) {
|
||||
setQuickStartPending(true)
|
||||
onBulkAddRoles(bulkRoles)
|
||||
} else {
|
||||
for (const r of bulkRoles) onAddRole(r.role_id, r.name, r.responsibility, r.reports_to)
|
||||
setQuickStartPending(true)
|
||||
}
|
||||
}
|
||||
|
||||
const handleReset = () => {
|
||||
if (!confirm('This will remove all org roles, employees, and work-item templates. Continue?')) return
|
||||
onResetArchitecture?.()
|
||||
}
|
||||
|
||||
const assignedRoleCount = useMemo(() => {
|
||||
let count = 0
|
||||
for (const role of roles) {
|
||||
if ((empByRole.get(role.role_id) ?? []).length > 0) count += 1
|
||||
}
|
||||
return count
|
||||
}, [roles, empByRole])
|
||||
const linkedEmployeeCount = useMemo(
|
||||
() => employees.filter(e => e.linked_agent_id).length,
|
||||
[employees],
|
||||
)
|
||||
const vacantRoleCount = Math.max(0, roles.length - assignedRoleCount)
|
||||
|
||||
// Show wizard only in org mode when no roles exist
|
||||
if (isCustomMode && roles.length === 0) {
|
||||
return (
|
||||
<div className="team-view">
|
||||
{quickStartPending ? (
|
||||
<div className="qs-wizard">
|
||||
<div className="qs-loading">
|
||||
<span className="spinner-inline" /> Setting up your team...
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<QuickStartWizard onComplete={handleQuickStart} onSwitchToTab={onSwitchToTab} />
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="team-view">
|
||||
<div className={`team-command-bar${isCustomMode ? ' team-command-bar--editable' : ' team-command-bar--readonly'}`}>
|
||||
<div className="team-command-copy">
|
||||
<span className="team-command-eyebrow">{isCustomMode ? 'Saved org workspace' : 'Corporate baseline'}</span>
|
||||
<span className="team-command-title">{isCustomMode ? 'Editable company architecture' : 'Built-in company architecture'}</span>
|
||||
</div>
|
||||
<div className="team-command-metrics">
|
||||
<span><b>{assignedRoleCount}</b> staffed roles</span>
|
||||
<span><b>{vacantRoleCount}</b> vacant</span>
|
||||
<span><b>{linkedEmployeeCount}</b> in office</span>
|
||||
</div>
|
||||
{isCustomMode && (
|
||||
<div className="team-command-actions">
|
||||
<button className="myorg-inline-btn" onClick={() => onSwitchToTab('employees')}>
|
||||
<img src={ICON.addPerson} alt="" className="myorg-inline-icon" /> Hire Talent
|
||||
</button>
|
||||
<button className="btn btn-ghost btn-sm" onClick={() => setShowExportForm(true)}>
|
||||
Export Package
|
||||
</button>
|
||||
{onResetArchitecture && (
|
||||
<button className="myorg-reset-btn" onClick={handleReset}>
|
||||
<img src={ICON.trash} alt="" className="myorg-reset-icon" /> Reset
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{!isCustomMode && (
|
||||
<div className="team-readonly-strip">
|
||||
Corporate is fixed and read-only; saved company architectures are edited separately.
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Structure Editor (Canvas + Table + Inspector) */}
|
||||
<StructureEditor
|
||||
roles={roles}
|
||||
employees={employees}
|
||||
sessionRecruitmentByRole={sessionRecruitmentByRole}
|
||||
isCustomMode={isCustomMode}
|
||||
onAddRole={onAddRole}
|
||||
onUpdateRole={onUpdateRole}
|
||||
onDeleteRole={onDeleteRole}
|
||||
savedOrgsList={savedOrgsList ?? null}
|
||||
activeSavedOrg={activeSavedOrg ?? null}
|
||||
currentOrgVersion={currentOrgVersion ?? 0}
|
||||
versionAtLoad={versionAtLoad ?? null}
|
||||
onSavedOrgsList={onSavedOrgsList}
|
||||
onSavedOrgSaveAs={onSavedOrgSaveAs}
|
||||
onSavedOrgLoad={onSavedOrgLoad}
|
||||
onSavedOrgDelete={onSavedOrgDelete}
|
||||
/>
|
||||
{/* Export form */}
|
||||
{showExportForm && (
|
||||
<div className="myorg-form">
|
||||
<h4 className="myorg-form-title">Export as .opcpkg</h4>
|
||||
<div className="oc-form-row"><label>Package ID</label>
|
||||
<input value={exportId} onChange={e => setExportId(e.target.value)} placeholder="my-architecture" /></div>
|
||||
<div className="oc-form-row"><label>Name</label>
|
||||
<input value={exportName} onChange={e => setExportName(e.target.value)} placeholder="My Architecture" /></div>
|
||||
<div className="oc-form-row"><label>Description</label>
|
||||
<input value={exportDesc} onChange={e => setExportDesc(e.target.value)} placeholder="An org team structure" /></div>
|
||||
<div className="oc-form-row"><label>Version</label>
|
||||
<input value={exportVersion} onChange={e => setExportVersion(e.target.value)} /></div>
|
||||
<div className="oc-form-actions">
|
||||
<button className="oc-btn-primary" onClick={handleExport} disabled={!exportId.trim() || !exportName.trim()}>Export</button>
|
||||
<button className="oc-btn-ghost" onClick={() => setShowExportForm(false)}>Cancel</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Team Roster — enhanced with actions */}
|
||||
<div className="myorg-section">
|
||||
<div className="myorg-section-header">
|
||||
<img src={ICON.team} alt="" className="myorg-section-icon" />
|
||||
<h3 className="myorg-section-title">Team Roster</h3>
|
||||
<span className="myorg-section-count">{employees.length} members</span>
|
||||
<span className="myorg-section-spacer" />
|
||||
<span className="myorg-section-note">{assignedRoleCount}/{roles.length} staffed</span>
|
||||
</div>
|
||||
<div className="team-roster-grid">
|
||||
{roles.map(r => {
|
||||
const emps = empByRole.get(r.role_id) || []
|
||||
return (
|
||||
<div key={r.role_id} className="team-roster-card">
|
||||
<div className="team-roster-card-header">
|
||||
<img src={resolveRoleIcon(r.icon)} alt="" className="team-roster-card-icon" />
|
||||
<span className="team-roster-role-name">{r.name}</span>
|
||||
<span className="team-roster-count">{emps.length || 'vacant'}</span>
|
||||
</div>
|
||||
{emps.length > 0 ? emps.map(e => (
|
||||
<div key={e.employee_id} className="team-roster-emp">
|
||||
<img src={ICON.person} alt="" className="team-roster-emp-avatar" />
|
||||
<div className="team-roster-emp-info">
|
||||
<span className="team-roster-emp-name">{e.name}</span>
|
||||
<span className={`team-roster-seniority team-roster-seniority--${e.seniority}`}>{e.seniority}</span>
|
||||
</div>
|
||||
{e.linked_agent_id ? (
|
||||
<span className="team-roster-badge team-roster-badge--active">In Office</span>
|
||||
) : onImportEmployee && e.role_id ? (
|
||||
<button className="team-roster-deploy-btn" onClick={() => onImportEmployee(e.employee_id)}
|
||||
title="Add this employee to the office workspace">
|
||||
<img src={ICON.deploy} alt="" className="team-roster-deploy-icon" /> Deploy
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
)) : (
|
||||
<div className="team-roster-vacant">
|
||||
<span className="team-roster-vacant-text">No members yet</span>
|
||||
{isCustomMode && <button className="team-roster-hire-btn" onClick={() => onSwitchToTab('employees')}>Hire</button>}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,226 @@
|
||||
/* Config import/export panel styles. */
|
||||
|
||||
/* ── Config Import/Export Panel ──────────────────────────────────── */
|
||||
|
||||
.cfg-io-panel {
|
||||
background: var(--bg-elevated);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 12px;
|
||||
padding: 16px;
|
||||
margin-top: 16px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
box-shadow:
|
||||
inset 0 1px 0 color-mix(in srgb, var(--text) 5%, transparent),
|
||||
0 1px 2px rgba(0, 0, 0, 0.3),
|
||||
0 8px 24px rgba(0, 0, 0, 0.12);
|
||||
}
|
||||
|
||||
.cfg-io-header {
|
||||
padding-bottom: 12px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
.cfg-io-title {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: var(--text);
|
||||
margin: 0;
|
||||
}
|
||||
.cfg-io-subtitle {
|
||||
font-size: 12px;
|
||||
color: var(--text-secondary);
|
||||
margin: 4px 0 0;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.cfg-io-section {
|
||||
padding: 14px 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
.cfg-io-section + .cfg-io-section {
|
||||
border-top: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.cfg-io-section-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
.cfg-io-section-icon {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
opacity: 0.6;
|
||||
}
|
||||
.cfg-io-section-title {
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: var(--text);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.4px;
|
||||
}
|
||||
|
||||
|
||||
/* Upload row: hidden file input + styled label */
|
||||
.cfg-io-upload-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
.cfg-io-file-label {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
cursor: pointer;
|
||||
}
|
||||
.cfg-io-file-input {
|
||||
position: absolute;
|
||||
width: 1px;
|
||||
height: 1px;
|
||||
padding: 0;
|
||||
margin: -1px;
|
||||
overflow: hidden;
|
||||
clip: rect(0 0 0 0);
|
||||
border: 0;
|
||||
}
|
||||
.cfg-io-file-btn {
|
||||
padding: 6px 14px;
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
border-radius: var(--radius-xs);
|
||||
border: 1px solid var(--border);
|
||||
background: var(--bg-secondary);
|
||||
color: var(--text);
|
||||
transition: border-color 0.15s, color 0.15s;
|
||||
}
|
||||
.cfg-io-file-label:hover .cfg-io-file-btn {
|
||||
border-color: var(--accent);
|
||||
color: var(--accent);
|
||||
}
|
||||
.cfg-io-file-input:focus-visible + .cfg-io-file-btn {
|
||||
outline: 2px solid var(--accent);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
.cfg-io-file-name {
|
||||
font-size: 12px;
|
||||
color: var(--text-secondary);
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
/* Textarea — monospace for YAML */
|
||||
.cfg-io-textarea {
|
||||
width: 100%;
|
||||
padding: 10px 12px;
|
||||
font-size: 12px;
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
|
||||
line-height: 1.5;
|
||||
background: var(--bg-secondary);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-xs);
|
||||
color: var(--text);
|
||||
box-sizing: border-box;
|
||||
resize: vertical;
|
||||
min-height: 140px;
|
||||
}
|
||||
.cfg-io-textarea:focus {
|
||||
border-color: var(--accent);
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.cfg-io-actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
margin-top: 2px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
/* Preview box (success / ready to apply) */
|
||||
.cfg-io-preview {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
padding: 12px 14px;
|
||||
background: color-mix(in srgb, var(--green) 8%, transparent);
|
||||
border: 1px solid color-mix(in srgb, var(--green) 28%, transparent);
|
||||
border-radius: var(--radius-xs);
|
||||
margin-top: 4px;
|
||||
}
|
||||
.cfg-io-preview-icon {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
flex-shrink: 0;
|
||||
margin-top: 1px;
|
||||
}
|
||||
.cfg-io-preview-body {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
.cfg-io-preview-title {
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: var(--green);
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
.cfg-io-preview-stats {
|
||||
display: flex;
|
||||
gap: 20px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.cfg-io-preview-stat {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
}
|
||||
.cfg-io-stat-label {
|
||||
font-size: 10px;
|
||||
color: var(--text-secondary);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.4px;
|
||||
font-weight: 500;
|
||||
}
|
||||
.cfg-io-stat-value {
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
color: var(--text);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
/* Error box */
|
||||
.cfg-io-error {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
padding: 12px 14px;
|
||||
background: color-mix(in srgb, var(--red) 8%, transparent);
|
||||
border: 1px solid color-mix(in srgb, var(--red) 28%, transparent);
|
||||
border-radius: var(--radius-xs);
|
||||
margin-top: 4px;
|
||||
}
|
||||
.cfg-io-error-icon {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
flex-shrink: 0;
|
||||
margin-top: 1px;
|
||||
}
|
||||
.cfg-io-error-body {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
.cfg-io-error-title {
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: var(--red);
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
.cfg-io-error-text {
|
||||
font-size: 11px;
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
|
||||
color: var(--text);
|
||||
background: var(--bg-secondary);
|
||||
padding: 8px 10px;
|
||||
border-radius: 4px;
|
||||
margin: 0;
|
||||
overflow-x: auto;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
line-height: 1.4;
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import dagre from '@dagrejs/dagre'
|
||||
import type { Node, Edge } from '@xyflow/react'
|
||||
|
||||
/**
|
||||
* Compute dagre layout and return nodes with updated x/y.
|
||||
* Top-down orientation (TB): root "owner" at top, leaves at bottom.
|
||||
*/
|
||||
export function computeDagreLayout(
|
||||
nodes: Node[],
|
||||
edges: Edge[],
|
||||
opts: { nodeWidth?: number; nodeHeight?: number } = {},
|
||||
): Node[] {
|
||||
const g = new dagre.graphlib.Graph()
|
||||
g.setDefaultEdgeLabel(() => ({}))
|
||||
g.setGraph({
|
||||
rankdir: 'TB',
|
||||
ranksep: 60,
|
||||
nodesep: 36,
|
||||
marginx: 24,
|
||||
marginy: 24,
|
||||
})
|
||||
const W = opts.nodeWidth ?? 220
|
||||
const H = opts.nodeHeight ?? 80
|
||||
nodes.forEach(n => g.setNode(n.id, { width: W, height: H }))
|
||||
edges.forEach(e => g.setEdge(e.source, e.target))
|
||||
dagre.layout(g)
|
||||
return nodes.map(n => {
|
||||
const { x, y } = g.node(n.id)
|
||||
return { ...n, position: { x: x - W / 2, y: y - H / 2 } }
|
||||
})
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,935 @@
|
||||
/* OrgTab shell and shared org UI primitives. */
|
||||
|
||||
/* ── Org Tab ──────────────────────────────────────────────────────── */
|
||||
|
||||
.org-tab {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
min-width: 0;
|
||||
overflow-y: auto;
|
||||
background:
|
||||
linear-gradient(180deg,
|
||||
color-mix(in srgb, var(--bg-elevated) 26%, var(--bg) 74%) 0%,
|
||||
var(--bg) 240px);
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.org-loading {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 100%;
|
||||
color: var(--text-secondary);
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.org-empty {
|
||||
padding: 32px;
|
||||
text-align: center;
|
||||
color: var(--text-secondary);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
/* ── Header ─────────────────────────────────────────────────────── */
|
||||
|
||||
.org-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 18px 28px 16px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
flex-shrink: 0;
|
||||
gap: 18px;
|
||||
flex-wrap: wrap;
|
||||
background:
|
||||
linear-gradient(180deg,
|
||||
color-mix(in srgb, var(--bg-elevated) 34%, var(--bg) 66%),
|
||||
color-mix(in srgb, var(--bg) 92%, var(--bg-elevated) 8%));
|
||||
backdrop-filter: blur(18px);
|
||||
-webkit-backdrop-filter: blur(18px);
|
||||
}
|
||||
|
||||
.org-header-main {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 7px;
|
||||
min-width: min(100%, 260px);
|
||||
flex: 1 1 420px;
|
||||
}
|
||||
|
||||
.org-eyebrow {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 7px;
|
||||
color: var(--text-secondary);
|
||||
font-size: 10px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
.org-eyebrow-separator {
|
||||
color: color-mix(in srgb, var(--text-secondary) 46%, transparent);
|
||||
}
|
||||
|
||||
.org-title-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
min-width: 0;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.org-title {
|
||||
font-size: 21px;
|
||||
font-weight: 650;
|
||||
margin: 0;
|
||||
max-width: min(58vw, 680px);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
letter-spacing: 0;
|
||||
}
|
||||
|
||||
.org-version-badge {
|
||||
font-size: 10px;
|
||||
padding: 2px 7px;
|
||||
border-radius: 4px;
|
||||
background: color-mix(in srgb, var(--accent) 13%, transparent);
|
||||
color: var(--accent);
|
||||
font-family: 'JetBrains Mono', 'SF Mono', monospace;
|
||||
}
|
||||
|
||||
.org-state-badge,
|
||||
.org-profile-badge {
|
||||
font-size: 10px;
|
||||
padding: 3px 8px;
|
||||
border-radius: 4px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.org-state-badge--editable {
|
||||
background: color-mix(in srgb, var(--green) 11%, transparent);
|
||||
color: var(--green);
|
||||
border: 1px solid color-mix(in srgb, var(--green) 20%, transparent);
|
||||
}
|
||||
.org-state-badge--readonly {
|
||||
background: color-mix(in srgb, var(--text-secondary) 10%, transparent);
|
||||
color: var(--text-secondary);
|
||||
border: 1px solid color-mix(in srgb, var(--border) 80%, transparent);
|
||||
}
|
||||
.org-profile-badge {
|
||||
background: color-mix(in srgb, var(--yellow) 11%, transparent);
|
||||
color: var(--yellow);
|
||||
}
|
||||
.org-header-meta {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 7px;
|
||||
flex-wrap: wrap;
|
||||
min-width: 0;
|
||||
}
|
||||
.org-meta-pill,
|
||||
.org-meta-code {
|
||||
min-height: 22px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: 3px 8px;
|
||||
border-radius: 999px;
|
||||
border: 1px solid color-mix(in srgb, var(--border) 80%, transparent);
|
||||
background: color-mix(in srgb, var(--bg) 58%, transparent);
|
||||
color: var(--text-secondary);
|
||||
font-size: 10.5px;
|
||||
line-height: 1;
|
||||
text-transform: capitalize;
|
||||
}
|
||||
.org-meta-code {
|
||||
max-width: min(52vw, 360px);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
|
||||
text-transform: none;
|
||||
color: var(--text);
|
||||
}
|
||||
.org-meta-pill--runtime {
|
||||
color: var(--accent);
|
||||
border-color: color-mix(in srgb, var(--accent) 22%, transparent);
|
||||
background: color-mix(in srgb, var(--accent) 8%, transparent);
|
||||
}
|
||||
.org-meta-pill--saved {
|
||||
color: var(--green);
|
||||
border-color: color-mix(in srgb, var(--green) 24%, transparent);
|
||||
background: color-mix(in srgb, var(--green) 8%, transparent);
|
||||
}
|
||||
|
||||
.org-control-panel {
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
justify-content: flex-end;
|
||||
gap: 10px;
|
||||
flex: 0 1 430px;
|
||||
min-width: min(100%, 300px);
|
||||
}
|
||||
|
||||
.org-switcher {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 5px;
|
||||
min-width: 210px;
|
||||
flex: 1 1 240px;
|
||||
}
|
||||
|
||||
.org-switcher-label {
|
||||
color: var(--text-secondary);
|
||||
font-size: 10px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.org-switcher-select-wrap {
|
||||
position: relative;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.org-switcher-select {
|
||||
width: 100%;
|
||||
min-height: 36px;
|
||||
padding: 7px 34px 7px 11px;
|
||||
border: 1px solid color-mix(in srgb, var(--border) 82%, transparent);
|
||||
border-radius: 7px;
|
||||
background:
|
||||
linear-gradient(180deg,
|
||||
color-mix(in srgb, var(--bg-elevated) 76%, var(--bg) 24%),
|
||||
color-mix(in srgb, var(--bg) 88%, var(--bg-elevated) 12%));
|
||||
color: var(--text);
|
||||
font: inherit;
|
||||
font-size: 12px;
|
||||
outline: none;
|
||||
cursor: pointer;
|
||||
appearance: none;
|
||||
}
|
||||
|
||||
.org-switcher-select option {
|
||||
background: var(--bg-elevated);
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.org-switcher-select-wrap::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
right: 12px;
|
||||
top: 50%;
|
||||
width: 7px;
|
||||
height: 7px;
|
||||
border-right: 1.5px solid var(--text-secondary);
|
||||
border-bottom: 1.5px solid var(--text-secondary);
|
||||
transform: translateY(-68%) rotate(45deg);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.org-switcher-select:hover,
|
||||
.org-switcher-select:focus {
|
||||
border-color: color-mix(in srgb, var(--accent) 38%, var(--border));
|
||||
}
|
||||
|
||||
.org-create-trigger {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
min-height: 36px;
|
||||
padding: 8px 14px;
|
||||
border: 1px solid color-mix(in srgb, var(--accent) 36%, transparent);
|
||||
border-radius: 7px;
|
||||
background:
|
||||
linear-gradient(180deg,
|
||||
color-mix(in srgb, var(--accent) 22%, var(--bg-elevated) 78%),
|
||||
color-mix(in srgb, var(--accent) 13%, var(--bg) 87%));
|
||||
color: color-mix(in srgb, var(--accent) 84%, var(--text) 16%);
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
white-space: nowrap;
|
||||
cursor: pointer;
|
||||
box-shadow: 0 10px 24px color-mix(in srgb, var(--accent) 10%, transparent);
|
||||
transition: transform 0.15s, border-color 0.15s, background 0.15s;
|
||||
}
|
||||
|
||||
.org-create-trigger:hover {
|
||||
border-color: color-mix(in srgb, var(--accent) 54%, transparent);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.org-create-trigger-icon {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
border-radius: 50%;
|
||||
background: color-mix(in srgb, var(--accent) 18%, transparent);
|
||||
font-size: 16px;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
/* ── Stats Strip ────────────────────────────────────────────────── */
|
||||
|
||||
.org-stats-strip {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, minmax(104px, auto));
|
||||
gap: 7px;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
flex: 1 1 460px;
|
||||
}
|
||||
.org-stat {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
justify-content: center;
|
||||
font-size: 11px;
|
||||
color: var(--text-secondary);
|
||||
min-height: 32px;
|
||||
padding: 6px 11px;
|
||||
background: color-mix(in srgb, var(--bg-elevated) 58%, var(--bg) 42%);
|
||||
border-radius: 999px;
|
||||
border: 1px solid color-mix(in srgb, var(--border) 82%, transparent);
|
||||
white-space: nowrap;
|
||||
}
|
||||
.org-stat b {
|
||||
color: var(--text);
|
||||
font-weight: 700;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
.org-stat-icon {
|
||||
width: 13px;
|
||||
height: 13px;
|
||||
opacity: 0.72;
|
||||
}
|
||||
|
||||
/* ── Sub-tab Navigation ────────────────────────────────────────── */
|
||||
|
||||
.org-subtabs {
|
||||
display: flex;
|
||||
gap: 2px;
|
||||
padding: 0 28px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
flex-shrink: 0;
|
||||
background: color-mix(in srgb, var(--bg) 92%, transparent);
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 10;
|
||||
overflow-x: auto;
|
||||
}
|
||||
.org-subtab {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 11px 16px 12px;
|
||||
margin-bottom: -1px;
|
||||
font-size: 12px;
|
||||
color: var(--text-secondary);
|
||||
background: none;
|
||||
border: none;
|
||||
border-bottom: 2px solid transparent;
|
||||
cursor: pointer;
|
||||
transition: color 0.15s, border-color 0.15s;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.org-subtab:hover:not(.org-subtab--active) {
|
||||
color: var(--text);
|
||||
}
|
||||
.org-subtab--active {
|
||||
color: var(--accent);
|
||||
border-bottom-color: var(--accent);
|
||||
font-weight: 600;
|
||||
}
|
||||
.org-subtab-icon {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
opacity: 0.6;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.org-subtab--active .org-subtab-icon {
|
||||
opacity: 1;
|
||||
}
|
||||
.org-subtab-label {
|
||||
font-size: 12px;
|
||||
}
|
||||
.org-subtab-count {
|
||||
font-size: 10px;
|
||||
padding: 1px 6px;
|
||||
border-radius: 8px;
|
||||
background: color-mix(in srgb, var(--bg-elevated) 70%, var(--bg) 30%);
|
||||
color: var(--text-secondary);
|
||||
font-weight: 500;
|
||||
}
|
||||
.org-subtab--active .org-subtab-count {
|
||||
background: color-mix(in srgb, var(--accent) 20%, transparent);
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
/* ── Toast Notification ─────────────────────────────────────────── */
|
||||
|
||||
.org-toast {
|
||||
padding: 8px 16px;
|
||||
margin: 0 20px;
|
||||
font-size: 12px;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.org-toast--info {
|
||||
background: color-mix(in srgb, var(--accent) 12%, transparent);
|
||||
color: var(--accent);
|
||||
border: 1px solid color-mix(in srgb, var(--accent) 25%, transparent);
|
||||
}
|
||||
.org-toast--warn {
|
||||
background: color-mix(in srgb, var(--yellow) 12%, transparent);
|
||||
color: var(--yellow);
|
||||
border: 1px solid color-mix(in srgb, var(--yellow) 25%, transparent);
|
||||
}
|
||||
.org-toast--ok {
|
||||
background: color-mix(in srgb, var(--green) 12%, transparent);
|
||||
color: var(--green);
|
||||
border: 1px solid color-mix(in srgb, var(--green) 25%, transparent);
|
||||
}
|
||||
.org-toast--error {
|
||||
background: color-mix(in srgb, var(--red) 12%, transparent);
|
||||
color: var(--red);
|
||||
border: 1px solid color-mix(in srgb, var(--red) 25%, transparent);
|
||||
}
|
||||
|
||||
/* ── Tab Content Area ──────────────────────────────────────────── */
|
||||
|
||||
.org-tab-content {
|
||||
flex: 1 0 auto;
|
||||
overflow: visible;
|
||||
padding: 16px 28px 28px;
|
||||
}
|
||||
|
||||
/* ── Create Organization Modal ─────────────────────────────────── */
|
||||
|
||||
.org-create-backdrop {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 1000;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 24px;
|
||||
background: color-mix(in srgb, #05070b 68%, transparent);
|
||||
}
|
||||
|
||||
.org-create-modal {
|
||||
width: min(720px, 100%);
|
||||
max-height: min(720px, calc(100vh - 48px));
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
padding: 20px;
|
||||
border: 1px solid color-mix(in srgb, var(--border) 78%, transparent);
|
||||
border-radius: 8px;
|
||||
background:
|
||||
linear-gradient(180deg,
|
||||
color-mix(in srgb, var(--bg-elevated) 88%, var(--bg) 12%),
|
||||
color-mix(in srgb, var(--bg) 94%, var(--bg-elevated) 6%));
|
||||
box-shadow: 0 28px 80px rgb(0 0 0 / 0.46);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.org-create-header,
|
||||
.org-create-actions,
|
||||
.org-create-review-head,
|
||||
.org-create-review-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.org-create-header {
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.org-create-eyebrow {
|
||||
display: block;
|
||||
margin-bottom: 5px;
|
||||
color: var(--text-secondary);
|
||||
font-size: 10px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.org-create-title {
|
||||
margin: 0;
|
||||
color: var(--text);
|
||||
font-size: 19px;
|
||||
font-weight: 650;
|
||||
letter-spacing: 0;
|
||||
}
|
||||
|
||||
.org-create-close,
|
||||
.org-create-icon-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border: 1px solid color-mix(in srgb, var(--border) 78%, transparent);
|
||||
border-radius: 6px;
|
||||
background: color-mix(in srgb, var(--bg) 60%, transparent);
|
||||
color: var(--text-secondary);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.org-create-close {
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.org-create-close:hover,
|
||||
.org-create-icon-btn:hover:not(:disabled) {
|
||||
color: var(--text);
|
||||
border-color: color-mix(in srgb, var(--text-secondary) 34%, var(--border));
|
||||
}
|
||||
|
||||
.org-create-steps {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
gap: 8px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.org-create-step {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
min-width: 0;
|
||||
min-height: 34px;
|
||||
padding: 7px 10px;
|
||||
border: 1px solid color-mix(in srgb, var(--border) 74%, transparent);
|
||||
border-radius: 7px;
|
||||
background: color-mix(in srgb, var(--bg) 58%, transparent);
|
||||
color: var(--text-secondary);
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.org-create-step span {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
border-radius: 50%;
|
||||
background: color-mix(in srgb, var(--text-secondary) 12%, transparent);
|
||||
color: var(--text-secondary);
|
||||
font-size: 10px;
|
||||
}
|
||||
|
||||
.org-create-step--active {
|
||||
border-color: color-mix(in srgb, var(--accent) 42%, var(--border));
|
||||
color: var(--text);
|
||||
background: color-mix(in srgb, var(--accent) 9%, var(--bg) 91%);
|
||||
}
|
||||
|
||||
.org-create-step--active span,
|
||||
.org-create-step--done span {
|
||||
background: color-mix(in srgb, var(--accent) 24%, transparent);
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.org-create-panel {
|
||||
min-height: 180px;
|
||||
overflow: auto;
|
||||
padding: 4px 2px;
|
||||
}
|
||||
|
||||
.org-create-field {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.org-create-field span {
|
||||
color: var(--text-secondary);
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.06em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.org-create-field input,
|
||||
.org-create-member-row input,
|
||||
.org-create-member-row select,
|
||||
.org-create-member-row textarea {
|
||||
min-height: 38px;
|
||||
padding: 8px 10px;
|
||||
border: 1px solid color-mix(in srgb, var(--border) 82%, transparent);
|
||||
border-radius: 7px;
|
||||
background: color-mix(in srgb, var(--bg) 64%, transparent);
|
||||
color: var(--text);
|
||||
font: inherit;
|
||||
font-size: 13px;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.org-create-field input:focus,
|
||||
.org-create-member-row input:focus,
|
||||
.org-create-member-row select:focus,
|
||||
.org-create-member-row textarea:focus {
|
||||
border-color: color-mix(in srgb, var(--accent) 45%, var(--border));
|
||||
}
|
||||
|
||||
.org-create-member-row select option {
|
||||
background: var(--bg-elevated);
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.org-create-member-row textarea {
|
||||
grid-column: 1 / -1;
|
||||
min-height: 62px;
|
||||
line-height: 1.45;
|
||||
resize: vertical;
|
||||
}
|
||||
|
||||
.org-create-member-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.org-create-member-row {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(130px, 1fr) minmax(150px, 1.5fr) minmax(120px, 0.8fr) 34px;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.org-create-icon-btn {
|
||||
width: 34px;
|
||||
height: 34px;
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.org-create-icon-btn:disabled {
|
||||
cursor: not-allowed;
|
||||
opacity: 0.42;
|
||||
}
|
||||
|
||||
.org-create-add {
|
||||
margin-top: 12px;
|
||||
min-height: 34px;
|
||||
padding: 7px 12px;
|
||||
border: 1px dashed color-mix(in srgb, var(--accent) 34%, var(--border));
|
||||
border-radius: 7px;
|
||||
background: color-mix(in srgb, var(--accent) 7%, transparent);
|
||||
color: var(--accent);
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.org-create-review {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.org-create-review-head,
|
||||
.org-create-review-row {
|
||||
justify-content: space-between;
|
||||
gap: 14px;
|
||||
min-height: 38px;
|
||||
padding: 8px 10px;
|
||||
border: 1px solid color-mix(in srgb, var(--border) 74%, transparent);
|
||||
border-radius: 7px;
|
||||
background: color-mix(in srgb, var(--bg) 56%, transparent);
|
||||
}
|
||||
|
||||
.org-create-review-head span,
|
||||
.org-create-review-row strong {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.org-create-review-head b,
|
||||
.org-create-review-row span {
|
||||
flex-shrink: 0;
|
||||
color: var(--text-secondary);
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.org-create-review-row span {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.org-create-review-row em {
|
||||
padding: 2px 6px;
|
||||
border-radius: 999px;
|
||||
background: color-mix(in srgb, var(--accent) 12%, transparent);
|
||||
color: var(--accent);
|
||||
font-style: normal;
|
||||
font-size: 10px;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.org-create-error {
|
||||
padding: 9px 11px;
|
||||
border: 1px solid color-mix(in srgb, var(--red) 28%, transparent);
|
||||
border-radius: 7px;
|
||||
background: color-mix(in srgb, var(--red) 10%, transparent);
|
||||
color: var(--red);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.org-create-actions {
|
||||
justify-content: flex-end;
|
||||
gap: 8px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
@media (max-width: 1040px) {
|
||||
.org-title {
|
||||
max-width: 100%;
|
||||
}
|
||||
.org-control-panel {
|
||||
flex: 1 1 100%;
|
||||
justify-content: stretch;
|
||||
}
|
||||
.org-stats-strip {
|
||||
grid-template-columns: repeat(2, minmax(128px, 1fr));
|
||||
justify-content: stretch;
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 760px) {
|
||||
.org-header {
|
||||
padding: 14px 16px 12px;
|
||||
}
|
||||
.org-header-main {
|
||||
flex-basis: 100%;
|
||||
}
|
||||
.org-title {
|
||||
max-width: 100%;
|
||||
font-size: 18px;
|
||||
}
|
||||
.org-stats-strip {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
.org-control-panel {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
}
|
||||
.org-switcher {
|
||||
min-width: 0;
|
||||
}
|
||||
.org-create-trigger {
|
||||
width: 100%;
|
||||
}
|
||||
.org-subtabs {
|
||||
padding: 0 16px;
|
||||
}
|
||||
.org-tab-content {
|
||||
padding: 12px 16px 18px;
|
||||
}
|
||||
.org-create-backdrop {
|
||||
align-items: flex-start;
|
||||
padding: 14px;
|
||||
}
|
||||
.org-create-modal {
|
||||
max-height: calc(100vh - 28px);
|
||||
padding: 16px;
|
||||
}
|
||||
.org-create-steps {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
.org-create-member-row {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
.org-create-icon-btn {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 460px) {
|
||||
.org-header {
|
||||
gap: 12px;
|
||||
}
|
||||
.org-stats-strip {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
.org-meta-code {
|
||||
max-width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
/* ── Unified button system ───────────────────────────────────────── */
|
||||
/* .btn = new canonical name. Old class names (e.g. .oc-btn-primary) */
|
||||
/* are kept as aliases in grouped selectors so existing TSX keeps */
|
||||
/* working without modification. */
|
||||
|
||||
.btn,
|
||||
.oc-btn-primary, .oc-btn-ghost, .oc-btn-danger,
|
||||
.mkt-btn, .mkt-btn-primary, .mkt-btn-ghost,
|
||||
.pkg-btn, .pkg-btn-primary, .pkg-btn-secondary, .pkg-btn-danger, .pkg-btn-ghost,
|
||||
.cfg-io-btn, .cfg-io-btn-primary, .cfg-io-btn-ghost,
|
||||
.tm-detail-hire-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 6px 14px;
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
border-radius: var(--radius-xs);
|
||||
border: 1px solid transparent;
|
||||
background: transparent;
|
||||
color: var(--text);
|
||||
cursor: pointer;
|
||||
white-space: nowrap;
|
||||
font-family: inherit;
|
||||
transition: background 0.15s, border-color 0.15s, color 0.15s, opacity 0.15s;
|
||||
}
|
||||
|
||||
.btn:disabled,
|
||||
.oc-btn-primary:disabled, .mkt-btn-primary:disabled,
|
||||
.pkg-btn-primary:disabled, .pkg-btn-danger:disabled,
|
||||
.cfg-io-btn:disabled, .cfg-io-btn-primary:disabled, .cfg-io-btn-ghost:disabled,
|
||||
.tm-detail-hire-btn:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
/* Primary: accent fill, white text */
|
||||
.btn-primary,
|
||||
.oc-btn-primary, .mkt-btn-primary, .pkg-btn-primary, .cfg-io-btn-primary,
|
||||
.tm-detail-hire-btn {
|
||||
background: var(--accent);
|
||||
color: var(--white);
|
||||
border-color: var(--accent);
|
||||
}
|
||||
.btn-primary:hover:not(:disabled),
|
||||
.oc-btn-primary:hover:not(:disabled), .mkt-btn-primary:hover:not(:disabled),
|
||||
.pkg-btn-primary:hover:not(:disabled), .cfg-io-btn-primary:hover:not(:disabled),
|
||||
.tm-detail-hire-btn:hover:not(:disabled) {
|
||||
opacity: 0.88;
|
||||
}
|
||||
|
||||
/* Ghost: transparent, text-secondary, hover text */
|
||||
.btn-ghost,
|
||||
.oc-btn-ghost, .mkt-btn-ghost, .pkg-btn-ghost, .cfg-io-btn-ghost {
|
||||
background: transparent;
|
||||
color: var(--text-secondary);
|
||||
border-color: var(--border);
|
||||
}
|
||||
.btn-ghost:hover:not(:disabled),
|
||||
.oc-btn-ghost:hover:not(:disabled), .mkt-btn-ghost:hover:not(:disabled),
|
||||
.pkg-btn-ghost:hover:not(:disabled), .cfg-io-btn-ghost:hover:not(:disabled) {
|
||||
color: var(--text);
|
||||
border-color: var(--text-secondary);
|
||||
}
|
||||
|
||||
/* Secondary: filled surface, subtle */
|
||||
.btn-secondary,
|
||||
.pkg-btn-secondary {
|
||||
background: var(--bg-secondary);
|
||||
color: var(--text);
|
||||
border-color: var(--border);
|
||||
}
|
||||
.btn-secondary:hover:not(:disabled),
|
||||
.pkg-btn-secondary:hover:not(:disabled) {
|
||||
border-color: var(--accent);
|
||||
}
|
||||
|
||||
/* Danger: red outline, hover red fill */
|
||||
.btn-danger,
|
||||
.oc-btn-danger, .pkg-btn-danger {
|
||||
background: transparent;
|
||||
color: var(--red);
|
||||
border-color: var(--red);
|
||||
}
|
||||
.btn-danger:hover:not(:disabled),
|
||||
.oc-btn-danger:hover:not(:disabled), .pkg-btn-danger:hover:not(:disabled) {
|
||||
background: var(--red);
|
||||
color: var(--white);
|
||||
}
|
||||
|
||||
/* Size: small */
|
||||
.btn-sm,
|
||||
.mkt-btn-sm, .pkg-btn-sm {
|
||||
padding: 4px 10px;
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
/* Size: large (hero CTA) */
|
||||
.btn-lg,
|
||||
.qs-create-btn {
|
||||
padding: 8px 24px;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
/* Icon slot inside a button */
|
||||
.btn-icon,
|
||||
.pkg-btn-icon, .cfg-io-btn-icon {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
opacity: 0.9;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* White-invert icon when inside a primary button (so icon is visible on accent bg) */
|
||||
.btn-primary .btn-icon,
|
||||
.btn-primary .pkg-btn-icon, .btn-primary .cfg-io-btn-icon,
|
||||
.oc-btn-primary img, .mkt-btn-primary img,
|
||||
.pkg-btn-primary .pkg-btn-icon, .cfg-io-btn-primary .cfg-io-btn-icon {
|
||||
filter: brightness(0) invert(1);
|
||||
}
|
||||
|
||||
/* ── Polish: universal focus-visible ring ──────────────────────────── */
|
||||
/* WCAG-friendly accessibility ring for keyboard navigation. */
|
||||
/* Applied to buttons (all .btn aliases), sub-tabs, and form controls. */
|
||||
|
||||
.btn:focus-visible,
|
||||
.oc-btn-primary:focus-visible, .oc-btn-ghost:focus-visible, .oc-btn-danger:focus-visible,
|
||||
.mkt-btn:focus-visible, .mkt-btn-primary:focus-visible, .mkt-btn-ghost:focus-visible,
|
||||
.pkg-btn:focus-visible, .pkg-btn-primary:focus-visible, .pkg-btn-secondary:focus-visible,
|
||||
.pkg-btn-danger:focus-visible, .pkg-btn-ghost:focus-visible,
|
||||
.cfg-io-btn:focus-visible, .cfg-io-btn-primary:focus-visible, .cfg-io-btn-ghost:focus-visible,
|
||||
.tm-detail-hire-btn:focus-visible,
|
||||
.org-subtab:focus-visible,
|
||||
.mkt-pill:focus-visible,
|
||||
.myorg-inline-btn:focus-visible, .myorg-reset-btn:focus-visible,
|
||||
.wfe-action-btn:focus-visible, .wfe-pattern-btn:focus-visible,
|
||||
.oc-action-btn:focus-visible, .oc-advanced-btn:focus-visible {
|
||||
outline: 2px solid var(--accent);
|
||||
outline-offset: 2px;
|
||||
border-radius: var(--radius-xs);
|
||||
}
|
||||
|
||||
/* Form controls: move the accent ring inside (via box-shadow) to avoid */
|
||||
/* clipping against tight parent containers. */
|
||||
.oc-form-row input:focus-visible,
|
||||
.oc-form-row select:focus-visible,
|
||||
.oc-form-row textarea:focus-visible,
|
||||
.cfg-io-textarea:focus-visible,
|
||||
.mkt-search:focus-visible,
|
||||
.pkg-search:focus-visible {
|
||||
outline: none;
|
||||
border-color: var(--accent);
|
||||
box-shadow: 0 0 0 2px color-mix(in srgb, var(--accent) 25%, transparent);
|
||||
}
|
||||
|
||||
/* Remove default outline on focus when NOT keyboard-nav (mouse click). */
|
||||
.btn:focus:not(:focus-visible),
|
||||
.org-subtab:focus:not(:focus-visible),
|
||||
.mkt-pill:focus:not(:focus-visible) {
|
||||
outline: none;
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
/**
|
||||
* Keyed icon library for Role icons.
|
||||
*
|
||||
* Avoids storing 300-char SVG data-URIs in config YAML by letting RoleConfig.icon
|
||||
* hold a short key like "leader" / "strategy" that maps to the inline SVG at
|
||||
* render time. Legacy data-URI strings still render via fallback in lookups.
|
||||
*/
|
||||
|
||||
export const ROLE_ICON_KEYS = [
|
||||
'leader', 'strategy', 'target',
|
||||
'code', 'terminal', 'database', 'settings', 'bug',
|
||||
'design', 'pen', 'layout',
|
||||
'marketing', 'analytics', 'writing',
|
||||
'clipboard', 'security', 'support', 'idea',
|
||||
'person', 'team', 'camera', 'calendar', 'group', 'ai',
|
||||
'generic',
|
||||
] as const
|
||||
|
||||
export type RoleIconKey = typeof ROLE_ICON_KEYS[number]
|
||||
|
||||
export const ROLE_ICONS: Record<RoleIconKey, string> = {
|
||||
// Inline SVG data-URIs — one per key. Content copied verbatim from
|
||||
// ROLE_ICON_GALLERY in OrgChart.tsx (post P4.5 CDN elimination).
|
||||
leader: "data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'%3E%3Cpath fill='%23888' d='M5 16L3 5l5.5 5L12 4l3.5 6L21 5l-2 11H5zm0 2h14v2H5v-2z'/%3E%3C/svg%3E",
|
||||
strategy: "data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'%3E%3Cpath fill='%23888' d='M19 22H5v-2h14v2zM17.16 8.26C18.22 9.63 19 10.85 19 12c0 2.21-3.13 4-7 4s-7-1.79-7-4c0-1.15.78-2.37 1.84-3.74C7.83 7.03 9 5.67 9 4.5a2.5 2.5 0 0 1 5 0c0 1.17 1.17 2.53 2.16 3.76zM12 6a.5.5 0 1 1 0-1 .5.5 0 0 1 0 1z'/%3E%3C/svg%3E",
|
||||
target: "data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'%3E%3Cpath fill='%23888' d='M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm0 18c-4.42 0-8-3.58-8-8s3.58-8 8-8 8 3.58 8 8-3.58 8-8 8zm0-14c-3.31 0-6 2.69-6 6s2.69 6 6 6 6-2.69 6-6-2.69-6-6-6zm0 10c-2.21 0-4-1.79-4-4s1.79-4 4-4 4 1.79 4 4-1.79 4-4 4zm0-6a2 2 0 1 0 0 4 2 2 0 0 0 0-4z'/%3E%3C/svg%3E",
|
||||
code: "data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'%3E%3Cpath fill='%23888' d='M9.4 16.6L4.8 12l4.6-4.6L8 6l-6 6 6 6 1.4-1.4zm5.2 0L19.2 12l-4.6-4.6L16 6l6 6-6 6-1.4-1.4z'/%3E%3C/svg%3E",
|
||||
terminal: "data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'%3E%3Cpath fill='%23888' d='M20 4H4c-1.11 0-2 .9-2 2v12c0 1.1.89 2 2 2h16c1.1 0 2-.9 2-2V6c0-1.1-.9-2-2-2zm0 14H4V8h16v10zM13 14h4v2h-4zm-3.41-.82l-2.59 2.59L8.41 17 12 13.41 8.41 9.82 7 11.24l2.59 2.59z'/%3E%3C/svg%3E",
|
||||
database: "data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'%3E%3Cpath fill='%23888' d='M12 2C6.48 2 2 3.79 2 6v12c0 2.21 4.48 4 10 4s10-1.79 10-4V6c0-2.21-4.48-4-10-4zm0 18c-4.42 0-8-1.34-8-3v-2.5c1.74 1.5 4.71 2.5 8 2.5s6.26-1 8-2.5V17c0 1.66-3.58 3-8 3zm0-5c-4.42 0-8-1.34-8-3v-2.5c1.74 1.5 4.71 2.5 8 2.5s6.26-1 8-2.5V12c0 1.66-3.58 3-8 3zm0-5c-4.42 0-8-1.34-8-3s3.58-3 8-3 8 1.34 8 3-3.58 3-8 3z'/%3E%3C/svg%3E",
|
||||
settings: "data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'%3E%3Cpath fill='%23888' d='M19.43 12.98c.04-.32.07-.64.07-.98 0-.34-.03-.66-.07-.98l2.11-1.65c.19-.15.24-.42.12-.64l-2-3.46c-.12-.22-.39-.3-.61-.22l-2.49 1c-.52-.4-1.08-.73-1.69-.98l-.38-2.65C14.46 2.18 14.25 2 14 2h-4c-.25 0-.46.18-.49.42l-.38 2.65c-.61.25-1.17.59-1.69.98l-2.49-1c-.23-.09-.49 0-.61.22l-2 3.46c-.13.22-.07.49.12.64l2.11 1.65c-.04.32-.07.65-.07.98 0 .33.03.66.07.98l-2.11 1.65c-.19.15-.24.42-.12.64l2 3.46c.12.22.39.3.61.22l2.49-1c.52.4 1.08.73 1.69.98l.38 2.65c.03.24.24.42.49.42h4c.25 0 .46-.18.49-.42l.38-2.65c.61-.25 1.17-.59 1.69-.98l2.49 1c.23.09.49 0 .61-.22l2-3.46c.12-.22.07-.49-.12-.64l-2.11-1.65zM12 15.5c-1.93 0-3.5-1.57-3.5-3.5s1.57-3.5 3.5-3.5 3.5 1.57 3.5 3.5-1.57 3.5-3.5 3.5z'/%3E%3C/svg%3E",
|
||||
bug: "data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'%3E%3Cpath fill='%23888' d='M20 8h-2.81c-.45-.78-1.07-1.45-1.82-1.96L17 4.41 15.59 3l-2.17 2.17C12.96 5.06 12.49 5 12 5s-.96.06-1.41.17L8.41 3 7 4.41l1.62 1.63C7.88 6.55 7.26 7.22 6.81 8H4v2h2.09c-.05.33-.09.66-.09 1v1H4v2h2v1c0 .34.04.67.09 1H4v2h2.81c1.04 1.79 2.97 3 5.19 3s4.15-1.21 5.19-3H20v-2h-2.09c.05-.33.09-.66.09-1v-1h2v-2h-2v-1c0-.34-.04-.67-.09-1H20V8zm-6 8h-4v-2h4v2zm0-4h-4v-2h4v2z'/%3E%3C/svg%3E",
|
||||
design: "data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'%3E%3Cpath fill='%23888' d='M12 2C6.49 2 2 6.49 2 12s4.49 10 10 10c1.38 0 2.5-1.12 2.5-2.5 0-.61-.23-1.2-.64-1.67-.08-.1-.13-.21-.13-.33 0-.28.22-.5.5-.5H16c3.31 0 6-2.69 6-6 0-4.96-4.49-9-10-9zm5.5 11c-.83 0-1.5-.67-1.5-1.5s.67-1.5 1.5-1.5 1.5.67 1.5 1.5-.67 1.5-1.5 1.5zm-3-4c-.83 0-1.5-.67-1.5-1.5S13.67 6 14.5 6s1.5.67 1.5 1.5S15.33 9 14.5 9zm-5 0C8.67 9 8 8.33 8 7.5S8.67 6 9.5 6s1.5.67 1.5 1.5S10.33 9 9.5 9zm-3 4c-.83 0-1.5-.67-1.5-1.5s.67-1.5 1.5-1.5 1.5.67 1.5 1.5-.67 1.5-1.5 1.5z'/%3E%3C/svg%3E",
|
||||
pen: "data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'%3E%3Cpath fill='%23888' d='M3 17.25V21h3.75L17.81 9.94l-3.75-3.75L3 17.25zM20.71 7.04c.39-.39.39-1.02 0-1.41l-2.34-2.34c-.39-.39-1.02-.39-1.41 0l-1.83 1.83 3.75 3.75 1.83-1.83z'/%3E%3C/svg%3E",
|
||||
layout: "data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'%3E%3Cpath fill='%23888' d='M3 13h8V3H3v10zm0 8h8v-6H3v6zm10 0h8V11h-8v10zm0-18v6h8V3h-8z'/%3E%3C/svg%3E",
|
||||
marketing: "data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'%3E%3Cpath fill='%23888' d='M18 11v2h4v-2h-4zm-2 6.61c.96.71 2.21 1.65 3.2 2.39.4-.53.8-1.07 1.2-1.6-.99-.74-2.24-1.68-3.2-2.4-.4.54-.8 1.08-1.2 1.61zM20.4 5.6c-.4-.53-.8-1.07-1.2-1.6-.99.74-2.24 1.68-3.2 2.4.4.53.8 1.07 1.2 1.6.96-.72 2.21-1.65 3.2-2.4zM4 9c-1.1 0-2 .9-2 2v2c0 1.1.9 2 2 2h1v4h2v-4h1l5 3V6L8 9H4zm11.5 3c0-1.33-.58-2.53-1.5-3.35v6.69c.92-.81 1.5-2.01 1.5-3.34z'/%3E%3C/svg%3E",
|
||||
analytics: "data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'%3E%3Cpath fill='%23888' d='M3.5 18.49l6-6.01 4 4L22 6.92l-1.41-1.41-7.09 7.97-4-4L2 16.99l1.5 1.5z'/%3E%3C/svg%3E",
|
||||
writing: "data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'%3E%3Cpath fill='%23888' d='M3 17.25V21h3.75L17.81 9.94l-3.75-3.75L3 17.25zM20.71 7.04c.39-.39.39-1.02 0-1.41l-2.34-2.34c-.39-.39-1.02-.39-1.41 0l-1.83 1.83 3.75 3.75 1.83-1.83z'/%3E%3C/svg%3E",
|
||||
clipboard: "data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'%3E%3Cpath fill='%23888' d='M19 3h-4.18C14.4 1.84 13.3 1 12 1c-1.3 0-2.4.84-2.82 2H5c-1.1 0-2 .9-2 2v16c0 1.1.9 2 2 2h14c1.1 0 2-.9 2-2V5c0-1.1-.9-2-2-2zm-7 0c.55 0 1 .45 1 1s-.45 1-1 1-1-.45-1-1 .45-1 1-1zm2 14H7v-2h7v2zm3-4H7v-2h10v2zm0-4H7V7h10v2z'/%3E%3C/svg%3E",
|
||||
security: "data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'%3E%3Cpath fill='%23888' d='M12 1L3 5v6c0 5.55 3.84 10.74 9 12 5.16-1.26 9-6.45 9-12V5l-9-4zm0 10.99h7c-.53 4.12-3.28 7.79-7 8.94V12H5V6.3l7-3.11V11.99z'/%3E%3C/svg%3E",
|
||||
support: "data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'%3E%3Cpath fill='%23888' d='M21 6h-2v9H6v2c0 .55.45 1 1 1h11l4 4V7c0-.55-.45-1-1-1zm-4 6V3c0-.55-.45-1-1-1H3c-.55 0-1 .45-1 1v14l4-4h10c.55 0 1-.45 1-1z'/%3E%3C/svg%3E",
|
||||
idea: "data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'%3E%3Cpath fill='%23888' d='M9 21c0 .55.45 1 1 1h4c.55 0 1-.45 1-1v-1H9v1zm3-19C8.14 2 5 5.14 5 9c0 2.38 1.19 4.47 3 5.74V17c0 .55.45 1 1 1h6c.55 0 1-.45 1-1v-2.26c1.81-1.27 3-3.36 3-5.74 0-3.86-3.14-7-7-7z'/%3E%3C/svg%3E",
|
||||
person: "data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'%3E%3Cpath fill='%23888' d='M12 12c2.21 0 4-1.79 4-4s-1.79-4-4-4-4 1.79-4 4 1.79 4 4 4zm0 2c-2.67 0-8 1.34-8 4v2h16v-2c0-2.66-5.33-4-8-4z'/%3E%3C/svg%3E",
|
||||
team: "data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'%3E%3Cpath fill='%23888' d='M16 11c1.66 0 2.99-1.34 2.99-3S17.66 5 16 5c-1.66 0-3 1.34-3 3s1.34 3 3 3zm-8 0c1.66 0 2.99-1.34 2.99-3S9.66 5 8 5C6.34 5 5 6.34 5 8s1.34 3 3 3zm0 2c-2.33 0-7 1.17-7 3.5V19h14v-2.5c0-2.33-4.67-3.5-7-3.5zm8 0c-.29 0-.62.02-.97.05 1.16.84 1.97 1.97 1.97 3.45V19h6v-2.5c0-2.33-4.67-3.5-7-3.5z'/%3E%3C/svg%3E",
|
||||
camera: "data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'%3E%3Cpath fill='%23888' d='M12 15.2c-1.77 0-3.2-1.43-3.2-3.2s1.43-3.2 3.2-3.2 3.2 1.43 3.2 3.2-1.43 3.2-3.2 3.2zM9 2L7.17 4H4c-1.1 0-2 .9-2 2v12c0 1.1.9 2 2 2h16c1.1 0 2-.9 2-2V6c0-1.1-.9-2-2-2h-3.17L15 2H9zm3 15c-2.76 0-5-2.24-5-5s2.24-5 5-5 5 2.24 5 5-2.24 5-5 5z'/%3E%3C/svg%3E",
|
||||
calendar: "data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'%3E%3Cpath fill='%23888' d='M19 3h-1V1h-2v2H8V1H6v2H5c-1.11 0-1.99.9-1.99 2L3 19c0 1.1.89 2 2 2h14c1.1 0 2-.9 2-2V5c0-1.1-.9-2-2-2zm0 16H5V8h14v11zM7 10h5v5H7z'/%3E%3C/svg%3E",
|
||||
group: "data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'%3E%3Cpath fill='%23888' d='M12 12.75c1.63 0 3.07.39 4.24.9 1.08.48 1.76 1.56 1.76 2.73L18 18H6l.01-1.62c0-1.17.68-2.25 1.76-2.73 1.17-.51 2.6-.9 4.23-.9zM4 13c1.1 0 2-.9 2-2s-.9-2-2-2-2 .9-2 2 .9 2 2 2zm1.13 1.1c-.37-.06-.74-.1-1.13-.1-.99 0-1.93.21-2.78.58C.48 14.9 0 15.62 0 16.43V18h4.5v-1.61c0-.83.23-1.61.63-2.29zM20 13c1.1 0 2-.9 2-2s-.9-2-2-2-2 .9-2 2 .9 2 2 2zm4 3.43c0-.81-.48-1.53-1.22-1.85-.85-.37-1.79-.58-2.78-.58-.39 0-.76.04-1.13.1.4.68.63 1.46.63 2.29V18H24v-1.57zM12 6c1.66 0 3 1.34 3 3s-1.34 3-3 3-3-1.34-3-3 1.34-3 3-3z'/%3E%3C/svg%3E",
|
||||
ai: "data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'%3E%3Cpath fill='%23888' d='M19 9l1.25-2.75L23 5l-2.75-1.25L19 1l-1.25 2.75L15 5l2.75 1.25L19 9zm-7.5.5L9 4 6.5 9.5 1 12l5.5 2.5L9 20l2.5-5.5L17 12l-5.5-2.5zM19 15l-1.25 2.75L15 19l2.75 1.25L19 23l1.25-2.75L23 19l-2.75-1.25L19 15z'/%3E%3C/svg%3E",
|
||||
generic: "data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'%3E%3Cpath fill='%23888' d='M12 12c2.21 0 4-1.79 4-4s-1.79-4-4-4-4 1.79-4 4 1.79 4 4 4zm0 2c-2.67 0-8 1.34-8 4v2h16v-2c0-2.66-5.33-4-8-4z'/%3E%3C/svg%3E",
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve a role.icon string to a usable `src` for <img>.
|
||||
* Key -> SVG lookup; legacy full data-URI -> pass through.
|
||||
*/
|
||||
export function resolveRoleIcon(icon: string | null | undefined): string {
|
||||
if (!icon) return ROLE_ICONS.generic
|
||||
if (icon.startsWith('data:')) return icon // legacy
|
||||
return ROLE_ICONS[icon as RoleIconKey] ?? ROLE_ICONS.generic
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,844 @@
|
||||
/* Team, runtime summary, and My Organization styles. */
|
||||
|
||||
/* ── Team View ─────────────────────────────────────────────────── */
|
||||
|
||||
.team-view {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 14px;
|
||||
padding: 0;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.team-command-bar {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(220px, 1fr) auto auto;
|
||||
gap: 14px;
|
||||
align-items: center;
|
||||
padding: 12px 14px;
|
||||
border: 1px solid color-mix(in srgb, var(--border) 84%, transparent);
|
||||
border-radius: 8px;
|
||||
background: color-mix(in srgb, var(--bg-elevated) 34%, var(--bg) 66%);
|
||||
}
|
||||
.team-command-bar--readonly {
|
||||
background: color-mix(in srgb, var(--bg-elevated) 24%, var(--bg) 76%);
|
||||
}
|
||||
.team-command-copy {
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 3px;
|
||||
}
|
||||
.team-command-eyebrow {
|
||||
color: var(--text-secondary);
|
||||
font-size: 10px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
.team-command-title {
|
||||
color: var(--text);
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
}
|
||||
.team-command-metrics {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 7px;
|
||||
flex-wrap: wrap;
|
||||
justify-content: flex-end;
|
||||
color: var(--text-secondary);
|
||||
font-size: 11px;
|
||||
}
|
||||
.team-command-metrics span {
|
||||
min-height: 24px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: 3px 8px;
|
||||
border: 1px solid color-mix(in srgb, var(--border) 78%, transparent);
|
||||
border-radius: 999px;
|
||||
background: color-mix(in srgb, var(--bg) 54%, transparent);
|
||||
white-space: nowrap;
|
||||
}
|
||||
.team-command-metrics b {
|
||||
color: var(--text);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
.team-command-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.team-readonly-strip {
|
||||
padding: 9px 12px;
|
||||
border: 1px solid color-mix(in srgb, var(--border) 78%, transparent);
|
||||
border-radius: 8px;
|
||||
background: color-mix(in srgb, var(--text-secondary) 6%, transparent);
|
||||
color: var(--text-secondary);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
/* Team Roster Grid */
|
||||
.team-roster-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(282px, 1fr));
|
||||
gap: 12px;
|
||||
}
|
||||
.team-roster-card {
|
||||
min-width: 0;
|
||||
background: color-mix(in srgb, var(--bg) 84%, var(--bg-elevated) 16%);
|
||||
border: 1px solid color-mix(in srgb, var(--border) 84%, transparent);
|
||||
border-radius: 8px;
|
||||
padding: 11px 12px;
|
||||
transition: border-color 150ms ease, background 150ms ease;
|
||||
}
|
||||
.team-roster-card:hover {
|
||||
background: color-mix(in srgb, var(--bg-elevated) 48%, var(--bg) 52%);
|
||||
border-color: color-mix(in srgb, var(--border-hover) 78%, var(--accent) 22%);
|
||||
}
|
||||
.team-roster-card-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 9px;
|
||||
margin-bottom: 10px;
|
||||
padding-bottom: 9px;
|
||||
border-bottom: 1px solid color-mix(in srgb, var(--border) 72%, transparent);
|
||||
}
|
||||
.team-roster-card-icon {
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
padding: 4px;
|
||||
border-radius: 7px;
|
||||
background: color-mix(in srgb, var(--accent) 12%, transparent);
|
||||
border: 1px solid color-mix(in srgb, var(--accent) 18%, transparent);
|
||||
}
|
||||
.team-roster-role-name {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: var(--text);
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.team-roster-count {
|
||||
font-size: 10px;
|
||||
color: var(--text-secondary);
|
||||
padding: 2px 7px;
|
||||
background: color-mix(in srgb, var(--bg-elevated) 78%, var(--bg) 22%);
|
||||
border-radius: 999px;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
/* Employee rows */
|
||||
.team-roster-emp {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 9px;
|
||||
padding: 7px 0;
|
||||
}
|
||||
.team-roster-emp + .team-roster-emp {
|
||||
border-top: 1px solid color-mix(in srgb, var(--border) 50%, transparent);
|
||||
}
|
||||
.team-roster-emp-avatar {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
border-radius: 50%;
|
||||
opacity: 0.58;
|
||||
}
|
||||
.team-roster-emp-info {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1px;
|
||||
}
|
||||
.team-roster-emp-name {
|
||||
font-size: 12.5px;
|
||||
font-weight: 500;
|
||||
color: var(--text);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.team-roster-seniority {
|
||||
font-size: 9px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.3px;
|
||||
font-weight: 600;
|
||||
}
|
||||
.team-roster-seniority--junior { color: var(--text-secondary); }
|
||||
.team-roster-seniority--mid { color: var(--yellow); }
|
||||
.team-roster-seniority--senior { color: var(--accent); }
|
||||
.team-roster-seniority--lead { color: var(--green); }
|
||||
|
||||
/* Badges and actions */
|
||||
.team-roster-badge {
|
||||
font-size: 9px;
|
||||
padding: 3px 8px;
|
||||
border-radius: 999px;
|
||||
font-weight: 500;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.team-roster-badge--active {
|
||||
background: color-mix(in srgb, var(--green) 12%, transparent);
|
||||
color: var(--green);
|
||||
}
|
||||
.team-roster-deploy-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
font-size: 10px;
|
||||
padding: 3px 10px;
|
||||
border: 1px solid var(--accent);
|
||||
border-radius: 999px;
|
||||
background: none;
|
||||
color: var(--accent);
|
||||
cursor: pointer;
|
||||
transition: all 0.15s;
|
||||
}
|
||||
.team-roster-deploy-btn:hover {
|
||||
background: color-mix(in srgb, var(--accent) 12%, transparent);
|
||||
}
|
||||
.team-roster-deploy-icon {
|
||||
width: 11px;
|
||||
height: 11px;
|
||||
}
|
||||
|
||||
/* Vacant state */
|
||||
.team-roster-vacant {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 10px;
|
||||
padding: 8px 0;
|
||||
}
|
||||
.team-roster-vacant-text {
|
||||
font-size: 11px;
|
||||
color: var(--text-secondary);
|
||||
font-style: italic;
|
||||
}
|
||||
.team-roster-hire-btn {
|
||||
font-size: 10px;
|
||||
padding: 3px 10px;
|
||||
border: 1px solid var(--yellow);
|
||||
border-radius: 999px;
|
||||
background: none;
|
||||
color: var(--yellow);
|
||||
cursor: pointer;
|
||||
transition: all 0.15s;
|
||||
}
|
||||
.team-roster-hire-btn:hover {
|
||||
background: color-mix(in srgb, var(--yellow) 12%, transparent);
|
||||
}
|
||||
|
||||
/* ══════════════════════════════════════════════════════════════════════
|
||||
WORKFLOW EDITOR (.wfe-*)
|
||||
══════════════════════════════════════════════════════════════════════ */
|
||||
|
||||
.wfe-container {
|
||||
background: var(--bg-secondary);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 10px;
|
||||
padding: 16px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
.wfe-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
margin-bottom: 12px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.wfe-title { font-size: 14px; font-weight: 600; color: var(--text); margin: 0; }
|
||||
.wfe-profile-badge {
|
||||
font-size: 9px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.4px;
|
||||
padding: 2px 6px;
|
||||
border-radius: 3px;
|
||||
background: var(--bg);
|
||||
color: var(--text-secondary);
|
||||
border: 1px solid var(--border);
|
||||
}
|
||||
.wfe-header-actions { display: flex; gap: 8px; margin-left: auto; }
|
||||
.wfe-action-btn {
|
||||
display: inline-flex; align-items: center; gap: 5px;
|
||||
padding: 5px 10px; font-size: 11px;
|
||||
border: 1px solid var(--border); border-radius: 6px;
|
||||
background: transparent; color: var(--text-secondary); cursor: pointer;
|
||||
transition: all 0.15s;
|
||||
}
|
||||
.wfe-action-btn:hover { border-color: var(--accent); color: var(--text); }
|
||||
.wfe-action-icon { width: 13px; height: 13px; opacity: 0.6; }
|
||||
.wfe-save-btn { border-color: var(--green, #27ae60); color: var(--green, #27ae60); }
|
||||
.wfe-save-btn:hover { background: var(--green, #27ae60); color: var(--white); }
|
||||
|
||||
.wfe-dag-wrap { overflow-x: auto; overflow-y: visible; padding: 8px 0; min-height: 0; }
|
||||
.wfe-dag-node { cursor: pointer; }
|
||||
.wfe-dag-node:hover { box-shadow: 0 0 0 2px var(--accent); border-radius: 6px; }
|
||||
.wfe-node-editing { box-shadow: 0 0 0 2px var(--accent); }
|
||||
|
||||
.wfe-editor {
|
||||
background: var(--bg);
|
||||
border: 1px solid var(--accent);
|
||||
border-radius: 8px;
|
||||
padding: 14px;
|
||||
margin-top: 12px;
|
||||
}
|
||||
.wfe-editor-header {
|
||||
display: flex; align-items: center; justify-content: space-between;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
.wfe-editor-title { font-size: 12px; font-weight: 600; color: var(--text); }
|
||||
.wfe-editor-close {
|
||||
font-size: 18px; background: none; border: none;
|
||||
color: var(--text-secondary); cursor: pointer;
|
||||
}
|
||||
.wfe-editor-close:hover { color: var(--text); }
|
||||
|
||||
.wfe-add-form {
|
||||
background: var(--bg);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
padding: 12px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
.wfe-readonly { opacity: 0.6; }
|
||||
.wfe-inline-input,
|
||||
.wfe-inline-select {
|
||||
margin-left: 6px;
|
||||
padding: 3px 6px;
|
||||
font-size: 11px;
|
||||
background: var(--bg-secondary);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 4px;
|
||||
color: var(--text);
|
||||
}
|
||||
.wfe-hint { font-size: 10px; color: var(--text-secondary); font-style: italic; }
|
||||
|
||||
.wfe-empty { text-align: center; padding: 30px; color: var(--text-secondary); font-size: 13px; }
|
||||
.wfe-empty-hint { font-size: 11px; opacity: 0.7; }
|
||||
|
||||
/* ══════════════════════════════════════════════════════════════════════
|
||||
MY ORGANIZATION (.myorg-*)
|
||||
══════════════════════════════════════════════════════════════════════ */
|
||||
|
||||
.myorg-container { padding: 0 4px; overflow-y: auto; flex: 1; }
|
||||
|
||||
.myorg-section {
|
||||
background:
|
||||
linear-gradient(180deg,
|
||||
color-mix(in srgb, var(--bg-elevated) 30%, var(--bg) 70%),
|
||||
color-mix(in srgb, var(--bg) 94%, var(--bg-elevated) 6%));
|
||||
border: 1px solid color-mix(in srgb, var(--border) 86%, transparent);
|
||||
border-radius: 8px;
|
||||
padding: 15px 16px 16px;
|
||||
margin-bottom: 0;
|
||||
}
|
||||
.myorg-section-header {
|
||||
display: flex; align-items: center; gap: 9px; margin-bottom: 14px; flex-wrap: wrap;
|
||||
}
|
||||
.myorg-section-icon { width: 16px; height: 16px; opacity: 0.68; }
|
||||
.myorg-section-title { font-size: 14px; font-weight: 600; color: var(--text); margin: 0; }
|
||||
.myorg-section-count { font-size: 11px; color: var(--text-secondary); }
|
||||
.myorg-section-spacer { flex: 1 1 auto; min-width: 12px; }
|
||||
.myorg-section-note {
|
||||
color: var(--text-secondary);
|
||||
font-size: 11px;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.myorg-form {
|
||||
background: var(--bg);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
padding: 14px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
.myorg-form-title { font-size: 13px; font-weight: 600; color: var(--text); margin: 0 0 10px; }
|
||||
|
||||
/* Roster grid */
|
||||
.myorg-roster-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
|
||||
gap: 10px;
|
||||
}
|
||||
.myorg-roster-role {
|
||||
background: var(--bg);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 6px;
|
||||
padding: 10px;
|
||||
}
|
||||
.myorg-roster-role-header {
|
||||
display: flex; justify-content: space-between; align-items: center;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
.myorg-roster-role-name { font-size: 12px; font-weight: 600; color: var(--text); }
|
||||
.myorg-roster-role-count { font-size: 10px; color: var(--text-secondary); }
|
||||
.myorg-roster-emp {
|
||||
display: flex; align-items: center; gap: 6px;
|
||||
padding: 3px 0;
|
||||
}
|
||||
.myorg-roster-emp-name { font-size: 11px; color: var(--text); }
|
||||
.myorg-roster-seniority {
|
||||
font-size: 9px; padding: 1px 5px; border-radius: 3px;
|
||||
text-transform: uppercase; letter-spacing: 0.3px;
|
||||
}
|
||||
.myorg-seniority-junior { background: var(--bg-secondary); color: var(--text-secondary); }
|
||||
.myorg-seniority-mid { background: rgba(52,152,219,0.15); color: var(--accent); }
|
||||
.myorg-seniority-senior { background: rgba(39,174,96,0.15); color: var(--green, #27ae60); }
|
||||
.myorg-roster-office-badge {
|
||||
font-size: 9px; color: var(--green, #27ae60);
|
||||
border: 1px solid var(--green, #27ae60);
|
||||
padding: 0 4px; border-radius: 3px;
|
||||
}
|
||||
.myorg-roster-vacant { font-size: 10px; color: var(--text-secondary); font-style: italic; }
|
||||
.myorg-empty-hint { font-size: 11px; color: var(--text-secondary); padding: 12px 0; }
|
||||
|
||||
/* Collapsible sections */
|
||||
.myorg-collapsible {
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
margin-bottom: 8px;
|
||||
overflow: hidden;
|
||||
}
|
||||
.myorg-collapsible-toggle {
|
||||
width: 100%;
|
||||
display: flex; align-items: center; gap: 8px;
|
||||
padding: 10px 14px;
|
||||
font-size: 12px;
|
||||
background: var(--bg-secondary);
|
||||
border: none;
|
||||
color: var(--text);
|
||||
cursor: pointer;
|
||||
text-align: left;
|
||||
}
|
||||
.myorg-collapsible-toggle:hover { background: var(--bg); }
|
||||
.myorg-toggle-icon { font-size: 10px; color: var(--text-secondary); width: 12px; }
|
||||
.myorg-collapsible-title { font-weight: 500; }
|
||||
.myorg-collapsible-count {
|
||||
font-size: 10px; color: var(--text-secondary);
|
||||
background: var(--bg); padding: 1px 6px; border-radius: 8px;
|
||||
}
|
||||
.myorg-collapsible-extra { margin-left: auto; }
|
||||
.myorg-collapsible-body { padding: 12px 14px; }
|
||||
.myorg-inline-btn {
|
||||
display: inline-flex; align-items: center; gap: 4px;
|
||||
font-size: 11px; color: var(--accent);
|
||||
background: color-mix(in srgb, var(--accent) 8%, transparent);
|
||||
border: 1px solid color-mix(in srgb, var(--accent) 16%, transparent);
|
||||
border-radius: 999px;
|
||||
padding: 5px 9px;
|
||||
cursor: pointer;
|
||||
}
|
||||
.myorg-inline-btn:hover { background: color-mix(in srgb, var(--accent) 13%, transparent); }
|
||||
.myorg-inline-icon { width: 12px; height: 12px; }
|
||||
|
||||
/* ── Reset Architecture ────────────────────────────────────────── */
|
||||
.myorg-action-bar {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
padding: 0;
|
||||
}
|
||||
.myorg-reset-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
padding: 5px 11px;
|
||||
font-size: 11px;
|
||||
color: var(--red);
|
||||
background: color-mix(in srgb, var(--red) 4%, transparent);
|
||||
border: 1px solid color-mix(in srgb, var(--red) 30%, transparent);
|
||||
border-radius: 999px;
|
||||
cursor: pointer;
|
||||
transition: background 0.15s, border-color 0.15s, color 0.15s;
|
||||
}
|
||||
.myorg-reset-btn:hover {
|
||||
background: color-mix(in srgb, var(--red) 10%, transparent);
|
||||
border-color: var(--red);
|
||||
}
|
||||
.myorg-reset-icon { width: 12px; height: 12px; filter: hue-rotate(0deg); }
|
||||
|
||||
/* ── OrgChart: show responsibility instead of ID ───────────────── */
|
||||
.oc-node-resp {
|
||||
font-size: 10px;
|
||||
color: var(--text-dim);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
max-width: 160px;
|
||||
}
|
||||
|
||||
/* ── Icon Picker ───────────────────────────────────────────────── */
|
||||
|
||||
.oc-icon-picker-trigger {
|
||||
position: relative;
|
||||
}
|
||||
.oc-icon-preview-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 6px 10px;
|
||||
background: var(--bg-secondary);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
color: var(--text);
|
||||
font-size: 12px;
|
||||
transition: border-color 0.15s;
|
||||
}
|
||||
.oc-icon-preview-btn:hover {
|
||||
border-color: var(--accent);
|
||||
}
|
||||
.oc-icon-preview-img {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
border-radius: 4px;
|
||||
}
|
||||
.oc-icon-placeholder {
|
||||
opacity: 0.35;
|
||||
}
|
||||
.oc-icon-preview-label {
|
||||
color: var(--text-secondary);
|
||||
font-size: 11px;
|
||||
}
|
||||
.oc-icon-clear-btn {
|
||||
padding: 2px 8px;
|
||||
font-size: 10px;
|
||||
color: var(--text-secondary);
|
||||
background: none;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
margin-left: 6px;
|
||||
}
|
||||
.oc-icon-clear-btn:hover {
|
||||
color: var(--red);
|
||||
border-color: var(--red);
|
||||
}
|
||||
.oc-icon-gallery {
|
||||
position: absolute;
|
||||
top: 100%;
|
||||
left: 0;
|
||||
z-index: 20;
|
||||
display: grid;
|
||||
grid-template-columns: repeat(6, 1fr);
|
||||
gap: 4px;
|
||||
padding: 8px;
|
||||
background: var(--bg-card);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 8px 24px rgba(0,0,0,0.3);
|
||||
margin-top: 4px;
|
||||
width: 240px;
|
||||
}
|
||||
.oc-icon-option {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 6px;
|
||||
border: 1px solid transparent;
|
||||
border-radius: 6px;
|
||||
background: none;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s;
|
||||
}
|
||||
.oc-icon-option:hover {
|
||||
background: color-mix(in srgb, var(--accent) 10%, transparent);
|
||||
border-color: var(--accent);
|
||||
}
|
||||
.oc-icon-option--active {
|
||||
background: color-mix(in srgb, var(--accent) 15%, transparent);
|
||||
border-color: var(--accent);
|
||||
}
|
||||
.oc-icon-option-img {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
}
|
||||
|
||||
/* ── Quick Start Wizard ────────────────────────────────────────── */
|
||||
|
||||
.qs-wizard {
|
||||
padding: 24px;
|
||||
max-width: 640px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
.qs-loading {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 10px;
|
||||
padding: 48px 24px;
|
||||
color: var(--accent);
|
||||
font-size: 14px;
|
||||
}
|
||||
.qs-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 14px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
.qs-header-icon {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.qs-header-title {
|
||||
margin: 0;
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
color: var(--text);
|
||||
}
|
||||
.qs-header-sub {
|
||||
margin: 2px 0 0;
|
||||
font-size: 12px;
|
||||
color: var(--text-dim);
|
||||
}
|
||||
.qs-link-btn {
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--accent);
|
||||
cursor: pointer;
|
||||
padding: 0;
|
||||
font-size: 12px;
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
/* Progress bar */
|
||||
.qs-progress {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
margin-bottom: 20px;
|
||||
padding: 8px 0;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
.qs-step {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 6px 12px;
|
||||
border-radius: 20px;
|
||||
font-size: 11px;
|
||||
font-weight: 500;
|
||||
color: var(--text-dim);
|
||||
background: var(--bg-card);
|
||||
transition: all 0.2s;
|
||||
}
|
||||
.qs-step-active {
|
||||
background: var(--accent);
|
||||
color: var(--white);
|
||||
}
|
||||
.qs-step-done {
|
||||
background: var(--green);
|
||||
color: var(--white);
|
||||
}
|
||||
.qs-step-icon {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
filter: grayscale(1) opacity(0.5);
|
||||
}
|
||||
.qs-step-active .qs-step-icon,
|
||||
.qs-step-done .qs-step-icon {
|
||||
filter: brightness(10);
|
||||
}
|
||||
|
||||
/* Panel */
|
||||
.qs-panel {
|
||||
background: var(--bg-card);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 10px;
|
||||
padding: 20px;
|
||||
}
|
||||
.qs-panel h4 {
|
||||
margin: 0 0 4px;
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
color: var(--text);
|
||||
}
|
||||
.qs-hint {
|
||||
font-size: 12px;
|
||||
color: var(--text-dim);
|
||||
margin: 0 0 16px;
|
||||
}
|
||||
|
||||
/* Member rows */
|
||||
.qs-members {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
.qs-member-row {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
align-items: center;
|
||||
}
|
||||
.qs-member-name {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
padding: 6px 10px;
|
||||
border-radius: 6px;
|
||||
border: 1px solid var(--border);
|
||||
background: var(--bg);
|
||||
color: var(--text);
|
||||
font-size: 12px;
|
||||
}
|
||||
.qs-member-resp {
|
||||
flex: 1.5;
|
||||
min-width: 0;
|
||||
padding: 6px 10px;
|
||||
border-radius: 6px;
|
||||
border: 1px solid var(--border);
|
||||
background: var(--bg);
|
||||
color: var(--text);
|
||||
font-size: 12px;
|
||||
}
|
||||
.qs-member-parent {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
padding: 6px 8px;
|
||||
border-radius: 6px;
|
||||
border: 1px solid var(--border);
|
||||
background: var(--bg);
|
||||
color: var(--text);
|
||||
font-size: 11px;
|
||||
}
|
||||
.qs-remove-btn {
|
||||
background: none;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
padding: 4px;
|
||||
opacity: 0.4;
|
||||
transition: opacity 0.15s;
|
||||
}
|
||||
.qs-remove-btn:hover { opacity: 1; }
|
||||
.qs-remove-icon { width: 14px; height: 14px; }
|
||||
|
||||
.qs-add-member {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 6px 12px;
|
||||
border-radius: 6px;
|
||||
border: 1px dashed var(--border);
|
||||
background: none;
|
||||
color: var(--accent);
|
||||
font-size: 12px;
|
||||
cursor: pointer;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
.qs-add-member:hover { background: var(--bg); }
|
||||
.qs-add-icon { width: 14px; height: 14px; }
|
||||
|
||||
/* Nav */
|
||||
.qs-nav {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-top: 16px;
|
||||
padding-top: 12px;
|
||||
border-top: 1px solid var(--border);
|
||||
}
|
||||
|
||||
/* Pattern cards */
|
||||
.qs-patterns {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
.qs-pattern-card {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 10px;
|
||||
padding: 12px 14px;
|
||||
border-radius: 8px;
|
||||
border: 2px solid var(--border);
|
||||
cursor: pointer;
|
||||
transition: border-color 0.15s;
|
||||
}
|
||||
.qs-pattern-card:hover { border-color: var(--accent); }
|
||||
.qs-pattern-selected {
|
||||
border-color: var(--accent);
|
||||
background: color-mix(in srgb, var(--accent) 8%, transparent);
|
||||
}
|
||||
.qs-pattern-card input[type="radio"] {
|
||||
margin-top: 3px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.qs-pattern-label {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: var(--text);
|
||||
}
|
||||
.qs-pattern-desc {
|
||||
font-size: 11px;
|
||||
color: var(--text-dim);
|
||||
margin: 2px 0;
|
||||
}
|
||||
.qs-pattern-preview {
|
||||
font-size: 11px;
|
||||
font-family: 'SF Mono', 'Fira Code', monospace;
|
||||
color: var(--accent);
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
/* Preview */
|
||||
.qs-preview {
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
.qs-preview-section {
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
.qs-preview-section h5 {
|
||||
margin: 0 0 6px;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: var(--text-dim);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
.qs-preview-member {
|
||||
font-size: 12px;
|
||||
padding: 4px 0;
|
||||
color: var(--text);
|
||||
}
|
||||
.qs-preview-resp { color: var(--text-dim); }
|
||||
.qs-preview-parent { color: var(--text-dim); font-size: 11px; }
|
||||
|
||||
@media (max-width: 980px) {
|
||||
.team-command-bar {
|
||||
grid-template-columns: 1fr;
|
||||
align-items: stretch;
|
||||
}
|
||||
.team-command-metrics,
|
||||
.team-command-actions {
|
||||
justify-content: flex-start;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 560px) {
|
||||
.team-roster-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
.team-command-actions {
|
||||
align-items: stretch;
|
||||
}
|
||||
.team-command-actions > * {
|
||||
justify-content: center;
|
||||
}
|
||||
.myorg-section {
|
||||
padding: 12px;
|
||||
}
|
||||
.qs-wizard {
|
||||
padding: 16px 0;
|
||||
}
|
||||
.qs-member-row {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user