From 861efa4e20bb4bfd1866fdc36a8faa9fc907eb97 Mon Sep 17 00:00:00 2001 From: "soroush.asadi" Date: Wed, 17 Jun 2026 00:37:50 +0330 Subject: [PATCH] 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 --- client/src/App.tsx | 2 + client/src/components/AppShell.tsx | 2 + client/src/pages/DeliveryPage.tsx | 227 +++++++++++++++++++++++++++++ 3 files changed, 231 insertions(+) create mode 100644 client/src/pages/DeliveryPage.tsx diff --git a/client/src/App.tsx b/client/src/App.tsx index 3db0d47..0aa57ff 100644 --- a/client/src/App.tsx +++ b/client/src/App.tsx @@ -4,6 +4,7 @@ import { AgentProfilesPage } from '@/pages/AgentProfilesPage' import { AnalyticsPage } from '@/pages/AnalyticsPage' import { BoardPage } from '@/pages/BoardPage' import { CartablePage } from '@/pages/CartablePage' +import { DeliveryPage } from '@/pages/DeliveryPage' import { GetStartedPage } from '@/pages/GetStartedPage' import { KnowledgeBasePage } from '@/pages/KnowledgeBasePage' import { LoginPage } from '@/pages/LoginPage' @@ -31,6 +32,7 @@ export default function App() { : } /> : } /> : } /> + : } /> : } /> : } /> : } /> diff --git a/client/src/components/AppShell.tsx b/client/src/components/AppShell.tsx index 8529066..ab209cc 100644 --- a/client/src/components/AppShell.tsx +++ b/client/src/components/AppShell.tsx @@ -17,6 +17,7 @@ import { Rocket, ShieldCheck, Sparkles, + TrendingUp, Users, } from 'lucide-react' import { Button } from '@/components/ui/button' @@ -113,6 +114,7 @@ export function AppShell({ children }: { children: ReactNode }) { + diff --git a/client/src/pages/DeliveryPage.tsx b/client/src/pages/DeliveryPage.tsx new file mode 100644 index 0000000..4ef3589 --- /dev/null +++ b/client/src/pages/DeliveryPage.tsx @@ -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 = { Backlog: 'Backlog', InProgress: 'In progress', InReview: 'In review', Done: 'Done' } + +interface TeamProgress { + team: Team + counts: Record + total: number + done: number + remaining: Task[] +} + +export function DeliveryPage() { + const organizationId = useAuth((s) => s.organizationId) + const [products, setProducts] = useState([]) + const [productId, setProductId] = useState(() => localStorage.getItem('teamup.delivery.product')) + const [teams, setTeams] = useState([]) + const [analytics, setAnalytics] = useState(null) + + useEffect(() => { + if (!organizationId) return + void (async () => { + try { + const [list, an] = await Promise.all([ + api.get(`/api/orgboard/products?organizationId=${organizationId}`), + api.get(`/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(`/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(`/api/orgboard/board?teamId=${team.id}`).catch(() => ({ columns: [] })) + const counts: Record = {} + 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 = { 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 ( + +
+
+

+ Delivery +

+ {products.length > 0 && ( + + )} +
+ + {product && ( + <> + {/* Progress hero */} +
+
+
+

{product.name}

+

{totals.pct}% complete

+

+ {totals.done} of {totals.total} tasks done ยท {totals.remaining} remaining +

+
+
+
+
+
+
+ + {/* Column breakdown */} +
+ {COLUMNS.map((c) => ( +
+

{LABEL[c]}

+

{totals.counts[c]}

+
+ ))} +
+ + {/* Quality (from analytics) */} + {analytics && ( +
+ + + +
+ )} + + {/* Per team */} +

Teams

+
+ {teams.map((t) => { + const pct = t.total > 0 ? Math.round((t.done / t.total) * 100) : 0 + return ( +
+ {t.team.name} +
+
+
+ {t.done}/{t.total} ยท {pct}% +
+ ) + })} + {teams.length === 0 &&

No teams on this product yet.

} +
+ + {/* Remaining */} +

+ Remaining ({totals.remaining}) +

+
+ {teams.flatMap((t) => t.remaining.map((task) => ({ task, team: t.team.name }))).slice(0, 40).map(({ task, team }) => ( +
+ {LABEL[task.status] ?? task.status} + {task.title} + {team} +
+ ))} + {totals.remaining === 0 &&

๐ŸŽ‰ Nothing remaining โ€” everything is done.

} +
+ + )} +
+ + ) +} + +function Stat({ label, value }: { label: string; value: string }) { + return ( +
+

{label}

+

{value}

+
+ ) +}