save latest changes and pulled from main

This commit is contained in:
Jun-te Kim 2026-04-20 15:59:11 +00:00
commit 119a800995
31 changed files with 21446 additions and 95 deletions

View file

@ -29,6 +29,7 @@ export async function GET(req: Request) {
s3FileBucket: uploadedFiles.s3FileBucket,
s3UploadTimestamp: uploadedFiles.s3UploadTimestamp,
fileType: uploadedFiles.fileType,
source: uploadedFiles.source,
uprn: uploadedFiles.uprn,
landlordPropertyId: uploadedFiles.landlordPropertyId,
measureName: uploadedFiles.measureName,
@ -41,6 +42,7 @@ export async function GET(req: Request) {
s3FileKey: row.s3FileKey,
s3FileBucket: row.s3FileBucket,
docType: row.fileType ?? "unknown",
source: row.source ?? null,
s3UploadTimestamp: row.s3UploadTimestamp.toISOString(),
uprn: row.uprn !== null ? String(row.uprn) : null,
landlordPropertyId: row.landlordPropertyId,

View file

@ -10,6 +10,7 @@ import { and, eq, inArray, sql } from "drizzle-orm";
import { z } from "zod";
import { getServerSession } from "next-auth";
import { AuthOptions } from "@/app/api/auth/[...nextauth]/authOptions";
import { syncMeasureApprovalsToHubSpot } from "@/app/lib/hubspot/dealSync";
async function getRequestingUserId(email: string): Promise<bigint | null> {
const rows = await db
@ -204,6 +205,33 @@ export async function POST(
});
}
const affectedDealIds = [...new Set(body.changes.map((c) => c.hubspotDealId))];
for (const dealId of affectedDealIds) {
const approvalRows = await db
.select({
measureName: dealMeasureApprovals.measureName,
approvedByEmail: user.email,
})
.from(dealMeasureApprovals)
.leftJoin(user, eq(user.id, dealMeasureApprovals.approvedBy))
.where(
and(
eq(dealMeasureApprovals.hubspotDealId, dealId),
eq(dealMeasureApprovals.isApproved, true),
),
);
void syncMeasureApprovalsToHubSpot({
hubspotDealId: dealId,
approvedMeasures: approvalRows.map((r) => ({
measureName: r.measureName,
approvedByEmail: r.approvedByEmail ?? "unknown",
})),
actedByEmail: session.user.email,
actedAt: now,
});
}
return NextResponse.json({ success: true });
} catch (err) {
console.error("POST /approvals error:", err);

View file

@ -7,6 +7,7 @@ import { and, eq, desc } from "drizzle-orm";
import { z } from "zod";
import { getServerSession } from "next-auth";
import { AuthOptions } from "@/app/api/auth/[...nextauth]/authOptions";
import { syncRemovalRequestToHubSpot, getDealBatch } from "@/app/lib/hubspot/dealSync";
const WRITE_ROLES = ["creator", "admin", "write"] as const;
@ -70,6 +71,7 @@ export async function GET(
.select({
id: propertyRemovalRequests.id,
hubspotDealId: propertyRemovalRequests.hubspotDealId,
type: propertyRemovalRequests.type,
reason: propertyRemovalRequests.reason,
status: propertyRemovalRequests.status,
requestedAt: propertyRemovalRequests.requestedAt,
@ -110,6 +112,7 @@ export async function GET(
return {
id: String(row.id),
hubspotDealId: row.hubspotDealId,
type: row.type,
reason: row.reason,
status: row.status,
requestedByEmail: row.requestedByEmail,
@ -130,6 +133,7 @@ export async function GET(
const postSchema = z.object({
hubspotDealId: z.string().min(1),
reason: z.string().min(1, "Reason is required"),
type: z.enum(["removal", "re_addition"]).default("removal"),
});
// POST /api/portfolio/[portfolioId]/removal-requests
@ -170,40 +174,71 @@ export async function POST(
return NextResponse.json({ error: parsed.error.flatten() }, { status: 400 });
}
const { hubspotDealId, reason } = parsed.data;
const { hubspotDealId, reason, type } = parsed.data;
try {
// Block if there's already a pending request for this deal
const existing = await db
.select({ id: propertyRemovalRequests.id })
// Fetch the most recent request for this deal to determine current state
const [latest] = await db
.select({
status: propertyRemovalRequests.status,
type: propertyRemovalRequests.type,
})
.from(propertyRemovalRequests)
.where(
and(
eq(propertyRemovalRequests.hubspotDealId, hubspotDealId),
eq(propertyRemovalRequests.portfolioId, BigInt(portfolioId)),
eq(propertyRemovalRequests.status, "pending"),
),
)
.orderBy(desc(propertyRemovalRequests.requestedAt))
.limit(1);
if (existing.length > 0) {
if (latest?.status === "pending") {
return NextResponse.json(
{ error: "A pending removal request already exists for this property" },
{ error: "A pending request already exists for this property" },
{ status: 409 },
);
}
if (type === "removal" && latest?.type === "removal" && latest?.status === "approved") {
return NextResponse.json(
{ error: "This property has already been removed" },
{ status: 409 },
);
}
if (type === "re_addition") {
const isRemoved =
(latest?.type === "removal" && latest?.status === "approved") ||
(latest?.type === "re_addition" && latest?.status === "declined");
if (!isRemoved) {
return NextResponse.json(
{ error: "This property is not currently removed" },
{ status: 409 },
);
}
}
const [inserted] = await db
.insert(propertyRemovalRequests)
.values({
hubspotDealId,
portfolioId: BigInt(portfolioId),
reason,
type,
status: "pending",
requestedBy: requestingUser.id,
})
.returning();
void syncRemovalRequestToHubSpot({
hubspotDealId,
type,
status: "pending",
reason,
requestedByEmail: requestingUser.email,
});
return NextResponse.json({ success: true, id: String(inserted.id) });
} catch (err) {
console.error("[removal-requests POST]", err);
@ -258,8 +293,16 @@ export async function PATCH(
try {
const target = await db
.select({ id: propertyRemovalRequests.id, status: propertyRemovalRequests.status })
.select({
id: propertyRemovalRequests.id,
type: propertyRemovalRequests.type,
status: propertyRemovalRequests.status,
hubspotDealId: propertyRemovalRequests.hubspotDealId,
reason: propertyRemovalRequests.reason,
requestedByEmail: user.email,
})
.from(propertyRemovalRequests)
.innerJoin(user, eq(user.id, propertyRemovalRequests.requestedBy))
.where(eq(propertyRemovalRequests.id, BigInt(requestId)))
.limit(1);
@ -274,15 +317,54 @@ export async function PATCH(
);
}
const requestType = (target[0].type ?? "removal") as "removal" | "re_addition";
const dealId = target[0].hubspotDealId;
// When approving a removal: fetch the current batch value and store it
// When approving a re-addition: find the stored batch from the original removal
let originalBatch: string | null = null;
let batchValue: string | null | undefined = undefined;
if (action === "approved") {
if (requestType === "removal") {
originalBatch = await getDealBatch(dealId);
} else if (requestType === "re_addition") {
const [originalRemoval] = await db
.select({ originalBatch: propertyRemovalRequests.originalBatch })
.from(propertyRemovalRequests)
.where(
and(
eq(propertyRemovalRequests.hubspotDealId, dealId),
eq(propertyRemovalRequests.type, "removal"),
eq(propertyRemovalRequests.status, "approved"),
),
)
.orderBy(desc(propertyRemovalRequests.reviewedAt))
.limit(1);
batchValue = originalRemoval?.originalBatch ?? null;
}
}
await db
.update(propertyRemovalRequests)
.set({
status: action,
reviewedBy: requestingUser.id,
reviewedAt: new Date(),
...(requestType === "removal" && action === "approved" ? { originalBatch } : {}),
})
.where(eq(propertyRemovalRequests.id, BigInt(requestId)));
void syncRemovalRequestToHubSpot({
hubspotDealId: dealId,
type: requestType,
status: action,
reason: target[0].reason,
requestedByEmail: target[0].requestedByEmail,
reviewedByEmail: requestingUser.email,
batchValue,
});
return NextResponse.json({ success: true });
} catch (err) {
console.error("[removal-requests PATCH]", err);

View file

@ -6,6 +6,7 @@ import { eq, inArray } from "drizzle-orm";
import { z } from "zod";
import { getServerSession } from "next-auth";
import { AuthOptions } from "@/app/api/auth/[...nextauth]/authOptions";
import { syncContractorDocUploadToHubSpot } from "@/app/lib/hubspot/dealSync";
// POST — record a contractor install document in uploaded_files (fileType optional — can be classified later)
export async function POST(req: NextRequest) {
@ -56,6 +57,12 @@ export async function POST(req: NextRequest) {
})
.returning({ id: uploadedFiles.id });
if (body.hubspotDealId) {
void syncContractorDocUploadToHubSpot({
hubspotDealId: body.hubspotDealId,
});
}
return NextResponse.json({ id: inserted.id.toString() }, { status: 201 });
} catch (err) {
console.error("POST /upload/contractor-install error:", err);

View file

@ -47,7 +47,7 @@ export default function AddNew({
className="
inline-flex items-center gap-1 px-4 py-2 rounded-md
bg-gray-50 text-gray-900 hover:bg-midblue hover:text-gray-100
transition-colors text-sm font-medium
transition-colors text-xs font-medium
"
>
<DocumentPlusIcon className="h-4 w-4 mr-2" />

View file

@ -31,7 +31,7 @@ export default function YourProjectsDropdown({
className="
inline-flex items-center gap-1 px-4 py-2 rounded-md
bg-gray-50 text-gray-900 hover:bg-midblue hover:text-gray-100
transition-colors text-sm font-medium
transition-colors text-xs font-medium
"
>
<RocketLaunchIcon className="h-4 w-4 mr-2" />

View file

@ -0,0 +1 @@
ALTER TABLE "property_removal_requests" ADD COLUMN "type" text DEFAULT 'removal' NOT NULL;

View file

@ -0,0 +1 @@
ALTER TABLE "property_removal_requests" ADD COLUMN IF NOT EXISTS "type" text NOT NULL DEFAULT 'removal';

View file

@ -0,0 +1,5 @@
ALTER TABLE "hubspot_deal_data" ADD COLUMN "batch" text;--> statement-breakpoint
ALTER TABLE "hubspot_deal_data" ADD COLUMN "block_reference" text;--> statement-breakpoint
ALTER TABLE "hubspot_deal_data" ADD COLUMN "epc_prn" text;--> statement-breakpoint
ALTER TABLE "hubspot_deal_data" ADD COLUMN "potential_post_sap_score_dropdown" text;--> statement-breakpoint
ALTER TABLE "property_removal_requests" ADD COLUMN "original_batch" text;

View file

@ -0,0 +1 @@
ALTER TABLE "property_removal_requests" ADD COLUMN IF NOT EXISTS "original_batch" text;

View file

@ -0,0 +1,4 @@
ALTER TABLE "hubspot_deal_data" ADD COLUMN "ei_score" text;--> statement-breakpoint
ALTER TABLE "hubspot_deal_data" ADD COLUMN "ei_score__potential_" text;--> statement-breakpoint
ALTER TABLE "hubspot_deal_data" ADD COLUMN "epc_sap_score" text;--> statement-breakpoint
ALTER TABLE "hubspot_deal_data" ADD COLUMN "epc_sap_score__potential_" text;

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

View file

@ -1254,6 +1254,34 @@
"when": 1776458454019,
"tag": "0178_parched_midnight",
"breakpoints": true
},
{
"idx": 179,
"version": "7",
"when": 1776459924335,
"tag": "0179_mighty_cardiac",
"breakpoints": true
},
{
"idx": 180,
"version": "7",
"when": 1776683523665,
"tag": "0180_ambitious_jane_foster",
"breakpoints": true
},
{
"idx": 181,
"version": "7",
"when": 1776697748194,
"tag": "0181_concerned_electro",
"breakpoints": true
},
{
"idx": 182,
"version": "7",
"when": 1776699608018,
"tag": "0182_messy_calypso",
"breakpoints": true
}
]
}

View file

@ -46,6 +46,14 @@ export const hubspotDealData = pgTable("hubspot_deal_data", {
coordination_comments: text("coordination_comments"),
surveyor: text("surveyor"),
damnpMouldAndRepairComments: text("damp_mould_and_repairs_comments"),
batch: text("batch"),
blockReference: text("block_reference"),
epcPrn: text("epc_prn"),
potentialPostSapScoreDropdown: text("potential_post_sap_score_dropdown"),
eiScore: text("ei_score"),
eiScorePotential: text("ei_score__potential_"),
epcSapScore: text("epc_sap_score"),
epcSapScorePotential: text("epc_sap_score__potential_"),
confirmedSurveyDate: timestamp("confirmed_survey_date", { precision: 6, withTimezone: true }),
confirmedSurveyTime: text("confirmed_survey_time"),
surveyedDate: timestamp("surveyed_date", { precision: 6, withTimezone: true }),

View file

@ -21,6 +21,8 @@ export const propertyRemovalRequests = pgTable(
.notNull()
.references(() => portfolio.id),
reason: text("reason").notNull(),
// 'removal' | 're_addition'
type: text("type").notNull().default("removal"),
// 'pending' | 'approved' | 'declined'
status: text("status").notNull().default("pending"),
requestedBy: bigint("requested_by", { mode: "bigint" })
@ -33,6 +35,7 @@ export const propertyRemovalRequests = pgTable(
() => user.id,
),
reviewedAt: timestamp("reviewed_at", { withTimezone: true }),
originalBatch: text("original_batch"),
},
(table) => [
index("idx_removal_requests_deal_id").on(table.hubspotDealId),

View file

@ -0,0 +1,14 @@
import { Client } from "@hubspot/api-client";
let _client: Client | null = null;
export function getHubSpotClient(): Client {
if (!_client) {
const accessToken = process.env.HUBSPOT_API_KEY;
if (!accessToken) {
throw new Error("HUBSPOT_API_KEY environment variable is not set");
}
_client = new Client({ accessToken });
}
return _client;
}

View file

@ -0,0 +1,157 @@
import { getHubSpotClient } from "./client";
export async function getDealBatch(hubspotDealId: string): Promise<string | null> {
try {
const client = getHubSpotClient();
const deal = await client.crm.deals.basicApi.getById(hubspotDealId, ["batch"]);
return (deal.properties["batch"] as string | null | undefined) ?? null;
} catch (err) {
console.error("[HubSpot] getDealBatch failed", { dealId: hubspotDealId, error: err });
return null;
}
}
export async function syncRemovalRequestToHubSpot(params: {
hubspotDealId: string;
type: "removal" | "re_addition";
status: "pending" | "approved" | "declined";
reason: string;
requestedByEmail: string;
reviewedByEmail?: string | null;
batchValue?: string | null;
}): Promise<void> {
try {
const client = getHubSpotClient();
let statusLabel: string;
if (params.type === "removal") {
statusLabel =
params.status === "pending"
? "Removal Request In Progress"
: params.status === "approved"
? "Removed From Project"
: "";
} else {
statusLabel =
params.status === "pending"
? "Re-addition Requested"
: params.status === "approved"
? ""
: "Removed From Project";
}
let log = `Requested by: ${params.requestedByEmail}\nReason: ${params.reason}`;
if (params.reviewedByEmail) {
if (params.type === "re_addition" && params.status === "approved") {
log += `\nRe-added to project by: ${params.reviewedByEmail}`;
} else {
const action = params.status === "approved" ? "Approved" : "Declined";
log += `\n${action} by: ${params.reviewedByEmail}`;
}
}
const properties: Record<string, string> = {
project_removal_status: statusLabel,
project_removal_request_log: log,
};
// Set batch when approving removal; restore it when approving re-addition (if we have a value)
if (params.type === "removal" && params.status === "approved") {
properties["batch"] = "Removed from Program";
} else if (params.type === "re_addition" && params.status === "approved" && params.batchValue != null) {
properties["batch"] = params.batchValue;
}
await client.crm.deals.basicApi.update(params.hubspotDealId, { properties });
} catch (err) {
console.error("[HubSpot] syncRemovalRequestToHubSpot failed", {
dealId: params.hubspotDealId,
error: err,
});
}
}
export async function syncContractorDocUploadToHubSpot(params: {
hubspotDealId: string;
}): Promise<void> {
const maxAttempts = 3;
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
try {
const client = getHubSpotClient();
await client.crm.deals.basicApi.update(params.hubspotDealId, {
properties: {
contractor_document_upload_log: "Documents available - uploaded by contractor",
},
});
return;
} catch (err) {
const isReset =
err instanceof Error &&
"code" in err &&
(err as NodeJS.ErrnoException).code === "ECONNRESET";
if (isReset && attempt < maxAttempts) {
await new Promise((resolve) => setTimeout(resolve, 200 * attempt));
continue;
}
console.error("[HubSpot] syncContractorDocUploadToHubSpot failed", {
dealId: params.hubspotDealId,
attempt,
error: err,
});
return;
}
}
}
export async function syncMeasureApprovalsToHubSpot(params: {
hubspotDealId: string;
approvedMeasures: Array<{ measureName: string; approvedByEmail: string }>;
actedByEmail: string;
actedAt: Date;
}): Promise<void> {
const dateStr = params.actedAt.toLocaleString("en-GB", {
day: "2-digit",
month: "short",
year: "numeric",
hour: "2-digit",
minute: "2-digit",
timeZone: "UTC",
timeZoneName: "short",
});
const log =
params.approvedMeasures.length === 0
? `All measures unapproved by ${params.actedByEmail} on ${dateStr}`
: [
`Approved measures (updated by ${params.actedByEmail} on ${dateStr}):`,
...params.approvedMeasures.map((m) => `- ${m.measureName} (approved by ${m.approvedByEmail})`),
].join("\r\n");
const maxAttempts = 3;
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
try {
const client = getHubSpotClient();
await client.crm.deals.basicApi.update(params.hubspotDealId, {
properties: {
client_measures_approval_log: log,
},
});
return;
} catch (err) {
const isReset =
err instanceof Error &&
"code" in err &&
(err as NodeJS.ErrnoException).code === "ECONNRESET";
if (isReset && attempt < maxAttempts) {
await new Promise((resolve) => setTimeout(resolve, 200 * attempt));
continue;
}
console.error("[HubSpot] syncMeasureApprovalsToHubSpot failed", {
dealId: params.hubspotDealId,
attempt,
error: err,
});
return;
}
}
}

View file

@ -73,7 +73,7 @@ export function BreakdownChart({
}
return rows;
}, [selected, epcBands, ageBands, propertyTypes, scenarioEpcBands, friendlyKeys.actual, friendlyKeys.estimated, friendlyKeys.scenario]);
}, [selected, epcBands, ageBands, propertyTypes, scenarioEpcBands]);
const categories =
selected === "epc"

View file

@ -27,9 +27,7 @@ async function getPortfolioUsers(portfolioId: string): Promise<Collaborator[]> {
const users = Array.isArray(json) ? json : json.users; // support both shapes
// Guard + shape to Collaborator[]
return Array.isArray(users)
? users
.filter((u: any) => u.role !== "creator") // 👈 filter out creator
.map((u: any) => ({
? users.map((u: any) => ({
portfolioUserId: String(u.portfolioUserId),
userId: String(u.userId),
name: u.name ?? null,
@ -251,12 +249,20 @@ export function UsersPermissionsCard({ portfolioId }: { portfolioId: string }) {
<TableCell>{c.name || "—"}</TableCell>
<TableCell>{c.email}</TableCell>
<TableCell className="min-w-40">
<RoleDropdown value={c.role} onChange={(r) => onChangeRole(c.portfolioUserId, r)} />
{c.role === "creator" || c.role === "admin" ? (
<span className="text-xs font-medium text-gray-500 px-2 py-1 bg-gray-100 rounded-md capitalize">
{c.role}
</span>
) : (
<RoleDropdown value={c.role as "read" | "write"} onChange={(r) => onChangeRole(c.portfolioUserId, r)} />
)}
</TableCell>
<TableCell className="text-right">
<Button variant="destructive" className="bg-red-700" onClick={() => onRemove(c.portfolioUserId)}>
Remove
</Button>
{c.role !== "creator" && (
<Button variant="destructive" className="bg-red-700" onClick={() => onRemove(c.portfolioUserId)}>
Remove
</Button>
)}
</TableCell>
</TableRow>
))

View file

@ -16,7 +16,7 @@ export type Collaborator = {
userId: string;
name?: string | null;
email: string;
role: Role;
role: Role | "creator" | "admin";
};
// Small role dropdown using shadcn Select

View file

@ -1,4 +1,5 @@
import { UsersPermissionsCard } from "../UsersPermissionsCard";
import { CapabilitiesCard } from "../CapabilitiesCard";
export default async function UserAccessPage(props: {
params: Promise<{ slug: string }>;
@ -8,6 +9,7 @@ export default async function UserAccessPage(props: {
return (
<div>
<UsersPermissionsCard portfolioId={slug} />
<CapabilitiesCard portfolioId={slug} />
</div>
);
}

View file

@ -9,7 +9,7 @@ import {
TabsTrigger,
} from "@/app/shadcn_components/ui/tabs";
import { Card, CardContent } from "@/app/shadcn_components/ui/card";
import { BarChart2, Table2, FolderOpen, Wrench } from "lucide-react";
import { BarChart2, Table2, FolderOpen, Wrench, AlertTriangle } from "lucide-react";
import DrillDownTable from "./DrillDownTable";
import PropertyTable from "./PropertyTable";
import DocumentTable from "./DocumentTable";
@ -24,6 +24,7 @@ import type {
ClassifiedDeal,
DocumentDrawerState,
DocStatusMap,
RemovalStatusByDeal,
} from "./types";
export default function LiveTracker({
@ -33,6 +34,7 @@ export default function LiveTracker({
docStatusMap,
userCapability,
approvalsByDeal,
removalStatusByDeal,
portfolioId,
userRole,
userEmail,
@ -49,6 +51,12 @@ export default function LiveTracker({
(p) => p.projectCode === currentProjectCode,
);
// ── Pending removal count for current project ────────────────────────
const pendingRemovalCount = (currentProject?.allDeals ?? []).filter((d) => {
const state = d.dealId ? removalStatusByDeal[d.dealId] : undefined;
return state === "pending_removal" || state === "pending_re_addition";
}).length;
// ── Drill-down table modal (used by AnalyticsView) ───────────────────
const [openTable, setOpenTable] = useState<TableModal | null>(null);
@ -117,6 +125,14 @@ export default function LiveTracker({
>
<Table2 className="h-3.5 w-3.5" />
Properties
<span
className={`ml-1 min-w-[18px] h-[18px] px-1 rounded-full bg-amber-500 text-white text-[10px] font-bold flex items-center justify-center leading-none transition-opacity ${
pendingRemovalCount > 0 ? "opacity-100" : "opacity-0 pointer-events-none"
}`}
aria-hidden={pendingRemovalCount === 0}
>
{pendingRemovalCount || ""}
</span>
</TabsTrigger>
<TabsTrigger
value="documents"
@ -180,11 +196,19 @@ export default function LiveTracker({
</div>
)}
<div className={`flex items-center gap-2.5 px-4 py-3 rounded-xl border border-amber-200 bg-amber-50 text-amber-800 text-sm ${pendingRemovalCount === 0 ? "hidden" : ""}`}>
<AlertTriangle className="h-4 w-4 text-amber-500 shrink-0" />
<span>
<span className="font-semibold">{pendingRemovalCount}</span>{" "}
{pendingRemovalCount === 1 ? "property has" : "properties have"} an outstanding removal request
</span>
</div>
<PropertyTable
data={currentProject?.allDeals ?? []}
onOpenDrawer={handleOpenDrawer}
onOpenDetail={setDetailDeal}
docStatusMap={docStatusMap}
removalStatusByDeal={removalStatusByDeal}
/>
</div>
</TabsContent>
@ -217,6 +241,8 @@ export default function LiveTracker({
data={currentProject?.allDeals ?? []}
onOpenDrawer={handleOpenDrawer}
docStatusMap={docStatusMap}
portfolioId={portfolioId}
userCapability={userCapability}
/>
</div>
</TabsContent>

View file

@ -2,7 +2,7 @@
import { useState } from "react";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { X, CheckCircle2, Circle, AlertTriangle, ChevronRight, ChevronDown, Trash2 } from "lucide-react";
import { X, CheckCircle2, Circle, AlertTriangle, ChevronRight, ChevronDown, Trash2, RotateCcw } from "lucide-react";
import {
Drawer,
DrawerClose,
@ -43,7 +43,7 @@ function RemovalRequestSection({
userCapability: PortfolioCapabilityType;
}) {
const queryClient = useQueryClient();
const [dialogOpen, setDialogOpen] = useState(false);
const [dialogType, setDialogType] = useState<"removal" | "re_addition" | null>(null);
const [reason, setReason] = useState("");
const [submitting, setSubmitting] = useState(false);
const [reviewing, setReviewing] = useState(false);
@ -64,26 +64,45 @@ function RemovalRequestSection({
staleTime: 30_000,
});
const pendingRequest = data?.requests?.find((r) => r.status === "pending") ?? null;
const latestResolvedRequest = data?.requests?.find((r) => r.status !== "pending") ?? null;
const latest = data?.requests?.[0] ?? null;
// Derive effective state from the most recent request
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()) return;
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() }),
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;
}
setDialogOpen(false);
setReason("");
closeDialog();
queryClient.invalidateQueries({ queryKey: ["removalRequests", portfolioId, dealId] });
} finally {
setSubmitting(false);
@ -110,6 +129,13 @@ function RemovalRequestSection({
}
}
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>;
}
@ -125,7 +151,7 @@ function RemovalRequestSection({
<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">
Pending Removal Request
{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>
@ -134,7 +160,6 @@ function RemovalRequestSection({
{" · "}
{formatDateTime(pendingRequest.requestedAt)}
</p>
{/* Approver actions */}
{isApprover && (
<div className="flex gap-2 pt-1">
<button
@ -142,7 +167,7 @@ function RemovalRequestSection({
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"
>
Approve Removal
{pendingRequest.type === "re_addition" ? "Approve Re-addition" : "Approve Removal"}
</button>
<button
onClick={() => handleReview(pendingRequest.id, "declined")}
@ -157,7 +182,7 @@ function RemovalRequestSection({
)}
{/* Most recent resolved request */}
{!pendingRequest && latestResolvedRequest && (
{latestResolvedRequest && (
<div className={`rounded-xl border p-3.5 space-y-1.5 ${
latestResolvedRequest.status === "approved"
? "border-emerald-200 bg-emerald-50"
@ -169,7 +194,7 @@ function RemovalRequestSection({
? "text-emerald-700 bg-emerald-100 border-emerald-200"
: "text-gray-600 bg-gray-100 border-gray-200"
}`}>
{latestResolvedRequest.status === "approved" ? "Removal Approved" : "Removal Declined"}
{resolvedLabel(latestResolvedRequest)}
</span>
</div>
<p className="text-xs text-gray-600 leading-relaxed">{latestResolvedRequest.reason}</p>
@ -188,50 +213,84 @@ function RemovalRequestSection({
</div>
)}
{/* Request button — only shown when no pending request exists */}
{/* Action buttons — shown when no pending request */}
{!pendingRequest && (
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<span className="inline-block w-full">
<button
onClick={() => { if (canRequest) setDialogOpen(true); }}
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 === "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>
)}
</>
)}
{/* Reason dialog */}
<Dialog open={dialogOpen} onOpenChange={(v) => { if (!v) { setDialogOpen(false); setReason(""); setError(null); } }}>
{/* Shared dialog for removal and re-addition requests */}
<Dialog open={dialogType !== null} onOpenChange={(v) => { if (!v) closeDialog(); }}>
<DialogContent className="max-w-md">
<DialogHeader>
<DialogTitle className="text-base font-semibold text-gray-800">
Request Removal from Project
{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">
Please provide a reason why this property should be removed from the project. This will be recorded for audit purposes.
{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-red-200 focus:border-red-300 resize-none"
placeholder="Reason for removal…"
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)}
@ -240,7 +299,7 @@ function RemovalRequestSection({
</div>
<DialogFooter className="gap-2">
<button
onClick={() => { setDialogOpen(false); setReason(""); setError(null); }}
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
@ -248,7 +307,11 @@ function RemovalRequestSection({
<button
onClick={handleSubmit}
disabled={!reason.trim() || submitting}
className="text-xs font-medium px-4 py-2 rounded-lg bg-red-600 text-white hover:bg-red-700 disabled:opacity-50 transition-colors"
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>

View file

@ -38,7 +38,7 @@ import {
import { Search, SlidersHorizontal, ChevronLeft, ChevronRight, Download } from "lucide-react";
import { createPropertyTableColumns } from "./PropertyTableColumns";
import { STAGE_ORDER } from "./types";
import type { ClassifiedDeal, DocStatusMap } from "./types";
import type { ClassifiedDeal, DocStatusMap, RemovalStatusByDeal, EffectiveRemovalState } from "./types";
// Human-readable labels for toggle dropdown
const COLUMN_LABELS: Record<string, string> = {
@ -58,6 +58,7 @@ const COLUMN_LABELS: Record<string, string> = {
};
type DocFilter = "all" | "has_docs" | "incomplete" | "none";
type RemovalFilter = "all" | "pending_removal" | "removed" | "pending_re_addition";
interface PropertyTableProps {
data: ClassifiedDeal[];
@ -65,6 +66,7 @@ interface PropertyTableProps {
onOpenDetail?: (deal: ClassifiedDeal) => void;
showDocuments?: boolean;
docStatusMap?: DocStatusMap;
removalStatusByDeal?: RemovalStatusByDeal;
}
const CSV_FIELDS: { key: keyof ClassifiedDeal; label: string }[] = [
@ -96,10 +98,11 @@ function escapeCell(value: unknown): string {
: str;
}
export default function PropertyTable({ data, onOpenDrawer, onOpenDetail, showDocuments = false, docStatusMap = {} }: PropertyTableProps) {
export default function PropertyTable({ data, onOpenDrawer, onOpenDetail, showDocuments = false, docStatusMap = {}, removalStatusByDeal = {} }: PropertyTableProps) {
const [globalFilter, setGlobalFilter] = useState("");
const [stageFilter, setStageFilter] = useState<string>("all");
const [docFilter, setDocFilter] = useState<DocFilter>("all");
const [removalFilter, setRemovalFilter] = useState<RemovalFilter>("all");
const [sorting, setSorting] = useState<SortingState>([]);
const [pagination, setPagination] = useState<PaginationState>({
pageIndex: 0,
@ -117,7 +120,7 @@ export default function PropertyTable({ data, onOpenDrawer, onOpenDetail, showDo
fullLodgementDate: false,
});
// Pre-filter by stage and doc status before TanStack gets it
// Pre-filter by stage, doc status, and removal status before TanStack gets it
const filteredData = useMemo(() => {
let result = data;
if (stageFilter !== "all") {
@ -132,12 +135,18 @@ export default function PropertyTable({ data, onOpenDrawer, onOpenDetail, showDo
return true;
});
}
if (removalFilter !== "all") {
result = result.filter((d) => {
const state: EffectiveRemovalState = (d.dealId ? removalStatusByDeal[d.dealId] : undefined) ?? "none";
return state === removalFilter;
});
}
return result;
}, [data, stageFilter, docFilter, docStatusMap]);
}, [data, stageFilter, docFilter, docStatusMap, removalFilter, removalStatusByDeal]);
const columns = useMemo(
() => createPropertyTableColumns(onOpenDrawer, showDocuments, docStatusMap, onOpenDetail),
[onOpenDrawer, showDocuments, docStatusMap, onOpenDetail]
() => createPropertyTableColumns(onOpenDrawer, showDocuments, docStatusMap, onOpenDetail, removalStatusByDeal),
[onOpenDrawer, showDocuments, docStatusMap, onOpenDetail, removalStatusByDeal]
);
const table = useReactTable({
@ -227,6 +236,31 @@ export default function PropertyTable({ data, onOpenDrawer, onOpenDetail, showDo
</SelectContent>
</Select>
{/* Removal status filter */}
<Select
value={removalFilter}
onValueChange={(v) => {
setRemovalFilter(v as RemovalFilter);
setPagination((p) => ({ ...p, pageIndex: 0 }));
}}
>
<SelectTrigger className="h-9 w-[180px] text-sm border-gray-200 shrink-0">
{removalFilter === "all"
? "All properties"
: removalFilter === "pending_removal"
? "Pending removal"
: removalFilter === "removed"
? "Removed"
: "Pending re-addition"}
</SelectTrigger>
<SelectContent>
<SelectItem value="all">All properties</SelectItem>
<SelectItem value="pending_removal">Pending removal</SelectItem>
<SelectItem value="removed">Removed</SelectItem>
<SelectItem value="pending_re_addition">Pending re-addition</SelectItem>
</SelectContent>
</Select>
{/* Docs filter */}
{showDocuments && (
<Select

View file

@ -3,7 +3,7 @@
import { ColumnDef } from "@tanstack/react-table";
import { ArrowUpDown, CheckCircle2, AlertCircle, FileX } from "lucide-react";
import { STAGE_COLORS } from "./types";
import type { ClassifiedDeal, DisplayStage, DocStatusMap } from "./types";
import type { ClassifiedDeal, DisplayStage, DocStatusMap, RemovalStatusByDeal } from "./types";
// -----------------------------------------------------------------------
// Stage badge — consistent pill rendering
@ -49,6 +49,7 @@ export function createPropertyTableColumns(
showDocuments: boolean = false,
docStatusMap: DocStatusMap = {},
onOpenDetail?: (deal: ClassifiedDeal) => void,
removalStatusByDeal: RemovalStatusByDeal = {},
): ColumnDef<ClassifiedDeal>[] {
const columns: ColumnDef<ClassifiedDeal>[] = [
// ── Address ──────────────────────────────────────────────────────────
@ -56,22 +57,29 @@ export function createPropertyTableColumns(
accessorKey: "dealname",
id: "dealname",
header: ({ column }) => <SortableHeader label="Address" column={column as any} />,
cell: ({ row }) => (
<div className="max-w-[220px]">
{onOpenDetail ? (
<button
onClick={() => onOpenDetail(row.original)}
className="text-sm font-medium text-brandblue hover:text-brandmidblue hover:underline underline-offset-2 leading-tight text-left truncate w-full transition-colors"
>
{row.original.dealname ?? "—"}
</button>
) : (
<p className="text-sm font-medium text-gray-900 leading-tight truncate">
{row.original.dealname ?? "—"}
</p>
)}
</div>
),
cell: ({ row }) => {
const removalState = row.original.dealId ? removalStatusByDeal[row.original.dealId] : undefined;
const hasPending = removalState === "pending_removal" || removalState === "pending_re_addition";
return (
<div className="max-w-[220px] flex items-center gap-1.5">
{hasPending && (
<span className="shrink-0 w-2 h-2 rounded-full bg-amber-400" title="Outstanding removal request" />
)}
{onOpenDetail ? (
<button
onClick={() => onOpenDetail(row.original)}
className="text-sm font-medium text-brandblue hover:text-brandmidblue hover:underline underline-offset-2 leading-tight text-left truncate transition-colors"
>
{row.original.dealname ?? "—"}
</button>
) : (
<p className="text-sm font-medium text-gray-900 leading-tight truncate">
{row.original.dealname ?? "—"}
</p>
)}
</div>
);
},
enableHiding: false,
},

View file

@ -1,7 +1,7 @@
import { getServerSession } from "next-auth";
import { AuthOptions } from "@/app/api/auth/[...nextauth]/authOptions";
import { redirect } from "next/navigation";
import { eq, inArray, and } from "drizzle-orm";
import { eq, inArray, and, desc } from "drizzle-orm";
import LiveTracker from "./LiveTracker";
import { computeLiveTrackerData } from "./transforms";
import { db } from "@/app/db/db";
@ -11,8 +11,9 @@ import { portfolioOrganisation } from "@/app/db/schema/portfolio_organisation";
import { organisation } from "@/app/db/schema/organisation";
import { portfolioCapabilities, portfolioUsers } from "@/app/db/schema/portfolio";
import { dealMeasureApprovals } from "@/app/db/schema/approvals";
import { propertyRemovalRequests } from "@/app/db/schema/removal_requests";
import { user as userTable } from "@/app/db/schema/users";
import type { HubspotDeal, DocStatusMap, DocStatus, PortfolioCapabilityType, ApprovalsByDeal } from "./types";
import type { HubspotDeal, DocStatusMap, DocStatus, PortfolioCapabilityType, ApprovalsByDeal, RemovalStatusByDeal, EffectiveRemovalState } from "./types";
import { EXPECTED_RETROFIT_ASSESSMENT_DOC_TYPES, SURVEY_ALL_DOC_TYPES } from "./types";
import type { InferSelectModel } from "drizzle-orm";
import { Card, CardContent } from "@/app/shadcn_components/ui/card";
@ -195,6 +196,34 @@ export default async function LiveReportingPage(props: {
}
}
// Compute effective removal state per deal
const removalStatusByDeal: RemovalStatusByDeal = {};
const removalRows = await db
.select({
hubspotDealId: propertyRemovalRequests.hubspotDealId,
type: propertyRemovalRequests.type,
status: propertyRemovalRequests.status,
})
.from(propertyRemovalRequests)
.where(eq(propertyRemovalRequests.portfolioId, BigInt(portfolioId)))
.orderBy(desc(propertyRemovalRequests.requestedAt));
// Keep only the most recent row per deal, then derive effective state
const seenDeals = new Set<string>();
for (const row of removalRows) {
if (seenDeals.has(row.hubspotDealId)) continue;
seenDeals.add(row.hubspotDealId);
let state: EffectiveRemovalState = "none";
if (row.status === "pending") {
state = row.type === "re_addition" ? "pending_re_addition" : "pending_removal";
} else if (row.type === "removal" && row.status === "approved") {
state = "removed";
} else if (row.type === "re_addition" && row.status === "declined") {
state = "removed";
}
if (state !== "none") removalStatusByDeal[row.hubspotDealId] = state;
}
// Fetch survey document status for all properties
const uprnList = deals
.map((d) => d.uprn)
@ -273,6 +302,7 @@ export default async function LiveReportingPage(props: {
docStatusMap={docStatusMap}
userCapability={userCapability}
approvalsByDeal={approvalsByDeal}
removalStatusByDeal={removalStatusByDeal}
portfolioId={portfolioId}
userRole={userRole}
userEmail={userEmail ?? ""}

View file

@ -375,7 +375,7 @@ export function computeOutcomeSlices(deals: ClassifiedDeal[]): OutcomeSlice[] {
// -----------------------------------------------------------------------
export function computeLiveTrackerData(
rawDeals: HubspotDeal[]
): Omit<LiveTrackerProps, "docStatusMap" | "userCapability" | "approvalsByDeal" | "portfolioId"> {
): Omit<LiveTrackerProps, "docStatusMap" | "userCapability" | "approvalsByDeal" | "removalStatusByDeal" | "portfolioId" | "userRole" | "userEmail"> {
// Classify all deals (add displayStage field)
const classified = classifyDeals(rawDeals);

View file

@ -169,12 +169,16 @@ export type PortfolioCapabilityType = ("approver" | "contractor")[];
// Approved measure names per HubSpot deal ID
export type ApprovalsByDeal = Record<string, string[]>;
export type EffectiveRemovalState = "none" | "pending_removal" | "removed" | "pending_re_addition";
export type RemovalStatusByDeal = Record<string, EffectiveRemovalState>;
// -----------------------------------------------------------------------
// Removal request record returned by the API
// -----------------------------------------------------------------------
export type RemovalRequest = {
id: string;
hubspotDealId: string;
type: "removal" | "re_addition";
status: "pending" | "approved" | "declined";
reason: string;
requestedByEmail: string;
@ -193,6 +197,7 @@ export type LiveTrackerProps = {
docStatusMap: DocStatusMap;
userCapability: PortfolioCapabilityType;
approvalsByDeal: ApprovalsByDeal;
removalStatusByDeal: RemovalStatusByDeal;
portfolioId: string;
userRole: string;
userEmail: string;

View file

@ -59,8 +59,9 @@ export async function uploadFileToS3({
});
if (!response.ok) {
console.error("Upload failed response:", response);
throw new Error("Network response was not ok");
const body = await response.text().catch(() => "(unreadable)");
console.error("Upload failed", { status: response.status, statusText: response.statusText, body });
throw new Error(`Upload failed: ${response.status} ${response.statusText}`);
}
} catch (error) {
console.error("Upload error:", error);