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:
@@ -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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user