add PIBI measure selector to property drawer

Adds PibiMeasureSelector component (issue #254) inside the drawer's
PIBI section. Approvers see a checkbox multi-select listing proposed +
instructed measures; approved measures are pre-ticked. Non-approvers
retain the existing read-only chip list. Saves via POST
/api/portfolio/[portfolioId]/pibi-measures and invalidates the query
cache on success.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Khalim Conn-Kowlessar 2026-05-05 19:14:33 +00:00
parent 1e5eb09c3e
commit 240b633928

View file

@ -1112,6 +1112,230 @@ function DomnaEditor({
);
}
// -----------------------------------------------------------------------
// PIBI measure selector — approver-only multi-select listing all measures
// associated with the deal (proposed + instructed). Approved measures are
// pre-ticked. The user can freely tick/untick; no hard block (issue #254).
// -----------------------------------------------------------------------
interface PibiMeasureSelectorProps {
dealId: string;
portfolioId: string;
/** Proposed measures parsed from the deal row. */
proposedMeasures: string[];
/** True when the user has the approver capability. */
canEdit: boolean;
}
function PibiMeasureSelector({
dealId,
portfolioId,
proposedMeasures,
canEdit,
}: PibiMeasureSelectorProps) {
const queryClient = useQueryClient();
// Fetch current PIBI selection, approved measures, and instructed measures.
const { data, isLoading } = useQuery<{
pibiMeasures: string[];
approvedMeasures: string[];
instructedMeasures: string[];
}>({
queryKey: ["pibiMeasures", portfolioId, dealId],
queryFn: async () => {
const res = await fetch(
`/api/portfolio/${portfolioId}/pibi-measures?dealId=${encodeURIComponent(dealId)}`,
);
if (!res.ok) throw new Error("Failed to fetch PIBI measures");
return res.json();
},
staleTime: 30_000,
});
// All measures to display: union of proposed + instructed (de-duped).
const allMeasures = useMemo(() => {
const instructed = data?.instructedMeasures ?? [];
const all = [...proposedMeasures];
for (const m of instructed) {
if (!all.includes(m)) all.push(m);
}
return all;
}, [proposedMeasures, data?.instructedMeasures]);
// Local selection state — initialised from server data once loaded.
const [selected, setSelected] = useState<Set<string>>(new Set());
const [initialised, setInitialised] = useState(false);
const [submitting, setSubmitting] = useState(false);
const [error, setError] = useState<string | null>(null);
// Re-initialise when the deal changes or server data arrives.
useEffect(() => {
if (!data) return;
// Pre-tick: current pibi_ordered rows if any, otherwise approved measures.
const initial =
data.pibiMeasures.length > 0
? data.pibiMeasures
: data.approvedMeasures;
setSelected(new Set(initial));
setInitialised(true);
setError(null);
}, [dealId, data]);
function toggleMeasure(measure: string) {
setSelected((prev) => {
const next = new Set(prev);
if (next.has(measure)) {
next.delete(measure);
} else {
next.add(measure);
}
return next;
});
}
async function handleSave() {
setSubmitting(true);
setError(null);
const measureNames = Array.from(selected);
try {
const res = await fetch(
`/api/portfolio/${portfolioId}/pibi-measures`,
{
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ dealId, measureNames }),
},
);
if (!res.ok) {
const json = await res.json().catch(() => ({}));
setError(
typeof json.error === "string"
? json.error
: "Failed to save PIBI selections",
);
return;
}
const json = (await res.json()) as {
ok: boolean;
hubspotSync?: "ok" | "failed";
hubspotError?: string;
};
if (json.hubspotSync === "failed") {
setError(
json.hubspotError
? `Saved locally — HubSpot sync failed: ${json.hubspotError}`
: "Saved locally — HubSpot sync failed",
);
}
queryClient.invalidateQueries({
queryKey: ["pibiMeasures", portfolioId, dealId],
});
} catch (err) {
setError(
err instanceof Error ? err.message : "Failed to save PIBI selections",
);
} finally {
setSubmitting(false);
}
}
if (!canEdit) return null;
if (isLoading || !initialised) {
return (
<p
data-testid="pibi-selector-loading"
className="text-xs text-gray-400 py-2"
>
Loading
</p>
);
}
if (allMeasures.length === 0) {
return (
<p className="text-xs text-gray-400 py-2">
No measures associated with this deal yet.
</p>
);
}
return (
<div className="space-y-3">
<p className="text-xs text-gray-500 font-medium">PIBI Measure Selection</p>
<div
data-testid="pibi-measure-selector"
className="flex flex-wrap gap-2"
>
{allMeasures.map((measure) => {
const checked = selected.has(measure);
const isApproved = (data?.approvedMeasures ?? []).includes(measure);
return (
<label
key={measure}
data-testid={`pibi-measure-option-${measure}`}
className={`flex items-center gap-1.5 cursor-pointer px-2.5 py-1 rounded-full text-xs border transition-colors ${
checked
? "bg-brandblue/10 border-brandblue/40 text-brandblue font-medium"
: "bg-gray-50 border-gray-200 text-gray-600 hover:bg-gray-100"
}`}
>
<input
type="checkbox"
className="sr-only"
checked={checked}
onChange={() => toggleMeasure(measure)}
disabled={submitting}
data-testid={`pibi-measure-checkbox-${measure}`}
/>
<span
className={`w-3 h-3 rounded-sm border flex items-center justify-center shrink-0 ${
checked
? "bg-brandblue border-brandblue"
: "bg-white border-gray-300"
}`}
>
{checked && (
<svg viewBox="0 0 10 8" className="w-2 h-2 fill-white">
<path d="M1 4l3 3 5-6" stroke="white" strokeWidth="1.5" fill="none" strokeLinecap="round" strokeLinejoin="round" />
</svg>
)}
</span>
{measure}
{isApproved && (
<span className="text-[10px] text-emerald-600 font-semibold ml-0.5">
</span>
)}
</label>
);
})}
</div>
<div className="flex items-center justify-between gap-3">
<p className="text-[11px] text-gray-400">
Tick measures going for PIBI. Approved measures are pre-ticked ().
</p>
<button
type="button"
data-testid="pibi-selector-save"
onClick={handleSave}
disabled={submitting}
className="text-xs font-medium px-3 py-1.5 rounded-lg bg-brandblue text-white hover:bg-brandmidblue disabled:opacity-50 transition-colors"
>
{submitting ? "Saving…" : "Save PIBI Selections"}
</button>
</div>
{error && (
<p
data-testid="pibi-selector-error"
className="text-xs text-red-600 bg-red-50 px-3 py-2 rounded-lg"
>
{error}
</p>
)}
</div>
);
}
// -----------------------------------------------------------------------
// Instruct measure editor — approver-only form to instruct an out-of-band
// measure that the coordinator did not propose (issue #253).
@ -1462,7 +1686,7 @@ export default function PropertyDetailDrawer({
</div>
</div>
{/* PIBI section — editable date pickers for write+ users (issue #252) */}
{/* PIBI section — date pickers (issue #252) + measure selector (issue #254) */}
<div ref={(el) => { sectionRefs.current.pibi = el; }}>
<SectionHeader id="pibi" label={SECTION_TITLES.pibi} />
<div className="space-y-3">
@ -1473,24 +1697,35 @@ export default function PropertyDetailDrawer({
initialCompletedDate={deal.pibiCompletedDate}
canEdit={WRITE_ROLES.includes(userRole)}
/>
{pibiMeasures.length > 0 && (
<div className="divide-y divide-gray-50">
<InfoRow
label="Measures for PIBI"
value={
<span className="flex flex-wrap gap-1.5">
{pibiMeasures.map((m) => (
<span
key={m}
className="px-2 py-0.5 rounded-full text-[11px] bg-gray-50 border border-gray-200 text-gray-600"
>
{m}
</span>
))}
</span>
}
/>
</div>
{/* Approver-only PIBI measure selector (issue #254). Non-approvers
still see the static read-only chip list from the deal row. */}
{userCapability.includes("approver") ? (
<PibiMeasureSelector
dealId={deal.dealId}
portfolioId={portfolioId}
proposedMeasures={parseMeasures(deal.proposedMeasures ?? null)}
canEdit
/>
) : (
pibiMeasures.length > 0 && (
<div className="divide-y divide-gray-50">
<InfoRow
label="Measures for PIBI"
value={
<span className="flex flex-wrap gap-1.5">
{pibiMeasures.map((m) => (
<span
key={m}
className="px-2 py-0.5 rounded-full text-[11px] bg-gray-50 border border-gray-200 text-gray-600"
>
{m}
</span>
))}
</span>
}
/>
</div>
)
)}
</div>
</div>