mirror of
https://github.com/Hestia-Homes/assessment-model.git
synced 2026-06-08 11:37:25 +00:00
add editable PIBI dates section and cypress spec
This commit is contained in:
parent
b8befe56fe
commit
3d08a423a6
2 changed files with 341 additions and 21 deletions
98
cypress/e2e/live-tracking/pibi-dates.cy.js
Normal file
98
cypress/e2e/live-tracking/pibi-dates.cy.js
Normal 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,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
@ -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>
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue