Fix board stage columns + add board view to Deliverables
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.
This commit is contained in:
parent
958de5f3a9
commit
e9f8fffdcc
5 changed files with 496 additions and 57 deletions
|
|
@ -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<string, string> = {
|
|||
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<string>("__all__");
|
||||
const [sortKey, setSortKey] = useState<SortKey>("dueDate");
|
||||
const [sortDir, setSortDir] = useState<SortDir>("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<DeliverableBoardGroupBy>("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 (
|
||||
<div className="flex h-full flex-col gap-4">
|
||||
{/* Header */}
|
||||
<div className="flex items-center gap-3">
|
||||
<ClipboardList className="h-6 w-6 text-[var(--primary)]" />
|
||||
<div>
|
||||
<h1 className="font-heading text-2xl font-bold">Deliverables</h1>
|
||||
<p className="text-sm text-[var(--muted-foreground)]">
|
||||
Every deliverable across every project you have access to.
|
||||
</p>
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div className="flex items-center gap-3">
|
||||
<ClipboardList className="h-6 w-6 text-[var(--primary)]" />
|
||||
<div>
|
||||
<h1 className="font-heading text-2xl font-bold">Deliverables</h1>
|
||||
<p className="text-sm text-[var(--muted-foreground)]">
|
||||
Every deliverable across every project you have access to.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{/* Grid ↔ Board. Board shows a grouping selector in the filter
|
||||
bar (by stage or by status). */}
|
||||
<div className="flex rounded-md border bg-[var(--card)] p-0.5">
|
||||
<Button
|
||||
size="sm"
|
||||
variant={view === "grid" ? "default" : "ghost"}
|
||||
className="h-7 px-2"
|
||||
onClick={() => setView("grid")}
|
||||
aria-label="Grid view"
|
||||
>
|
||||
<LayoutGrid className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant={view === "board" ? "default" : "ghost"}
|
||||
className="h-7 px-2"
|
||||
onClick={() => setView("board")}
|
||||
aria-label="Board view"
|
||||
>
|
||||
<Columns3 className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
|
@ -273,6 +339,21 @@ export default function DeliverablesPage() {
|
|||
options={PRIORITIES.map((p) => ({ value: p, label: p }))}
|
||||
/>
|
||||
|
||||
{view === "board" && (
|
||||
<Select
|
||||
value={boardGroupBy}
|
||||
onValueChange={(v) => setBoardGroupBy(v as DeliverableBoardGroupBy)}
|
||||
>
|
||||
<SelectTrigger className="h-9 w-[170px] text-xs">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="stage">Group by: Stage</SelectItem>
|
||||
<SelectItem value="status">Group by: Status</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
)}
|
||||
|
||||
{anyFilterActive && (
|
||||
<Button size="sm" variant="ghost" onClick={clearFilters} className="h-9">
|
||||
<X className="mr-1 h-3 w-3" />
|
||||
|
|
@ -287,7 +368,17 @@ export default function DeliverablesPage() {
|
|||
</div>
|
||||
</div>
|
||||
|
||||
{/* Board view */}
|
||||
{view === "board" && (
|
||||
<DeliverableBoard
|
||||
deliverables={sorted}
|
||||
groupBy={boardGroupBy}
|
||||
pipelineStages={defaultPipelineStages}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Table */}
|
||||
{view === "grid" && (
|
||||
<div className="flex-1 overflow-auto rounded-lg border bg-[var(--card)]">
|
||||
<table className="w-full min-w-[1100px] border-collapse text-xs">
|
||||
<thead className="sticky top-0 z-10 bg-[var(--muted)]/60">
|
||||
|
|
@ -392,6 +483,7 @@ export default function DeliverablesPage() {
|
|||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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" && (
|
||||
<ProjectBoard projects={filtered} groupBy={boardGroupBy} />
|
||||
<ProjectBoard
|
||||
projects={filtered}
|
||||
groupBy={boardGroupBy}
|
||||
pipelineStages={defaultPipelineStages}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Excel-grid table */}
|
||||
|
|
|
|||
260
src/components/deliverables/deliverable-board.tsx
Normal file
260
src/components/deliverables/deliverable-board.tsx
Normal file
|
|
@ -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<string, string> = {
|
||||
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 (
|
||||
<div className="rounded-lg border bg-[var(--card)] p-12 text-center text-sm text-[var(--muted-foreground)]">
|
||||
No deliverables to display.
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex gap-3 overflow-x-auto pb-2">
|
||||
{columns.map((col) => (
|
||||
<div
|
||||
key={col.key}
|
||||
className="flex w-[280px] shrink-0 flex-col rounded-lg border bg-[var(--muted)]/30"
|
||||
>
|
||||
<div className="flex items-center justify-between border-b px-3 py-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<span
|
||||
className="h-2 w-2 rounded-full"
|
||||
style={{ background: col.accent }}
|
||||
/>
|
||||
<span className="text-[11px] font-bold uppercase tracking-wider">
|
||||
{col.label}
|
||||
</span>
|
||||
</div>
|
||||
<span className="rounded-full bg-[var(--card)] px-2 py-0.5 text-[10px] font-semibold tabular-nums text-[var(--muted-foreground)]">
|
||||
{col.deliverables.length}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-1 flex-col gap-2 p-2">
|
||||
{col.deliverables.length === 0 ? (
|
||||
<div className="py-6 text-center text-[10px] text-[var(--muted-foreground)]/60">
|
||||
—
|
||||
</div>
|
||||
) : (
|
||||
col.deliverables.map((d) => (
|
||||
<DeliverableCard key={d.id} deliverable={d} />
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── 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 (
|
||||
<Link
|
||||
href={`/projects/${d.project.id}/deliverables/${d.id}`}
|
||||
className="block rounded-md border bg-[var(--card)] p-2.5 text-left shadow-[var(--shadow-xs)] transition-all hover:border-[var(--primary)] hover:shadow-[var(--shadow-sm)]"
|
||||
>
|
||||
{/* Top row: OMG #, priority dot, team */}
|
||||
<div className="mb-1.5 flex items-center gap-1.5 text-[10px]">
|
||||
{d.project.omgJobNumber && (
|
||||
<span className="font-mono tabular-nums text-[var(--muted-foreground)]">
|
||||
#{d.project.omgJobNumber}
|
||||
</span>
|
||||
)}
|
||||
<span
|
||||
className={cn("h-1.5 w-1.5 shrink-0 rounded-full", PRIORITY_DOT[d.priority])}
|
||||
aria-label={d.priority}
|
||||
/>
|
||||
{d.project.clientTeam && (
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="ml-auto h-4 px-1 text-[9px] uppercase tracking-wider"
|
||||
>
|
||||
{d.project.clientTeam.name}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Deliverable name */}
|
||||
<div className="mb-1 line-clamp-2 text-xs font-semibold leading-snug">{d.name}</div>
|
||||
|
||||
{/* Project name (smaller, muted) */}
|
||||
<div className="mb-1.5 line-clamp-1 text-[10px] text-[var(--muted-foreground)]">
|
||||
{d.project.name}
|
||||
</div>
|
||||
|
||||
{/* Assignee + deadline */}
|
||||
<div className="flex items-center justify-between text-[10px] text-[var(--muted-foreground)]">
|
||||
<span className="truncate">
|
||||
{primary ?? <span className="italic">Unassigned</span>}
|
||||
</span>
|
||||
{due && (
|
||||
<span className={cn("shrink-0 tabular-nums", overdue && "font-semibold text-red-600")}>
|
||||
{format(due, "MMM d")}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
|
@ -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<string, string> = {
|
|||
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) {
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue