Gate XLSX + per-stage notes + expanded deliverable info panel

1. /projects Import XLSX now behind the same isAdmin gate as New
   Project. Producers won't see either — bulk + manual intake are
   both admin-only escape hatches.

2. Per-stage notes. Backend was already wired (DeliverableStage.notes
   + updateStageSchema.notes + PATCH handler); just no UI.
   - New StageNotes component — inline editor per stage row. Empty
     state shows a "+ Add note" link; existing notes render as
     muted italic text, click anywhere on them to edit. Save on
     blur or ⌘/Ctrl+Enter, Esc to cancel.
   - useUpdateStageNotes hook for the PATCH.
   - Wired into each stage row on the deliverable detail page.

3. Deliverable info panel expanded + promoted above Pipeline Stages.
   - getDeliverable service now includes parent project context
     (id, name, projectCode, omgJobNumber, clientTeam) so the UI
     doesn't need a second round-trip.
   - Metadata grid always renders; empty fields show em-dash so
     producers can spot what's missing. Project row at the top
     surfaces project name, code, OMG #, client team. Deliverable
     notes are now shown as a block alongside the rest.
This commit is contained in:
DJP 2026-04-21 14:49:07 -04:00
parent 4fe65f2a61
commit c12e647546
5 changed files with 308 additions and 57 deletions

View file

@ -24,11 +24,33 @@ import {
useUpdateStageStatus,
} from "@/hooks/use-deliverables";
import { StageDatePopover } from "@/components/stages/stage-date-popover";
import { StageNotes } from "@/components/stages/stage-notes";
import { PipelineProgress } from "@/components/deliverables/pipeline-progress";
import { StageStatusBadge } from "@/components/stages/stage-status-badge";
import { TooltipProvider } from "@/components/ui/tooltip";
import { cn } from "@/lib/utils";
// Compact label+value cell for the deliverable info grid. Value accepts
// a ReactNode so links/badges can be passed in.
function MetaField({
label,
value,
mono,
}: {
label: string;
value: React.ReactNode;
mono?: boolean;
}) {
return (
<div>
<p className="text-[12px] font-semibold uppercase tracking-wider text-[var(--muted-foreground)]">
{label}
</p>
<p className={mono ? "font-mono text-sm tabular-nums" : "text-sm"}>{value}</p>
</div>
);
}
const PRIORITY_STYLES: Record<string, string> = {
LOW: "bg-[var(--status-not-started)]/10 text-[var(--status-not-started)]",
MEDIUM: "bg-[var(--status-in-progress)]/10 text-[var(--status-in-progress)]",
@ -150,64 +172,114 @@ export default function DeliverableDetailPage() {
</div>
</div>
{/* Deliverable metadata fields */}
{(deliverable.cmfSku ||
deliverable.assetCount != null ||
deliverable.requestedDueDate ||
deliverable.plannedDeliveryDate ||
deliverable.actualDeliveryDate ||
deliverable.wfInputDate) && (
<div className="grid gap-x-6 gap-y-2 rounded-xl border p-4 text-sm shadow-[var(--shadow-xs)] sm:grid-cols-2 md:grid-cols-3">
{/* Deliverable metadata always rendered, empty fields show an
em-dash so producers see the whole picture + know which fields
are missing data. Project context sits at the top so you never
lose track of which project this deliverable belongs to. */}
<div className="rounded-xl border p-4 shadow-[var(--shadow-xs)]">
<p className="mb-3 text-[12px] font-semibold uppercase tracking-wider text-[var(--muted-foreground)]">
Deliverable Info
</p>
<div className="grid gap-x-6 gap-y-3 text-sm sm:grid-cols-2 md:grid-cols-3">
{deliverable.project && (
<>
<MetaField
label="Project"
value={
<Link
href={`/projects/${deliverable.project.id}`}
className="text-[var(--primary)] hover:underline"
>
{deliverable.project.name}
</Link>
}
/>
<MetaField
label="Project Code"
value={deliverable.project.projectCode ?? "—"}
mono
/>
<MetaField
label="OMG Job #"
value={deliverable.project.omgJobNumber ?? "—"}
mono
/>
<MetaField
label="Client Team"
value={deliverable.project.clientTeam?.name ?? "—"}
/>
</>
)}
<MetaField
label="Priority"
value={deliverable.priority ?? "—"}
/>
<MetaField
label="Status"
value={(deliverable.status ?? "").replace(/_/g, " ") || "—"}
/>
<MetaField
label="Asset Count"
value={deliverable.assetCount != null ? String(deliverable.assetCount) : "—"}
/>
<MetaField
label="Due Date"
value={
deliverable.dueDate
? format(new Date(deliverable.dueDate), "MMM d, yyyy")
: "—"
}
/>
<MetaField
label="Requested Due Date"
value={
deliverable.requestedDueDate
? format(new Date(deliverable.requestedDueDate), "MMM d, yyyy")
: "—"
}
/>
<MetaField
label="Planned Delivery"
value={
deliverable.plannedDeliveryDate
? format(new Date(deliverable.plannedDeliveryDate), "MMM d, yyyy")
: "—"
}
/>
<MetaField
label="Actual Delivery"
value={
deliverable.actualDeliveryDate
? format(new Date(deliverable.actualDeliveryDate), "MMM d, yyyy")
: "—"
}
/>
<MetaField
label="WF Input Date"
value={
deliverable.wfInputDate
? format(new Date(deliverable.wfInputDate), "MMM d, yyyy")
: "—"
}
/>
{deliverable.cmfSku && (
<div className="sm:col-span-2 md:col-span-3">
<p className="text-[12px] font-semibold uppercase tracking-wider text-[var(--muted-foreground)]">
CMF / SKU
</p>
<p className="whitespace-pre-line">{deliverable.cmfSku}</p>
<p className="whitespace-pre-line text-sm">{deliverable.cmfSku}</p>
</div>
)}
{deliverable.assetCount != null && (
<div>
{deliverable.notes && (
<div className="sm:col-span-2 md:col-span-3">
<p className="text-[12px] font-semibold uppercase tracking-wider text-[var(--muted-foreground)]">
Asset Count
Deliverable Notes
</p>
<p>{deliverable.assetCount}</p>
</div>
)}
{deliverable.requestedDueDate && (
<div>
<p className="text-[12px] font-semibold uppercase tracking-wider text-[var(--muted-foreground)]">
Requested Due Date
</p>
<p>{format(new Date(deliverable.requestedDueDate), "MMM d, yyyy")}</p>
</div>
)}
{deliverable.plannedDeliveryDate && (
<div>
<p className="text-[12px] font-semibold uppercase tracking-wider text-[var(--muted-foreground)]">
Planned Delivery
</p>
<p>{format(new Date(deliverable.plannedDeliveryDate), "MMM d, yyyy")}</p>
</div>
)}
{deliverable.actualDeliveryDate && (
<div>
<p className="text-[12px] font-semibold uppercase tracking-wider text-[var(--muted-foreground)]">
Actual Delivery
</p>
<p>{format(new Date(deliverable.actualDeliveryDate), "MMM d, yyyy")}</p>
</div>
)}
{deliverable.wfInputDate && (
<div>
<p className="text-[12px] font-semibold uppercase tracking-wider text-[var(--muted-foreground)]">
WF Input Date
</p>
<p>{format(new Date(deliverable.wfInputDate), "MMM d, yyyy")}</p>
<p className="whitespace-pre-wrap text-sm">{deliverable.notes}</p>
</div>
)}
</div>
)}
</div>
{/* Pipeline overview */}
<Card>
@ -360,6 +432,12 @@ export default function DeliverableDetailPage() {
Waiting for prerequisite stages
</div>
)}
{/* Free-text notes per stage producers + assignees can
attach context (e.g. "waiting on spec", "ref attached",
"needs legal review"). Always editable; empty state
is a compact "+ Add note" affordance. */}
<StageNotes stageId={stage.id} notes={stage.notes ?? null} />
</div>
);
})}

View file

@ -134,9 +134,9 @@ export default function ProjectsPage() {
color: s.color ?? null,
}));
}, [pipelineTemplates]);
// Manual "New Project" is ADMIN-only — normal intake flow is via the
// OMG webhook. Producers can still edit + delete existing projects via
// the row-level controls. (XLSX bulk import left open for now.)
// Manual "New Project" and "Import XLSX" are ADMIN-only — normal
// intake flow is via the OMG webhook. Producers can still edit +
// delete existing projects via the row-level controls.
const isAdmin = useIsAdmin();
const createProject = useCreateProject();
const deleteProject = useDeleteProject();
@ -287,15 +287,17 @@ export default function ProjectsPage() {
<Download className="mr-1.5 h-3.5 w-3.5" />
Export CSV
</Button>
<Button variant="outline" size="sm" onClick={() => setShowBulkImport(true)}>
<FileSpreadsheet className="mr-1.5 h-3.5 w-3.5" />
Import XLSX
</Button>
{isAdmin && (
<Button size="sm" onClick={() => setShowCreate(true)}>
<Plus className="mr-1.5 h-3.5 w-3.5" />
New Project
</Button>
<>
<Button variant="outline" size="sm" onClick={() => setShowBulkImport(true)}>
<FileSpreadsheet className="mr-1.5 h-3.5 w-3.5" />
Import XLSX
</Button>
<Button size="sm" onClick={() => setShowCreate(true)}>
<Plus className="mr-1.5 h-3.5 w-3.5" />
New Project
</Button>
</>
)}
</div>
</div>

View file

@ -0,0 +1,137 @@
"use client";
/**
* Inline per-stage note editor for the deliverable detail page.
*
* Design:
* - When no note and not editing subtle "+ Add note" link.
* - When a note exists show it with a pencil-click hit area; click
* anywhere on the text enters edit mode.
* - Editing textarea; Save on Enter or blur, Cancel on Escape.
*
* Backend already has DeliverableStage.notes; uses useUpdateStageNotes
* hook which invalidates the deliverable query so the row re-renders
* with the fresh value.
*/
import { useEffect, useRef, useState } from "react";
import { Pencil, Plus } from "lucide-react";
import { cn } from "@/lib/utils";
import { useUpdateStageNotes } from "@/hooks/use-deliverables";
import { toast } from "sonner";
interface StageNotesProps {
stageId: string;
notes: string | null;
/** Hide when the viewer shouldn't be able to edit. Non-destructive:
* existing notes still render, they just can't be changed. */
readOnly?: boolean;
}
export function StageNotes({ stageId, notes, readOnly = false }: StageNotesProps) {
const [editing, setEditing] = useState(false);
const [draft, setDraft] = useState(notes ?? "");
const textareaRef = useRef<HTMLTextAreaElement | null>(null);
const update = useUpdateStageNotes();
// Sync draft when the note prop changes from outside (e.g., another
// browser tab or a refetch). Avoid clobbering in-progress edits.
useEffect(() => {
if (!editing) setDraft(notes ?? "");
}, [notes, editing]);
useEffect(() => {
if (editing && textareaRef.current) {
textareaRef.current.focus();
// Move cursor to the end of existing text
const len = textareaRef.current.value.length;
textareaRef.current.setSelectionRange(len, len);
}
}, [editing]);
const commit = () => {
const trimmed = draft.trim();
const next = trimmed.length === 0 ? null : trimmed;
if (next === (notes ?? null)) {
setEditing(false);
return;
}
update.mutate(
{ stageId, notes: next },
{
onSuccess: () => {
setEditing(false);
},
onError: (e: any) => {
toast.error(e.message ?? "Failed to save note");
},
}
);
};
const cancel = () => {
setDraft(notes ?? "");
setEditing(false);
};
if (editing) {
return (
<div className="mt-1">
<textarea
ref={textareaRef}
value={draft}
onChange={(e) => setDraft(e.target.value)}
onBlur={commit}
onKeyDown={(e) => {
if (e.key === "Enter" && (e.metaKey || e.ctrlKey)) {
e.preventDefault();
commit();
} else if (e.key === "Escape") {
e.preventDefault();
cancel();
}
}}
placeholder="Add context for this stage…"
rows={2}
className="w-full resize-y rounded border bg-[var(--background)] px-2 py-1 text-[13px] leading-relaxed focus:border-[var(--primary)] focus:outline-none"
disabled={update.isPending}
/>
<div className="mt-0.5 text-[11px] text-[var(--muted-foreground)]">
/Ctrl+Enter to save · Esc to cancel · blur auto-saves
</div>
</div>
);
}
if (!notes) {
if (readOnly) return null;
return (
<button
type="button"
onClick={() => setEditing(true)}
className="mt-1 inline-flex items-center gap-1 text-[11px] text-[var(--muted-foreground)] hover:text-[var(--primary)]"
>
<Plus className="h-3 w-3" />
Add note
</button>
);
}
return (
<button
type="button"
onClick={readOnly ? undefined : () => setEditing(true)}
className={cn(
"group mt-1 flex w-full items-start gap-1 rounded border border-dashed border-transparent px-1 py-0.5 text-left text-[13px] leading-relaxed text-[var(--muted-foreground)]",
!readOnly &&
"hover:border-[var(--border)] hover:bg-[var(--muted)]/40 hover:text-[var(--foreground)]"
)}
aria-label={readOnly ? "Stage note" : "Edit stage note"}
>
<span className="flex-1 whitespace-pre-wrap italic">{notes}</span>
{!readOnly && (
<Pencil className="mt-0.5 h-3 w-3 shrink-0 opacity-0 transition-opacity group-hover:opacity-60" />
)}
</button>
);
}

View file

@ -97,6 +97,27 @@ export function useDeleteDeliverable(projectId: string) {
});
}
/**
* Per-stage notes free-text context the team wants attached to a
* single stage on this deliverable. Backend schema already has
* DeliverableStage.notes; this hook is a thin PATCH wrapper.
*/
export function useUpdateStageNotes() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({ stageId, notes }: { stageId: string; notes: string | null }) =>
fetchJson(`/api/stages/${stageId}`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ notes }),
}),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["deliverables"] });
queryClient.invalidateQueries({ queryKey: ["deliverable"] });
},
});
}
export function useUpdateStageStatus() {
const queryClient = useQueryClient();

View file

@ -221,6 +221,19 @@ export async function getDeliverable(id: string, ctx: VisibilityContext) {
],
},
include: {
// Parent project context — the detail page surfaces it above the
// stage list so producers see which project + team + OMG number
// this deliverable belongs to without a second round-trip.
project: {
select: {
id: true,
name: true,
projectCode: true,
omgJobNumber: true,
status: true,
clientTeam: { select: { id: true, name: true, slug: true } },
},
},
stages: {
include: {
template: true,