Delivery dashboard: per-product progress, remaining work, and quality stats

Theme 1 of the roadmap. New /delivery page shows, per product: % complete
with a progress bar, a Backlog/InProgress/InReview/Done column breakdown,
quality stats from governance analytics (approval rate, avg edit distance,
awaiting review), per-team progress bars, and the remaining-task list.
Selected product persists in localStorage. Wired into routing and the
Insights sidebar group.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
soroush.asadi
2026-06-17 00:37:50 +03:30
parent e26f304675
commit 861efa4e20
3 changed files with 231 additions and 0 deletions
+2
View File
@@ -4,6 +4,7 @@ import { AgentProfilesPage } from '@/pages/AgentProfilesPage'
import { AnalyticsPage } from '@/pages/AnalyticsPage' import { AnalyticsPage } from '@/pages/AnalyticsPage'
import { BoardPage } from '@/pages/BoardPage' import { BoardPage } from '@/pages/BoardPage'
import { CartablePage } from '@/pages/CartablePage' import { CartablePage } from '@/pages/CartablePage'
import { DeliveryPage } from '@/pages/DeliveryPage'
import { GetStartedPage } from '@/pages/GetStartedPage' import { GetStartedPage } from '@/pages/GetStartedPage'
import { KnowledgeBasePage } from '@/pages/KnowledgeBasePage' import { KnowledgeBasePage } from '@/pages/KnowledgeBasePage'
import { LoginPage } from '@/pages/LoginPage' import { LoginPage } from '@/pages/LoginPage'
@@ -31,6 +32,7 @@ export default function App() {
<Route path="/seats" element={token ? <SeatsPage /> : <Navigate to="/login" replace />} /> <Route path="/seats" element={token ? <SeatsPage /> : <Navigate to="/login" replace />} />
<Route path="/reviews" element={token ? <ReviewsPage /> : <Navigate to="/login" replace />} /> <Route path="/reviews" element={token ? <ReviewsPage /> : <Navigate to="/login" replace />} />
<Route path="/analytics" element={token ? <AnalyticsPage /> : <Navigate to="/login" replace />} /> <Route path="/analytics" element={token ? <AnalyticsPage /> : <Navigate to="/login" replace />} />
<Route path="/delivery" element={token ? <DeliveryPage /> : <Navigate to="/login" replace />} />
<Route path="/cartable" element={token ? <CartablePage /> : <Navigate to="/login" replace />} /> <Route path="/cartable" element={token ? <CartablePage /> : <Navigate to="/login" replace />} />
<Route path="/members" element={token ? <MembersPage /> : <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="/org" element={token ? <OrgChartPage /> : <Navigate to="/login" replace />} />
+2
View File
@@ -17,6 +17,7 @@ import {
Rocket, Rocket,
ShieldCheck, ShieldCheck,
Sparkles, Sparkles,
TrendingUp,
Users, Users,
} from 'lucide-react' } from 'lucide-react'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
@@ -113,6 +114,7 @@ export function AppShell({ children }: { children: ReactNode }) {
<NavItem icon={Package} label="Product profiles" to="/product-profiles" color="#a78bfa" /> <NavItem icon={Package} label="Product profiles" to="/product-profiles" color="#a78bfa" />
<NavSection label="Insights" /> <NavSection label="Insights" />
<NavItem icon={TrendingUp} label="Delivery" to="/delivery" color="#2dd4bf" />
<NavItem icon={Gauge} label="Performance" to="/performance" color="#2dd4bf" /> <NavItem icon={Gauge} label="Performance" to="/performance" color="#2dd4bf" />
<NavItem icon={ChartColumn} label="Analytics" to="/analytics" color="#2dd4bf" /> <NavItem icon={ChartColumn} label="Analytics" to="/analytics" color="#2dd4bf" />
+227
View File
@@ -0,0 +1,227 @@
import { useCallback, useEffect, useMemo, useState } from 'react'
import { Gauge } from 'lucide-react'
import { toast } from 'sonner'
import { AppShell } from '@/components/AppShell'
import {
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
import { api } from '@/lib/api'
import { useAuth } from '@/store/auth'
interface Product {
id: string
name: string
}
interface Team {
id: string
name: string
productId: string | null
}
interface Task {
id: string
title: string
status: string
type: string
}
interface Board {
columns: { status: string; items: Task[] }[]
}
interface Analytics {
approvalRate: number | null
avgEditDistance: number | null
tasksDone: number
pendingReviews: number
}
const COLUMNS = ['Backlog', 'InProgress', 'InReview', 'Done'] as const
const LABEL: Record<string, string> = { Backlog: 'Backlog', InProgress: 'In progress', InReview: 'In review', Done: 'Done' }
interface TeamProgress {
team: Team
counts: Record<string, number>
total: number
done: number
remaining: Task[]
}
export function DeliveryPage() {
const organizationId = useAuth((s) => s.organizationId)
const [products, setProducts] = useState<Product[]>([])
const [productId, setProductId] = useState<string | null>(() => localStorage.getItem('teamup.delivery.product'))
const [teams, setTeams] = useState<TeamProgress[]>([])
const [analytics, setAnalytics] = useState<Analytics | null>(null)
useEffect(() => {
if (!organizationId) return
void (async () => {
try {
const [list, an] = await Promise.all([
api.get<Product[]>(`/api/orgboard/products?organizationId=${organizationId}`),
api.get<Analytics>(`/api/governance/analytics?organizationId=${organizationId}`).catch(() => null),
])
setProducts(list)
setAnalytics(an)
setProductId((cur) => (cur && list.some((p) => p.id === cur) ? cur : list[0]?.id ?? null))
} catch (err) {
toast.error((err as Error).message)
}
})()
}, [organizationId])
const loadProduct = useCallback(async (pid: string) => {
try {
const allTeams = await api.get<Team[]>(`/api/orgboard/teams?organizationId=${organizationId}`)
const productTeams = allTeams.filter((t) => t.productId === pid)
const progress = await Promise.all(
productTeams.map(async (team) => {
const board = await api.get<Board>(`/api/orgboard/board?teamId=${team.id}`).catch(() => ({ columns: [] }))
const counts: Record<string, number> = {}
let total = 0
const remaining: Task[] = []
for (const col of board.columns) {
counts[col.status] = col.items.length
total += col.items.length
if (col.status !== 'Done') remaining.push(...col.items)
}
return { team, counts, total, done: counts.Done ?? 0, remaining }
}),
)
setTeams(progress)
} catch (err) {
toast.error((err as Error).message)
}
}, [organizationId])
useEffect(() => {
if (productId) {
localStorage.setItem('teamup.delivery.product', productId)
void loadProduct(productId)
}
}, [productId, loadProduct])
const totals = useMemo(() => {
const counts: Record<string, number> = { Backlog: 0, InProgress: 0, InReview: 0, Done: 0 }
let total = 0
for (const t of teams) {
for (const c of COLUMNS) counts[c] += t.counts[c] ?? 0
total += t.total
}
const done = counts.Done
const pct = total > 0 ? Math.round((done / total) * 100) : 0
return { counts, total, done, pct, remaining: total - done }
}, [teams])
const product = products.find((p) => p.id === productId) ?? null
return (
<AppShell>
<div className="mx-auto max-w-4xl p-6">
<header className="mb-5 flex items-center justify-between gap-4">
<h1 className="flex items-center gap-2 text-2xl font-semibold tracking-tight">
<Gauge className="size-6" /> Delivery
</h1>
{products.length > 0 && (
<Select value={productId ?? ''} onValueChange={setProductId}>
<SelectTrigger className="w-56"><SelectValue placeholder="Pick a product" /></SelectTrigger>
<SelectContent>
<SelectGroup>
{products.map((p) => <SelectItem key={p.id} value={p.id}>{p.name}</SelectItem>)}
</SelectGroup>
</SelectContent>
</Select>
)}
</header>
{product && (
<>
{/* Progress hero */}
<div className="mb-5 rounded-xl border bg-card/60 p-5 backdrop-blur-sm">
<div className="flex items-end justify-between gap-4">
<div>
<p className="text-sm text-muted-foreground">{product.name}</p>
<p className="text-3xl font-semibold">{totals.pct}% complete</p>
<p className="text-sm text-muted-foreground">
{totals.done} of {totals.total} tasks done · {totals.remaining} remaining
</p>
</div>
</div>
<div className="mt-4 h-3 w-full overflow-hidden rounded-full bg-muted/60">
<div className="h-full rounded-full bg-primary transition-all" style={{ width: `${totals.pct}%` }} />
</div>
</div>
{/* Column breakdown */}
<div className="mb-5 grid grid-cols-2 gap-3 sm:grid-cols-4">
{COLUMNS.map((c) => (
<div key={c} className="rounded-lg border bg-card/50 p-3 backdrop-blur-sm">
<p className="text-xs text-muted-foreground">{LABEL[c]}</p>
<p className="text-2xl font-semibold">{totals.counts[c]}</p>
</div>
))}
</div>
{/* Quality (from analytics) */}
{analytics && (
<div className="mb-5 grid grid-cols-2 gap-3 sm:grid-cols-3">
<Stat label="Approval rate" value={analytics.approvalRate == null ? '—' : `${Math.round(analytics.approvalRate * 100)}%`} />
<Stat label="Avg edit distance" value={analytics.avgEditDistance == null ? '—' : analytics.avgEditDistance.toFixed(2)} />
<Stat label="Awaiting review" value={String(analytics.pendingReviews)} />
</div>
)}
{/* Per team */}
<h2 className="mb-2 text-xs font-semibold uppercase tracking-wide text-muted-foreground">Teams</h2>
<div className="mb-6 flex flex-col gap-2">
{teams.map((t) => {
const pct = t.total > 0 ? Math.round((t.done / t.total) * 100) : 0
return (
<div key={t.team.id} className="flex items-center gap-3 rounded-lg border bg-card/50 px-3 py-2 backdrop-blur-sm">
<span className="w-40 shrink-0 truncate text-sm font-medium">{t.team.name}</span>
<div className="h-2 flex-1 overflow-hidden rounded-full bg-muted/60">
<div className="h-full rounded-full bg-approved" style={{ width: `${pct}%` }} />
</div>
<span className="w-24 shrink-0 text-right text-xs text-muted-foreground">{t.done}/{t.total} · {pct}%</span>
</div>
)
})}
{teams.length === 0 && <p className="text-sm text-muted-foreground">No teams on this product yet.</p>}
</div>
{/* Remaining */}
<h2 className="mb-2 text-xs font-semibold uppercase tracking-wide text-muted-foreground">
Remaining ({totals.remaining})
</h2>
<div className="flex flex-col gap-1.5">
{teams.flatMap((t) => t.remaining.map((task) => ({ task, team: t.team.name }))).slice(0, 40).map(({ task, team }) => (
<div key={task.id} className="flex items-center gap-2 rounded-md border bg-card/40 px-3 py-1.5 text-sm backdrop-blur-sm">
<span className="rounded bg-muted px-1.5 py-0.5 text-[10px] uppercase text-muted-foreground">{LABEL[task.status] ?? task.status}</span>
<span className="min-w-0 flex-1 truncate">{task.title}</span>
<span className="shrink-0 text-xs text-muted-foreground">{team}</span>
</div>
))}
{totals.remaining === 0 && <p className="text-sm text-muted-foreground">🎉 Nothing remaining everything is done.</p>}
</div>
</>
)}
</div>
</AppShell>
)
}
function Stat({ label, value }: { label: string; value: string }) {
return (
<div className="rounded-lg border bg-card/50 p-3 backdrop-blur-sm">
<p className="text-xs text-muted-foreground">{label}</p>
<p className="text-2xl font-semibold">{value}</p>
</div>
)
}