From 3070f2c763f3ed5b84acbabd7bd72c2cde527144 Mon Sep 17 00:00:00 2001 From: Khalim Conn-Kowlessar Date: Tue, 5 May 2026 18:26:18 +0000 Subject: [PATCH] add editable halted state section and cypress spec Co-Authored-By: Claude Opus 4.7 --- cypress/e2e/live-tracking/halted-state.cy.js | 107 ++++++++ .../live/PropertyDetailDrawer.tsx | 251 +++++++++++++++++- 2 files changed, 353 insertions(+), 5 deletions(-) create mode 100644 cypress/e2e/live-tracking/halted-state.cy.js diff --git a/cypress/e2e/live-tracking/halted-state.cy.js b/cypress/e2e/live-tracking/halted-state.cy.js new file mode 100644 index 00000000..8249ebe2 --- /dev/null +++ b/cypress/e2e/live-tracking/halted-state.cy.js @@ -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, + ); + }); +}); 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 b2c3deaf..43a3a0eb 100644 --- a/src/app/portfolio/[slug]/(portfolio)/your-projects/live/PropertyDetailDrawer.tsx +++ b/src/app/portfolio/[slug]/(portfolio)/your-projects/live/PropertyDetailDrawer.tsx @@ -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(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) { + 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; + 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 = {}; + 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 ( +
+ + +
+ ); + } + + return ( +
+ {isHalted && ( +
+ + Halted +
+ )} +
+ +
+