Initial commit
This commit is contained in:
@@ -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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user