From 3d08a423a6d8881e0dc1efbe27bc9941513cc255 Mon Sep 17 00:00:00 2001 From: Khalim Conn-Kowlessar Date: Tue, 5 May 2026 14:34:05 +0000 Subject: [PATCH] add editable PIBI dates section and cypress spec --- cypress/e2e/live-tracking/pibi-dates.cy.js | 98 +++++++ .../live/PropertyDetailDrawer.tsx | 264 ++++++++++++++++-- 2 files changed, 341 insertions(+), 21 deletions(-) create mode 100644 cypress/e2e/live-tracking/pibi-dates.cy.js diff --git a/cypress/e2e/live-tracking/pibi-dates.cy.js b/cypress/e2e/live-tracking/pibi-dates.cy.js new file mode 100644 index 0000000..84098ab --- /dev/null +++ b/cypress/e2e/live-tracking/pibi-dates.cy.js @@ -0,0 +1,98 @@ +/** + * Live Tracking — PIBI dates editor (issue #252) + * + * Verifies the write-role flow on the PIBI section of the property detail + * drawer: a `write` user can pick PIBI order and completion dates, hit + * Save, and the chosen dates are still reflected after the page is + * reloaded (i.e. the values were persisted server-side). + * + * The spec assumes an authenticated `write` session can be reused (or the + * test harness logs in via the same flow as the rest of the suite). The + * target portfolio slug + a deal id with PIBI fields editable for the + * write 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_PIBI_DEAL_NAME"); + +const ORDER_DATE = "2025-03-12"; +const COMPLETED_DATE = "2025-04-02"; + +describe("PIBI dates editor — write user 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-pibi]").should("exist"); + } + + it("lets a write user set PIBI order + completion dates and persists them across reload", () => { + openDrawerForTargetDeal(); + + // Both date inputs render for write+ users. + cy.get("[data-testid=pibi-order-date-input]").should("be.visible"); + cy.get("[data-testid=pibi-completed-date-input]").should("be.visible"); + + cy.get("[data-testid=pibi-order-date-input]") + .clear() + .type(ORDER_DATE); + cy.get("[data-testid=pibi-completed-date-input]") + .clear() + .type(COMPLETED_DATE); + + // Save button should be enabled once values change. + cy.get("[data-testid=pibi-save-button]") + .should("not.be.disabled") + .click(); + + // Saving completes — the button label flips back from "Saving…" to + // "Save PIBI Dates" and no error banner is shown. + cy.get("[data-testid=pibi-save-button]").should( + "contain.text", + "Save PIBI Dates", + ); + cy.get("[data-testid=pibi-error]").should("not.exist"); + + // Optimistic update — the inputs already reflect the new values. + cy.get("[data-testid=pibi-order-date-input]").should( + "have.value", + ORDER_DATE, + ); + cy.get("[data-testid=pibi-completed-date-input]").should( + "have.value", + COMPLETED_DATE, + ); + + // Reload the page and reopen the drawer — the persisted values must + // still be there. + cy.reload(); + openDrawerForTargetDeal(); + + cy.get("[data-testid=pibi-order-date-input]").should( + "have.value", + ORDER_DATE, + ); + cy.get("[data-testid=pibi-completed-date-input]").should( + "have.value", + COMPLETED_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 70f5989..b2c3dea 100644 --- a/src/app/portfolio/[slug]/(portfolio)/your-projects/live/PropertyDetailDrawer.tsx +++ b/src/app/portfolio/[slug]/(portfolio)/your-projects/live/PropertyDetailDrawer.tsx @@ -1,6 +1,6 @@ "use client"; -import { useEffect, useRef, useState } from "react"; +import { useEffect, useMemo, useRef, useState } from "react"; import { useQuery, useQueryClient } from "@tanstack/react-query"; import { X, CheckCircle2, Circle, AlertTriangle, ChevronRight, ChevronDown, Trash2, RotateCcw } from "lucide-react"; import { @@ -477,6 +477,221 @@ function MilestoneTimeline({ deal }: { deal: ClassifiedDeal }) { ); } +// ----------------------------------------------------------------------- +// PIBI section — editable date pickers for write+ users (issue #252) +// ----------------------------------------------------------------------- + +/** Renders a `` value (yyyy-mm-dd) from a Date|string|null. */ +function toDateInputValue(d: Date | string | null | undefined): string { + if (!d) return ""; + try { + const date = typeof d === "string" ? new Date(d) : d; + if (Number.isNaN(date.getTime())) return ""; + // Use the date components (no timezone shift) so the picker shows the + // date the user originally selected. + const yyyy = date.getUTCFullYear(); + const mm = String(date.getUTCMonth() + 1).padStart(2, "0"); + const dd = String(date.getUTCDate()).padStart(2, "0"); + return `${yyyy}-${mm}-${dd}`; + } catch { + return ""; + } +} + +/** + * Convert a yyyy-mm-dd value from the date input into an ISO string at UTC + * midnight, or null if the field has been cleared. We anchor to UTC so the + * date round-trips cleanly through HubSpot, which uses date-only properties. + */ +function dateInputToIso(value: string): string | null { + if (!value) return null; + return new Date(`${value}T00:00:00.000Z`).toISOString(); +} + +interface PibiDatesEditorProps { + dealId: string; + portfolioId: string; + /** Initial (server-provided) values, used when no optimistic update yet. */ + initialOrderDate: Date | string | null; + initialCompletedDate: Date | string | null; + /** True when the user can write (creator/admin/write). */ + canEdit: boolean; +} + +function PibiDatesEditor({ + dealId, + portfolioId, + initialOrderDate, + initialCompletedDate, + canEdit, +}: PibiDatesEditorProps) { + const initialOrder = useMemo( + () => toDateInputValue(initialOrderDate), + [initialOrderDate], + ); + const initialCompleted = useMemo( + () => toDateInputValue(initialCompletedDate), + [initialCompletedDate], + ); + + const [orderValue, setOrderValue] = useState(initialOrder); + const [completedValue, setCompletedValue] = useState(initialCompleted); + const [savedOrder, setSavedOrder] = useState(initialOrder); + const [savedCompleted, setSavedCompleted] = useState(initialCompleted); + const [submitting, setSubmitting] = useState(false); + const [error, setError] = useState(null); + + // Reset values when the user opens a different deal in the same drawer + // session. + useEffect(() => { + setOrderValue(initialOrder); + setSavedOrder(initialOrder); + }, [initialOrder]); + useEffect(() => { + setCompletedValue(initialCompleted); + setSavedCompleted(initialCompleted); + }, [initialCompleted]); + + const dirty = + orderValue !== savedOrder || completedValue !== savedCompleted; + + async function handleSave() { + if (!dirty) return; + setSubmitting(true); + setError(null); + const fields: Record = {}; + if (orderValue !== savedOrder) { + fields.pibi_order_date = dateInputToIso(orderValue); + } + if (completedValue !== savedCompleted) { + fields.pibi_completed_date = dateInputToIso(completedValue); + } + // Optimistic update — surface the new values immediately. + const prevOrder = savedOrder; + const prevCompleted = savedCompleted; + setSavedOrder(orderValue); + setSavedCompleted(completedValue); + + 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) { + // Revert on hard failure so the UI stays truthful. + setSavedOrder(prevOrder); + setSavedCompleted(prevCompleted); + setOrderValue(prevOrder); + setCompletedValue(prevCompleted); + const json = await res.json().catch(() => ({})); + setError( + typeof json.error === "string" + ? json.error + : "Failed to update PIBI dates", + ); + return; + } + const json = (await res.json()) as { + results: Record; + hubspotSync: "ok" | "failed" | "skipped"; + hubspotError?: string; + }; + // Per-field errors win over a global HubSpot failure. + 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) { + setSavedOrder(prevOrder); + setSavedCompleted(prevCompleted); + setOrderValue(prevOrder); + setCompletedValue(prevCompleted); + setError(err instanceof Error ? err.message : "Failed to update PIBI dates"); + } finally { + setSubmitting(false); + } + } + + if (!canEdit) { + return ( +
+ + +
+ ); + } + + return ( +
+
+ + +
+
+

+ Pick the actual date — leave blank to clear. Changes sync to HubSpot. +

+ +
+ {error && ( +

+ {error} +

+ )} +
+ ); +} + // ----------------------------------------------------------------------- // PropertyDetailDrawer — main component // ----------------------------------------------------------------------- @@ -680,29 +895,36 @@ export default function PropertyDetailDrawer({ - {/* PIBI section */} + {/* PIBI section — editable date pickers for write+ users (issue #252) */}
{ sectionRefs.current.pibi = el; }}> -
- - - 0 ? ( - - {pibiMeasures.map((m) => ( - - {m} - - ))} - - ) : null - } +
+ + {pibiMeasures.length > 0 && ( +
+ + {pibiMeasures.map((m) => ( + + {m} + + ))} + + } + /> +
+ )}