refactoring details drawer to orchestrator format

This commit is contained in:
Khalim Conn-Kowlessar 2026-05-12 11:24:08 +00:00
parent f6b9f9f65c
commit 166cea397b
13 changed files with 1618 additions and 1686 deletions

View file

@ -3,23 +3,8 @@
import { ColumnDef } from "@tanstack/react-table";
import { ArrowUpDown, CheckCircle2, AlertCircle, FileX } from "lucide-react";
import Link from "next/link";
import { STAGE_COLORS } from "./types";
import type { ClassifiedDeal, DisplayStage, DocStatusMap, RemovalStatusByDeal } from "./types";
// -----------------------------------------------------------------------
// Stage badge — consistent pill rendering
// -----------------------------------------------------------------------
function StageBadge({ stage }: { stage: DisplayStage }) {
const c = STAGE_COLORS[stage] ?? STAGE_COLORS["Unknown Stage"];
return (
<span
className={`inline-flex items-center gap-1.5 px-2.5 py-1 rounded-full text-xs font-medium border whitespace-nowrap ${c.bg} ${c.text} ${c.border}`}
>
<span className={`w-1.5 h-1.5 rounded-full shrink-0 ${c.dot}`} />
{stage}
</span>
);
}
import type { ClassifiedDeal, DocStatusMap, RemovalStatusByDeal } from "./types";
import { StageBadge } from "./ui";
// Sortable column header helper
function SortableHeader({

View file

@ -21,22 +21,19 @@ import { parseMeasures } from "@/app/lib/parseMeasures";
import { outOfOrderInstructionWarning } from "@/app/lib/softWarnings";
import type { ClassifiedDeal, PortfolioCapabilityType, DocStatus, EffectiveRemovalState, PropertyDocument } from "../types";
import PropertyDocumentsContent from "../PropertyDocumentsContent";
import { STAGE_COLORS } from "../types";
import { StageBadge } from "../ui";
import {
InfoRow,
StageBadge,
MilestoneTimeline,
formatDate,
MeasureApprovalEditor,
InstructMeasureEditor,
ApprovalLogSection,
SurveyRequestSection,
RemovalRequestSection,
SectionHeader,
SECTION_TITLES,
WRITE_ROLES,
} from "../PropertyDetailDrawer";
import { PibiSection } from "../PibiSection";
} from "../deal-detail/primitives";
import { MeasureApprovalEditor } from "../deal-detail/MeasureApprovalEditor";
import { InstructMeasureEditor } from "../deal-detail/pibi/InstructMeasureEditor";
import { PibiSurveysTabContent } from "../deal-detail/PibiSurveysTabContent";
import { ActivityLog } from "../ActivityLog";
type Tab = "works" | "pibi-surveys" | "documents";
const VALID_TABS: Tab[] = ["works", "pibi-surveys", "documents"];
@ -102,7 +99,6 @@ export default function DealPage({
);
const isApprover = userCapability.includes("approver");
const canWrite = WRITE_ROLES.includes(userRole);
const stageColors = STAGE_COLORS[deal.displayStage] ?? STAGE_COLORS["Unknown Stage"];
return (
<TooltipProvider>
@ -292,7 +288,7 @@ export default function DealPage({
</button>
{isLogOpen && (
<div className="mt-3">
<ApprovalLogSection dealId={deal.dealId} portfolioId={portfolioId} />
<ActivityLog dealId={deal.dealId} portfolioId={portfolioId} />
</div>
)}
</div>
@ -302,36 +298,12 @@ export default function DealPage({
<div
className={`p-5 space-y-6 ${activeTab === "pibi-surveys" ? "block" : "hidden"}`}
>
<div>
<SectionHeader id="pibi" label={SECTION_TITLES.pibi} />
<PibiSection
dealId={deal.dealId}
portfolioId={portfolioId}
proposedMeasures={parseMeasures(deal.proposedMeasures ?? null)}
canEdit={isApprover}
/>
</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>
<SurveyRequestSection
dealId={deal.dealId}
portfolioId={portfolioId}
canEdit={isApprover}
/>
</div>
<div className="border-t border-gray-100 pt-4">
<h3 className="text-xs font-bold uppercase tracking-wider text-gray-400 mb-3">
Project Removal
</h3>
<RemovalRequestSection
dealId={deal.dealId}
portfolioId={portfolioId}
userRole={userRole}
userCapability={userCapability}
/>
</div>
<PibiSurveysTabContent
deal={deal}
portfolioId={portfolioId}
userRole={userRole}
userCapability={userCapability}
/>
</div>
{/* ── Documents ── */}

View file

@ -0,0 +1,243 @@
"use client";
import { useState, useMemo, useEffect } from "react";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { ApprovalConfirmDialog } from "../ApprovalConfirmDialog";
import type { PendingDiff } from "../ApprovalConfirmDialog";
interface MeasureApprovalEditorProps {
dealId: string;
dealName: string | null;
portfolioId: string;
proposedMeasures: string[];
isApprover: boolean;
}
export function MeasureApprovalEditor({
dealId,
dealName,
portfolioId,
proposedMeasures,
isApprover,
}: MeasureApprovalEditorProps) {
const queryClient = useQueryClient();
// Reuse the pibi-measures query — it already returns approvedMeasures and
// instructedMeasures alongside the PIBI selection.
const { data, isLoading } = useQuery<{
pibiMeasures: string[];
approvedMeasures: string[];
instructedMeasures: string[];
}>({
queryKey: ["pibiMeasures", portfolioId, dealId],
queryFn: async () => {
const res = await fetch(
`/api/portfolio/${portfolioId}/pibi-measures?dealId=${encodeURIComponent(dealId)}`,
);
if (!res.ok) throw new Error("Failed to fetch measures");
return res.json();
},
staleTime: 30_000,
});
const allMeasures = useMemo(() => {
const instructed = data?.instructedMeasures ?? [];
const all = [...proposedMeasures];
for (const m of instructed) {
if (!all.includes(m)) all.push(m);
}
return all;
}, [proposedMeasures, data?.instructedMeasures]);
const [selected, setSelected] = useState<Set<string>>(new Set());
const [savedSelected, setSavedSelected] = useState<Set<string>>(new Set());
const [initialised, setInitialised] = useState(false);
const [confirmOpen, setConfirmOpen] = useState(false);
const [submitting, setSubmitting] = useState(false);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
if (!data) return;
const initial = new Set(data.approvedMeasures);
setSelected(new Set(initial));
setSavedSelected(new Set(initial));
setInitialised(true);
setError(null);
}, [dealId, data]);
function toggleMeasure(measure: string) {
if (!isApprover) return;
setSelected((prev) => {
const next = new Set(prev);
if (next.has(measure)) next.delete(measure);
else next.add(measure);
return next;
});
}
const pendingDiff: PendingDiff = useMemo(() => {
const added = allMeasures.filter((m) => selected.has(m) && !savedSelected.has(m));
const removed = allMeasures.filter((m) => !selected.has(m) && savedSelected.has(m));
return { added, removed };
}, [allMeasures, selected, savedSelected]);
const isDirty = pendingDiff.added.length > 0 || pendingDiff.removed.length > 0;
async function handleConfirm() {
setSubmitting(true);
setError(null);
const changes = [
...pendingDiff.added.map((measureName) => ({
hubspotDealId: dealId,
measureName,
approved: true,
})),
...pendingDiff.removed.map((measureName) => ({
hubspotDealId: dealId,
measureName,
approved: false,
})),
];
try {
const res = await fetch(`/api/portfolio/${portfolioId}/approvals`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ changes }),
});
if (!res.ok) {
const json = await res.json().catch(() => ({}));
setError(
typeof json.error === "string" ? json.error : "Failed to save approvals",
);
setConfirmOpen(false);
return;
}
setSavedSelected(new Set(selected));
setConfirmOpen(false);
queryClient.invalidateQueries({
queryKey: ["pibiMeasures", portfolioId, dealId],
});
} catch (err) {
setError(err instanceof Error ? err.message : "Failed to save approvals");
setConfirmOpen(false);
} finally {
setSubmitting(false);
}
}
if (isLoading || !initialised) {
if (allMeasures.length === 0 && isLoading) {
return (
<p
data-testid="measure-approval-loading"
className="text-xs text-gray-400 py-2"
>
Loading
</p>
);
}
}
if (allMeasures.length === 0) {
return (
<p className="text-xs text-gray-400 py-2">
No proposed measures for this property yet.
</p>
);
}
return (
<div className="space-y-3">
<div
data-testid="measure-approval-chips"
className="flex flex-wrap gap-2"
>
{allMeasures.map((measure) => {
const isApproved = selected.has(measure);
return (
<label
key={measure}
data-testid={`measure-approval-chip-${measure}`}
className={`flex items-center gap-1.5 px-2.5 py-1 rounded-full text-xs border transition-colors ${
isApprover ? "cursor-pointer" : "cursor-default"
} ${
isApproved
? "bg-brandblue/10 border-brandblue/40 text-brandblue font-medium"
: "bg-gray-50 border-gray-200 text-gray-600 hover:bg-gray-100"
}`}
>
{isApprover && (
<input
type="checkbox"
className="sr-only"
checked={isApproved}
onChange={() => toggleMeasure(measure)}
disabled={submitting}
data-testid={`measure-approval-checkbox-${measure}`}
/>
)}
<span
className={`w-3 h-3 rounded-sm border flex items-center justify-center shrink-0 ${
isApproved
? "bg-brandblue border-brandblue"
: "bg-white border-gray-300"
}`}
>
{isApproved && (
<svg viewBox="0 0 10 8" className="w-2 h-2 fill-white">
<path
d="M1 4l3 3 5-6"
stroke="white"
strokeWidth="1.5"
fill="none"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
)}
</span>
{measure}
</label>
);
})}
</div>
{isApprover && (
<div className="flex items-center justify-between gap-3">
<p className="text-[11px] text-gray-400">
Tick measures to approve. Changes require confirmation before saving.
</p>
<button
type="button"
data-testid="measure-approval-save"
onClick={() => setConfirmOpen(true)}
disabled={!isDirty || 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…" : "Review & Save"}
</button>
</div>
)}
{error && (
<p
data-testid="measure-approval-error"
className="text-xs text-red-600 bg-red-50 px-3 py-2 rounded-lg"
>
{error}
</p>
)}
<ApprovalConfirmDialog
open={confirmOpen}
pendingDiffs={{ [dealId]: pendingDiff }}
dealNames={{ [dealId]: dealName ?? dealId }}
onConfirm={handleConfirm}
onCancel={() => setConfirmOpen(false)}
isPending={submitting}
/>
</div>
);
}

View file

@ -0,0 +1,59 @@
"use client";
import { parseMeasures } from "@/app/lib/parseMeasures";
import type { ClassifiedDeal, PortfolioCapabilityType } from "../types";
import { PibiSection } from "../PibiSection";
import { SectionHeader, SECTION_TITLES } from "./primitives";
import { SurveyRequestSection } from "./SurveyRequestSection";
import { RemovalRequestSection } from "./RemovalRequestSection";
interface PibiSurveysTabContentProps {
deal: ClassifiedDeal;
portfolioId: string;
userRole: string;
userCapability: PortfolioCapabilityType;
}
export function PibiSurveysTabContent({
deal,
portfolioId,
userRole,
userCapability,
}: PibiSurveysTabContentProps) {
const isApprover = userCapability.includes("approver");
return (
<>
<div>
<SectionHeader id="pibi" label={SECTION_TITLES.pibi} />
<PibiSection
dealId={deal.dealId}
portfolioId={portfolioId}
proposedMeasures={parseMeasures(deal.proposedMeasures ?? null)}
canEdit={isApprover}
/>
</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>
<SurveyRequestSection
dealId={deal.dealId}
portfolioId={portfolioId}
canEdit={isApprover}
/>
</div>
<div className="border-t border-gray-100 pt-4">
<h3 className="text-xs font-bold uppercase tracking-wider text-gray-400 mb-3">
Project Removal
</h3>
<RemovalRequestSection
dealId={deal.dealId}
portfolioId={portfolioId}
userRole={userRole}
userCapability={userCapability}
/>
</div>
</>
);
}

View file

@ -0,0 +1,306 @@
"use client";
import { useState } from "react";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { Trash2, RotateCcw } from "lucide-react";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogFooter,
} from "@/app/shadcn_components/ui/dialog";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@/app/shadcn_components/ui/tooltip";
import type { PortfolioCapabilityType, RemovalRequest } from "../types";
import { WRITE_ROLES, formatDateTime } from "./primitives";
export function RemovalRequestSection({
dealId,
portfolioId,
userRole,
userCapability,
}: {
dealId: string;
portfolioId: string;
userRole: string;
userCapability: PortfolioCapabilityType;
}) {
const queryClient = useQueryClient();
const [dialogType, setDialogType] = useState<"removal" | "re_addition" | null>(null);
const [reason, setReason] = useState("");
const [submitting, setSubmitting] = useState(false);
const [reviewing, setReviewing] = useState(false);
const [error, setError] = useState<string | null>(null);
const canRequest = WRITE_ROLES.includes(userRole);
const isApprover = userCapability.includes("approver");
const { data, isLoading } = useQuery<{ requests: RemovalRequest[] }>({
queryKey: ["removalRequests", portfolioId, dealId],
queryFn: async () => {
const res = await fetch(
`/api/portfolio/${portfolioId}/removal-requests?dealId=${dealId}`,
);
if (!res.ok) throw new Error("Failed to fetch removal requests");
return res.json();
},
staleTime: 30_000,
});
const latest = data?.requests?.[0] ?? null;
type EffectiveState = "active" | "pending_removal" | "removed" | "pending_re_addition";
const effectiveState: EffectiveState = (() => {
if (!latest) return "active";
if (latest.status === "pending") {
return latest.type === "re_addition" ? "pending_re_addition" : "pending_removal";
}
if (latest.type === "removal" && latest.status === "approved") return "removed";
if (latest.type === "re_addition" && latest.status === "declined") return "removed";
return "active";
})();
const pendingRequest = latest?.status === "pending" ? latest : null;
const latestResolvedRequest = latest?.status !== "pending" ? latest : null;
function closeDialog() {
setDialogType(null);
setReason("");
setError(null);
}
async function handleSubmit() {
if (!reason.trim() || !dialogType) return;
setSubmitting(true);
setError(null);
try {
const res = await fetch(`/api/portfolio/${portfolioId}/removal-requests`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ hubspotDealId: dealId, reason: reason.trim(), type: dialogType }),
});
if (!res.ok) {
const json = await res.json().catch(() => ({}));
setError(json.error ?? "Failed to submit request");
return;
}
closeDialog();
queryClient.invalidateQueries({ queryKey: ["removalRequests", portfolioId, dealId] });
} finally {
setSubmitting(false);
}
}
async function handleReview(requestId: string, action: "approved" | "declined") {
setReviewing(true);
setError(null);
try {
const res = await fetch(`/api/portfolio/${portfolioId}/removal-requests`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ requestId: Number(requestId), action }),
});
if (!res.ok) {
const json = await res.json().catch(() => ({}));
setError(json.error ?? "Failed to review request");
return;
}
queryClient.invalidateQueries({ queryKey: ["removalRequests", portfolioId, dealId] });
} finally {
setReviewing(false);
}
}
function resolvedLabel(req: RemovalRequest): string {
if (req.type === "re_addition") {
return req.status === "approved" ? "Re-addition Approved" : "Re-addition Declined";
}
return req.status === "approved" ? "Removal Approved" : "Removal Declined";
}
if (isLoading) {
return <p className="text-xs text-gray-400 py-2">Loading</p>;
}
return (
<div className="space-y-3">
{error && (
<p className="text-xs text-red-600 bg-red-50 px-3 py-2 rounded-lg">{error}</p>
)}
{pendingRequest && (
<div className="rounded-xl border border-amber-200 bg-amber-50 p-3.5 space-y-2">
<div className="flex items-center gap-2">
<span className="text-xs font-semibold text-amber-700 bg-amber-100 px-2 py-0.5 rounded-full border border-amber-200">
{pendingRequest.type === "re_addition" ? "Pending Re-addition Request" : "Pending Removal Request"}
</span>
</div>
<p className="text-xs text-gray-700 leading-relaxed">{pendingRequest.reason}</p>
<p className="text-[11px] text-gray-400">
Requested by <span className="font-medium text-gray-600">{pendingRequest.requestedByEmail}</span>
{" · "}
{formatDateTime(pendingRequest.requestedAt)}
</p>
{isApprover && (
<div className="flex gap-2 pt-1">
<button
onClick={() => handleReview(pendingRequest.id, "approved")}
disabled={reviewing}
className="flex-1 text-xs font-medium px-3 py-1.5 rounded-lg bg-emerald-600 text-white hover:bg-emerald-700 disabled:opacity-50 transition-colors"
>
{pendingRequest.type === "re_addition" ? "Approve Re-addition" : "Approve Removal"}
</button>
<button
onClick={() => handleReview(pendingRequest.id, "declined")}
disabled={reviewing}
className="flex-1 text-xs font-medium px-3 py-1.5 rounded-lg border border-gray-200 text-gray-600 hover:bg-gray-100 disabled:opacity-50 transition-colors"
>
Decline
</button>
</div>
)}
</div>
)}
{latestResolvedRequest && (
<div className={`rounded-xl border p-3.5 space-y-1.5 ${
latestResolvedRequest.status === "approved"
? "border-emerald-200 bg-emerald-50"
: "border-gray-200 bg-gray-50"
}`}>
<div className="flex items-center gap-2">
<span className={`text-xs font-semibold px-2 py-0.5 rounded-full border ${
latestResolvedRequest.status === "approved"
? "text-emerald-700 bg-emerald-100 border-emerald-200"
: "text-gray-600 bg-gray-100 border-gray-200"
}`}>
{resolvedLabel(latestResolvedRequest)}
</span>
</div>
<p className="text-xs text-gray-600 leading-relaxed">{latestResolvedRequest.reason}</p>
<p className="text-[11px] text-gray-400">
Requested by <span className="font-medium text-gray-600">{latestResolvedRequest.requestedByEmail}</span>
{" · "}
{formatDateTime(latestResolvedRequest.requestedAt)}
</p>
{latestResolvedRequest.reviewedByEmail && (
<p className="text-[11px] text-gray-400">
{latestResolvedRequest.status === "approved" ? "Approved" : "Declined"} by{" "}
<span className="font-medium text-gray-600">{latestResolvedRequest.reviewedByEmail}</span>
{latestResolvedRequest.reviewedAt && ` · ${formatDateTime(latestResolvedRequest.reviewedAt)}`}
</p>
)}
</div>
)}
{!pendingRequest && (
<>
{effectiveState === "active" && (
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<span className="inline-block w-full">
<button
onClick={() => { if (canRequest) setDialogType("removal"); }}
disabled={!canRequest}
className={`w-full flex items-center justify-center gap-2 text-xs font-medium px-3 py-2 rounded-lg border transition-colors ${
canRequest
? "border-red-200 text-red-600 hover:bg-red-50 bg-white"
: "border-gray-100 text-gray-300 bg-gray-50 cursor-not-allowed"
}`}
>
<Trash2 className="h-3.5 w-3.5" />
Request Removal from Project
</button>
</span>
</TooltipTrigger>
{!canRequest && (
<TooltipContent side="top" className="text-xs">
Not available with read-only permissions
</TooltipContent>
)}
</Tooltip>
</TooltipProvider>
)}
{effectiveState === "removed" && (
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<span className="inline-block w-full">
<button
onClick={() => { if (canRequest) setDialogType("re_addition"); }}
disabled={!canRequest}
className={`w-full flex items-center justify-center gap-2 text-xs font-medium px-3 py-2 rounded-lg border transition-colors ${
canRequest
? "border-blue-200 text-blue-600 hover:bg-blue-50 bg-white"
: "border-gray-100 text-gray-300 bg-gray-50 cursor-not-allowed"
}`}
>
<RotateCcw className="h-3.5 w-3.5" />
Request Re-addition to Project
</button>
</span>
</TooltipTrigger>
{!canRequest && (
<TooltipContent side="top" className="text-xs">
Not available with read-only permissions
</TooltipContent>
)}
</Tooltip>
</TooltipProvider>
)}
</>
)}
<Dialog open={dialogType !== null} onOpenChange={(v) => { if (!v) closeDialog(); }}>
<DialogContent className="max-w-md">
<DialogHeader>
<DialogTitle className="text-base font-semibold text-gray-800">
{dialogType === "re_addition" ? "Request Re-addition to Project" : "Request Removal from Project"}
</DialogTitle>
</DialogHeader>
<div className="space-y-3 py-2">
<p className="text-xs text-gray-500 leading-relaxed">
{dialogType === "re_addition"
? "Please provide a reason why this property should be re-added to the project. This will be recorded for audit purposes."
: "Please provide a reason why this property should be removed from the project. This will be recorded for audit purposes."}
</p>
<textarea
className="w-full rounded-lg border border-gray-200 px-3 py-2 text-sm text-gray-800 placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-blue-200 focus:border-blue-300 resize-none"
placeholder={dialogType === "re_addition" ? "Reason for re-addition…" : "Reason for removal…"}
rows={4}
value={reason}
onChange={(e) => setReason(e.target.value)}
/>
{error && <p className="text-xs text-red-600">{error}</p>}
</div>
<DialogFooter className="gap-2">
<button
onClick={closeDialog}
className="text-xs font-medium px-4 py-2 rounded-lg border border-gray-200 text-gray-600 hover:bg-gray-50 transition-colors"
>
Cancel
</button>
<button
onClick={handleSubmit}
disabled={!reason.trim() || submitting}
className={`text-xs font-medium px-4 py-2 rounded-lg text-white disabled:opacity-50 transition-colors ${
dialogType === "re_addition"
? "bg-blue-600 hover:bg-blue-700"
: "bg-red-600 hover:bg-red-700"
}`}
>
{submitting ? "Submitting…" : "Submit Request"}
</button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
);
}

View file

@ -0,0 +1,158 @@
"use client";
import { useState } from "react";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { formatDateTime } from "./primitives";
const SURVEY_TYPES = [
{ value: "technical_building_survey", label: "Technical Building Survey" },
] as const;
type SurveyRequestRecord = {
id: string;
hubspotDealId: string;
surveyType: string | null;
status: string;
requestedByEmail: string;
requestedAt: string | null;
fulfilledAt: string | null;
};
export function SurveyRequestSection({
dealId,
portfolioId,
canEdit,
}: {
dealId: string;
portfolioId: string;
canEdit: boolean;
}) {
const queryClient = useQueryClient();
const [selectedType, setSelectedType] = useState<string>(SURVEY_TYPES[0].value);
const [submitting, setSubmitting] = useState(false);
const [error, setError] = useState<string | null>(null);
const { data, isLoading } = useQuery<{ requests: SurveyRequestRecord[] }>({
queryKey: ["surveyRequests", portfolioId, dealId],
queryFn: async () => {
const res = await fetch(
`/api/portfolio/${portfolioId}/survey-requests?dealId=${encodeURIComponent(dealId)}`,
);
if (!res.ok) throw new Error("Failed to fetch survey requests");
return res.json();
},
staleTime: 30_000,
});
const pending = data?.requests?.find((r) => r.status === "pending") ?? null;
const fulfilled = (data?.requests ?? []).filter((r) => r.status === "fulfilled");
async function handleSubmit() {
if (submitting) return;
setSubmitting(true);
setError(null);
try {
const res = await fetch(`/api/portfolio/${portfolioId}/survey-requests`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ hubspotDealId: dealId, surveyType: selectedType }),
});
if (!res.ok) {
const json = await res.json().catch(() => ({}));
setError(typeof json.error === "string" ? json.error : "Failed to submit request");
return;
}
const json = (await res.json()) as { ok: boolean; hubspotSync?: string; hubspotError?: string };
if (json.hubspotSync === "failed") {
setError(json.hubspotError ? `Saved locally — HubSpot sync failed: ${json.hubspotError}` : "Saved locally — HubSpot sync failed");
}
queryClient.invalidateQueries({ queryKey: ["surveyRequests", portfolioId, dealId] });
} finally {
setSubmitting(false);
}
}
function surveyTypeLabel(value: string | null) {
if (!value) return value;
return SURVEY_TYPES.find((t) => t.value === value)?.label ?? value;
}
if (isLoading) {
return <p className="text-xs text-gray-400 py-2">Loading</p>;
}
return (
<div data-testid="survey-request-form" className="space-y-3">
{error && (
<p className="text-xs text-red-600 bg-red-50 px-3 py-2 rounded-lg">{error}</p>
)}
{!pending && fulfilled.length === 0 && (
<p className="text-xs text-gray-400 py-1">No surveys requested</p>
)}
{pending && (
<div
data-testid="survey-request-pending-badge"
className="rounded-xl border border-amber-200 bg-amber-50 p-3.5 space-y-1.5"
>
<span className="text-xs font-semibold text-amber-700 bg-amber-100 px-2 py-0.5 rounded-full border border-amber-200">
Survey Requested
</span>
<p className="text-xs text-gray-700 font-medium">{surveyTypeLabel(pending.surveyType)}</p>
<p className="text-[11px] text-gray-400">
Requested by{" "}
<span className="font-medium text-gray-600">{pending.requestedByEmail}</span>
{pending.requestedAt && ` · ${formatDateTime(pending.requestedAt)}`}
</p>
</div>
)}
{canEdit && !pending && (
<div className="space-y-2">
<label className="flex flex-col gap-1">
<span className="text-xs text-gray-500 font-medium">Survey type</span>
<select
data-testid="survey-request-type-select"
value={selectedType}
onChange={(e) => setSelectedType(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 bg-white"
>
{SURVEY_TYPES.map((t) => (
<option key={t.value} value={t.value}>{t.label}</option>
))}
</select>
</label>
<div className="flex justify-end">
<button
data-testid="survey-request-submit"
type="button"
onClick={handleSubmit}
disabled={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 ? "Submitting…" : "Request Survey"}
</button>
</div>
</div>
)}
{fulfilled.length > 0 && (
<div className="space-y-1.5 pt-1">
<p className="text-[10px] font-semibold uppercase tracking-wide text-gray-400">Past Requests</p>
{fulfilled.map((r) => (
<div key={r.id} className="rounded-lg border border-emerald-100 bg-emerald-50/50 px-3 py-2.5 space-y-1">
<span className="text-[10px] font-semibold text-emerald-700">Fulfilled</span>
<p className="text-xs text-gray-700 font-medium">{surveyTypeLabel(r.surveyType)}</p>
<p className="text-[11px] text-gray-400">
By <span className="font-medium text-gray-600">{r.requestedByEmail}</span>
{r.requestedAt && ` · ${formatDateTime(r.requestedAt)}`}
</p>
</div>
))}
</div>
)}
</div>
);
}

View file

@ -0,0 +1,16 @@
export type { DrawerSection } from "./primitives";
export {
SECTION_TITLES,
WRITE_ROLES,
formatDate,
InfoRow,
SectionHeader,
MilestoneTimeline,
} from "./primitives";
export { RemovalRequestSection } from "./RemovalRequestSection";
export { SurveyRequestSection } from "./SurveyRequestSection";
export { MeasureApprovalEditor } from "./MeasureApprovalEditor";
export { PibiSurveysTabContent } from "./PibiSurveysTabContent";
export { PibiDatesEditor } from "./pibi/PibiDatesEditor";
export { PibiMeasureSelector } from "./pibi/PibiMeasureSelector";
export { InstructMeasureEditor } from "./pibi/InstructMeasureEditor";

View file

@ -0,0 +1,226 @@
"use client";
import { useState, useMemo, useEffect } from "react";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { AlertTriangle, Loader2 } from "lucide-react";
import { MEASURE_NAMES } from "@/app/lib/measureDocumentRequirements";
import { useToast } from "@/app/hooks/use-toast";
interface InstructMeasureEditorProps {
dealId: string;
portfolioId: string;
proposedMeasures: string[];
canEdit: boolean;
outOfOrderWarning: string | null;
onSuccess?: () => void;
}
export function InstructMeasureEditor({
dealId,
portfolioId,
proposedMeasures,
canEdit,
outOfOrderWarning,
onSuccess,
}: InstructMeasureEditorProps) {
const queryClient = useQueryClient();
const { toast } = useToast();
const { data: pibiData } = useQuery<{
pibiMeasures: string[];
approvedMeasures: string[];
instructedMeasures: string[];
}>({
queryKey: ["pibiMeasures", portfolioId, dealId],
queryFn: async () => {
const res = await fetch(
`/api/portfolio/${portfolioId}/pibi-measures?dealId=${encodeURIComponent(dealId)}`,
);
if (!res.ok) throw new Error("Failed to fetch measures");
return res.json();
},
staleTime: 30_000,
enabled: canEdit,
});
const excluded = useMemo(() => {
const set = new Set<string>(proposedMeasures);
for (const m of pibiData?.approvedMeasures ?? []) set.add(m);
for (const m of pibiData?.instructedMeasures ?? []) set.add(m);
return set;
}, [proposedMeasures, pibiData]);
const eligible = useMemo(
() => MEASURE_NAMES.filter((m) => !excluded.has(m)),
[excluded],
);
const [checked, setChecked] = useState<Set<string>>(new Set());
const [confirmText, setConfirmText] = useState("");
const [submitting, setSubmitting] = useState(false);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
setChecked(new Set());
setConfirmText("");
setError(null);
}, [dealId]);
if (!canEdit) return null;
function toggleMeasure(m: string) {
setChecked((prev) => {
const next = new Set(prev);
if (next.has(m)) next.delete(m);
else next.add(m);
return next;
});
}
async function handleSubmit() {
const measureNames = Array.from(checked);
if (measureNames.length === 0 || confirmText !== "confirm") return;
setSubmitting(true);
setError(null);
try {
const res = await fetch(
`/api/portfolio/${portfolioId}/instructed-measures`,
{
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ dealId, measureNames }),
},
);
if (!res.ok) {
const json = await res.json().catch(() => ({}));
setError(
typeof json.error === "string"
? json.error
: "Failed to instruct measures",
);
return;
}
const json = (await res.json()) as {
ok: boolean;
hubspotSync: "ok" | "failed";
hubspotError?: string;
};
setChecked(new Set());
setConfirmText("");
void queryClient.invalidateQueries({ queryKey: ["pibiMeasures", portfolioId, dealId] });
onSuccess?.();
if (json.hubspotSync === "failed") {
toast({
title: "Measures instructed",
description: json.hubspotError
? `Saved locally — HubSpot sync failed: ${json.hubspotError}`
: "Saved locally — HubSpot sync failed",
variant: "destructive",
});
} else {
toast({
title: "Measures instructed",
description: `${measureNames.join(", ")} ${measureNames.length === 1 ? "has" : "have"} been instructed.`,
});
}
} catch (err) {
setError(
err instanceof Error ? err.message : "Failed to instruct measures",
);
} finally {
setSubmitting(false);
}
}
const hasSelection = checked.size > 0;
const canSubmit = hasSelection && confirmText === "confirm" && !submitting;
return (
<div className="space-y-3">
{outOfOrderWarning && (
<div
data-testid="instruct-measure-warning"
className="flex items-start gap-2 text-xs text-amber-800 bg-amber-50 border border-amber-200 px-3 py-2 rounded-lg"
>
<AlertTriangle className="h-3.5 w-3.5 mt-0.5 shrink-0" />
<span>{outOfOrderWarning}</span>
</div>
)}
<div className="flex flex-col gap-1">
<span className="text-xs text-gray-500 font-medium">Instruct measures</span>
{eligible.length === 0 ? (
<p className="text-xs text-gray-400 italic">All measures already proposed or approved.</p>
) : (
<div
data-testid="instruct-measure-checklist"
className="flex flex-col gap-0.5 max-h-48 overflow-y-auto rounded-lg border border-gray-200 p-2"
>
{eligible.map((m) => (
<label
key={m}
className="flex items-center gap-2 px-2 py-1 rounded cursor-pointer hover:bg-gray-50 text-xs text-gray-800"
>
<input
type="checkbox"
checked={checked.has(m)}
onChange={() => toggleMeasure(m)}
disabled={submitting}
className="accent-brandblue"
/>
{m}
</label>
))}
</div>
)}
</div>
{hasSelection && (
<div className="space-y-2 border-t border-gray-100 pt-3">
<div className="flex flex-wrap gap-1">
{Array.from(checked).map((m) => (
<span
key={m}
className="px-2 py-0.5 rounded-full text-[11px] bg-blue-50 border border-blue-200 text-blue-700"
>
{m}
</span>
))}
</div>
<label className="flex flex-col gap-1">
<span className="text-xs text-gray-500">
Type <span className="font-mono font-semibold">confirm</span> to instruct
</span>
<input
data-testid="instruct-measure-confirm-input"
type="text"
value={confirmText}
onChange={(e) => setConfirmText(e.target.value)}
disabled={submitting}
placeholder="confirm"
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 w-full"
/>
</label>
<button
type="button"
data-testid="instruct-measure-submit"
onClick={handleSubmit}
disabled={!canSubmit}
className="flex items-center gap-1.5 text-xs font-medium px-3 py-1.5 rounded-lg bg-brandblue text-white hover:bg-brandmidblue disabled:opacity-50 transition-colors"
>
{submitting && <Loader2 className="h-3.5 w-3.5 animate-spin" />}
{submitting ? "Instructing…" : `Instruct ${checked.size} measure${checked.size === 1 ? "" : "s"}`}
</button>
</div>
)}
{error && (
<p
data-testid="instruct-measure-error"
className="text-xs text-red-600 bg-red-50 px-3 py-2 rounded-lg"
>
{error}
</p>
)}
</div>
);
}

View file

@ -0,0 +1,200 @@
"use client";
import { useState, useMemo, useEffect } from "react";
import { InfoRow, formatDate } from "../primitives";
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 "";
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 "";
}
}
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;
initialOrderDate: Date | string | null;
initialCompletedDate: Date | string | null;
canEdit: boolean;
}
export 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);
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);
}
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) {
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;
};
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>
);
}

View file

@ -0,0 +1,216 @@
"use client";
import { useState, useMemo, useEffect } from "react";
import { useQuery, useQueryClient } from "@tanstack/react-query";
interface PibiMeasureSelectorProps {
dealId: string;
portfolioId: string;
proposedMeasures: string[];
canEdit: boolean;
}
export function PibiMeasureSelector({
dealId,
portfolioId,
proposedMeasures,
canEdit,
}: PibiMeasureSelectorProps) {
const queryClient = useQueryClient();
const { data, isLoading } = useQuery<{
pibiMeasures: string[];
approvedMeasures: string[];
instructedMeasures: string[];
}>({
queryKey: ["pibiMeasures", portfolioId, dealId],
queryFn: async () => {
const res = await fetch(
`/api/portfolio/${portfolioId}/pibi-measures?dealId=${encodeURIComponent(dealId)}`,
);
if (!res.ok) throw new Error("Failed to fetch PIBI measures");
return res.json();
},
staleTime: 30_000,
});
const allMeasures = useMemo(() => {
const instructed = data?.instructedMeasures ?? [];
const all = [...proposedMeasures];
for (const m of instructed) {
if (!all.includes(m)) all.push(m);
}
return all;
}, [proposedMeasures, data?.instructedMeasures]);
const [selected, setSelected] = useState<Set<string>>(new Set());
const [initialised, setInitialised] = useState(false);
const [submitting, setSubmitting] = useState(false);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
if (!data) return;
const initial =
data.pibiMeasures.length > 0
? data.pibiMeasures
: data.approvedMeasures;
setSelected(new Set(initial));
setInitialised(true);
setError(null);
}, [dealId, data]);
function toggleMeasure(measure: string) {
setSelected((prev) => {
const next = new Set(prev);
if (next.has(measure)) {
next.delete(measure);
} else {
next.add(measure);
}
return next;
});
}
async function handleSave() {
setSubmitting(true);
setError(null);
const measureNames = Array.from(selected);
try {
const res = await fetch(
`/api/portfolio/${portfolioId}/pibi-measures`,
{
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ dealId, measureNames }),
},
);
if (!res.ok) {
const json = await res.json().catch(() => ({}));
setError(
typeof json.error === "string"
? json.error
: "Failed to save PIBI selections",
);
return;
}
const json = (await res.json()) as {
ok: boolean;
hubspotSync: "ok" | "failed";
hubspotError?: string;
};
if (json.hubspotSync === "failed") {
setError(
json.hubspotError
? `Saved locally — HubSpot sync failed: ${json.hubspotError}`
: "Saved locally — HubSpot sync failed",
);
}
queryClient.invalidateQueries({
queryKey: ["pibiMeasures", portfolioId, dealId],
});
} catch (err) {
setError(
err instanceof Error ? err.message : "Failed to save PIBI selections",
);
} finally {
setSubmitting(false);
}
}
if (!canEdit) return null;
if (isLoading || !initialised) {
return (
<p
data-testid="pibi-selector-loading"
className="text-xs text-gray-400 py-2"
>
Loading
</p>
);
}
if (allMeasures.length === 0) {
return (
<p className="text-xs text-gray-400 py-2">
No measures associated with this deal yet.
</p>
);
}
return (
<div className="space-y-3">
<p className="text-xs text-gray-500 font-medium">PIBI Measure Selection</p>
<div
data-testid="pibi-measure-selector"
className="flex flex-wrap gap-2"
>
{allMeasures.map((measure) => {
const checked = selected.has(measure);
const isApproved = (data?.approvedMeasures ?? []).includes(measure);
return (
<label
key={measure}
data-testid={`pibi-measure-option-${measure}`}
className={`flex items-center gap-1.5 cursor-pointer px-2.5 py-1 rounded-full text-xs border transition-colors ${
checked
? "bg-brandblue/10 border-brandblue/40 text-brandblue font-medium"
: "bg-gray-50 border-gray-200 text-gray-600 hover:bg-gray-100"
}`}
>
<input
type="checkbox"
className="sr-only"
checked={checked}
onChange={() => toggleMeasure(measure)}
disabled={submitting}
data-testid={`pibi-measure-checkbox-${measure}`}
/>
<span
className={`w-3 h-3 rounded-sm border flex items-center justify-center shrink-0 ${
checked
? "bg-brandblue border-brandblue"
: "bg-white border-gray-300"
}`}
>
{checked && (
<svg viewBox="0 0 10 8" className="w-2 h-2 fill-white">
<path d="M1 4l3 3 5-6" stroke="white" strokeWidth="1.5" fill="none" strokeLinecap="round" strokeLinejoin="round" />
</svg>
)}
</span>
{measure}
{isApproved && (
<span className="text-[10px] text-emerald-600 font-semibold ml-0.5">
</span>
)}
</label>
);
})}
</div>
<div className="flex items-center justify-between gap-3">
<p className="text-[11px] text-gray-400">
Tick measures going for PIBI. Approved measures are pre-ticked ().
</p>
<button
type="button"
data-testid="pibi-selector-save"
onClick={handleSave}
disabled={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 Selections"}
</button>
</div>
{error && (
<p
data-testid="pibi-selector-error"
className="text-xs text-red-600 bg-red-50 px-3 py-2 rounded-lg"
>
{error}
</p>
)}
</div>
);
}

View file

@ -0,0 +1,140 @@
"use client";
import { CheckCircle2, Circle } from "lucide-react";
import type { ClassifiedDeal } from "../types";
export type DrawerSection = "survey" | "measures" | "pibi" | "domna" | "technical";
export const SECTION_TITLES: Record<DrawerSection, string> = {
survey: "Survey",
measures: "Measures",
pibi: "PIBI",
domna: "Domna Survey",
technical: "Technical Approved",
};
export const WRITE_ROLES = ["creator", "admin", "write"];
export function formatDate(d: Date | string | null | undefined): string | null {
if (!d) return null;
try {
return new Date(d).toLocaleDateString("en-GB", {
day: "numeric",
month: "short",
year: "numeric",
});
} catch {
return null;
}
}
export function formatDateTime(d: string | Date | null | undefined): string {
if (!d) return "";
try {
return new Date(d).toLocaleString("en-GB", {
day: "numeric",
month: "short",
year: "numeric",
hour: "2-digit",
minute: "2-digit",
});
} catch {
return "";
}
}
export function InfoRow({ label, value }: { label: string; value: React.ReactNode }) {
if (!value) return null;
return (
<div className="flex items-start gap-3 py-2 border-b border-gray-50 last:border-0">
<span className="text-xs text-gray-400 font-medium w-32 shrink-0 pt-0.5">{label}</span>
<span className="text-xs text-gray-700 flex-1 leading-relaxed">{value}</span>
</div>
);
}
export function SectionHeader({ id, label }: { id: string; label: string }) {
return (
<h3
data-testid={`drawer-section-${id}`}
className="text-xs font-bold uppercase tracking-wider text-gray-400 mb-3"
>
{label}
</h3>
);
}
const MILESTONES: { label: string; field: keyof ClassifiedDeal; sublabel?: string }[] = [
{ label: "Booking Confirmed", field: "confirmedSurveyDate" },
{ label: "Assessment Completed", field: "surveyedDate" },
{ label: "Coordination (V1)", field: "ioeV1Date", sublabel: "IOE/MTP V1" },
{ label: "Coordination (V2)", field: "ioeV2Date", sublabel: "IOE/MTP V2" },
{ label: "Design Completed", field: "designDate" },
{ label: "Measures Lodged", field: "measuresLodgementDate" },
{ label: "Stage 1 Lodgement", field: "fullLodgementDate" },
];
export function MilestoneTimeline({ deal }: { deal: ClassifiedDeal }) {
const milestones = MILESTONES.map((m) => ({
...m,
date: formatDate(deal[m.field] as Date | string | null),
}));
const lastCompletedIdx = milestones.reduce((acc, m, i) => (m.date ? i : acc), -1);
return (
<div className="relative">
{milestones.map((m, i) => {
const completed = !!m.date;
const isLast = i === milestones.length - 1;
return (
<div key={m.field} className="flex items-stretch gap-3">
<div className="flex flex-col items-center w-5 shrink-0">
<div className={`relative z-10 flex items-center justify-center w-5 h-5 rounded-full border-2 mt-0.5 transition-all duration-300 ${
completed
? "bg-brandmidblue border-brandmidblue"
: i <= lastCompletedIdx + 1
? "bg-white border-brandblue/30"
: "bg-white border-gray-200"
}`}>
{completed ? (
<CheckCircle2 className="h-3 w-3 text-white" />
) : (
<Circle className={`h-2 w-2 ${i <= lastCompletedIdx + 1 ? "text-brandblue/40" : "text-gray-300"}`} />
)}
</div>
{!isLast && (
<div className={`w-0.5 flex-1 my-0.5 ${
completed && milestones[i + 1]?.date ? "bg-brandmidblue/40" : "bg-gray-100"
}`} />
)}
</div>
<div className={`pb-4 flex-1 min-w-0 ${isLast ? "pb-0" : ""}`}>
<div className="flex items-start justify-between gap-2">
<div>
<p className={`text-xs font-semibold leading-tight ${
completed ? "text-gray-800" : "text-gray-400"
}`}>
{m.label}
</p>
{m.sublabel && (
<p className="text-[10px] text-gray-400 mt-0.5">{m.sublabel}</p>
)}
</div>
{m.date ? (
<span className="text-[11px] font-medium text-brandmidblue bg-brandlightblue/60 px-2 py-0.5 rounded-full shrink-0 whitespace-nowrap">
{m.date}
</span>
) : (
<span className="text-[11px] text-gray-300 shrink-0">Pending</span>
)}
</div>
</div>
</div>
);
})}
</div>
);
}

View file

@ -0,0 +1,16 @@
"use client";
import { STAGE_COLORS } from "./types";
import type { ClassifiedDeal } from "./types";
export function StageBadge({ stage }: { stage: ClassifiedDeal["displayStage"] }) {
const c = STAGE_COLORS[stage] ?? STAGE_COLORS["Unknown Stage"];
return (
<span
className={`inline-flex items-center gap-1.5 px-2.5 py-1 rounded-full text-xs font-semibold border whitespace-nowrap ${c.bg} ${c.text} ${c.border}`}
>
<span className={`w-1.5 h-1.5 rounded-full shrink-0 ${c.dot}`} />
{stage}
</span>
);
}