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:
parent
4fe65f2a61
commit
c12e647546
5 changed files with 308 additions and 57 deletions
|
|
@ -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>
|
||||
);
|
||||
})}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
137
src/components/stages/stage-notes.tsx
Normal file
137
src/components/stages/stage-notes.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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();
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue