261 lines
9.9 KiB
TypeScript
261 lines
9.9 KiB
TypeScript
|
|
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>
|
||
|
|
)
|
||
|
|
}
|