From 240b633928e7a70f03290b7ee5daf1adcb5291e2 Mon Sep 17 00:00:00 2001 From: Khalim Conn-Kowlessar Date: Tue, 5 May 2026 19:14:33 +0000 Subject: [PATCH] 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 --- .../live/PropertyDetailDrawer.tsx | 273 ++++++++++++++++-- 1 file changed, 254 insertions(+), 19 deletions(-) diff --git a/src/app/portfolio/[slug]/(portfolio)/your-projects/live/PropertyDetailDrawer.tsx b/src/app/portfolio/[slug]/(portfolio)/your-projects/live/PropertyDetailDrawer.tsx index a9d807f..daeb4c6 100644 --- a/src/app/portfolio/[slug]/(portfolio)/your-projects/live/PropertyDetailDrawer.tsx +++ b/src/app/portfolio/[slug]/(portfolio)/your-projects/live/PropertyDetailDrawer.tsx @@ -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>(new Set()); + const [initialised, setInitialised] = useState(false); + const [submitting, setSubmitting] = useState(false); + const [error, setError] = useState(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 ( +

+ Loading… +

+ ); + } + + if (allMeasures.length === 0) { + return ( +

+ No measures associated with this deal yet. +

+ ); + } + + return ( +
+

PIBI Measure Selection

+
+ {allMeasures.map((measure) => { + const checked = selected.has(measure); + const isApproved = (data?.approvedMeasures ?? []).includes(measure); + return ( + + ); + })} +
+
+

+ Tick measures going for PIBI. Approved measures are pre-ticked (✓). +

+ +
+ {error && ( +

+ {error} +

+ )} +
+ ); +} + // ----------------------------------------------------------------------- // 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({ - {/* PIBI section — editable date pickers for write+ users (issue #252) */} + {/* PIBI section — date pickers (issue #252) + measure selector (issue #254) */}
{ sectionRefs.current.pibi = el; }}>
@@ -1473,24 +1697,35 @@ export default function PropertyDetailDrawer({ initialCompletedDate={deal.pibiCompletedDate} canEdit={WRITE_ROLES.includes(userRole)} /> - {pibiMeasures.length > 0 && ( -
- - {pibiMeasures.map((m) => ( - - {m} - - ))} - - } - /> -
+ {/* Approver-only PIBI measure selector (issue #254). Non-approvers + still see the static read-only chip list from the deal row. */} + {userCapability.includes("approver") ? ( + + ) : ( + pibiMeasures.length > 0 && ( +
+ + {pibiMeasures.map((m) => ( + + {m} + + ))} + + } + /> +
+ ) )}