add editable halted state section and cypress spec

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
Khalim Conn-Kowlessar 2026-05-05 18:26:18 +00:00
parent e020b3fd83
commit 3070f2c763
2 changed files with 353 additions and 5 deletions

View file

@ -0,0 +1,107 @@
/**
* Live Tracking Halted state editor (issue #255)
*
* Verifies the approver flow on the Halted section of the property detail
* drawer:
* 1. an approver can set a halted date + free-text reason and save them,
* 2. the drawer reflects the halted state (badge + persisted values),
* 3. clicking Resume clears the date but keeps the reason as the
* last-set value, both in the input and after a reload.
*
* Mirrors `pibi-dates.cy.js`. Assumes an authenticated approver session
* is reusable by the test harness; the target portfolio + a deal whose
* Halted section is editable by the current user are read from Cypress
* env vars so the spec stays portable.
*/
const PORTFOLIO_SLUG = Cypress.env("LIVE_PORTFOLIO_SLUG");
const TARGET_DEAL_NAME = Cypress.env("LIVE_HALTED_DEAL_NAME");
const HALTED_DATE = "2025-06-01";
const HALTED_REASON = "Awaiting roof access from landlord";
describe("Halted state editor — approver flow", function () {
before(function () {
if (!PORTFOLIO_SLUG) {
cy.log(
"LIVE_PORTFOLIO_SLUG env var not set — skipping live tracking specs",
);
this.skip();
}
});
function openDrawerForTargetDeal() {
cy.visit(`/portfolio/${PORTFOLIO_SLUG}/your-projects/live`);
// Switch to the Measures tab — the easiest way into the drawer.
cy.contains("button, [role=tab]", "Measures").click();
if (TARGET_DEAL_NAME) {
cy.contains("[data-testid=measures-row]", TARGET_DEAL_NAME).click();
} else {
cy.get("[data-testid=measures-row]").first().click();
}
cy.get("[data-testid=property-detail-drawer]").should("be.visible");
cy.get("[data-testid=drawer-section-halted]").should("exist");
}
it("lets an approver halt a property and resume it while preserving the reason", () => {
openDrawerForTargetDeal();
// Approver sees editable inputs.
cy.get("[data-testid=halted-date-input]").should("be.visible");
cy.get("[data-testid=halted-reason-input]").should("be.visible");
// Set halted date + reason.
cy.get("[data-testid=halted-date-input]").clear().type(HALTED_DATE);
cy.get("[data-testid=halted-reason-input]")
.clear()
.type(HALTED_REASON);
cy.get("[data-testid=halted-save-button]")
.should("not.be.disabled")
.click();
// Save completes — button label flips back, no error banner.
cy.get("[data-testid=halted-save-button]").should(
"contain.text",
"Save Halted State",
);
cy.get("[data-testid=halted-error]").should("not.exist");
// Drawer reflects halted state via the status badge + persisted values.
cy.get("[data-testid=halted-status-badge]").should("contain.text", "Halted");
cy.get("[data-testid=halted-date-input]").should("have.value", HALTED_DATE);
cy.get("[data-testid=halted-reason-input]").should(
"have.value",
HALTED_REASON,
);
// Now resume — date clears, reason stays.
cy.get("[data-testid=halted-resume-button]")
.should("be.visible")
.click();
// Once resumed the badge + resume button disappear, but the reason is
// still visible in the textarea.
cy.get("[data-testid=halted-status-badge]").should("not.exist");
cy.get("[data-testid=halted-resume-button]").should("not.exist");
cy.get("[data-testid=halted-date-input]").should("have.value", "");
cy.get("[data-testid=halted-reason-input]").should(
"have.value",
HALTED_REASON,
);
// Reload the page — the cleared date and preserved reason persist
// server-side.
cy.reload();
openDrawerForTargetDeal();
cy.get("[data-testid=halted-date-input]").should("have.value", "");
cy.get("[data-testid=halted-reason-input]").should(
"have.value",
HALTED_REASON,
);
});
});

View file

@ -692,6 +692,244 @@ function PibiDatesEditor({
);
}
// -----------------------------------------------------------------------
// Halted section — editable for approvers (issue #255)
// -----------------------------------------------------------------------
interface HaltedEditorProps {
dealId: string;
portfolioId: string;
initialHaltedDate: Date | string | null;
initialHaltedReason: string | null;
/** True when the user has the approver capability on this portfolio. */
canEdit: boolean;
}
function HaltedEditor({
dealId,
portfolioId,
initialHaltedDate,
initialHaltedReason,
canEdit,
}: HaltedEditorProps) {
const initialDate = useMemo(
() => toDateInputValue(initialHaltedDate),
[initialHaltedDate],
);
const initialReason = initialHaltedReason ?? "";
const [dateValue, setDateValue] = useState(initialDate);
const [reasonValue, setReasonValue] = useState(initialReason);
const [savedDate, setSavedDate] = useState(initialDate);
const [savedReason, setSavedReason] = useState(initialReason);
const [submitting, setSubmitting] = useState(false);
const [error, setError] = useState<string | null>(null);
// Reset state when the drawer switches deals.
useEffect(() => {
setDateValue(initialDate);
setSavedDate(initialDate);
}, [initialDate]);
useEffect(() => {
setReasonValue(initialReason);
setSavedReason(initialReason);
}, [initialReason]);
const dirty = dateValue !== savedDate || reasonValue !== savedReason;
const isHalted = !!savedDate;
/**
* Send the supplied delta to the deal-properties endpoint. Used both by
* Save (sends only changed fields) and Resume (sends only the date null).
*/
async function patchFields(fields: Record<string, string | null>) {
setSubmitting(true);
setError(null);
try {
const res = await fetch(
`/api/portfolio/${portfolioId}/deal-properties`,
{
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ dealId, fields }),
},
);
if (!res.ok) {
const json = await res.json().catch(() => ({}));
return {
ok: false as const,
error:
typeof json.error === "string"
? json.error
: "Failed to update halted state",
};
}
const json = (await res.json()) as {
results: Record<string, { ok: boolean; error?: string }>;
hubspotSync: "ok" | "failed" | "skipped";
hubspotError?: string;
};
const fieldErrors = Object.entries(json.results ?? {})
.filter(([, r]) => !r.ok)
.map(([k, r]) => `${k}: ${r.error ?? "rejected"}`);
if (fieldErrors.length > 0) {
return { ok: false as const, error: fieldErrors.join("; ") };
}
if (json.hubspotSync === "failed") {
return {
ok: true as const,
warning: json.hubspotError
? `Saved locally — HubSpot sync failed: ${json.hubspotError}`
: "Saved locally — HubSpot sync failed",
};
}
return { ok: true as const };
} catch (err) {
return {
ok: false as const,
error:
err instanceof Error ? err.message : "Failed to update halted state",
};
} finally {
setSubmitting(false);
}
}
async function handleSave() {
if (!dirty) return;
const fields: Record<string, string | null> = {};
if (dateValue !== savedDate) {
fields.property_halted_date = dateInputToIso(dateValue);
}
if (reasonValue !== savedReason) {
fields.property_halted_reason = reasonValue.trim() === "" ? null : reasonValue;
}
// Optimistic update.
const prevDate = savedDate;
const prevReason = savedReason;
setSavedDate(dateValue);
setSavedReason(reasonValue);
const result = await patchFields(fields);
if (!result.ok) {
setSavedDate(prevDate);
setSavedReason(prevReason);
setDateValue(prevDate);
setReasonValue(prevReason);
setError(result.error);
return;
}
if (result.warning) setError(result.warning);
}
async function handleResume() {
// Resume clears only the date — reason is preserved as the last-set
// value per acceptance criteria.
const prevDate = savedDate;
setSavedDate("");
setDateValue("");
const result = await patchFields({ property_halted_date: null });
if (!result.ok) {
setSavedDate(prevDate);
setDateValue(prevDate);
setError(result.error);
return;
}
if (result.warning) setError(result.warning);
}
if (!canEdit) {
return (
<div className="divide-y divide-gray-50">
<InfoRow
label="Property Halted Date"
value={formatDate(initialHaltedDate)}
/>
<InfoRow label="Property Halted Reason" value={initialHaltedReason} />
</div>
);
}
return (
<div className="space-y-3">
{isHalted && (
<div
data-testid="halted-status-badge"
className="inline-flex items-center gap-1.5 px-2.5 py-1 rounded-full text-xs font-semibold border bg-amber-50 text-amber-700 border-amber-200"
>
<AlertTriangle className="h-3.5 w-3.5" />
Halted
</div>
)}
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
<label className="flex flex-col gap-1">
<span className="text-xs text-gray-500 font-medium">
Property Halted Date
</span>
<input
type="date"
data-testid="halted-date-input"
value={dateValue}
onChange={(e) => setDateValue(e.target.value)}
disabled={submitting}
className="rounded-lg border border-gray-200 px-3 py-1.5 text-xs text-gray-800 focus:outline-none focus:ring-2 focus:ring-amber-200 focus:border-amber-300"
/>
</label>
</div>
<label className="flex flex-col gap-1">
<span className="text-xs text-gray-500 font-medium">
Reason
</span>
<textarea
data-testid="halted-reason-input"
value={reasonValue}
onChange={(e) => setReasonValue(e.target.value)}
disabled={submitting}
rows={3}
className="rounded-lg border border-gray-200 px-3 py-2 text-xs text-gray-800 placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-amber-200 focus:border-amber-300 resize-none"
placeholder="Free-text reason for halting this property"
/>
</label>
<div className="flex flex-wrap items-center justify-between gap-3">
<p className="text-[11px] text-gray-400">
Setting a date marks the property as halted. Resume clears the date
but keeps the reason.
</p>
<div className="flex items-center gap-2">
{isHalted && (
<button
type="button"
data-testid="halted-resume-button"
onClick={handleResume}
disabled={submitting}
className="text-xs font-medium px-3 py-1.5 rounded-lg border border-emerald-200 text-emerald-700 bg-white hover:bg-emerald-50 disabled:opacity-50 transition-colors"
>
{submitting ? "Working…" : "Resume"}
</button>
)}
<button
type="button"
data-testid="halted-save-button"
onClick={handleSave}
disabled={!dirty || 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 Halted State"}
</button>
</div>
</div>
{error && (
<p
data-testid="halted-error"
className="text-xs text-red-600 bg-red-50 px-3 py-2 rounded-lg"
>
{error}
</p>
)}
</div>
);
}
// -----------------------------------------------------------------------
// PropertyDetailDrawer — main component
// -----------------------------------------------------------------------
@ -937,13 +1175,16 @@ export default function PropertyDetailDrawer({
</div>
</div>
{/* Halted section */}
{/* Halted section — editable for approvers (issue #255) */}
<div ref={(el) => { sectionRefs.current.halted = el; }}>
<SectionHeader id="halted" label={SECTION_TITLES.halted} />
<div className="divide-y divide-gray-50">
<InfoRow label="Property Halted Date" value={formatDate(deal.propertyHaltedDate)} />
<InfoRow label="Property Halted Reason" value={deal.propertyHaltedReason} />
</div>
<HaltedEditor
dealId={deal.dealId}
portfolioId={portfolioId}
initialHaltedDate={deal.propertyHaltedDate}
initialHaltedReason={deal.propertyHaltedReason ?? null}
canEdit={userCapability.includes("approver")}
/>
</div>
{/* Technical Approved section */}