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:
@@ -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,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" />
|
||||
|
||||
@@ -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 }
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
))}
|
||||
|
||||
@@ -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