Project detail: always show all fields + admin-editable + owner

Three fixes the user flagged on the /projects/:id page:

- Metadata panel rendered only populated fields; empty ones vanished
  entirely. A freshly-created project showed "Business Unit: Display"
  and nothing else, making admins think half the fields didn't exist.
  Now all fields always render with an em-dash ("—") for empty —
  obvious what's missing, and fmt helpers keep dates/money clean.
- Default the collapsible "Project Details" to OPEN. Was collapsed,
  which hid the whole form behind a click.
- New Edit button inside the details panel header (admins only).
  Reuses the existing ProjectFormDialog in edit mode with
  defaultValues populated from the loaded project; submit goes via
  useUpdateProject (already existed). Toast on success/error.
- "Owner" is now a labeled field in the panel — maps to the
  existing Project.requestor column (same field the XLSX intake
  column is named "Owner"; the form dialog already labeled it that
  way but the read view called it "Requestor").

Also added Description block above the metadata grid so narrative
context isn't hidden behind the Edit dialog.
This commit is contained in:
DJP 2026-04-21 16:46:41 -04:00
parent 02593ece83
commit 9b0156afc8

View file

@ -13,6 +13,7 @@ import {
Upload,
ChevronRight,
Users,
Pencil,
} from "lucide-react";
import { format } from "date-fns";
import { Button } from "@/components/ui/button";
@ -37,7 +38,7 @@ import {
CollapsibleTrigger,
} from "@/components/ui/collapsible";
import { cn } from "@/lib/utils";
import { useProject } from "@/hooks/use-projects";
import { useProject, useUpdateProject } from "@/hooks/use-projects";
import {
useDeliverables,
useCreateDeliverable,
@ -46,9 +47,12 @@ import {
import { PipelineProgress } from "@/components/deliverables/pipeline-progress";
import { StageStatusBadge } from "@/components/stages/stage-status-badge";
import { DeliverableFormDialog } from "@/components/deliverables/deliverable-form-dialog";
import { ProjectFormDialog } from "@/components/projects/project-form-dialog";
import { ExportButton } from "@/components/excel/export-button";
import { ImportDialog } from "@/components/excel/import-dialog";
import { useIsAdmin } from "@/hooks/use-current-user";
import { toast } from "sonner";
import { format as formatDate } from "date-fns";
const PRIORITY_STYLES: Record<string, string> = {
LOW: "bg-[var(--status-not-started)]/10 text-[var(--status-not-started)]",
@ -58,12 +62,20 @@ const PRIORITY_STYLES: Record<string, string> = {
};
function MetaField({ label, value }: { label: string; value: string }) {
const isEmpty = value === "—";
return (
<div>
<p className="text-[12px] font-semibold uppercase tracking-wider text-[var(--muted-foreground)]">
{label}
</p>
<p className="text-sm">{value}</p>
<p
className={cn(
"text-sm",
isEmpty && "text-[var(--muted-foreground)]/60"
)}
>
{value}
</p>
</div>
);
}
@ -72,61 +84,104 @@ function ProjectMetadataPanel({
project,
open,
onOpenChange,
canEdit,
onEdit,
}: {
project: any;
open: boolean;
onOpenChange: (open: boolean) => void;
canEdit: boolean;
onEdit: () => void;
}) {
const fields: [string, string | undefined][] = [
["Business Unit", project.businessUnit],
["Form Factor", project.formFactor],
["Code Name", project.codeName],
["NPI / Refresh", project.npiOrRefresh],
["Quarter", project.quarter],
["Requestor", project.requestor],
["Agency", project.agency],
["Workfront ID", project.workfrontId],
["OMG Code", project.omgCode],
["BMT ID", project.bmtId],
[
"Estimated Cost",
project.estimatedCost != null
? `$${Number(project.estimatedCost).toFixed(2)}`
: undefined,
],
[
"Actual Cost",
project.actualCost != null
? `$${Number(project.actualCost).toFixed(2)}`
: undefined,
],
// Always render every field — empty values get an em-dash so admins
// can see at a glance what's missing and hit Edit to fill them.
const EM = "—";
const fmt = (v: unknown): string => {
if (v == null || v === "") return EM;
return String(v);
};
const fmtDate = (v: unknown): string => {
if (!v) return EM;
try {
return formatDate(new Date(v as string), "MMM d, yyyy");
} catch {
return EM;
}
};
const fmtMoney = (v: unknown): string => {
if (v == null) return EM;
const n = Number(v);
if (!Number.isFinite(n)) return EM;
return `$${n.toFixed(2)}`;
};
const fields: Array<[string, string]> = [
["OMG Job #", fmt(project.omgJobNumber)],
["Client Team", fmt(project.clientTeam?.name)],
["Status", fmt((project.status ?? "").replace?.(/_/g, " "))],
["Priority", fmt(project.priority)],
["Owner", fmt(project.requestor)],
["Category", fmt(project.businessUnit)],
["Start Date", fmtDate(project.startDate)],
["Due Date", fmtDate(project.dueDate)],
["Quarter", fmt(project.quarter)],
["Form Factor", fmt(project.formFactor)],
["Code Name", fmt(project.codeName)],
["NPI / Refresh", fmt(project.npiOrRefresh)],
["Agency", fmt(project.agency)],
["Workfront ID", fmt(project.workfrontId)],
["OMG Code", fmt(project.omgCode)],
["BMT ID", fmt(project.bmtId)],
["Estimated Cost", fmtMoney(project.estimatedCost)],
["Actual Cost", fmtMoney(project.actualCost)],
];
const populated = fields.filter(([, v]) => v != null && v !== "");
if (populated.length === 0) return null;
return (
<Collapsible open={open} onOpenChange={onOpenChange} className="mt-4">
<CollapsibleTrigger asChild>
<Button
variant="ghost"
size="sm"
className="h-7 gap-1 text-xs text-[var(--muted-foreground)]"
>
<ChevronRight
className={cn(
"h-3.5 w-3.5 transition-transform",
open && "rotate-90"
)}
/>
Project Details
</Button>
</CollapsibleTrigger>
<div className="flex items-center gap-2">
<CollapsibleTrigger asChild>
<Button
variant="ghost"
size="sm"
className="h-7 gap-1 text-xs text-[var(--muted-foreground)]"
>
<ChevronRight
className={cn(
"h-3.5 w-3.5 transition-transform",
open && "rotate-90"
)}
/>
Project Details
</Button>
</CollapsibleTrigger>
{canEdit && (
<Button
variant="outline"
size="sm"
className="ml-auto h-7 text-xs"
onClick={onEdit}
>
<Pencil className="mr-1 h-3 w-3" />
Edit
</Button>
)}
</div>
<CollapsibleContent className="mt-2">
<Card>
<CardContent className="py-4">
{project.description && (
<div className="mb-4 rounded border bg-[var(--muted)]/30 p-3">
<p className="text-[12px] font-semibold uppercase tracking-wider text-[var(--muted-foreground)]">
Description
</p>
<p className="mt-1 whitespace-pre-wrap text-sm">
{project.description}
</p>
</div>
)}
<div className="grid grid-cols-2 gap-x-6 gap-y-3 md:grid-cols-4">
{populated.map(([label, value]) => (
<MetaField key={label} label={label} value={value!} />
{fields.map(([label, value]) => (
<MetaField key={label} label={label} value={value} />
))}
</div>
</CardContent>
@ -141,15 +196,22 @@ export default function ProjectDetailPage() {
const projectId = params.projectId;
const [showCreate, setShowCreate] = useState(false);
const [showImport, setShowImport] = useState(false);
const [detailsOpen, setDetailsOpen] = useState(false);
const [showEdit, setShowEdit] = useState(false);
// Default open so admins see everything about the project without
// an extra click. Previously collapsed, which made "there's no
// owner field!" a common complaint even when the data was there.
const [detailsOpen, setDetailsOpen] = useState(true);
const { data: project, isLoading: projectLoading } = useProject(projectId);
const { data: deliverables, isLoading: delivsLoading } =
const { data: deliverablesRaw, isLoading: delivsLoading } =
useDeliverables(projectId);
const deliverables = (deliverablesRaw as any[] | undefined) ?? undefined;
const createDeliverable = useCreateDeliverable(projectId);
const deleteDeliverable = useDeleteDeliverable(projectId);
const updateProject = useUpdateProject(projectId);
// Manual "Add Deliverable" is ADMIN-only — deliverables normally
// arrive via the deliverables webhook.
// arrive via the deliverables webhook. Same gate for editing the
// project itself.
const isAdmin = useIsAdmin();
const isLoading = projectLoading || delivsLoading;
@ -219,6 +281,8 @@ export default function ProjectDetailPage() {
project={project as any}
open={detailsOpen}
onOpenChange={setDetailsOpen}
canEdit={isAdmin}
onEdit={() => setShowEdit(true)}
/>
) : null}
@ -344,6 +408,57 @@ export default function ProjectDetailPage() {
open={showImport}
onOpenChange={setShowImport}
/>
{/* Edit project same ProjectFormDialog used for create, pre-
filled from the loaded project. Only renders when opened so
form defaults reset each edit session. */}
{!!project && showEdit && (
<ProjectFormDialog
open={showEdit}
onOpenChange={setShowEdit}
title="Edit Project"
isPending={updateProject.isPending}
hasDeliverables={(deliverables as any[] | undefined)?.length ? true : false}
defaultValues={{
projectCode: (project as any).projectCode ?? "",
name: (project as any).name ?? "",
description: (project as any).description ?? undefined,
status: (project as any).status,
priority: (project as any).priority,
startDate: (project as any).startDate
? new Date((project as any).startDate).toISOString().slice(0, 10)
: undefined,
dueDate: (project as any).dueDate
? new Date((project as any).dueDate).toISOString().slice(0, 10)
: undefined,
clientTeamId: (project as any).clientTeamId ?? undefined,
omgJobNumber: (project as any).omgJobNumber ?? undefined,
businessUnit: (project as any).businessUnit ?? undefined,
quarter: (project as any).quarter ?? undefined,
requestor: (project as any).requestor ?? undefined,
formFactor: (project as any).formFactor ?? undefined,
codeName: (project as any).codeName ?? undefined,
npiOrRefresh: (project as any).npiOrRefresh ?? undefined,
workfrontId: (project as any).workfrontId ?? undefined,
omgCode: (project as any).omgCode ?? undefined,
bmtId: (project as any).bmtId ?? undefined,
estimatedCost: (project as any).estimatedCost ?? undefined,
actualCost: (project as any).actualCost ?? undefined,
agency: (project as any).agency ?? undefined,
pipelineTemplateId: (project as any).pipelineTemplateId ?? undefined,
}}
onSubmit={(data) => {
updateProject.mutate(data, {
onSuccess: () => {
toast.success("Project updated");
setShowEdit(false);
},
onError: (e: any) =>
toast.error(e?.message ?? "Failed to update project"),
});
}}
/>
)}
</div>
);
}