[W9-3b] Enrich product table with cell mappings + task progress (#530)

Backend: ProductSummaryResponse gains cells: [{team, project_id, project_name}] and progress: {done, active, blocked}. ProductService.progress_for_products does one grouped query over tasks for every distinct project_id any product references, summed per product (monorepo case dedups a project once per product via a seen set). list_all eager-loads cells + each cell's project (selectinload + joinedload) so product_to_summary reads project.name without an N+1. No migration — reads existing tasks.status + product_projects.

Frontend: ProductTable renders a Cells column (team badges + project names, Unmapped when empty) and a Progress column (done/active/blocked counts + a health dot: amber at-risk when blocked>0). Both desktop Table and mobile ResponsiveTableCard variants. Mock products carry the new shape.

Co-authored-by: Renn F <rennf93@users.noreply.github.com>
This commit is contained in:
Renzo F
2026-07-15 04:34:01 +02:00
committed by GitHub
co-authored by Renn F
parent 9a364abb74
commit 86d31bf3d3
8 changed files with 444 additions and 15 deletions
@@ -0,0 +1,52 @@
import { describe, it, expect } from "vitest";
import { render, screen } from "@testing-library/react";
import { ProductTable } from "../product-table";
import { Team } from "@/types";
import type { ProductSummary } from "@/types";
const product: ProductSummary = {
id: "p1",
name: "RoboCo Platform",
slug: "roboco-platform",
cell_count: 3,
cells: [
{ team: Team.BACKEND, project_id: "proj-1", project_name: "roboco" },
{ team: Team.FRONTEND, project_id: "proj-1", project_name: "roboco" },
],
progress: { done: 42, active: 5, blocked: 1 },
};
describe("ProductTable", () => {
it("renders the product name, cells, and progress", () => {
render(<ProductTable products={[product]} isLoading={false} />);
expect(screen.getByText("RoboCo Platform")).toBeInTheDocument();
expect(screen.getByText("Backend")).toBeInTheDocument();
expect(screen.getAllByText("roboco").length).toBe(2);
expect(screen.getByText("42 done")).toBeInTheDocument();
expect(screen.getByText("1 blocked")).toBeInTheDocument();
});
it("shows the empty state when there are no products", () => {
render(<ProductTable products={[]} isLoading={false} />);
expect(screen.getByText("No products found")).toBeInTheDocument();
});
it("shows the unmapped label for a product with no cells", () => {
const bare: ProductSummary = {
id: "p2",
name: "Bare Product",
slug: "bare",
cell_count: 0,
cells: [],
progress: { done: 0, active: 0, blocked: 0 },
};
render(<ProductTable products={[bare]} isLoading={false} />);
expect(screen.getAllByText("Unmapped").length).toBeGreaterThan(0);
});
it("does not show the empty state while loading", () => {
render(<ProductTable products={undefined} isLoading={true} />);
expect(screen.queryByText("No products found")).not.toBeInTheDocument();
});
});
@@ -19,14 +19,73 @@ import {
} from "@/components/ui/responsive-table";
import { Skeleton } from "@/components/ui/skeleton";
import { Boxes, Pencil } from "lucide-react";
import type { ProductSummary } from "@/types";
import type { ProductSummary, Team } from "@/types";
import { EditProductDialog } from "./edit-product-dialog";
const TEAM_LABELS: Record<Team, string> = {
board: "Board",
main_pm: "Main PM",
backend: "Backend",
frontend: "Frontend",
ux_ui: "UX/UI",
marketing: "Marketing",
};
interface ProductTableProps {
products: ProductSummary[] | undefined;
isLoading: boolean;
}
function CellsList({ cells }: { cells: ProductSummary["cells"] }) {
if (cells.length === 0) {
return <span className="text-muted-foreground text-sm">Unmapped</span>;
}
return (
<div className="flex flex-col gap-1">
{cells.map((c) => (
<div key={`${c.team}-${c.project_id}`} className="flex items-center gap-2">
<Badge
variant="outline"
className="bg-blue-500/10 text-blue-500 text-xs"
>
{TEAM_LABELS[c.team] ?? c.team}
</Badge>
<span className="text-muted-foreground text-xs truncate">
{c.project_name || "—"}
</span>
</div>
))}
</div>
);
}
function ProgressCell({
progress,
}: {
progress: ProductSummary["progress"];
}) {
const { done, active, blocked } = progress;
const atRisk = blocked > 0;
return (
<div className="flex items-center gap-2">
<span
className={
"h-2 w-2 rounded-full " +
(atRisk ? "bg-amber-500" : done > 0 ? "bg-emerald-500" : "bg-muted")
}
title={atRisk ? "At risk: blocked tasks" : "Healthy"}
/>
<div className="flex items-center gap-2 text-xs">
<span className="text-emerald-600 dark:text-emerald-400">{done} done</span>
<span className="text-muted-foreground">{active} active</span>
{blocked > 0 && (
<span className="text-amber-600 dark:text-amber-400">{blocked} blocked</span>
)}
</div>
</div>
);
}
export function ProductTable({ products, isLoading }: ProductTableProps) {
const [editingProductId, setEditingProductId] = useState<string | null>(null);
@@ -59,7 +118,8 @@ export function ProductTable({ products, isLoading }: ProductTableProps) {
<TableHeader>
<TableRow>
<TableHead>Product</TableHead>
<TableHead>Cells Mapped</TableHead>
<TableHead>Cells</TableHead>
<TableHead>Progress</TableHead>
<TableHead className="w-[100px]">Actions</TableHead>
</TableRow>
</TableHeader>
@@ -81,9 +141,10 @@ export function ProductTable({ products, isLoading }: ProductTableProps) {
</div>
</TableCell>
<TableCell>
<Badge className="bg-blue-500/10 text-blue-500">
{product.cell_count} / 3
</Badge>
<CellsList cells={product.cells} />
</TableCell>
<TableCell>
<ProgressCell progress={product.progress} />
</TableCell>
<TableCell>
<div className="flex items-center gap-1">
@@ -131,10 +192,25 @@ export function ProductTable({ products, isLoading }: ProductTableProps) {
</Button>
</div>
<div className="mt-3 divide-y">
<ResponsiveTableCardRow label="Cells Mapped">
<Badge className="bg-blue-500/10 text-blue-500">
{product.cell_count} / 3
</Badge>
<ResponsiveTableCardRow label="Cells">
<div className="flex flex-col items-end gap-1">
{product.cells.map((c) => (
<span
key={`${c.team}-${c.project_id}`}
className="text-xs text-muted-foreground"
>
{TEAM_LABELS[c.team] ?? c.team}: {c.project_name || "—"}
</span>
))}
{product.cells.length === 0 && (
<span className="text-muted-foreground text-xs">
Unmapped
</span>
)}
</div>
</ResponsiveTableCardRow>
<ResponsiveTableCardRow label="Progress">
<ProgressCell progress={product.progress} />
</ResponsiveTableCardRow>
</div>
</ResponsiveTableCard>
+31 -1
View File
@@ -6,10 +6,40 @@ import type {
ProductSummary,
ProductUpdate,
} from "@/types";
import { Team } from "@/types";
// Mock products — shapes match the real ProductSummaryResponse (cells + progress).
function mockProducts(): ProductSummary[] {
return [
{
id: "p-mock-1",
name: "RoboCo Platform",
slug: "roboco-platform",
cell_count: 3,
cells: [
{ team: Team.BACKEND, project_id: "proj-1", project_name: "roboco" },
{ team: Team.FRONTEND, project_id: "proj-1", project_name: "roboco" },
{ team: Team.UX_UI, project_id: "proj-1", project_name: "roboco" },
],
progress: { done: 42, active: 5, blocked: 1 },
},
{
id: "p-mock-2",
name: "Docs Site",
slug: "docs-site",
cell_count: 2,
cells: [
{ team: Team.FRONTEND, project_id: "proj-2", project_name: "roboco-website" },
{ team: Team.BACKEND, project_id: "proj-3", project_name: "docs-api" },
],
progress: { done: 18, active: 3, blocked: 0 },
},
];
}
export const productsApi = {
list: async (): Promise<ProductSummary[]> => {
if (isMockMode()) return [];
if (isMockMode()) return mockProducts();
const { data } = await api.get<ProductSummary[]>("/products");
return data;
},
+14
View File
@@ -1128,11 +1128,25 @@ export interface Product {
updated_at: string | null;
}
export interface ProductCellSummary {
team: Team;
project_id: string;
project_name: string;
}
export interface ProductProgressSummary {
done: number;
active: number;
blocked: number;
}
export interface ProductSummary {
id: string;
name: string;
slug: string;
cell_count: number;
cells: ProductCellSummary[];
progress: ProductProgressSummary;
}
export interface ProductCreate {