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

261 lines
9.9 KiB
TypeScript
Raw Normal View History

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>
)
}