diff --git a/src/app/(app)/resources/page.tsx b/src/app/(app)/resources/page.tsx index d400535..6129754 100644 --- a/src/app/(app)/resources/page.tsx +++ b/src/app/(app)/resources/page.tsx @@ -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(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
+ {/* Scope toggle — book against a whole project, or a specific + deliverable. The list below filters by the picked scope. */} +
+ +
+ {(["project", "deliverable"] as const).map((s) => ( + + ))} +
+
+
setJobNumber(j.jobNumber)} onDoubleClick={() => valid && onAssign(j.jobNumber, hours)} @@ -819,8 +864,15 @@ function AssignPopover({ > {j.jobNumber} - - {j.name} + + + {j.name} + + {j.scope === "deliverable" && j.projectName && ( + + in {j.projectName} + + )} ); diff --git a/src/hooks/use-bookings.ts b/src/hooks/use-bookings.ts index a9df9fd..6d07e51 100644 --- a/src/hooks/use-bookings.ts +++ b/src/hooks/use-bookings.ts @@ -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>( - "/api/resources/job-numbers" - ), + fetchJson("/api/resources/job-numbers"), staleTime: 60_000, }); } diff --git a/src/lib/services/booking-service.ts b/src/lib/services/booking-service.ts index 7b4fc45..7c4b07d 100644 --- a/src/lib/services/booking-service.ts +++ b/src/lib/services/booking-service.ts @@ -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 + * " / " — 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]; }