2026-06-10 18:13:52 +03:30
|
|
|
import { useCallback, useEffect, useState } from 'react'
|
2026-06-15 18:16:59 +03:30
|
|
|
import { Boxes, FileText, Plus } from 'lucide-react'
|
2026-06-10 18:13:52 +03:30
|
|
|
import { toast } from 'sonner'
|
|
|
|
|
import { AppShell } from '@/components/AppShell'
|
2026-06-15 18:16:59 +03:30
|
|
|
import { MarkdownEditor } from '@/components/MarkdownEditor'
|
2026-06-10 18:13:52 +03:30
|
|
|
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'
|
2026-06-15 18:16:59 +03:30
|
|
|
import { Sheet, SheetContent, SheetDescription, SheetHeader, SheetTitle } from '@/components/ui/sheet'
|
2026-06-10 18:13:52 +03:30
|
|
|
import { api } from '@/lib/api'
|
|
|
|
|
import { useAuth } from '@/store/auth'
|
|
|
|
|
|
2026-06-15 18:16:59 +03:30
|
|
|
// A starter PRODUCT.md so an empty product gets useful structure to fill in.
|
|
|
|
|
const IDENTITY_TEMPLATE = (name: string) =>
|
|
|
|
|
`---
|
|
|
|
|
product: ${name}
|
|
|
|
|
goals:
|
|
|
|
|
domain:
|
|
|
|
|
conventions:
|
|
|
|
|
glossary:
|
|
|
|
|
---
|
|
|
|
|
|
|
|
|
|
# About ${name}
|
|
|
|
|
|
|
|
|
|
Describe what this product is, who it serves, and the conventions every agent on it should follow.
|
|
|
|
|
This identity is shared by every agent across the product's teams.
|
|
|
|
|
`
|
|
|
|
|
|
2026-06-10 18:13:52 +03:30
|
|
|
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 })
|
2026-06-15 18:16:59 +03:30
|
|
|
const [identity, setIdentity] = useState<{ productId: string; name: string; content: string } | null>(null)
|
|
|
|
|
const [savingIdentity, setSavingIdentity] = useState(false)
|
2026-06-10 18:13:52 +03:30
|
|
|
|
|
|
|
|
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.')
|
|
|
|
|
})
|
|
|
|
|
|
2026-06-15 18:16:59 +03:30
|
|
|
// Open the product's shared identity (PRODUCT.md) — load current text, or start from the template.
|
|
|
|
|
const openIdentity = async (p: Product) => {
|
|
|
|
|
try {
|
|
|
|
|
const current = await api.get<{ identity: string | null }>(`/api/orgboard/products/${p.id}/identity`)
|
|
|
|
|
setIdentity({ productId: p.id, name: p.name, content: current.identity ?? IDENTITY_TEMPLATE(p.name) })
|
|
|
|
|
} catch (err) {
|
|
|
|
|
toast.error((err as Error).message)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const saveIdentity = async () => {
|
|
|
|
|
if (!identity) return
|
|
|
|
|
setSavingIdentity(true)
|
|
|
|
|
try {
|
|
|
|
|
await api.put(`/api/orgboard/products/${identity.productId}/identity`, { identity: identity.content })
|
|
|
|
|
toast.success(`Identity saved for ${identity.name} — every agent on it now shares it.`)
|
|
|
|
|
setIdentity(null)
|
|
|
|
|
} catch (err) {
|
|
|
|
|
toast.error((err as Error).message)
|
|
|
|
|
} finally {
|
|
|
|
|
setSavingIdentity(false)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-10 18:13:52 +03:30
|
|
|
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>
|
2026-06-15 18:16:59 +03:30
|
|
|
<Button variant="ghost" size="sm" className="ml-auto" onClick={() => openIdentity(p)}>
|
|
|
|
|
<FileText data-icon="inline-start" /> Identity
|
|
|
|
|
</Button>
|
2026-06-10 18:13:52 +03:30
|
|
|
</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>
|
2026-06-15 18:16:59 +03:30
|
|
|
|
|
|
|
|
{identity && (
|
|
|
|
|
<Sheet open onOpenChange={(o) => !o && setIdentity(null)}>
|
|
|
|
|
<SheetContent className="flex w-full flex-col gap-0 overflow-y-auto sm:max-w-2xl">
|
|
|
|
|
<SheetHeader>
|
|
|
|
|
<SheetTitle>Product identity — {identity.name}</SheetTitle>
|
|
|
|
|
<SheetDescription>
|
|
|
|
|
A shared PRODUCT.md (goals, domain, conventions) injected into every agent run on this
|
|
|
|
|
product, across all its teams. Treated as data, never as instructions.
|
|
|
|
|
</SheetDescription>
|
|
|
|
|
</SheetHeader>
|
|
|
|
|
<div className="flex flex-col gap-4 px-4 pb-6">
|
|
|
|
|
<MarkdownEditor
|
|
|
|
|
rows={22}
|
|
|
|
|
mono
|
|
|
|
|
frontmatter
|
|
|
|
|
value={identity.content}
|
|
|
|
|
onChange={(content) => setIdentity({ ...identity, content })}
|
|
|
|
|
/>
|
|
|
|
|
<div className="flex items-center justify-end gap-2">
|
|
|
|
|
<Button variant="ghost" onClick={() => setIdentity(null)}>Cancel</Button>
|
|
|
|
|
<Button disabled={savingIdentity} onClick={saveIdentity}>Save identity</Button>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
</SheetContent>
|
|
|
|
|
</Sheet>
|
|
|
|
|
)}
|
2026-06-10 18:13:52 +03:30
|
|
|
</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>
|
|
|
|
|
)
|
|
|
|
|
}
|