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:
parent
bcd1b245bd
commit
ae6ebc6da2
4 changed files with 276 additions and 78 deletions
|
|
@ -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}
|
||||
/>
|
||||
)}
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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<{
|
||||
|
|
|
|||
|
|
@ -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 } },
|
||||
},
|
||||
},
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue