mirror of
https://github.com/Hestia-Homes/assessment-model.git
synced 2026-06-08 11:37:25 +00:00
refactoring details drawer to orchestrator format
This commit is contained in:
parent
f6b9f9f65c
commit
166cea397b
13 changed files with 1618 additions and 1686 deletions
File diff suppressed because it is too large
Load diff
|
|
@ -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({
|
||||
|
|
|
|||
|
|
@ -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 ── */}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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";
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue