mirror of
https://github.com/Hestia-Homes/assessment-model.git
synced 2026-06-08 11:37:25 +00:00
modifying halted + moved pibi + additional survey
This commit is contained in:
parent
19139b6253
commit
b919fb1646
2 changed files with 55 additions and 329 deletions
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue