mirror of
https://github.com/Hestia-Homes/assessment-model.git
synced 2026-06-08 11:37:25 +00:00
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:
parent
1e5eb09c3e
commit
240b633928
1 changed files with 254 additions and 19 deletions
|
|
@ -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>
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue