From e9f8fffdccebd634ac65ea6beb02039146b1bd45 Mon Sep 17 00:00:00 2001 From: DJP Date: Tue, 21 Apr 2026 12:28:11 -0400 Subject: [PATCH] Fix board stage columns + add board view to Deliverables MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two related bugs + one feature the user flagged: 1. **dominantStage picked Canceled for every fresh project** The old logic included BLOCKED in "in-flight" and picked the highest-order match. Dow's pipeline puts "On Hold" (order 10) and "Canceled" (order 11) as parking stages with NO prereqs — so on fresh deliverables they start as NOT_STARTED, which made "Canceled" the highest-order in-flight stage for every deliverable. Result: board view only rendered a Canceled column. Fix — same two-step pick in both project-service.ts and deliverables/page.tsx currentStage(): 1) highest-order ACTIVE stage (IN_PROGRESS/IN_REVIEW/CHANGES_REQUESTED) 2) else lowest-order NOT_STARTED (next-up) BLOCKED is skipped entirely — it means "prereqs not done", not "where work is". The lowest-order NOT_STARTED rule naturally keeps parking stages out of the dominant pick unless they're actually being worked. 2. **Board hid empty stage columns** In stage-grouped mode the board only rendered columns for stages seen in the data, so when the dominantStage bug bucketed everything into Canceled, all other columns disappeared. ProjectBoard + the new DeliverableBoard now accept a pipelineStages prop (from the default pipeline template) and render every stage as a column in canonical order, empty or not. 3. **Deliverables page: Board view** New component src/components/deliverables/deliverable-board.tsx. Grid/Board toggle in the header, group-by selector (Stage/Status) next to the filters. Cards show OMG #, priority dot, team, name, project, primary assignee, deadline. Clicking a card navigates to the deliverable detail page. --- src/app/(app)/deliverables/page.tsx | 150 ++++++++-- src/app/(app)/projects/page.tsx | 23 +- .../deliverables/deliverable-board.tsx | 260 ++++++++++++++++++ src/components/projects/project-board.tsx | 61 +++- src/lib/services/project-service.ts | 59 ++-- 5 files changed, 496 insertions(+), 57 deletions(-) create mode 100644 src/components/deliverables/deliverable-board.tsx diff --git a/src/app/(app)/deliverables/page.tsx b/src/app/(app)/deliverables/page.tsx index 243aeda..3bf2d15 100644 --- a/src/app/(app)/deliverables/page.tsx +++ b/src/app/(app)/deliverables/page.tsx @@ -13,7 +13,7 @@ import { useMemo, useState } from "react"; import Link from "next/link"; import { format, parseISO } from "date-fns"; -import { ClipboardList, Search, X } from "lucide-react"; +import { ClipboardList, Search, X, LayoutGrid, Columns3 } from "lucide-react"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { Skeleton } from "@/components/ui/skeleton"; @@ -27,18 +27,23 @@ import { } from "@/components/ui/select"; import { cn } from "@/lib/utils"; import { useAllDeliverables, type AllDeliverableRow } from "@/hooks/use-deliverables"; +import { + DeliverableBoard, + type DeliverableBoardGroupBy, +} from "@/components/deliverables/deliverable-board"; +import { usePipelineTemplates } from "@/hooks/use-pipelines"; type SortKey = "name" | "project" | "stage" | "dueDate" | "priority" | "status"; type SortDir = "asc" | "desc"; -// Stages considered "still in flight" — mirrors listProjects pipelineProgress -// logic so "Current stage" in this view matches the Projects grid. -const IN_FLIGHT = new Set([ - "NOT_STARTED", +// "Actively worked" = stages that actually have work happening on them. +// BLOCKED is *not* here: a BLOCKED stage is waiting for prereqs, not +// being worked. NOT_STARTED is handled separately — it's the next thing +// queued to start, not current work. +const ACTIVE_STATUSES = new Set([ "IN_PROGRESS", "IN_REVIEW", "CHANGES_REQUESTED", - "BLOCKED", ]); const DELIVERABLE_STATUSES = [ @@ -58,30 +63,40 @@ const PRIORITY_COLORS: Record = { LOW: "bg-gray-100 text-gray-700 border-gray-200 dark:bg-gray-800 dark:text-gray-300 dark:border-gray-700", }; -function currentStage(row: AllDeliverableRow) { - let best: { order: number; name: string; slug: string } | null = null; +export function currentStage(row: AllDeliverableRow) { + // Same two-step pick as project-service.ts dominantStage: + // 1. Highest-order ACTIVE stage (IN_PROGRESS/IN_REVIEW/CHANGES_REQUESTED) + // 2. Fallback to lowest-order NOT_STARTED (next queued) + // BLOCKED is skipped — not reached yet. Parking stages (On Hold / + // Canceled in Dow's template) have no prereqs so they start as + // NOT_STARTED, but their high order means the LOWEST-order NOT_STARTED + // rule naturally picks the real first stage (Pipeline) instead. + let bestActive: { order: number; name: string; slug: string } | null = null; + let bestUpcoming: { order: number; name: string; slug: string } | null = null; + for (const s of row.stages) { - if (!IN_FLIGHT.has(s.status)) continue; const order = s.stageDefinition?.order ?? s.template?.order ?? -1; - if (!best || order > best.order) { - best = { - order, - name: s.stageDefinition?.name ?? "—", - slug: s.stageDefinition?.slug ?? "", - }; + const meta = { + order, + name: s.stageDefinition?.name ?? "—", + slug: s.stageDefinition?.slug ?? "", + }; + if (ACTIVE_STATUSES.has(s.status)) { + if (!bestActive || order > bestActive.order) bestActive = meta; + } else if (s.status === "NOT_STARTED") { + if (!bestUpcoming || order < bestUpcoming.order) bestUpcoming = meta; } } - return best; + return bestActive ?? bestUpcoming; } function primaryAssignee(row: AllDeliverableRow): string | null { - // First assignment on the current (in-flight) stage if any, else first - // assignment on any stage. Keeps the column legible when stages have - // different assignees across the pipeline. - const inFlight = row.stages.find( - (s) => IN_FLIGHT.has(s.status) && s.assignments.length > 0 + // Prefer an assignee on the actively-worked stage; fall back to any + // assignment so the column still has something meaningful. + const active = row.stages.find( + (s) => ACTIVE_STATUSES.has(s.status) && s.assignments.length > 0 ); - if (inFlight) return inFlight.assignments[0].user.name ?? inFlight.assignments[0].user.email; + if (active) return active.assignments[0].user.name ?? active.assignments[0].user.email; for (const s of row.stages) { if (s.assignments.length > 0) { @@ -102,6 +117,31 @@ export default function DeliverablesPage() { const [teamFilter, setTeamFilter] = useState("__all__"); const [sortKey, setSortKey] = useState("dueDate"); const [sortDir, setSortDir] = useState("asc"); + // Grid/Board toggle — board groups by stage by default (the most + // useful lens for deliverables, since each has its own stage). + const [view, setView] = useState<"grid" | "board">("grid"); + const [boardGroupBy, setBoardGroupBy] = useState("stage"); + + // Pipeline stages drive board columns so the full workflow shows even + // when a column has no deliverables in it yet. + const { data: pipelineTemplates } = usePipelineTemplates() as { + data: Array<{ + id: string; + name: string; + isDefault: boolean; + stages: Array<{ slug: string; name: string; order: number; color?: string | null }>; + }> | undefined; + }; + const defaultPipelineStages = useMemo(() => { + if (!pipelineTemplates || pipelineTemplates.length === 0) return []; + const def = pipelineTemplates.find((t) => t.isDefault) ?? pipelineTemplates[0]; + return (def?.stages ?? []).map((s) => ({ + slug: s.slug, + name: s.name, + order: s.order, + color: s.color ?? null, + })); + }, [pipelineTemplates]); const rows = data ?? []; @@ -220,13 +260,39 @@ export default function DeliverablesPage() { return (
{/* Header */} -
- -
-

Deliverables

-

- Every deliverable across every project you have access to. -

+
+
+ +
+

Deliverables

+

+ Every deliverable across every project you have access to. +

+
+
+
+ {/* Grid ↔ Board. Board shows a grouping selector in the filter + bar (by stage or by status). */} +
+ + +
@@ -273,6 +339,21 @@ export default function DeliverablesPage() { options={PRIORITIES.map((p) => ({ value: p, label: p }))} /> + {view === "board" && ( + + )} + {anyFilterActive && (
+ {/* Board view */} + {view === "board" && ( + + )} + {/* Table */} + {view === "grid" && (
@@ -392,6 +483,7 @@ export default function DeliverablesPage() {
+ )}
); } diff --git a/src/app/(app)/projects/page.tsx b/src/app/(app)/projects/page.tsx index 23dff7d..6773594 100644 --- a/src/app/(app)/projects/page.tsx +++ b/src/app/(app)/projects/page.tsx @@ -30,6 +30,7 @@ import { import { Skeleton } from "@/components/ui/skeleton"; import { useProjects, useCreateProject, useDeleteProject } from "@/hooks/use-projects"; import { useClientTeams } from "@/hooks/use-client-teams"; +import { usePipelineTemplates } from "@/hooks/use-pipelines"; import { ProjectFormDialog } from "@/components/projects/project-form-dialog"; import { ProjectBoard, type BoardGroupBy } from "@/components/projects/project-board"; import { BulkImportDialog } from "@/components/excel/bulk-import-dialog"; @@ -116,6 +117,22 @@ export default function ProjectsPage() { isLoading: boolean; }; const { data: clientTeams } = useClientTeams(); + // Pipeline templates — used to drive board columns in stage-grouped view + // so the full workflow is visible (not just stages that happen to have + // projects in them right now). + const { data: pipelineTemplates } = usePipelineTemplates() as { + data: Array<{ id: string; name: string; isDefault: boolean; stages: Array<{ slug: string; name: string; order: number; color?: string | null }> }> | undefined; + }; + const defaultPipelineStages = useMemo(() => { + if (!pipelineTemplates || pipelineTemplates.length === 0) return []; + const def = pipelineTemplates.find((t) => t.isDefault) ?? pipelineTemplates[0]; + return (def?.stages ?? []).map((s) => ({ + slug: s.slug, + name: s.name, + order: s.order, + color: s.color ?? null, + })); + }, [pipelineTemplates]); const createProject = useCreateProject(); const deleteProject = useDeleteProject(); @@ -339,7 +356,11 @@ export default function ProjectsPage() { {/* Board view (Kanban columns) */} {view === "board" && ( - + )} {/* Excel-grid table */} diff --git a/src/components/deliverables/deliverable-board.tsx b/src/components/deliverables/deliverable-board.tsx new file mode 100644 index 0000000..5bbb7ca --- /dev/null +++ b/src/components/deliverables/deliverable-board.tsx @@ -0,0 +1,260 @@ +"use client"; + +/** + * Kanban board for deliverables. + * + * Unlike the Projects board (which groups projects by a *derived* dominant + * stage), a deliverable has its OWN stage — so the board truly reflects + * where each deliverable is in the pipeline. + * + * Two grouping modes: + * - `stage` — columns = pipeline stages, deliverables bucketed by + * their "current stage" (see currentStage() helper in + * the deliverables page) + * - `status` — columns = DeliverableStatus (NOT_STARTED / IN_PROGRESS + * / IN_REVIEW / APPROVED / ON_HOLD) + * + * Read-only for now. Dragging a deliverable to a new column on the stage + * lens would require deciding which stage transition to fire (it could + * be "advance to next stage" or "jump to stage X"); we'll wire that up + * when the UX is nailed down. + */ + +import Link from "next/link"; +import { format, parseISO } from "date-fns"; +import { cn } from "@/lib/utils"; +import { Badge } from "@/components/ui/badge"; +import type { AllDeliverableRow } from "@/hooks/use-deliverables"; +import { currentStage } from "@/app/(app)/deliverables/page"; + +export type DeliverableBoardGroupBy = "stage" | "status"; + +export interface BoardPipelineStage { + slug: string; + name: string; + order: number; + color?: string | null; +} + +interface DeliverableBoardProps { + deliverables: AllDeliverableRow[]; + groupBy: DeliverableBoardGroupBy; + pipelineStages?: BoardPipelineStage[]; +} + +const STATUS_COLUMNS: Array<{ key: string; label: string; accent: string }> = [ + { key: "NOT_STARTED", label: "Not Started", accent: "#6B7280" }, + { key: "IN_PROGRESS", label: "In Progress", accent: "#2563EB" }, + { key: "IN_REVIEW", label: "In Review", accent: "#D97706" }, + { key: "APPROVED", label: "Approved", accent: "#16A34A" }, + { key: "ON_HOLD", label: "On Hold", accent: "#9CA3AF" }, +]; + +const PRIORITY_DOT: Record = { + URGENT: "bg-red-500", + HIGH: "bg-orange-500", + MEDIUM: "bg-blue-500", + LOW: "bg-gray-400", +}; + +export function DeliverableBoard({ + deliverables, + groupBy, + pipelineStages, +}: DeliverableBoardProps) { + const columns = computeColumns(deliverables, groupBy, pipelineStages); + + if (columns.length === 0) { + return ( +
+ No deliverables to display. +
+ ); + } + + return ( +
+ {columns.map((col) => ( +
+
+
+ + + {col.label} + +
+ + {col.deliverables.length} + +
+ +
+ {col.deliverables.length === 0 ? ( +
+ — +
+ ) : ( + col.deliverables.map((d) => ( + + )) + )} +
+
+ ))} +
+ ); +} + +// ─── Helpers ────────────────────────────────────────────────── + +function computeColumns( + deliverables: AllDeliverableRow[], + groupBy: DeliverableBoardGroupBy, + pipelineStages?: BoardPipelineStage[] +): Array<{ key: string; label: string; accent: string; deliverables: AllDeliverableRow[] }> { + if (groupBy === "status") { + return STATUS_COLUMNS.map((c) => ({ + ...c, + deliverables: deliverables.filter((d) => d.status === c.key), + })); + } + + // Stage grouping — prefer the canonical pipeline list so empty stages + // still render. Fall back to "stages observed in the data". + if (pipelineStages && pipelineStages.length > 0) { + const cols = pipelineStages.map((s) => ({ + key: s.slug, + label: s.name, + accent: s.color || "#2563EB", + deliverables: [] as AllDeliverableRow[], + })); + const noStage = { + key: "__none__", + label: "No active stage", + accent: "#9CA3AF", + deliverables: [] as AllDeliverableRow[], + }; + const bySlug = new Map(cols.map((c) => [c.key, c])); + for (const d of deliverables) { + const cs = currentStage(d); + if (!cs) { + noStage.deliverables.push(d); + continue; + } + const col = bySlug.get(cs.slug); + if (col) col.deliverables.push(d); + else noStage.deliverables.push(d); + } + const result = [...cols]; + if (noStage.deliverables.length > 0) result.push(noStage); + return result; + } + + // Data-derived columns. + const byKey = new Map< + string, + { key: string; label: string; accent: string; order: number; deliverables: AllDeliverableRow[] } + >(); + const NO_STAGE = { + key: "__none__", + label: "No active stage", + accent: "#9CA3AF", + order: 9999, + }; + for (const d of deliverables) { + const cs = currentStage(d); + if (!cs) { + if (!byKey.has(NO_STAGE.key)) { + byKey.set(NO_STAGE.key, { ...NO_STAGE, deliverables: [] }); + } + byKey.get(NO_STAGE.key)!.deliverables.push(d); + continue; + } + if (!byKey.has(cs.slug)) { + byKey.set(cs.slug, { + key: cs.slug, + label: cs.name, + accent: "#2563EB", + order: cs.order, + deliverables: [], + }); + } + byKey.get(cs.slug)!.deliverables.push(d); + } + return Array.from(byKey.values()).sort((a, b) => a.order - b.order); +} + +function DeliverableCard({ deliverable: d }: { deliverable: AllDeliverableRow }) { + const due = d.dueDate ? parseISO(d.dueDate) : null; + const overdue = due && due.getTime() < Date.now() && d.status !== "APPROVED"; + + // Primary assignee — same pick rule as the table view + const primary = (() => { + const active = d.stages.find( + (s) => + ["IN_PROGRESS", "IN_REVIEW", "CHANGES_REQUESTED"].includes(s.status) && + s.assignments.length > 0 + ); + if (active) return active.assignments[0].user.name ?? active.assignments[0].user.email; + for (const s of d.stages) { + if (s.assignments.length > 0) { + return s.assignments[0].user.name ?? s.assignments[0].user.email; + } + } + return null; + })(); + + return ( + + {/* Top row: OMG #, priority dot, team */} +
+ {d.project.omgJobNumber && ( + + #{d.project.omgJobNumber} + + )} + + {d.project.clientTeam && ( + + {d.project.clientTeam.name} + + )} +
+ + {/* Deliverable name */} +
{d.name}
+ + {/* Project name (smaller, muted) */} +
+ {d.project.name} +
+ + {/* Assignee + deadline */} +
+ + {primary ?? Unassigned} + + {due && ( + + {format(due, "MMM d")} + + )} +
+ + ); +} diff --git a/src/components/projects/project-board.tsx b/src/components/projects/project-board.tsx index 76944c6..eb204ba 100644 --- a/src/components/projects/project-board.tsx +++ b/src/components/projects/project-board.tsx @@ -49,9 +49,22 @@ interface BoardProject { export type BoardGroupBy = "status" | "stage"; +export interface BoardPipelineStage { + slug: string; + name: string; + order: number; + color?: string | null; +} + interface ProjectBoardProps { projects: BoardProject[]; groupBy: BoardGroupBy; + /** + * Canonical list of pipeline stages — drives column order + ensures + * empty-stage columns render in stage-grouped view. When absent, the + * board falls back to "only stages that appear in the data". + */ + pipelineStages?: BoardPipelineStage[]; } // Canonical column order for the status lens — matches ProjectStatus enum @@ -73,8 +86,8 @@ const PRIORITY_DOT: Record = { LOW: "bg-gray-400", }; -export function ProjectBoard({ projects, groupBy }: ProjectBoardProps) { - const columns = computeColumns(projects, groupBy); +export function ProjectBoard({ projects, groupBy, pipelineStages }: ProjectBoardProps) { + const columns = computeColumns(projects, groupBy, pipelineStages); if (columns.length === 0) { return ( @@ -127,7 +140,8 @@ export function ProjectBoard({ projects, groupBy }: ProjectBoardProps) { function computeColumns( projects: BoardProject[], - groupBy: BoardGroupBy + groupBy: BoardGroupBy, + pipelineStages?: BoardPipelineStage[] ): Array<{ key: string; label: string; accent: string; projects: BoardProject[] }> { if (groupBy === "status") { // Fixed canonical columns — always show all status buckets, even empty @@ -138,15 +152,46 @@ function computeColumns( })); } - // Pipeline-stage columns are derived from whatever stages actually appear - // in the data. Order by dominantStage.order so columns flow Pipeline → - // Completed even with a custom stage set. + // Stage-grouped view. + // If the caller provided the canonical stage list (pipeline template), + // render every stage as a column in pipeline order — even empty ones — + // so the full workflow is always visible. Otherwise fall back to + // "stages seen in the data". + if (pipelineStages && pipelineStages.length > 0) { + const cols = pipelineStages.map((s) => ({ + key: s.slug, + label: s.name, + accent: s.color || "#2563EB", + projects: [] as BoardProject[], + })); + const noStageCol = { + key: "__none__", + label: "No active stage", + accent: "#9CA3AF", + projects: [] as BoardProject[], + }; + const bySlug = new Map(cols.map((c) => [c.key, c])); + for (const p of projects) { + const ds = p.pipelineProgress?.dominantStage; + if (!ds) { + noStageCol.projects.push(p); + continue; + } + const col = bySlug.get(ds.slug); + if (col) col.projects.push(p); + else noStageCol.projects.push(p); + } + const result = [...cols]; + if (noStageCol.projects.length > 0) result.push(noStageCol); + return result; + } + + // No template provided — derive columns from the data. Stage columns + // ordered by dominantStage.order. const byKey = new Map< string, { key: string; label: string; accent: string; order: number; projects: BoardProject[] } >(); - - // Bucket for projects with no active deliverables / no dominant stage const NO_STAGE = { key: "__none__", label: "No active stage", accent: "#9CA3AF", order: 9999 }; for (const p of projects) { diff --git a/src/lib/services/project-service.ts b/src/lib/services/project-service.ts index 8ee10ce..45a217f 100644 --- a/src/lib/services/project-service.ts +++ b/src/lib/services/project-service.ts @@ -44,15 +44,27 @@ export async function listProjects(organizationId: string, ctx: VisibilityContex const deliverables = p.deliverables ?? []; const stages = p.pipelineTemplate?.stages ?? []; - // Count deliverables whose FURTHEST IN-FLIGHT stage is each stage. - // For each deliverable, find the highest-order stage that isn't yet - // APPROVED/DELIVERED/SKIPPED — that's where that deliverable "lives". - const inFlight = new Set([ - "NOT_STARTED", + // Where does this deliverable actually "live" right now? + // + // Two-step pick: + // 1. If any stage is actively being worked (IN_PROGRESS, IN_REVIEW, + // CHANGES_REQUESTED), take the highest-order one — that's the + // furthest-along live stage. + // 2. Otherwise fall back to the LOWEST-order NOT_STARTED stage — + // the next stage queued to start. + // + // BLOCKED is explicitly excluded because a BLOCKED stage hasn't been + // reached yet (its prereqs aren't done). That was the old bug: Dow's + // On Hold / Canceled stages have no prereqs and therefore start as + // NOT_STARTED (not BLOCKED) on fresh deliverables, so the old + // "highest-order in-flight wins" logic always picked Canceled (order + // 11) for fresh work. Preferring ACTIVE over NOT_STARTED and, for + // NOT_STARTED, the lowest order, keeps parking stages from hijacking + // the calc unless they're actually being worked. + const ACTIVE_STATUSES = new Set([ "IN_PROGRESS", "IN_REVIEW", "CHANGES_REQUESTED", - "BLOCKED", ]); const stageOrder = new Map(stages.map((s) => [s.id, s.order] as const)); const stageMeta = new Map(stages.map((s) => [s.id, s] as const)); @@ -60,27 +72,36 @@ export async function listProjects(organizationId: string, ctx: VisibilityContex let completed = 0; for (const d of deliverables) { - // Highest-order stage still in flight - let bestStageId: string | null = null; - let bestOrder = -1; - let anyInFlight = false; + let bestActiveId: string | null = null; + let bestActiveOrder = -1; + let bestUpcomingId: string | null = null; + let bestUpcomingOrder = Number.POSITIVE_INFINITY; + for (const s of d.stages) { if (!s.stageDefinitionId) continue; - if (!inFlight.has(s.status)) continue; - anyInFlight = true; const order = stageOrder.get(s.stageDefinitionId) ?? -1; - if (order > bestOrder) { - bestOrder = order; - bestStageId = s.stageDefinitionId; + + if (ACTIVE_STATUSES.has(s.status)) { + // Highest-order actively-worked stage = furthest-along progress. + if (order > bestActiveOrder) { + bestActiveOrder = order; + bestActiveId = s.stageDefinitionId; + } + } else if (s.status === "NOT_STARTED") { + // Lowest-order NOT_STARTED = next in the queue. + if (order < bestUpcomingOrder) { + bestUpcomingOrder = order; + bestUpcomingId = s.stageDefinitionId; + } } } - if (!anyInFlight) { + + const pickedId = bestActiveId ?? bestUpcomingId; + if (!pickedId) { completed++; continue; } - if (bestStageId) { - counts.set(bestStageId, (counts.get(bestStageId) ?? 0) + 1); - } + counts.set(pickedId, (counts.get(pickedId) ?? 0) + 1); } // Pick the dominant stage (most deliverables at that stage)