Deliverables board: drag highlight, pipeline filter, "in pipelines" footer

Three connected improvements on the Deliverables board:

1. Drag highlight. When you start dragging a card, every column
   previews whether a drop would be allowed. Same rules as the
   server validator:
     - forward-by-order (target.order > current.order)   → green
     - backward + declared rework edge exists            → green
     - same column                                        → no-op, no tint
     - backward without a rework edge                     → red
     - cross-pipeline / "__none__" bucket                 → red
   Columns the drop can't target also fade to 50% opacity to pull
   focus toward the valid ones. Status mode is always green since
   Deliverable.status is a direct write.

   Drag validity uses rework edges pulled live from the selected
   pipeline's detail (usePipelineTemplate), so configurator changes
   in /settings/pipelines show up immediately on the board.

2. Pipeline filter. New "All Pipelines" select in the filter row,
   shown only when >1 pipeline template exists (noise otherwise).
   When set, filters deliverables by project.pipelineTemplateId AND
   drives the board columns + rework edges from that pipeline.

3. "In pipelines" footer. At the base of each stage column, shows
   which pipeline templates include that stage slug — but only when
   the stage is shared across ≥2 pipelines. Single-pipeline labels
   would be redundant.

Plumbing:
- getAllDeliverables service + AllDeliverableRow now include
  project.pipelineTemplateId so the page can filter without a
  second round-trip.
- DeliverableBoard accepts two new optional props: pipelineStages
  extended with reworkToSlugs (per-stage rework targets) and
  allPipelines (list of { id, name, stageSlugs } for the footer).
- Deliverables page computes boardPipelineStages from the active
  pipeline's detail (falls back to the default when filter is
  "all") and allPipelinesSummary from the list response.
This commit is contained in:
DJP 2026-04-22 12:53:40 -04:00
parent bcd1b245bd
commit ae6ebc6da2
4 changed files with 276 additions and 78 deletions

View file

@ -31,7 +31,10 @@ import {
DeliverableBoard,
type DeliverableBoardGroupBy,
} from "@/components/deliverables/deliverable-board";
import { usePipelineTemplates } from "@/hooks/use-pipelines";
import {
usePipelineTemplates,
usePipelineTemplate,
} from "@/hooks/use-pipelines";
type SortKey = "name" | "project" | "stage" | "dueDate" | "priority" | "status";
type SortDir = "asc" | "desc";
@ -115,6 +118,11 @@ export default function DeliverablesPage() {
const [priorityFilter, setPriorityFilter] = useState<string>("__all__");
const [stageFilter, setStageFilter] = useState<string>("__all__");
const [teamFilter, setTeamFilter] = useState<string>("__all__");
// Pipeline filter — when set, only show deliverables on projects
// that use this pipeline, AND use this pipeline's stages for board
// columns. "__all__" falls back to the default pipeline for column
// layout (current behaviour).
const [pipelineFilter, setPipelineFilter] = 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
@ -122,8 +130,9 @@ export default function DeliverablesPage() {
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.
// Pipeline templates drive: (a) the pipeline-filter dropdown,
// (b) the board column stage list, and (c) the "In pipelines" footer
// under each stage column.
const { data: pipelineTemplates } = usePipelineTemplates() as {
data: Array<{
id: string;
@ -132,14 +141,61 @@ export default function DeliverablesPage() {
stages: Array<{ slug: string; name: string; order: number; color?: string | null }>;
}> | undefined;
};
const defaultPipelineStages = useMemo(() => {
if (!pipelineTemplates || pipelineTemplates.length === 0) return [];
// Which pipeline drives the board columns + rework-edge lookup.
// When the user picks one from the filter we use that; otherwise
// fall back to the org's default.
const activePipelineId = useMemo(() => {
if (pipelineFilter !== "__all__") return pipelineFilter;
if (!pipelineTemplates || pipelineTemplates.length === 0) return null;
const def = pipelineTemplates.find((t) => t.isDefault) ?? pipelineTemplates[0];
return (def?.stages ?? []).map((s) => ({
return def?.id ?? null;
}, [pipelineFilter, pipelineTemplates]);
// Fetch the ACTIVE pipeline's detail so we have its rework edges
// (listPipelineTemplates doesn't include them). Drives the green /
// red drag-target highlight on the board.
const { data: activePipelineDetail } = usePipelineTemplate(
activePipelineId ?? ""
) as {
data:
| {
id: string;
name: string;
stages: Array<{
id: string;
slug: string;
name: string;
order: number;
color?: string | null;
reworkFrom?: Array<{ toStage: { slug: string } }>;
}>;
}
| undefined;
};
const boardPipelineStages = useMemo(() => {
if (!activePipelineDetail) return [];
return activePipelineDetail.stages.map((s) => ({
slug: s.slug,
name: s.name,
order: s.order,
color: s.color ?? null,
// Flatten each stage's rework edges into a simple list of
// target slugs — that's all the drag-preview logic needs.
reworkToSlugs: (s.reworkFrom ?? []).map((r) => r.toStage.slug),
}));
}, [activePipelineDetail]);
// Footer data — every pipeline's name + the slugs of its stages.
// Used to show "this stage lives in: <pipelines>" under each stage
// column. Only renders when a stage is shared across ≥2 pipelines.
const allPipelinesSummary = useMemo(() => {
if (!pipelineTemplates) return [];
return pipelineTemplates.map((p) => ({
id: p.id,
name: p.name,
stageSlugs: p.stages.map((s) => s.slug),
}));
}, [pipelineTemplates]);
@ -186,6 +242,12 @@ export default function DeliverablesPage() {
if (statusFilter !== "__all__" && r.status !== statusFilter) return false;
if (priorityFilter !== "__all__" && r.priority !== priorityFilter) return false;
if (teamFilter !== "__all__" && r.project.clientTeam?.slug !== teamFilter) return false;
if (
pipelineFilter !== "__all__" &&
r.project.pipelineTemplateId !== pipelineFilter
) {
return false;
}
if (stageFilter !== "__all__") {
const cs = currentStage(r);
if (!cs || cs.slug !== stageFilter) return false;
@ -203,7 +265,7 @@ export default function DeliverablesPage() {
}
return true;
});
}, [rows, query, projectFilter, statusFilter, priorityFilter, teamFilter, stageFilter]);
}, [rows, query, projectFilter, statusFilter, priorityFilter, teamFilter, stageFilter, pipelineFilter]);
const sorted = useMemo(() => {
const dir = sortDir === "asc" ? 1 : -1;
@ -247,6 +309,7 @@ export default function DeliverablesPage() {
setPriorityFilter("__all__");
setStageFilter("__all__");
setTeamFilter("__all__");
setPipelineFilter("__all__");
};
const anyFilterActive =
@ -255,7 +318,8 @@ export default function DeliverablesPage() {
statusFilter !== "__all__" ||
priorityFilter !== "__all__" ||
stageFilter !== "__all__" ||
teamFilter !== "__all__";
teamFilter !== "__all__" ||
pipelineFilter !== "__all__";
return (
<div className="flex h-full flex-col gap-4">
@ -326,6 +390,21 @@ export default function DeliverablesPage() {
placeholder="All Teams"
options={teamOptions.map((t) => ({ value: t.slug, label: t.name }))}
/>
{/* Pipeline filter useful when the org has more than one
pipeline template (e.g. a standard vs. a short-form one).
Drives both the deliverable filter AND the board columns
(when Board view is active). */}
{pipelineTemplates && pipelineTemplates.length > 1 && (
<FilterSelect
value={pipelineFilter}
onChange={setPipelineFilter}
placeholder="All Pipelines"
options={pipelineTemplates.map((p) => ({
value: p.id,
label: p.name,
}))}
/>
)}
<FilterSelect
value={statusFilter}
onChange={setStatusFilter}
@ -373,7 +452,8 @@ export default function DeliverablesPage() {
<DeliverableBoard
deliverables={sorted}
groupBy={boardGroupBy}
pipelineStages={defaultPipelineStages}
pipelineStages={boardPipelineStages}
allPipelines={allPipelinesSummary}
/>
)}

View file

@ -3,24 +3,30 @@
/**
* 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.
* 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
* their current stage (see currentStage() in
* the deliverables page)
* - `status` columns = DeliverableStatus (NOT_STARTED / IN_PROGRESS
* / IN_REVIEW / APPROVED / ON_HOLD)
* - `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.
* While dragging, columns light up green / red to preview whether a
* drop is allowed. In stage mode the preview uses the same two rules
* the server applies: forward-by-order always OK, backward only if
* the pipeline declares a rework edge from source target. Status
* mode is always green (Deliverable.status is a direct write).
*
* Optional `allPipelines` prop drives a small footer under each
* stage column that lists which pipelines include that stage slug.
* Omit the prop to hide the footer.
*/
import Link from "next/link";
import { useState } from "react";
import { format, parseISO } from "date-fns";
import { DragDropContext, Droppable, Draggable, type DropResult } from "@hello-pangea/dnd";
import { cn } from "@/lib/utils";
@ -40,12 +46,27 @@ export interface BoardPipelineStage {
name: string;
order: number;
color?: string | null;
/** Stage slugs this stage can rework back to (i.e., if the user
* drags a deliverable from THIS stage's column to one of these
* slugs, it's a valid rework drop). Comes from the pipeline
* template's PipelineStageRework rows. */
reworkToSlugs?: string[];
}
export interface BoardPipelineSummary {
id: string;
name: string;
stageSlugs: string[];
}
interface DeliverableBoardProps {
deliverables: AllDeliverableRow[];
groupBy: DeliverableBoardGroupBy;
pipelineStages?: BoardPipelineStage[];
/** Every pipeline template on the org. Drives the per-column footer
* that lists "this stage exists in: <pipeline names>". Only renders
* the footer when multiple pipelines share a stage. */
allPipelines?: BoardPipelineSummary[];
}
const STATUS_COLUMNS: Array<{ key: string; label: string; accent: string }> = [
@ -67,16 +88,64 @@ export function DeliverableBoard({
deliverables,
groupBy,
pipelineStages,
allPipelines,
}: DeliverableBoardProps) {
const columns = computeColumns(deliverables, groupBy, pipelineStages);
const transition = useTransitionDeliverable();
const updateDeliverable = useUpdateDeliverableById();
// Deliverable-id → {project, stages} lookup so the drop handler can
// pull what it needs without threading data through Droppable props.
// Deliverable-id → row lookup so the drop handler + drag-preview
// logic can pull what they need without threading data through
// Droppable props.
const deliverableIndex = new Map(deliverables.map((d) => [d.id, d]));
// Stage-slug → order + reworkToSlugs lookup for the drag preview
// (stage mode only). Keyed by slug because columns are keyed by slug.
const stageMeta = new Map<
string,
{ order: number; reworkToSlugs: Set<string> }
>();
if (pipelineStages) {
for (const s of pipelineStages) {
stageMeta.set(s.slug, {
order: s.order,
reworkToSlugs: new Set(s.reworkToSlugs ?? []),
});
}
}
const [draggingId, setDraggingId] = useState<string | null>(null);
// Is a drop from the currently-dragging deliverable onto this column
// allowed? Used to tint the column during drag.
const dropAllowed = (targetKey: string): boolean => {
if (!draggingId) return true;
if (groupBy === "status") return true; // direct write, any bucket OK
if (targetKey === "__none__") return false; // derived bucket, no writes
const d = deliverableIndex.get(draggingId);
if (!d) return false;
const current = currentStage(d);
if (!current) return false;
if (current.slug === targetKey) return false; // same column = no-op
const targetMeta = stageMeta.get(targetKey);
const sourceMeta = stageMeta.get(current.slug);
if (!targetMeta || !sourceMeta) return false;
// Forward: any later stage is allowed (server may still reject
// for required-intermediates but those are rare).
if (targetMeta.order > sourceMeta.order) return true;
// Backward: only if the source stage declares a rework edge to
// the target slug. Matches the server validator.
return sourceMeta.reworkToSlugs.has(targetKey);
};
const onDragStart = (start: { draggableId: string }) => {
setDraggingId(start.draggableId);
};
const onDragEnd = (result: DropResult) => {
setDraggingId(null);
if (!result.destination) return;
const fromCol = result.source.droppableId;
const toCol = result.destination.droppableId;
@ -86,7 +155,6 @@ export function DeliverableBoard({
if (!deliverable) return;
if (groupBy === "stage") {
// "No active stage" is a derived bucket — we can't write to it.
if (toCol === "__none__") {
toast.error("Can't move into the \"No active stage\" bucket");
return;
@ -108,7 +176,6 @@ export function DeliverableBoard({
}
);
} else {
// Status grouping — direct Deliverable.status write.
updateDeliverable.mutate(
{
projectId: deliverable.project.id,
@ -136,63 +203,110 @@ export function DeliverableBoard({
}
return (
<DragDropContext onDragEnd={onDragEnd}>
<DragDropContext onDragStart={onDragStart} onDragEnd={onDragEnd}>
<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-[13px] font-bold uppercase tracking-wider">
{col.label}
{columns.map((col) => {
const allowed = dropAllowed(col.key);
// Pipelines that contain this stage slug — only computed +
// rendered when we were handed the full-pipeline list and
// more than one pipeline includes this slug (otherwise the
// label is redundant noise).
let pipelinesWithStage: string[] = [];
if (
groupBy === "stage" &&
allPipelines &&
col.key !== "__none__"
) {
pipelinesWithStage = allPipelines
.filter((p) => p.stageSlugs.includes(col.key))
.map((p) => p.name);
}
return (
<div
key={col.key}
className={cn(
"flex w-[280px] shrink-0 flex-col rounded-lg border bg-[var(--muted)]/30 transition-colors",
draggingId && !allowed && "opacity-50"
)}
>
{/* Header */}
<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>
<span className="rounded-full bg-[var(--card)] px-2 py-0.5 text-[12px] font-semibold tabular-nums text-[var(--muted-foreground)]">
{col.deliverables.length}
</span>
</div>
<Droppable droppableId={col.key}>
{(provided, snapshot) => (
<div
ref={provided.innerRef}
{...provided.droppableProps}
className={cn(
"flex flex-1 flex-col gap-2 p-2 transition-colors",
snapshot.isDraggingOver && "bg-[var(--primary)]/5"
)}
>
{col.deliverables.length === 0 && !snapshot.isDraggingOver && (
<div className="py-6 text-center text-[12px] text-[var(--muted-foreground)]/60">
</div>
)}
{col.deliverables.map((d, idx) => (
<Draggable key={d.id} draggableId={d.id} index={idx}>
{(drag, dragSnapshot) => (
<div
ref={drag.innerRef}
{...drag.draggableProps}
{...drag.dragHandleProps}
className={cn(dragSnapshot.isDragging && "opacity-80")}
>
<DeliverableCard deliverable={d} />
</div>
)}
</Draggable>
))}
{provided.placeholder}
<Droppable droppableId={col.key}>
{(provided, snapshot) => (
<div
ref={provided.innerRef}
{...provided.droppableProps}
className={cn(
"flex flex-1 flex-col gap-2 p-2 transition-colors",
// During drag: green if this is a valid drop
// target, red if not. Only apply the tint while
// the user is hovering over the column itself so
// we don't paint the entire board red.
snapshot.isDraggingOver &&
allowed &&
"bg-emerald-500/15 ring-2 ring-inset ring-emerald-500/40",
snapshot.isDraggingOver &&
!allowed &&
"bg-red-500/10 ring-2 ring-inset ring-red-500/40"
)}
>
{col.deliverables.length === 0 && !snapshot.isDraggingOver && (
<div className="py-6 text-center text-[10px] text-[var(--muted-foreground)]/60">
</div>
)}
{col.deliverables.map((d, idx) => (
<Draggable key={d.id} draggableId={d.id} index={idx}>
{(drag, dragSnapshot) => (
<div
ref={drag.innerRef}
{...drag.draggableProps}
{...drag.dragHandleProps}
className={cn(dragSnapshot.isDragging && "opacity-80")}
>
<DeliverableCard deliverable={d} />
</div>
)}
</Draggable>
))}
{provided.placeholder}
</div>
)}
</Droppable>
{/* Footer which pipelines include this stage. Only
rendered when we have the data and the stage is shared
across 2 pipelines; showing a single-pipeline label
is noise since the user already filtered to it. */}
{pipelinesWithStage.length > 1 && (
<div className="border-t bg-[var(--muted)]/10 px-3 py-1.5 text-[9px] leading-snug text-[var(--muted-foreground)]">
<div className="uppercase tracking-wider opacity-70">
In pipelines
</div>
<div className="mt-0.5 truncate" title={pipelinesWithStage.join(", ")}>
{pipelinesWithStage.join(" · ")}
</div>
</div>
)}
</Droppable>
</div>
))}
</div>
);
})}
</div>
</DragDropContext>
);
@ -303,7 +417,7 @@ function DeliverableCard({ deliverable: d }: { deliverable: AllDeliverableRow })
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-[12px]">
<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}
@ -316,7 +430,7 @@ function DeliverableCard({ deliverable: d }: { deliverable: AllDeliverableRow })
{d.project.clientTeam && (
<Badge
variant="outline"
className="ml-auto h-4 px-1 text-[11px] uppercase tracking-wider"
className="ml-auto h-4 px-1 text-[9px] uppercase tracking-wider"
>
{d.project.clientTeam.name}
</Badge>
@ -327,12 +441,12 @@ function DeliverableCard({ deliverable: d }: { deliverable: AllDeliverableRow })
<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-[12px] text-[var(--muted-foreground)]">
<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-[12px] text-[var(--muted-foreground)]">
<div className="flex items-center justify-between text-[10px] text-[var(--muted-foreground)]">
<span className="truncate">
{primary ?? <span className="italic">Unassigned</span>}
</span>

View file

@ -43,6 +43,7 @@ export interface AllDeliverableRow {
projectCode: string | null;
omgJobNumber: string | null;
status: string;
pipelineTemplateId: string | null;
clientTeam: { id: string; name: string; slug: string } | null;
};
stages: Array<{

View file

@ -189,6 +189,9 @@ export async function listAllDeliverables(
projectCode: true,
omgJobNumber: true,
status: true,
// Exposed so the Deliverables board can filter by pipeline
// and pick the right column set for that deliverable.
pipelineTemplateId: true,
clientTeam: { select: { id: true, name: true, slug: true } },
},
},