diff --git a/src/app/(app)/deliverables/page.tsx b/src/app/(app)/deliverables/page.tsx index ad6324d..96db215 100644 --- a/src/app/(app)/deliverables/page.tsx +++ b/src/app/(app)/deliverables/page.tsx @@ -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("__all__"); const [stageFilter, setStageFilter] = useState("__all__"); const [teamFilter, setTeamFilter] = useState("__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("__all__"); const [sortKey, setSortKey] = useState("dueDate"); const [sortDir, setSortDir] = useState("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("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: " 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 (
@@ -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 && ( + ({ + value: p.id, + label: p.name, + }))} + /> + )} )} diff --git a/src/components/deliverables/deliverable-board.tsx b/src/components/deliverables/deliverable-board.tsx index e75b2fd..e3fb769 100644 --- a/src/components/deliverables/deliverable-board.tsx +++ b/src/components/deliverables/deliverable-board.tsx @@ -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: ". 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 } + >(); + if (pipelineStages) { + for (const s of pipelineStages) { + stageMeta.set(s.slug, { + order: s.order, + reworkToSlugs: new Set(s.reworkToSlugs ?? []), + }); + } + } + + const [draggingId, setDraggingId] = useState(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 ( - +
- {columns.map((col) => ( -
-
-
- - - {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 ( +
+ {/* Header */} +
+
+ + + {col.label} + +
+ + {col.deliverables.length}
- - {col.deliverables.length} - -
- - {(provided, snapshot) => ( -
- {col.deliverables.length === 0 && !snapshot.isDraggingOver && ( -
- — -
- )} - {col.deliverables.map((d, idx) => ( - - {(drag, dragSnapshot) => ( -
- -
- )} -
- ))} - {provided.placeholder} + + {(provided, snapshot) => ( +
+ {col.deliverables.length === 0 && !snapshot.isDraggingOver && ( +
+ — +
+ )} + {col.deliverables.map((d, idx) => ( + + {(drag, dragSnapshot) => ( +
+ +
+ )} +
+ ))} + {provided.placeholder} +
+ )} +
+ + {/* 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 && ( +
+
+ In pipelines +
+
+ {pipelinesWithStage.join(" · ")} +
)} - -
- ))} +
+ ); + })}
); @@ -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 */} -
+
{d.project.omgJobNumber && ( #{d.project.omgJobNumber} @@ -316,7 +430,7 @@ function DeliverableCard({ deliverable: d }: { deliverable: AllDeliverableRow }) {d.project.clientTeam && ( {d.project.clientTeam.name} @@ -327,12 +441,12 @@ function DeliverableCard({ deliverable: d }: { deliverable: AllDeliverableRow })
{d.name}
{/* Project name (smaller, muted) */} -
+
{d.project.name}
{/* Assignee + deadline */} -
+
{primary ?? Unassigned} diff --git a/src/hooks/use-deliverables.ts b/src/hooks/use-deliverables.ts index 952110e..d2b844d 100644 --- a/src/hooks/use-deliverables.ts +++ b/src/hooks/use-deliverables.ts @@ -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<{ diff --git a/src/lib/services/deliverable-service.ts b/src/lib/services/deliverable-service.ts index bccbc64..398c4fe 100644 --- a/src/lib/services/deliverable-service.ts +++ b/src/lib/services/deliverable-service.ts @@ -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 } }, }, },