diff --git a/cypress/e2e/live-tracking/domna-survey.cy.js b/cypress/e2e/live-tracking/domna-survey.cy.js new file mode 100644 index 00000000..cf5ec538 --- /dev/null +++ b/cypress/e2e/live-tracking/domna-survey.cy.js @@ -0,0 +1,100 @@ +/** + * Live Tracking — Domna Survey editor (issue #256) + * + * Verifies the approver flow on the Domna section of the property detail + * drawer: + * 1. an approver can set a Domna survey type (free text) and date and + * save them, + * 2. the drawer reflects the saved values immediately (optimistic + * update), + * 3. the values persist across a page reload (i.e. the deal-properties + * endpoint wrote them server-side). + * + * Mirrors `halted-state.cy.js`. Assumes an authenticated approver session + * is reusable by the test harness; the target portfolio + a deal whose + * Domna 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_DOMNA_DEAL_NAME"); + +const SURVEY_TYPE = "Standard"; +const SURVEY_DATE = "2025-07-15"; + +describe("Domna survey 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-domna]").should("exist"); + } + + it("lets an approver set domna survey type + date and persists them across reload", () => { + openDrawerForTargetDeal(); + + // Approver sees editable inputs. + cy.get("[data-testid=domna-survey-type-input]").should("be.visible"); + cy.get("[data-testid=domna-survey-date-input]").should("be.visible"); + + cy.get("[data-testid=domna-survey-type-input]") + .clear() + .type(SURVEY_TYPE); + cy.get("[data-testid=domna-survey-date-input]") + .clear() + .type(SURVEY_DATE); + + cy.get("[data-testid=domna-save-button]") + .should("not.be.disabled") + .click(); + + // Save completes — button label flips back, no error banner. + cy.get("[data-testid=domna-save-button]").should( + "contain.text", + "Save Domna Survey", + ); + cy.get("[data-testid=domna-error]").should("not.exist"); + + // Optimistic update — the inputs already reflect the new values. + cy.get("[data-testid=domna-survey-type-input]").should( + "have.value", + SURVEY_TYPE, + ); + cy.get("[data-testid=domna-survey-date-input]").should( + "have.value", + SURVEY_DATE, + ); + + // Reload the page and reopen the drawer — the persisted values must + // still be there. + cy.reload(); + openDrawerForTargetDeal(); + + cy.get("[data-testid=domna-survey-type-input]").should( + "have.value", + SURVEY_TYPE, + ); + cy.get("[data-testid=domna-survey-date-input]").should( + "have.value", + SURVEY_DATE, + ); + }); +}); 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 43a3a0eb..0d9bb7f8 100644 --- a/src/app/portfolio/[slug]/(portfolio)/your-projects/live/PropertyDetailDrawer.tsx +++ b/src/app/portfolio/[slug]/(portfolio)/your-projects/live/PropertyDetailDrawer.tsx @@ -930,6 +930,186 @@ function HaltedEditor({ ); } +// ----------------------------------------------------------------------- +// Domna section — editable for approvers (issue #256) +// ----------------------------------------------------------------------- +interface DomnaEditorProps { + dealId: string; + portfolioId: string; + initialSurveyType: string | null; + initialSurveyDate: Date | string | null; + /** True when the user has the approver capability on this portfolio. */ + canEdit: boolean; +} + +function DomnaEditor({ + dealId, + portfolioId, + initialSurveyType, + initialSurveyDate, + canEdit, +}: DomnaEditorProps) { + const initialType = initialSurveyType ?? ""; + const initialDate = useMemo( + () => toDateInputValue(initialSurveyDate), + [initialSurveyDate], + ); + + const [typeValue, setTypeValue] = useState(initialType); + const [dateValue, setDateValue] = useState(initialDate); + const [savedType, setSavedType] = useState(initialType); + const [savedDate, setSavedDate] = useState(initialDate); + const [submitting, setSubmitting] = useState(false); + const [error, setError] = useState(null); + + // Reset state when the drawer switches deals. + useEffect(() => { + setTypeValue(initialType); + setSavedType(initialType); + }, [initialType]); + useEffect(() => { + setDateValue(initialDate); + setSavedDate(initialDate); + }, [initialDate]); + + const dirty = typeValue !== savedType || dateValue !== savedDate; + + async function handleSave() { + if (!dirty) return; + setSubmitting(true); + setError(null); + const fields: Record = {}; + if (typeValue !== savedType) { + fields.domna_survey_type = typeValue.trim() === "" ? null : typeValue; + } + if (dateValue !== savedDate) { + fields.domna_survey_date = dateInputToIso(dateValue); + } + // Optimistic update. + const prevType = savedType; + const prevDate = savedDate; + setSavedType(typeValue); + setSavedDate(dateValue); + + 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) { + setSavedType(prevType); + setSavedDate(prevDate); + setTypeValue(prevType); + setDateValue(prevDate); + const json = await res.json().catch(() => ({})); + setError( + typeof json.error === "string" + ? json.error + : "Failed to update Domna survey", + ); + return; + } + 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) { + setError(fieldErrors.join("; ")); + } else if (json.hubspotSync === "failed") { + setError( + json.hubspotError + ? `Saved locally — HubSpot sync failed: ${json.hubspotError}` + : "Saved locally — HubSpot sync failed", + ); + } + } catch (err) { + setSavedType(prevType); + setSavedDate(prevDate); + setTypeValue(prevType); + setDateValue(prevDate); + setError(err instanceof Error ? err.message : "Failed to update Domna survey"); + } finally { + setSubmitting(false); + } + } + + if (!canEdit) { + return ( +
+ + +
+ ); + } + + return ( +
+
+ + +
+
+

+ Free text — leave blank to clear. Changes sync to HubSpot. +

+ +
+ {error && ( +

+ {error} +

+ )} +
+ ); +} + // ----------------------------------------------------------------------- // PropertyDetailDrawer — main component // ----------------------------------------------------------------------- @@ -1008,16 +1188,6 @@ export default function PropertyDetailDrawer({ deal?.technicalApprovedMeasuresForInstall ?? null, ); - // Domna section: prefer the new text column when present, otherwise fall - // back to the legacy boolean ("Required" / "Not required"). - const domnaSurveyTypeDisplay: string | null = (() => { - if (!deal) return null; - if (deal.domnaSurveyType) return deal.domnaSurveyType; - if (deal.domnaSurveyRequired === true) return "Required"; - if (deal.domnaSurveyRequired === false) return "Not required"; - return null; - })(); - return ( !v && onClose()} direction="right"> - {/* Domna Survey section */} + {/* Domna Survey section — editable for approvers (issue #256) */}
{ sectionRefs.current.domna = el; }}> -
- - -
+
{/* Halted section — editable for approvers (issue #255) */}