modifying halted + moved pibi + additional survey

This commit is contained in:
Khalim Conn-Kowlessar 2026-05-06 20:57:06 +00:00
parent 19139b6253
commit b919fb1646
2 changed files with 55 additions and 329 deletions

View file

@ -39,27 +39,24 @@ export type DrawerSection =
| "measures"
| "pibi"
| "domna"
| "halted"
| "technical";
// The four tabs inside the drawer.
type DrawerTab = "overview" | "works" | "pibi" | "survey-admin";
// The tabs inside the drawer.
type DrawerTab = "overview" | "works" | "pibi-surveys";
// Maps each focusable section to the tab that contains it.
const SECTION_TO_TAB: Record<DrawerSection, DrawerTab> = {
survey: "overview",
measures: "works",
technical: "works",
pibi: "pibi",
domna: "survey-admin",
halted: "survey-admin",
pibi: "pibi-surveys",
domna: "pibi-surveys",
};
const TAB_LABELS: Record<DrawerTab, string> = {
overview: "Overview",
works: "Works",
pibi: "PIBI",
"survey-admin": "Survey & Admin",
"pibi-surveys": "PIBIs & Surveys",
};
// -----------------------------------------------------------------------
@ -877,243 +874,6 @@ export 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;
}
export 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>
);
}
// -----------------------------------------------------------------------
@ -1845,7 +1605,6 @@ export const SECTION_TITLES: Record<DrawerSection, string> = {
measures: "Measures",
pibi: "PIBI",
domna: "Domna Survey",
halted: "Halted",
technical: "Technical Approved",
};
@ -1888,7 +1647,7 @@ export default function PropertyDetailDrawer({
deal?.technicalApprovedMeasuresForInstall ?? null,
);
const TABS: DrawerTab[] = ["overview", "works", "pibi", "survey-admin"];
const TABS: DrawerTab[] = ["overview", "works", "pibi-surveys"];
return (
<Drawer open={open} onOpenChange={(v) => !v && onClose()} direction="right">
@ -2104,11 +1863,12 @@ export default function PropertyDetailDrawer({
</div>
</div>
{/* ── PIBI ── */}
{/* ── PIBIs & Surveys ── */}
<div
data-testid="drawer-tab-panel-pibi"
className={`px-6 py-5 space-y-6 ${activeTab === "pibi" ? "block" : "hidden"}`}
data-testid="drawer-tab-panel-pibi-surveys"
className={`px-6 py-5 space-y-6 ${activeTab === "pibi-surveys" ? "block" : "hidden"}`}
>
{/* PIBI */}
<div>
<SectionHeader id="pibi" label={SECTION_TITLES.pibi} />
<div className="space-y-3">
@ -2149,27 +1909,9 @@ export default function PropertyDetailDrawer({
)}
</div>
</div>
</div>
{/* ── Survey & Admin ── */}
<div
data-testid="drawer-tab-panel-survey-admin"
className={`px-6 py-5 space-y-6 ${activeTab === "survey-admin" ? "block" : "hidden"}`}
>
{/* Halted */}
<div>
<SectionHeader id="halted" label={SECTION_TITLES.halted} />
<HaltedEditor
dealId={deal.dealId}
portfolioId={portfolioId}
initialHaltedDate={deal.propertyHaltedDate}
initialHaltedReason={deal.propertyHaltedReason ?? null}
canEdit={userCapability.includes("approver")}
/>
</div>
{/* Survey request */}
<div>
<div className="border-t border-gray-100 pt-4">
<h3 className="text-xs font-bold uppercase tracking-wider text-gray-400 mb-3">
Survey Request
</h3>

View file

@ -30,7 +30,6 @@ import {
ApprovalLogSection,
PibiDatesEditor,
PibiMeasureSelector,
HaltedEditor,
SurveyRequestSection,
RemovalRequestSection,
SectionHeader,
@ -38,13 +37,12 @@ import {
WRITE_ROLES,
} from "../PropertyDetailDrawer";
type Tab = "works" | "pibi" | "survey-admin" | "documents";
const VALID_TABS: Tab[] = ["works", "pibi", "survey-admin", "documents"];
type Tab = "works" | "pibi-surveys" | "documents";
const VALID_TABS: Tab[] = ["works", "pibi-surveys", "documents"];
const TAB_LABELS: Record<Tab, string> = {
works: "Works",
pibi: "PIBI",
"survey-admin": "Survey & Admin",
"pibi-surveys": "PIBIs & Surveys",
documents: "Documents",
};
@ -304,65 +302,51 @@ export default function DealPage({
</div>
</div>
{/* ── PIBI ── */}
{/* ── PIBIs & Surveys ── */}
<div
className={`p-5 space-y-6 ${activeTab === "pibi" ? "block" : "hidden"}`}
className={`p-5 space-y-6 ${activeTab === "pibi-surveys" ? "block" : "hidden"}`}
>
<SectionHeader id="pibi" label={SECTION_TITLES.pibi} />
<div className="space-y-3">
<PibiDatesEditor
dealId={deal.dealId}
portfolioId={portfolioId}
initialOrderDate={deal.pibiOrderDate}
initialCompletedDate={deal.pibiCompletedDate}
canEdit={canWrite}
/>
{isApprover ? (
<PibiMeasureSelector
<div>
<SectionHeader id="pibi" label={SECTION_TITLES.pibi} />
<div className="space-y-3">
<PibiDatesEditor
dealId={deal.dealId}
portfolioId={portfolioId}
proposedMeasures={parseMeasures(deal.proposedMeasures ?? null)}
canEdit
initialOrderDate={deal.pibiOrderDate}
initialCompletedDate={deal.pibiCompletedDate}
canEdit={canWrite}
/>
) : (
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>
)
)}
{isApprover ? (
<PibiMeasureSelector
dealId={deal.dealId}
portfolioId={portfolioId}
proposedMeasures={parseMeasures(deal.proposedMeasures ?? null)}
canEdit
/>
) : (
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>
</div>
{/* ── Survey & Admin ── */}
<div
className={`p-5 space-y-6 ${activeTab === "survey-admin" ? "block" : "hidden"}`}
>
<div>
<SectionHeader id="halted" label={SECTION_TITLES.halted} />
<HaltedEditor
dealId={deal.dealId}
portfolioId={portfolioId}
initialHaltedDate={deal.propertyHaltedDate}
initialHaltedReason={deal.propertyHaltedReason ?? null}
canEdit={isApprover}
/>
</div>
<div>
<div className="border-t border-gray-100 pt-4">
<h3 className="text-xs font-bold uppercase tracking-wider text-gray-400 mb-3">
Survey Request
</h3>
@ -503,7 +487,7 @@ export default function DealPage({
{/* Request Survey */}
<button
onClick={() => switchTab("survey-admin")}
onClick={() => switchTab("pibi-surveys")}
className="w-full flex items-center justify-between px-4 py-3 rounded-lg bg-gray-50 border border-gray-200 text-gray-700 text-sm font-semibold hover:border-brandblue/30 hover:text-brandblue transition-colors"
>
<span>Request Survey</span>
@ -512,7 +496,7 @@ export default function DealPage({
{/* Request Removal */}
<button
onClick={() => switchTab("survey-admin")}
onClick={() => switchTab("pibi-surveys")}
className="w-full flex items-center justify-between px-4 py-3 rounded-lg bg-gray-50 border border-gray-200 text-gray-700 text-sm font-semibold hover:border-brandblue/30 hover:text-brandblue transition-colors"
>
<span>Request Removal</span>