Resources page: scope toggle for project vs deliverable assignment

Producers wanted to be able to book a person against a specific
deliverable's OMG #, not just a whole project's. The data model
already supports it (ResourceBooking.jobNumber is just a string),
but the UI only sourced project numbers.

Service (booking-service.listKnownJobNumbers):
  - Returns rows from BOTH projects and deliverables that have an
    omgJobNumber, each tagged with a `scope` discriminator
    ("project" | "deliverable") and (for deliverable rows) the
    parent project name.

Hook (use-bookings.useKnownJobNumbers):
  - Exposes the new shape via the KnownJobRow type.

UI (resources page AssignPopover):
  - New segmented Scope toggle at the top of the popover. Default is
    Project (the more common high-level case); switching to
    Deliverable filters the list to deliverable-scope rows.
  - Search filter now also matches the parent project name when
    scope=deliverable.
  - Deliverable rows render with a small "in <Project Name>" subtitle
    so producers can pick by context, not just by an opaque OMG #.
  - Label flips between "Project OMG #" and "Deliverable OMG #" so
    the field meaning is unambiguous.

No schema change. ResourceBooking.jobNumber is unchanged; rows just
get a different value depending on the picked scope, and the existing
display + reporting paths continue to work.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
DJP 2026-05-15 20:11:34 -04:00
parent aa897354e7
commit 5f409c6c46
3 changed files with 125 additions and 19 deletions

View file

@ -699,6 +699,13 @@ function CapBar({ booked, capacity }: { booked: number; capacity: number }) {
// ── Assign-job popover ────────────────────────────────────────
interface KnownJob {
jobNumber: string;
name: string;
scope: "project" | "deliverable";
projectName?: string;
}
function AssignPopover({
anchor,
onClose,
@ -709,11 +716,16 @@ function AssignPopover({
anchor: { x: number; y: number };
onClose: () => void;
onAssign: (jobNumber: string, hours: number) => void;
knownJobs: Array<{ jobNumber: string; name: string }>;
knownJobs: KnownJob[];
isPending: boolean;
}) {
const [jobNumber, setJobNumber] = useState("");
const [hours, setHours] = useState(4);
// Producer toggles between booking against a project's OMG # (whole-
// project capacity planning) or a specific deliverable's OMG #
// (fine-grained when you know what they're working on). Default to
// project because that's the more common high-level case.
const [scope, setScope] = useState<"project" | "deliverable">("project");
const ref = useRef<HTMLDivElement | null>(null);
useEffect(() => {
@ -731,15 +743,20 @@ function AssignPopover({
};
}, [onClose]);
// Scoped + text-filtered list. Scope picks the row family
// (project vs deliverable); the text filter narrows by job number or
// name (and parent project name for deliverable rows).
const suggestions = useMemo(() => {
const q = jobNumber.trim().toLowerCase();
if (!q) return knownJobs;
return knownJobs.filter(
const inScope = knownJobs.filter((j) => j.scope === scope);
if (!q) return inScope;
return inScope.filter(
(j) =>
j.jobNumber.toLowerCase().includes(q) ||
j.name.toLowerCase().includes(q)
j.name.toLowerCase().includes(q) ||
(j.projectName?.toLowerCase().includes(q) ?? false)
);
}, [jobNumber, knownJobs]);
}, [jobNumber, knownJobs, scope]);
const valid = jobNumber.trim().length > 0;
@ -760,9 +777,37 @@ function AssignPopover({
Assign Job
</div>
<div className="flex min-h-0 flex-1 flex-col gap-3 p-3">
{/* Scope toggle book against a whole project, or a specific
deliverable. The list below filters by the picked scope. */}
<div>
<label className="mb-1 block text-[12px] font-bold uppercase tracking-wider text-[var(--muted-foreground)]">
Scope
</label>
<div className="grid grid-cols-2 gap-1 rounded border bg-[var(--muted)]/20 p-0.5">
{(["project", "deliverable"] as const).map((s) => (
<button
key={s}
type="button"
onClick={() => {
setScope(s);
setJobNumber("");
}}
className={cn(
"rounded px-2 py-1 text-xs font-semibold capitalize transition-colors",
scope === s
? "bg-[var(--card)] text-[var(--foreground)] shadow-sm"
: "text-[var(--muted-foreground)] hover:text-[var(--foreground)]"
)}
>
{s}
</button>
))}
</div>
</div>
<div className="flex min-h-0 flex-col">
<label className="mb-1 block text-[12px] font-bold uppercase tracking-wider text-[var(--muted-foreground)]">
Job Number
{scope === "project" ? "Project OMG #" : "Deliverable OMG #"}
</label>
<Input
autoFocus
@ -798,7 +843,7 @@ function AssignPopover({
const selected = jobNumber === j.jobNumber;
return (
<button
key={j.jobNumber}
key={`${j.scope}-${j.jobNumber}`}
type="button"
onClick={() => setJobNumber(j.jobNumber)}
onDoubleClick={() => valid && onAssign(j.jobNumber, hours)}
@ -819,8 +864,15 @@ function AssignPopover({
>
{j.jobNumber}
</span>
<span className="flex-1 truncate text-[12px] text-[var(--foreground)]">
{j.name}
<span className="flex min-w-0 flex-1 flex-col">
<span className="truncate text-[12px] text-[var(--foreground)]">
{j.name}
</span>
{j.scope === "deliverable" && j.projectName && (
<span className="truncate text-[11px] text-[var(--muted-foreground)]">
in {j.projectName}
</span>
)}
</span>
</button>
);

View file

@ -90,13 +90,19 @@ export function useDeleteBooking(weekStartIso: string) {
});
}
export interface KnownJobRow {
jobNumber: string;
name: string;
scope: "project" | "deliverable";
/** Parent project name — only set for deliverable rows. */
projectName?: string;
}
export function useKnownJobNumbers() {
return useQuery({
queryKey: ["resource-job-numbers"],
queryFn: () =>
fetchJson<Array<{ jobNumber: string; name: string }>>(
"/api/resources/job-numbers"
),
fetchJson<KnownJobRow[]>("/api/resources/job-numbers"),
staleTime: 60_000,
});
}

View file

@ -112,17 +112,65 @@ export async function deleteBooking(organizationId: string, bookingId: string) {
/**
* Used by the Resource Manager UI to populate the job-number autocomplete.
* Returns distinct OMG job numbers + project names from all projects the
* caller can see (visibility is enforced upstream in the API route).
* Returns distinct OMG job numbers + names for both projects AND
* deliverables in the org. Each row is tagged with a `scope` so the
* Resources page can let the producer pick whether they're booking
* someone onto a whole project or a specific deliverable.
*
* Deliverable rows include the parent project name so the picker reads
* "<project> / <deliverable>" producers pick by context, not just by
* an opaque OMG number.
*/
export async function listKnownJobNumbers(organizationId: string) {
const rows = await prisma.project.findMany({
export async function listKnownJobNumbers(
organizationId: string
): Promise<
Array<{
jobNumber: string;
name: string;
scope: "project" | "deliverable";
projectName?: string;
}>
> {
const projects = await prisma.project.findMany({
where: { organizationId, omgJobNumber: { not: null } },
select: { omgJobNumber: true, name: true },
orderBy: { updatedAt: "desc" },
take: 200,
});
return rows
.filter((r): r is { omgJobNumber: string; name: string } => !!r.omgJobNumber)
.map((r) => ({ jobNumber: r.omgJobNumber, name: r.name }));
const deliverables = await prisma.deliverable.findMany({
where: {
organizationId,
omgJobNumber: { not: null },
},
select: {
omgJobNumber: true,
name: true,
project: { select: { name: true } },
},
orderBy: { updatedAt: "desc" },
take: 400,
});
const projectRows = projects
.filter((p): p is { omgJobNumber: string; name: string } => !!p.omgJobNumber)
.map((p) => ({
jobNumber: p.omgJobNumber,
name: p.name,
scope: "project" as const,
}));
const deliverableRows = deliverables
.filter(
(d): d is { omgJobNumber: string; name: string; project: { name: string } } =>
!!d.omgJobNumber
)
.map((d) => ({
jobNumber: d.omgJobNumber,
name: d.name,
scope: "deliverable" as const,
projectName: d.project.name,
}));
return [...projectRows, ...deliverableRows];
}