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:
parent
aa897354e7
commit
5f409c6c46
3 changed files with 125 additions and 19 deletions
|
|
@ -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>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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];
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue