mirror of
https://github.com/rennf93/roboco.git
synced 2026-08-03 07:23:24 +02:00
[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:
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
},
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user