mirror of
https://github.com/Hestia-Homes/assessment-model.git
synced 2026-06-30 12:55:02 +00:00
add editable halted state section and cypress spec
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
parent
e020b3fd83
commit
3070f2c763
2 changed files with 353 additions and 5 deletions
107
cypress/e2e/live-tracking/halted-state.cy.js
Normal file
107
cypress/e2e/live-tracking/halted-state.cy.js
Normal 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,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
@ -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 */}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue