Files
Teamup/client/src/pages/StructurePage.tsx
T

335 lines
13 KiB
TypeScript
Raw Normal View History

import { useCallback, useEffect, useState } from 'react'
import { Boxes, FileText, Plus } from 'lucide-react'
import { toast } from 'sonner'
import { AppShell } from '@/components/AppShell'
import { MarkdownEditor } from '@/components/MarkdownEditor'
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 { Sheet, SheetContent, SheetDescription, SheetHeader, SheetTitle } from '@/components/ui/sheet'
import { api } from '@/lib/api'
import { useAuth } from '@/store/auth'
// 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.
`
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 [identity, setIdentity] = useState<{ productId: string; name: string; content: string } | null>(null)
const [savingIdentity, setSavingIdentity] = useState(false)
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.')
})
// 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)
}
}
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>
<Button variant="ghost" size="sm" className="ml-auto" onClick={() => openIdentity(p)}>
<FileText data-icon="inline-start" /> Identity
</Button>
</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>
{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>
)}
</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>
)
}