add editable PIBI dates section and cypress spec

This commit is contained in:
Khalim Conn-Kowlessar 2026-05-05 14:34:05 +00:00
parent b8befe56fe
commit 3d08a423a6
2 changed files with 341 additions and 21 deletions

View file

@ -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,
);
});
});

View file

@ -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 `<input type="date">` 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<string | null>(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<string, string | null> = {};
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<string, { ok: boolean; error?: string }>;
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 (
<div className="divide-y divide-gray-50">
<InfoRow label="PIBI Order Date" value={formatDate(initialOrderDate)} />
<InfoRow
label="PIBI Completed Date"
value={formatDate(initialCompletedDate)}
/>
</div>
);
}
return (
<div className="space-y-3">
<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">
PIBI Order Date
</span>
<input
type="date"
data-testid="pibi-order-date-input"
value={orderValue}
onChange={(e) => setOrderValue(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-blue-200 focus:border-blue-300"
/>
</label>
<label className="flex flex-col gap-1">
<span className="text-xs text-gray-500 font-medium">
PIBI Completed Date
</span>
<input
type="date"
data-testid="pibi-completed-date-input"
value={completedValue}
onChange={(e) => setCompletedValue(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-blue-200 focus:border-blue-300"
/>
</label>
</div>
<div className="flex items-center justify-between gap-3">
<p className="text-[11px] text-gray-400">
Pick the actual date leave blank to clear. Changes sync to HubSpot.
</p>
<button
type="button"
data-testid="pibi-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 PIBI Dates"}
</button>
</div>
{error && (
<p
data-testid="pibi-error"
className="text-xs text-red-600 bg-red-50 px-3 py-2 rounded-lg"
>
{error}
</p>
)}
</div>
);
}
// -----------------------------------------------------------------------
// PropertyDetailDrawer — main component
// -----------------------------------------------------------------------
@ -680,29 +895,36 @@ export default function PropertyDetailDrawer({
</div>
</div>
{/* PIBI section */}
{/* PIBI section — editable date pickers for write+ users (issue #252) */}
<div ref={(el) => { sectionRefs.current.pibi = el; }}>
<SectionHeader id="pibi" label={SECTION_TITLES.pibi} />
<div className="divide-y divide-gray-50">
<InfoRow label="PIBI Order Date" value={formatDate(deal.pibiOrderDate)} />
<InfoRow label="PIBI Completed Date" value={formatDate(deal.pibiCompletedDate)} />
<InfoRow
label="Measures for PIBI"
value={
pibiMeasures.length > 0 ? (
<span className="flex flex-wrap gap-1.5">
{pibiMeasures.map((m) => (
<span
key={m}
className="px-2 py-0.5 rounded-full text-[11px] bg-gray-50 border border-gray-200 text-gray-600"
>
{m}
</span>
))}
</span>
) : null
}
<div className="space-y-3">
<PibiDatesEditor
dealId={deal.dealId}
portfolioId={portfolioId}
initialOrderDate={deal.pibiOrderDate}
initialCompletedDate={deal.pibiCompletedDate}
canEdit={WRITE_ROLES.includes(userRole)}
/>
{pibiMeasures.length > 0 && (
<div className="divide-y divide-gray-50">
<InfoRow
label="Measures for PIBI"
value={
<span className="flex flex-wrap gap-1.5">
{pibiMeasures.map((m) => (
<span
key={m}
className="px-2 py-0.5 rounded-full text-[11px] bg-gray-50 border border-gray-200 text-gray-600"
>
{m}
</span>
))}
</span>
}
/>
</div>
)}
</div>
</div>