Org structure: divisions → products/services → teams + custom model base URL

The object spine becomes definable (data model was designed-for from day one):
- Division and Product entities (Product carries kind: Product|Service, optional DivisionId);
  Team gains nullable ProductId — pre-structure teams keep working. AddDivisionsAndProducts
  migration; org-scoped validation; owner-only writes (audited); list endpoints.
- /structure page: define divisions, products/services (with division), teams (under a
  product). Org chart now renders the full spine — org → divisions → products → teams →
  seats — with parentless layers linking up to the org.
- BYOK custom URL: the SeatsPage model-connection form gains a Base URL field (provider
  list: stub/openai/ollama/vllm/custom). Backend already supported it end to end —
  ApiConfig.Endpoint flows into the OpenAI-compatible adapter ({base}/v1/chat/completions),
  so any OpenAI-compatible gateway or self-hosted model works; the config list shows it.

Verified: ArchitectureTests 8/8, IntegrationTests 45/45 (new OrgStructureTests: spine
creation, kind tags, org-scoped validation 400s, Member 403), client build green.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
soroush.asadi
2026-06-10 18:13:52 +03:30
parent 4416d99360
commit 1e65654114
15 changed files with 1153 additions and 21 deletions
+2
View File
@@ -9,6 +9,7 @@ import { OrgChartPage } from '@/pages/OrgChartPage'
import { PerformancePage } from '@/pages/PerformancePage'
import { ReviewsPage } from '@/pages/ReviewsPage'
import { SeatsPage } from '@/pages/SeatsPage'
import { StructurePage } from '@/pages/StructurePage'
import { useAuth } from '@/store/auth'
export default function App() {
@@ -25,6 +26,7 @@ export default function App() {
<Route path="/cartable" element={token ? <CartablePage /> : <Navigate to="/login" replace />} />
<Route path="/members" element={token ? <MembersPage /> : <Navigate to="/login" replace />} />
<Route path="/org" element={token ? <OrgChartPage /> : <Navigate to="/login" replace />} />
<Route path="/structure" element={token ? <StructurePage /> : <Navigate to="/login" replace />} />
<Route path="/performance" element={token ? <PerformancePage /> : <Navigate to="/login" replace />} />
<Route path="*" element={<Navigate to="/" replace />} />
</Routes>
+2
View File
@@ -2,6 +2,7 @@ import type { ReactNode } from 'react'
import { Link, useLocation } from 'react-router'
import {
Bot,
Boxes,
ChartColumn,
Gauge,
Inbox,
@@ -42,6 +43,7 @@ export function AppShell({ children }: { children: ReactNode }) {
<NavItem icon={ShieldCheck} label="Review inbox" to="/reviews" />
<NavItem icon={Bot} label="AI seats" to="/seats" />
<NavItem icon={Network} label="Org chart" to="/org" />
<NavItem icon={Boxes} label="Structure" to="/structure" />
<NavItem icon={Users} label="Members" to="/members" />
<NavItem icon={Gauge} label="Performance" to="/performance" />
<NavItem icon={ChartColumn} label="Analytics" to="/analytics" />
+88 -9
View File
@@ -7,18 +7,34 @@ import { api } from '@/lib/api'
import { useAuth } from '@/store/auth'
import type { SeatRow } from '@/lib/useDirectory'
interface Division {
id: string
name: string
}
interface Product {
id: string
divisionId: string | null
name: string
kind: string
}
interface Team {
id: string
organizationId: string
name: string
productId: string | null
}
const TEAM_WIDTH = 280
const SEAT_HEIGHT = 64
const LAYER_HEIGHT = 100
/** The live org chart: org → teams → seats, painted with the human/open/AI triad. */
/** The live org chart: org → divisions → products → teams → seats, painted with the triad. */
export function OrgChartPage() {
const organizationId = useAuth((s) => s.organizationId)
const [divisions, setDivisions] = useState<Division[]>([])
const [products, setProducts] = useState<Product[]>([])
const [teams, setTeams] = useState<Team[]>([])
const [seatsByTeam, setSeatsByTeam] = useState<Record<string, SeatRow[]>>({})
@@ -26,7 +42,13 @@ export function OrgChartPage() {
if (!organizationId) return
void (async () => {
try {
const teamList = await api.get<Team[]>(`/api/orgboard/teams?organizationId=${organizationId}`)
const [divisionList, productList, teamList] = await Promise.all([
api.get<Division[]>(`/api/orgboard/divisions?organizationId=${organizationId}`),
api.get<Product[]>(`/api/orgboard/products?organizationId=${organizationId}`),
api.get<Team[]>(`/api/orgboard/teams?organizationId=${organizationId}`),
])
setDivisions(divisionList)
setProducts(productList)
setTeams(teamList)
const entries = await Promise.all(
teamList.map(async (team) => {
@@ -44,7 +66,10 @@ export function OrgChartPage() {
})()
}, [organizationId])
const { nodes, edges } = useMemo(() => buildGraph(teams, seatsByTeam), [teams, seatsByTeam])
const { nodes, edges } = useMemo(
() => buildGraph(divisions, products, teams, seatsByTeam),
[divisions, products, teams, seatsByTeam],
)
return (
<AppShell>
@@ -67,14 +92,26 @@ export function OrgChartPage() {
)
}
function buildGraph(teams: Team[], seatsByTeam: Record<string, SeatRow[]>): { nodes: Node[]; edges: Edge[] } {
function buildGraph(
divisions: Division[],
products: Product[],
teams: Team[],
seatsByTeam: Record<string, SeatRow[]>,
): { nodes: Node[]; edges: Edge[] } {
const nodes: Node[] = []
const edges: Edge[] = []
if (teams.length === 0) {
if (teams.length === 0 && products.length === 0 && divisions.length === 0) {
return { nodes, edges }
}
const totalWidth = teams.length * TEAM_WIDTH
const hasDivisions = divisions.length > 0
const hasProducts = products.length > 0
const divisionY = LAYER_HEIGHT
const productY = divisionY + (hasDivisions ? LAYER_HEIGHT : 0)
const teamY = productY + (hasProducts ? LAYER_HEIGHT : 0)
const seatY = teamY + LAYER_HEIGHT
const totalWidth = Math.max(teams.length, products.length, divisions.length, 1) * TEAM_WIDTH
nodes.push({
id: 'org',
@@ -90,22 +127,24 @@ function buildGraph(teams: Team[], seatsByTeam: Record<string, SeatRow[]>): { no
},
})
// Teams anchor the columns; seats stack underneath each team.
const teamX = new Map<string, number>()
teams.forEach((team, teamIndex) => {
const x = teamIndex * TEAM_WIDTH
teamX.set(team.id, x)
nodes.push({
id: team.id,
position: { x, y: 110 },
position: { x, y: teamY },
data: { label: team.name },
style: { borderRadius: 10, fontWeight: 600, width: 200 },
})
edges.push({ id: `org-${team.id}`, source: 'org', target: team.id })
const seats = seatsByTeam[team.id] ?? []
seats.forEach((seat, seatIndex) => {
const color = seat.state === 'Ai' ? '#4f46e5' : seat.state === 'Human' ? '#475569' : '#d97706'
nodes.push({
id: seat.id,
position: { x: x + 10, y: 210 + seatIndex * SEAT_HEIGHT },
position: { x: x + 10, y: seatY + seatIndex * SEAT_HEIGHT },
data: { label: `${seat.roleName} · ${seat.state === 'Ai' ? 'AI' : seat.state}` },
style: {
background: color,
@@ -120,5 +159,45 @@ function buildGraph(teams: Team[], seatsByTeam: Record<string, SeatRow[]>): { no
})
})
// Products sit above their teams (centered); parentless ones get slots after the team row.
const productX = new Map<string, number>()
let overflowX = totalWidth
products.forEach((product) => {
const childXs = teams.filter((t) => t.productId === product.id).map((t) => teamX.get(t.id) ?? 0)
const x = childXs.length > 0 ? childXs.reduce((a, b) => a + b, 0) / childXs.length : (overflowX += TEAM_WIDTH) - TEAM_WIDTH
productX.set(product.id, x)
nodes.push({
id: product.id,
position: { x: x + 5, y: productY },
data: { label: `${product.name} · ${product.kind.toLowerCase()}` },
style: { borderRadius: 10, width: 190, fontSize: 13, border: '1.5px solid #4f46e5' },
})
})
// Divisions sit above their products.
const divisionX = new Map<string, number>()
divisions.forEach((division) => {
const childXs = products.filter((p) => p.divisionId === division.id).map((p) => productX.get(p.id) ?? 0)
const x = childXs.length > 0 ? childXs.reduce((a, b) => a + b, 0) / childXs.length : (overflowX += TEAM_WIDTH) - TEAM_WIDTH
divisionX.set(division.id, x)
nodes.push({
id: division.id,
position: { x: x + 10, y: divisionY },
data: { label: division.name },
style: { borderRadius: 10, width: 180, fontWeight: 600, fontSize: 13, background: '#eef2ff' },
})
edges.push({ id: `org-${division.id}`, source: 'org', target: division.id })
})
// Wire each layer to its nearest existing parent.
products.forEach((product) => {
const source = product.divisionId && divisionX.has(product.divisionId) ? product.divisionId : 'org'
edges.push({ id: `${source}-${product.id}`, source, target: product.id })
})
teams.forEach((team) => {
const source = team.productId && productX.has(team.productId) ? team.productId : 'org'
edges.push({ id: `${source}-${team.id}`, source, target: team.id })
})
return { nodes, edges }
}
+17 -5
View File
@@ -29,6 +29,7 @@ interface ApiConfig {
name: string
provider: string
model: string
endpoint: string | null
}
interface Seat {
@@ -71,7 +72,7 @@ export function SeatsPage() {
const [seats, setSeats] = useState<Seat[]>([])
const [skills, setSkills] = useState<Skill[]>([])
const [cfg, setCfg] = useState({ name: '', provider: 'stub', model: 'gpt-4o-mini', apiKey: '' })
const [cfg, setCfg] = useState({ name: '', provider: 'stub', model: 'gpt-4o-mini', apiKey: '', endpoint: '' })
const [newSeat, setNewSeat] = useState('')
const [selectedSeat, setSelectedSeat] = useState<string | null>(null)
const [agent, setAgent] = useState({
@@ -115,8 +116,8 @@ export function SeatsPage() {
const createConfig = () =>
run(async () => {
await api.post('/api/integrations/api-configs', { organizationId, ...cfg })
setCfg({ name: '', provider: 'stub', model: 'gpt-4o-mini', apiKey: '' })
await api.post('/api/integrations/api-configs', { organizationId, ...cfg, endpoint: cfg.endpoint.trim() || null })
setCfg({ name: '', provider: 'stub', model: 'gpt-4o-mini', apiKey: '', endpoint: '' })
await loadConfigs()
toast.success('API config saved (key encrypted).')
})
@@ -207,7 +208,7 @@ export function SeatsPage() {
<SelectTrigger className="w-36"><SelectValue /></SelectTrigger>
<SelectContent>
<SelectGroup>
{['stub', 'openai', 'anthropic', 'vertex', 'ollama'].map((p) => (
{['stub', 'openai', 'ollama', 'vllm', 'custom'].map((p) => (
<SelectItem key={p} value={p}>{p}</SelectItem>
))}
</SelectGroup>
@@ -220,13 +221,24 @@ export function SeatsPage() {
<Field label="API key">
<Input type="password" value={cfg.apiKey} onChange={(e) => setCfg({ ...cfg, apiKey: e.target.value })} className="w-44" placeholder="sk-…" />
</Field>
<Field label="Base URL (OpenAI-compatible; optional)">
<Input
value={cfg.endpoint}
onChange={(e) => setCfg({ ...cfg, endpoint: e.target.value })}
className="w-72"
placeholder="https://my-gateway.example.com"
/>
</Field>
<Button onClick={createConfig}><Plus data-icon="inline-start" />Add</Button>
</div>
<div className="flex flex-col gap-2">
{configs.map((c) => (
<div key={c.id} className="flex items-center justify-between rounded-md border px-3 py-2 text-sm">
<span className="font-medium">{c.name}</span>
<span className="text-muted-foreground">{c.provider} · {c.model}</span>
<span className="text-muted-foreground">
{c.provider} · {c.model}
{c.endpoint ? ` · ${c.endpoint}` : ''}
</span>
<Button variant="outline" size="sm" onClick={() => testConfig(c.id)}>Test</Button>
</div>
))}
+260
View File
@@ -0,0 +1,260 @@
import { useCallback, useEffect, useState } from 'react'
import { Boxes, Plus } from 'lucide-react'
import { toast } from 'sonner'
import { AppShell } from '@/components/AppShell'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import {
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
import { api } from '@/lib/api'
import { useAuth } from '@/store/auth'
interface Division {
id: string
organizationId: string
name: string
}
interface Product {
id: string
organizationId: string
divisionId: string | null
name: string
kind: string
}
interface Team {
id: string
organizationId: string
name: string
productId: string | null
}
const NONE = 'none'
/** Define the org structure: divisions → products/services → teams. */
export function StructurePage() {
const organizationId = useAuth((s) => s.organizationId)
const [divisions, setDivisions] = useState<Division[]>([])
const [products, setProducts] = useState<Product[]>([])
const [teams, setTeams] = useState<Team[]>([])
const [busy, setBusy] = useState(false)
const [divisionName, setDivisionName] = useState('')
const [product, setProduct] = useState({ name: '', kind: 'Product', divisionId: NONE })
const [team, setTeam] = useState({ name: '', productId: NONE })
const load = useCallback(async () => {
if (!organizationId) return
try {
const [d, p, t] = await Promise.all([
api.get<Division[]>(`/api/orgboard/divisions?organizationId=${organizationId}`),
api.get<Product[]>(`/api/orgboard/products?organizationId=${organizationId}`),
api.get<Team[]>(`/api/orgboard/teams?organizationId=${organizationId}`),
])
setDivisions(d)
setProducts(p)
setTeams(t)
} catch (err) {
toast.error((err as Error).message)
}
}, [organizationId])
useEffect(() => {
void load()
}, [load])
const run = async (action: () => Promise<void>) => {
setBusy(true)
try {
await action()
await load()
} catch (err) {
toast.error((err as Error).message)
} finally {
setBusy(false)
}
}
const addDivision = () =>
run(async () => {
await api.post('/api/orgboard/divisions', { organizationId, name: divisionName })
setDivisionName('')
toast.success('Division created.')
})
const addProduct = () =>
run(async () => {
await api.post('/api/orgboard/products', {
organizationId,
name: product.name,
kind: product.kind,
divisionId: product.divisionId === NONE ? null : product.divisionId,
})
setProduct({ name: '', kind: 'Product', divisionId: NONE })
toast.success('Product created.')
})
const addTeam = () =>
run(async () => {
await api.post('/api/orgboard/teams', {
organizationId,
name: team.name,
productId: team.productId === NONE ? null : team.productId,
})
setTeam({ name: '', productId: NONE })
toast.success('Team created.')
})
return (
<AppShell>
<div className="mx-auto max-w-4xl p-6">
<header className="mb-6">
<h1 className="flex items-center gap-2 text-2xl font-semibold tracking-tight">
<Boxes className="size-6" /> Structure
</h1>
<p className="text-sm text-muted-foreground">
The object spine: organization divisions products/services teams.
</p>
</header>
<div className="flex flex-col gap-6">
<Card>
<CardHeader>
<CardTitle className="text-base">Divisions</CardTitle>
<CardDescription>Technical, Finance, HR, Sales the top-level slices.</CardDescription>
</CardHeader>
<CardContent className="flex flex-col gap-3">
<div className="flex items-end gap-3">
<Field label="Name">
<Input value={divisionName} onChange={(e) => setDivisionName(e.target.value)} className="w-56" placeholder="Technical" />
</Field>
<Button disabled={busy || !divisionName.trim()} onClick={addDivision}>
<Plus data-icon="inline-start" />Add division
</Button>
</div>
<div className="flex flex-wrap gap-2">
{divisions.map((d) => (
<Badge key={d.id} variant="secondary">{d.name}</Badge>
))}
{divisions.length === 0 && <p className="text-sm text-muted-foreground">No divisions yet optional, but they unlock the full org chart.</p>}
</div>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle className="text-base">Products & services</CardTitle>
<CardDescription>Engineering divisions ship products; other divisions run services.</CardDescription>
</CardHeader>
<CardContent className="flex flex-col gap-3">
<div className="flex flex-wrap items-end gap-3">
<Field label="Name">
<Input value={product.name} onChange={(e) => setProduct({ ...product, name: e.target.value })} className="w-48" placeholder="IPNOPS" />
</Field>
<Field label="Kind">
<Select value={product.kind} onValueChange={(v) => setProduct({ ...product, kind: v })}>
<SelectTrigger className="w-32"><SelectValue /></SelectTrigger>
<SelectContent>
<SelectGroup>
<SelectItem value="Product">Product</SelectItem>
<SelectItem value="Service">Service</SelectItem>
</SelectGroup>
</SelectContent>
</Select>
</Field>
<Field label="Division">
<Select value={product.divisionId} onValueChange={(v) => setProduct({ ...product, divisionId: v })}>
<SelectTrigger className="w-44"><SelectValue /></SelectTrigger>
<SelectContent>
<SelectGroup>
<SelectItem value={NONE}> none </SelectItem>
{divisions.map((d) => (
<SelectItem key={d.id} value={d.id}>{d.name}</SelectItem>
))}
</SelectGroup>
</SelectContent>
</Select>
</Field>
<Button disabled={busy || !product.name.trim()} onClick={addProduct}>
<Plus data-icon="inline-start" />Add product
</Button>
</div>
<div className="flex flex-col gap-2">
{products.map((p) => (
<div key={p.id} className="flex items-center gap-2 rounded-md border px-3 py-2 text-sm">
<span className="font-medium">{p.name}</span>
<Badge variant="outline">{p.kind}</Badge>
<span className="text-muted-foreground">
{p.divisionId ? divisions.find((d) => d.id === p.divisionId)?.name ?? '' : 'no division'}
</span>
</div>
))}
{products.length === 0 && <p className="text-sm text-muted-foreground">No products yet.</p>}
</div>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle className="text-base">Teams</CardTitle>
<CardDescription>Teams run delivery. Attach them to a product to complete the spine.</CardDescription>
</CardHeader>
<CardContent className="flex flex-col gap-3">
<div className="flex flex-wrap items-end gap-3">
<Field label="Name">
<Input value={team.name} onChange={(e) => setTeam({ ...team, name: e.target.value })} className="w-48" placeholder="Core team" />
</Field>
<Field label="Product / service">
<Select value={team.productId} onValueChange={(v) => setTeam({ ...team, productId: v })}>
<SelectTrigger className="w-44"><SelectValue /></SelectTrigger>
<SelectContent>
<SelectGroup>
<SelectItem value={NONE}> none </SelectItem>
{products.map((p) => (
<SelectItem key={p.id} value={p.id}>{p.name}</SelectItem>
))}
</SelectGroup>
</SelectContent>
</Select>
</Field>
<Button disabled={busy || !team.name.trim()} onClick={addTeam}>
<Plus data-icon="inline-start" />Add team
</Button>
</div>
<div className="flex flex-col gap-2">
{teams.map((t) => (
<div key={t.id} className="flex items-center gap-2 rounded-md border px-3 py-2 text-sm">
<span className="font-medium">{t.name}</span>
<span className="text-muted-foreground">
{t.productId ? products.find((p) => p.id === t.productId)?.name ?? '' : 'directly under the org'}
</span>
</div>
))}
{teams.length === 0 && <p className="text-sm text-muted-foreground">No teams yet.</p>}
</div>
</CardContent>
</Card>
</div>
</div>
</AppShell>
)
}
function Field({ label, children }: { label: string; children: React.ReactNode }) {
return (
<div className="flex flex-col gap-1.5">
<Label className="text-xs">{label}</Label>
{children}
</div>
)
}