Merge pull request #227 from Hestia-Homes/feature/installer-interaction

Feature/installer interaction
This commit is contained in:
Jun-te Kim 2026-04-17 21:52:28 +01:00 committed by GitHub
commit a86aabf852
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
26 changed files with 10304 additions and 144 deletions

View file

@ -31,6 +31,7 @@ export async function GET(req: Request) {
fileType: uploadedFiles.fileType,
uprn: uploadedFiles.uprn,
landlordPropertyId: uploadedFiles.landlordPropertyId,
measureName: uploadedFiles.measureName,
})
.from(uploadedFiles)
.where(condition);
@ -43,6 +44,7 @@ export async function GET(req: Request) {
s3UploadTimestamp: row.s3UploadTimestamp.toISOString(),
uprn: row.uprn !== null ? String(row.uprn) : null,
landlordPropertyId: row.landlordPropertyId,
measureName: row.measureName ?? null,
}));
return NextResponse.json(documents);

View file

@ -0,0 +1,215 @@
import { db } from "@/app/db/db";
import { NextRequest, NextResponse } from "next/server";
import {
dealMeasureApprovals,
dealMeasureApprovalEvents,
} from "@/app/db/schema/approvals";
import { portfolioCapabilities } from "@/app/db/schema/portfolio";
import { user } from "@/app/db/schema/users";
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";
async function getRequestingUserId(email: string): Promise<bigint | null> {
const rows = await db
.select({ id: user.id })
.from(user)
.where(eq(user.email, email))
.limit(1);
return rows[0]?.id ?? null;
}
async function hasApproverCapability(
portfolioId: bigint,
userId: bigint,
): Promise<boolean> {
const rows = await db
.select({ id: portfolioCapabilities.id })
.from(portfolioCapabilities)
.where(
and(
eq(portfolioCapabilities.portfolioId, portfolioId),
eq(portfolioCapabilities.userId, userId),
eq(portfolioCapabilities.capability, "approver"),
),
)
.limit(1);
return rows.length > 0;
}
// GET — return currently approved measures per deal, and optionally the audit event log
// Query params:
// dealIds comma-separated HubSpot deal IDs (required)
// include "events" to also return the audit log
export async function GET(
req: NextRequest,
props: { params: Promise<{ portfolioId: string }> },
) {
const url = new URL(req.url);
const dealIdsParam = url.searchParams.get("dealIds");
const includeEvents = url.searchParams.get("include") === "events";
if (!dealIdsParam) {
return NextResponse.json(includeEvents ? { approved: {}, events: [] } : {});
}
const dealIds = dealIdsParam.split(",").filter(Boolean);
if (dealIds.length === 0) {
return NextResponse.json(includeEvents ? { approved: {}, events: [] } : {});
}
try {
// Current approved measures
const approvalRows = await db
.select({
hubspotDealId: dealMeasureApprovals.hubspotDealId,
measureName: dealMeasureApprovals.measureName,
approvedByEmail: user.email,
approvedByName: user.firstName,
approvedAt: dealMeasureApprovals.approvedAt,
})
.from(dealMeasureApprovals)
.leftJoin(user, eq(user.id, dealMeasureApprovals.approvedBy))
.where(
and(
inArray(dealMeasureApprovals.hubspotDealId, dealIds),
eq(dealMeasureApprovals.isApproved, true),
),
);
const approved: Record<string, string[]> = {};
for (const row of approvalRows) {
(approved[row.hubspotDealId] ??= []).push(row.measureName);
}
if (!includeEvents) {
return NextResponse.json(approved);
}
// Audit event log
const eventRows = await db
.select({
id: dealMeasureApprovalEvents.id,
hubspotDealId: dealMeasureApprovalEvents.hubspotDealId,
measureName: dealMeasureApprovalEvents.measureName,
action: dealMeasureApprovalEvents.action,
actedByEmail: user.email,
actedByName: user.firstName,
actedAt: dealMeasureApprovalEvents.actedAt,
})
.from(dealMeasureApprovalEvents)
.leftJoin(user, eq(user.id, dealMeasureApprovalEvents.actedBy))
.where(inArray(dealMeasureApprovalEvents.hubspotDealId, dealIds))
.orderBy(dealMeasureApprovalEvents.actedAt);
const events = eventRows.map((e) => ({
id: e.id.toString(),
hubspotDealId: e.hubspotDealId,
measureName: e.measureName,
action: e.action,
actedByEmail: e.actedByEmail ?? "",
actedByName: e.actedByName ?? null,
actedAt: e.actedAt.toISOString(),
}));
return NextResponse.json({ approved, events });
} catch (err) {
console.error("GET /approvals error:", err);
return NextResponse.json(
{ error: "Failed to fetch approvals" },
{ status: 500 },
);
}
}
// POST — apply explicit approve/unapprove changes, updating current state + audit log
// Body: { changes: [{ hubspotDealId, measureName, approved: boolean }] }
export async function POST(
req: NextRequest,
props: { params: Promise<{ portfolioId: string }> },
) {
const session = await getServerSession(AuthOptions);
if (!session?.user?.email) {
return NextResponse.json({ error: "Unauthorised" }, { status: 401 });
}
const { portfolioId } = await props.params;
const pId = BigInt(portfolioId);
const userId = await getRequestingUserId(session.user.email);
if (!userId) {
return NextResponse.json({ error: "User not found" }, { status: 404 });
}
const isApprover = await hasApproverCapability(pId, userId);
if (!isApprover) {
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
}
const bodySchema = z.object({
changes: z.array(
z.object({
hubspotDealId: z.string(),
measureName: z.string(),
approved: z.boolean(),
}),
),
});
let body: z.infer<typeof bodySchema>;
try {
body = bodySchema.parse(await req.json());
} catch {
return NextResponse.json({ error: "Invalid body" }, { status: 400 });
}
if (body.changes.length === 0) {
return NextResponse.json({ success: true });
}
try {
const now = new Date();
for (const change of body.changes) {
// 1. Upsert current state
await db
.insert(dealMeasureApprovals)
.values({
hubspotDealId: change.hubspotDealId,
measureName: change.measureName,
isApproved: change.approved,
approvedBy: userId,
approvedAt: now,
})
.onConflictDoUpdate({
target: [
dealMeasureApprovals.hubspotDealId,
dealMeasureApprovals.measureName,
],
set: {
isApproved: change.approved,
approvedBy: userId,
approvedAt: now,
},
});
// 2. Append to audit log
await db.insert(dealMeasureApprovalEvents).values({
hubspotDealId: change.hubspotDealId,
measureName: change.measureName,
action: change.approved ? "approved" : "unapproved",
actedBy: userId,
actedAt: now,
});
}
return NextResponse.json({ success: true });
} catch (err) {
console.error("POST /approvals error:", err);
return NextResponse.json(
{ error: "Failed to save approvals" },
{ status: 500 },
);
}
}

View file

@ -0,0 +1,171 @@
import { db } from "@/app/db/db";
import { NextRequest, NextResponse } from "next/server";
import {
portfolioUsers,
portfolioCapabilities,
PortfolioCapabilityType,
} from "@/app/db/schema/portfolio";
import { user } from "@/app/db/schema/users";
import { and, eq } from "drizzle-orm";
import { z } from "zod";
import { getServerSession } from "next-auth";
import { AuthOptions } from "@/app/api/auth/[...nextauth]/authOptions";
const CAPABILITY_OPTIONS = ["approver", "contractor"] as const;
async function getRequestingUserRole(portfolioId: bigint, email: string) {
const rows = await db
.select({ role: portfolioUsers.role })
.from(portfolioUsers)
.innerJoin(user, eq(user.id, portfolioUsers.userId))
.where(
and(
eq(portfolioUsers.portfolioId, portfolioId),
eq(user.email, email),
),
)
.limit(1);
return rows[0]?.role ?? null;
}
// GET — list all capability assignments for this portfolio
export async function GET(
_req: NextRequest,
props: { params: Promise<{ portfolioId: string }> },
) {
const { portfolioId } = await props.params;
try {
const rows = await db
.select({
id: portfolioCapabilities.id,
userId: portfolioCapabilities.userId,
capability: portfolioCapabilities.capability,
name: user.firstName,
email: user.email,
})
.from(portfolioCapabilities)
.leftJoin(user, eq(user.id, portfolioCapabilities.userId))
.where(eq(portfolioCapabilities.portfolioId, BigInt(portfolioId)));
return NextResponse.json(
rows.map((r) => ({
id: r.id?.toString(),
userId: r.userId?.toString(),
capability: r.capability,
name: r.name ?? null,
email: r.email ?? "",
})),
);
} catch (err) {
console.error("GET /capabilities error:", err);
return NextResponse.json(
{ error: "Failed to fetch capabilities" },
{ status: 500 },
);
}
}
// POST — assign a capability to a user
export async function POST(
req: NextRequest,
props: { params: Promise<{ portfolioId: string }> },
) {
const session = await getServerSession(AuthOptions);
if (!session?.user?.email) {
return NextResponse.json({ error: "Unauthorised" }, { status: 401 });
}
const { portfolioId } = await props.params;
const pId = BigInt(portfolioId);
const requestingRole = await getRequestingUserRole(pId, session.user.email);
if (requestingRole !== "admin" && requestingRole !== "creator") {
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
}
const bodySchema = z.object({
userId: z.string(),
capability: z.enum(CAPABILITY_OPTIONS),
});
let body: z.infer<typeof bodySchema>;
try {
body = bodySchema.parse(await req.json());
} catch {
return NextResponse.json({ error: "Invalid body" }, { status: 400 });
}
try {
await db
.insert(portfolioCapabilities)
.values({
portfolioId: pId,
userId: BigInt(body.userId),
capability: body.capability as PortfolioCapabilityType,
})
.onConflictDoNothing();
return NextResponse.json({ success: true }, { status: 200 });
} catch (err) {
console.error("POST /capabilities error:", err);
return NextResponse.json(
{ error: "Failed to assign capability" },
{ status: 500 },
);
}
}
// DELETE — remove a capability from a user
export async function DELETE(
req: NextRequest,
props: { params: Promise<{ portfolioId: string }> },
) {
const session = await getServerSession(AuthOptions);
if (!session?.user?.email) {
return NextResponse.json({ error: "Unauthorised" }, { status: 401 });
}
const { portfolioId } = await props.params;
const pId = BigInt(portfolioId);
const requestingRole = await getRequestingUserRole(pId, session.user.email);
if (requestingRole !== "admin" && requestingRole !== "creator") {
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
}
const bodySchema = z.object({
userId: z.string(),
capability: z.enum(CAPABILITY_OPTIONS),
});
let body: z.infer<typeof bodySchema>;
try {
body = bodySchema.parse(await req.json());
} catch {
return NextResponse.json({ error: "Invalid body" }, { status: 400 });
}
try {
await db
.delete(portfolioCapabilities)
.where(
and(
eq(portfolioCapabilities.portfolioId, pId),
eq(portfolioCapabilities.userId, BigInt(body.userId)),
eq(
portfolioCapabilities.capability,
body.capability as PortfolioCapabilityType,
),
),
);
return NextResponse.json({ success: true }, { status: 200 });
} catch (err) {
console.error("DELETE /capabilities error:", err);
return NextResponse.json(
{ error: "Failed to remove capability" },
{ status: 500 },
);
}
}

View file

@ -0,0 +1,291 @@
import { db } from "@/app/db/db";
import { NextRequest, NextResponse } from "next/server";
import { propertyRemovalRequests } from "@/app/db/schema/removal_requests";
import { portfolioUsers, portfolioCapabilities } from "@/app/db/schema/portfolio";
import { user } from "@/app/db/schema/users";
import { and, eq, desc } from "drizzle-orm";
import { z } from "zod";
import { getServerSession } from "next-auth";
import { AuthOptions } from "@/app/api/auth/[...nextauth]/authOptions";
const WRITE_ROLES = ["creator", "admin", "write"] as const;
async function getRequestingUser(email: string) {
const rows = await db
.select({ id: user.id, email: user.email })
.from(user)
.where(eq(user.email, email))
.limit(1);
return rows[0] ?? null;
}
async function getUserRole(portfolioId: bigint, userId: bigint) {
const rows = await db
.select({ role: portfolioUsers.role })
.from(portfolioUsers)
.where(
and(
eq(portfolioUsers.portfolioId, portfolioId),
eq(portfolioUsers.userId, userId),
),
)
.limit(1);
return rows[0]?.role ?? null;
}
async function hasApproverCapability(portfolioId: bigint, userId: bigint) {
const rows = await db
.select({ id: portfolioCapabilities.id })
.from(portfolioCapabilities)
.where(
and(
eq(portfolioCapabilities.portfolioId, portfolioId),
eq(portfolioCapabilities.userId, userId),
eq(portfolioCapabilities.capability, "approver"),
),
)
.limit(1);
return rows.length > 0;
}
// GET /api/portfolio/[portfolioId]/removal-requests?dealId=xxx
// Returns the most recent removal request for this deal (all statuses)
export async function GET(
req: NextRequest,
props: { params: Promise<{ portfolioId: string }> },
) {
const { portfolioId } = await props.params;
const dealId = req.nextUrl.searchParams.get("dealId");
if (!dealId) {
return NextResponse.json({ error: "dealId required" }, { status: 400 });
}
try {
const requester = user;
const reviewer = { ...user } as typeof user;
// Fetch all requests for this deal, most recent first, joining requester and reviewer emails
const rows = await db
.select({
id: propertyRemovalRequests.id,
hubspotDealId: propertyRemovalRequests.hubspotDealId,
reason: propertyRemovalRequests.reason,
status: propertyRemovalRequests.status,
requestedAt: propertyRemovalRequests.requestedAt,
reviewedAt: propertyRemovalRequests.reviewedAt,
requestedByEmail: user.email,
})
.from(propertyRemovalRequests)
.innerJoin(user, eq(user.id, propertyRemovalRequests.requestedBy))
.where(
and(
eq(propertyRemovalRequests.hubspotDealId, dealId),
eq(propertyRemovalRequests.portfolioId, BigInt(portfolioId)),
),
)
.orderBy(desc(propertyRemovalRequests.requestedAt))
.limit(10);
// For rows with a reviewer, fetch their email separately
const requests = await Promise.all(
rows.map(async (row) => {
// Find the full row to get reviewedBy
const fullRow = await db
.select({ reviewedBy: propertyRemovalRequests.reviewedBy })
.from(propertyRemovalRequests)
.where(eq(propertyRemovalRequests.id, row.id))
.limit(1);
let reviewedByEmail: string | null = null;
if (fullRow[0]?.reviewedBy) {
const reviewerRow = await db
.select({ email: user.email })
.from(user)
.where(eq(user.id, fullRow[0].reviewedBy))
.limit(1);
reviewedByEmail = reviewerRow[0]?.email ?? null;
}
return {
id: String(row.id),
hubspotDealId: row.hubspotDealId,
reason: row.reason,
status: row.status,
requestedByEmail: row.requestedByEmail,
requestedAt: row.requestedAt?.toISOString() ?? null,
reviewedByEmail,
reviewedAt: row.reviewedAt?.toISOString() ?? null,
};
}),
);
return NextResponse.json({ requests });
} catch (err) {
console.error("[removal-requests GET]", err);
return NextResponse.json({ error: "Internal server error" }, { status: 500 });
}
}
const postSchema = z.object({
hubspotDealId: z.string().min(1),
reason: z.string().min(1, "Reason is required"),
});
// POST /api/portfolio/[portfolioId]/removal-requests
// Submit a new removal request — requires write+ role
export async function POST(
req: NextRequest,
props: { params: Promise<{ portfolioId: string }> },
) {
const { portfolioId } = await props.params;
const session = await getServerSession(AuthOptions);
if (!session?.user?.email) {
return NextResponse.json({ error: "Unauthorised" }, { status: 401 });
}
const requestingUser = await getRequestingUser(session.user.email);
if (!requestingUser) {
return NextResponse.json({ error: "User not found" }, { status: 401 });
}
const role = await getUserRole(BigInt(portfolioId), requestingUser.id);
if (!role || !WRITE_ROLES.includes(role as (typeof WRITE_ROLES)[number])) {
return NextResponse.json(
{ error: "Write access required to submit a removal request" },
{ status: 403 },
);
}
let body: unknown;
try {
body = await req.json();
} catch {
return NextResponse.json({ error: "Invalid JSON" }, { status: 400 });
}
const parsed = postSchema.safeParse(body);
if (!parsed.success) {
return NextResponse.json({ error: parsed.error.flatten() }, { status: 400 });
}
const { hubspotDealId, reason } = parsed.data;
try {
// Block if there's already a pending request for this deal
const existing = await db
.select({ id: propertyRemovalRequests.id })
.from(propertyRemovalRequests)
.where(
and(
eq(propertyRemovalRequests.hubspotDealId, hubspotDealId),
eq(propertyRemovalRequests.portfolioId, BigInt(portfolioId)),
eq(propertyRemovalRequests.status, "pending"),
),
)
.limit(1);
if (existing.length > 0) {
return NextResponse.json(
{ error: "A pending removal request already exists for this property" },
{ status: 409 },
);
}
const [inserted] = await db
.insert(propertyRemovalRequests)
.values({
hubspotDealId,
portfolioId: BigInt(portfolioId),
reason,
status: "pending",
requestedBy: requestingUser.id,
})
.returning();
return NextResponse.json({ success: true, id: String(inserted.id) });
} catch (err) {
console.error("[removal-requests POST]", err);
return NextResponse.json({ error: "Internal server error" }, { status: 500 });
}
}
const patchSchema = z.object({
requestId: z.number().int().positive(),
action: z.enum(["approved", "declined"]),
});
// PATCH /api/portfolio/[portfolioId]/removal-requests
// Approve or decline a pending request — requires approver capability
export async function PATCH(
req: NextRequest,
props: { params: Promise<{ portfolioId: string }> },
) {
const { portfolioId } = await props.params;
const session = await getServerSession(AuthOptions);
if (!session?.user?.email) {
return NextResponse.json({ error: "Unauthorised" }, { status: 401 });
}
const requestingUser = await getRequestingUser(session.user.email);
if (!requestingUser) {
return NextResponse.json({ error: "User not found" }, { status: 401 });
}
const isApprover = await hasApproverCapability(BigInt(portfolioId), requestingUser.id);
if (!isApprover) {
return NextResponse.json(
{ error: "Approver capability required to review a removal request" },
{ status: 403 },
);
}
let body: unknown;
try {
body = await req.json();
} catch {
return NextResponse.json({ error: "Invalid JSON" }, { status: 400 });
}
const parsed = patchSchema.safeParse(body);
if (!parsed.success) {
return NextResponse.json({ error: parsed.error.flatten() }, { status: 400 });
}
const { requestId, action } = parsed.data;
try {
const target = await db
.select({ id: propertyRemovalRequests.id, status: propertyRemovalRequests.status })
.from(propertyRemovalRequests)
.where(eq(propertyRemovalRequests.id, BigInt(requestId)))
.limit(1);
if (!target.length) {
return NextResponse.json({ error: "Request not found" }, { status: 404 });
}
if (target[0].status !== "pending") {
return NextResponse.json(
{ error: "Only pending requests can be reviewed" },
{ status: 409 },
);
}
await db
.update(propertyRemovalRequests)
.set({
status: action,
reviewedBy: requestingUser.id,
reviewedAt: new Date(),
})
.where(eq(propertyRemovalRequests.id, BigInt(requestId)));
return NextResponse.json({ success: true });
} catch (err) {
console.error("[removal-requests PATCH]", err);
return NextResponse.json({ error: "Internal server error" }, { status: 500 });
}
}

View file

@ -0,0 +1,111 @@
import { db } from "@/app/db/db";
import { NextRequest, NextResponse } from "next/server";
import { uploadedFiles } from "@/app/db/schema/uploaded_files";
import { user } from "@/app/db/schema/users";
import { eq, inArray } from "drizzle-orm";
import { z } from "zod";
import { getServerSession } from "next-auth";
import { AuthOptions } from "@/app/api/auth/[...nextauth]/authOptions";
// POST — record a contractor install document in uploaded_files (fileType optional — can be classified later)
export async function POST(req: NextRequest) {
const session = await getServerSession(AuthOptions);
if (!session?.user?.email) {
return NextResponse.json({ error: "Unauthorised" }, { status: 401 });
}
const bodySchema = z.object({
s3FileKey: z.string(),
s3FileBucket: z.string(),
fileType: z.string().optional(), // optional — null means unclassified
measureName: z.string().optional(),
uprn: z.string().optional(),
hubspotDealId: z.string().optional(),
landlordPropertyId: z.string().optional(),
});
let body: z.infer<typeof bodySchema>;
try {
body = bodySchema.parse(await req.json());
} catch {
return NextResponse.json({ error: "Invalid body" }, { status: 400 });
}
try {
const userRow = await db
.select({ id: user.id })
.from(user)
.where(eq(user.email, session.user.email))
.limit(1);
const uploadedBy = userRow[0]?.id ?? null;
const [inserted] = await db
.insert(uploadedFiles)
.values({
s3FileBucket: body.s3FileBucket,
s3FileKey: body.s3FileKey,
s3UploadTimestamp: new Date(),
fileType: (body.fileType as any) ?? null,
source: "contractor",
measureName: body.measureName ?? null,
uploadedBy: uploadedBy ?? undefined,
uprn: body.uprn ? BigInt(body.uprn) : undefined,
hubsotDealId: body.hubspotDealId ?? null,
landlordPropertyId: body.landlordPropertyId ?? null,
})
.returning({ id: uploadedFiles.id });
return NextResponse.json({ id: inserted.id.toString() }, { status: 201 });
} catch (err) {
console.error("POST /upload/contractor-install error:", err);
return NextResponse.json({ error: "Failed to record upload" }, { status: 500 });
}
}
// PATCH — update fileType and measureName for previously unclassified uploads
export async function PATCH(req: NextRequest) {
const session = await getServerSession(AuthOptions);
if (!session?.user?.email) {
return NextResponse.json({ error: "Unauthorised" }, { status: 401 });
}
const bodySchema = z.object({
updates: z.array(
z.object({
id: z.string(),
fileType: z.string(),
measureName: z.string().optional(),
}),
),
});
let body: z.infer<typeof bodySchema>;
try {
body = bodySchema.parse(await req.json());
} catch {
return NextResponse.json({ error: "Invalid body" }, { status: 400 });
}
if (body.updates.length === 0) {
return NextResponse.json({ success: true });
}
try {
// Update each record individually (small batches — no bulk update without raw SQL)
for (const update of body.updates) {
await db
.update(uploadedFiles)
.set({
fileType: update.fileType as any,
measureName: update.measureName ?? null,
})
.where(eq(uploadedFiles.id, BigInt(update.id)));
}
return NextResponse.json({ success: true });
} catch (err) {
console.error("PATCH /upload/contractor-install error:", err);
return NextResponse.json({ error: "Failed to update classifications" }, { status: 500 });
}
}

View file

@ -0,0 +1,27 @@
CREATE TABLE "property_removal_requests" (
"id" bigserial PRIMARY KEY NOT NULL,
"hubspot_deal_id" text NOT NULL,
"portfolio_id" bigint NOT NULL,
"reason" text NOT NULL,
"status" text DEFAULT 'pending' NOT NULL,
"requested_by" bigint NOT NULL,
"requested_at" timestamp with time zone DEFAULT now() NOT NULL,
"reviewed_by" bigint,
"reviewed_at" timestamp with time zone
);
--> statement-breakpoint
ALTER TABLE "installed_measure" ALTER COLUMN "measure_type" SET DATA TYPE text;--> statement-breakpoint
DROP TYPE "public"."measure_type";--> statement-breakpoint
CREATE TYPE "public"."measure_type" AS ENUM('air_source_heat_pump', 'boiler_upgrade', 'high_heat_retention_storage_heaters', 'secondary_heating', 'roomstat_programmer_trvs', 'time_temperature_zone_control', 'cylinder_thermostat', 'cavity_wall_insulation', 'extension_cavity_wall_insulation', 'external_wall_insulation', 'internal_wall_insulation', 'loft_insulation', 'flat_roof_insulation', 'room_roof_insulation', 'solid_floor_insulation', 'suspended_floor_insulation', 'double_glazing', 'secondary_glazing', 'draught_proofing', 'mechanical_ventilation', 'low_energy_lighting', 'solar_pv', 'hot_water_tank_insulation', 'sealing_open_fireplace');--> statement-breakpoint
ALTER TABLE "installed_measure" ALTER COLUMN "measure_type" SET DATA TYPE "public"."measure_type" USING "measure_type"::"public"."measure_type";--> statement-breakpoint
ALTER TABLE "uploaded_files" ALTER COLUMN "file_type" SET DATA TYPE text;--> statement-breakpoint
DROP TYPE "public"."file_type";--> statement-breakpoint
CREATE TYPE "public"."file_type" AS ENUM('photo_pack', 'site_note', 'rd_sap_site_note', 'pas_2023_ventilation', 'pas_2023_condition', 'pas_significance', 'par_photo_pack', 'pas_2023_property', 'pas_2023_occupancy', 'ecmk_site_note', 'ecmk_rd_sap_site_note', 'ecmk_survey_xml', 'pre_photo', 'mid_photo', 'post_photo', 'loft_hatch_photo', 'dmev_photos', 'door_undercut_photos', 'trickle_vent_photos', 'pre_installation_building_inspection', 'point_of_work_risk_assessment', 'claim_of_compliance', 'mcs_compliance_certificate', 'certificate_of_conformity', 'minor_works_electrical_certificate', 'trustmark_licence_numbers', 'operative_competency', 'ventilation_assessment_checklist', 'anemometer_readings', 'commissioning_records', 'part_f_ventilation_document', 'handover_pack', 'insurance_guarantee', 'workmanship_warranty', 'g98_notification', 'installer_qualifications', 'installer_feedback', 'contractor_other');--> statement-breakpoint
ALTER TABLE "uploaded_files" ALTER COLUMN "file_type" SET DATA TYPE "public"."file_type" USING "file_type"::"public"."file_type";--> statement-breakpoint
ALTER TABLE "property_removal_requests" ADD CONSTRAINT "property_removal_requests_portfolio_id_portfolio_id_fk" FOREIGN KEY ("portfolio_id") REFERENCES "public"."portfolio"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "property_removal_requests" ADD CONSTRAINT "property_removal_requests_requested_by_user_id_fk" FOREIGN KEY ("requested_by") REFERENCES "public"."user"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "property_removal_requests" ADD CONSTRAINT "property_removal_requests_reviewed_by_user_id_fk" FOREIGN KEY ("reviewed_by") REFERENCES "public"."user"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
CREATE INDEX "idx_removal_requests_deal_id" ON "property_removal_requests" USING btree ("hubspot_deal_id");--> statement-breakpoint
CREATE INDEX "idx_removal_requests_portfolio_id" ON "property_removal_requests" USING btree ("portfolio_id");--> statement-breakpoint
ALTER TABLE "bulk_address_uploads" DROP COLUMN "task_id";--> statement-breakpoint
ALTER TABLE "bulk_address_uploads" DROP COLUMN "combined_output_s3_uri";

File diff suppressed because it is too large Load diff

View file

@ -1247,6 +1247,13 @@
"when": 1776451871348,
"tag": "0177_wooden_dexter_bennett",
"breakpoints": true
},
{
"idx": 178,
"version": "7",
"when": 1776458454019,
"tag": "0178_parched_midnight",
"breakpoints": true
}
]
}

View file

@ -0,0 +1,65 @@
import {
bigserial,
boolean,
text,
timestamp,
pgTable,
bigint,
index,
unique,
} from "drizzle-orm/pg-core";
import { user } from "./users";
import { InferModel } from "drizzle-orm";
// Current approval state per (deal, measure) — upserted on each change.
// Query WHERE is_approved = true to get the currently approved set.
export const dealMeasureApprovals = pgTable(
"deal_measure_approvals",
{
id: bigserial("id", { mode: "bigint" }).primaryKey(),
hubspotDealId: text("hubspot_deal_id").notNull(),
measureName: text("measure_name").notNull(),
isApproved: boolean("is_approved").notNull().default(true),
approvedBy: bigint("approved_by", { mode: "bigint" })
.notNull()
.references(() => user.id),
approvedAt: timestamp("approved_at", { withTimezone: true })
.defaultNow()
.notNull(),
},
(table) => [
unique("uq_deal_measure").on(table.hubspotDealId, table.measureName),
index("idx_deal_measure_approvals_deal_id").on(table.hubspotDealId),
],
);
// Append-only audit log — never deleted.
export const dealMeasureApprovalEvents = pgTable(
"deal_measure_approval_events",
{
id: bigserial("id", { mode: "bigint" }).primaryKey(),
hubspotDealId: text("hubspot_deal_id").notNull(),
measureName: text("measure_name").notNull(),
// 'approved' | 'unapproved'
action: text("action").notNull(),
actedBy: bigint("acted_by", { mode: "bigint" })
.notNull()
.references(() => user.id),
actedAt: timestamp("acted_at", { withTimezone: true })
.defaultNow()
.notNull(),
},
(table) => [
index("idx_deal_measure_events_deal_id").on(table.hubspotDealId),
index("idx_deal_measure_events_acted_at").on(table.actedAt),
],
);
export type DealMeasureApproval = InferModel<
typeof dealMeasureApprovals,
"select"
>;
export type DealMeasureApprovalEvent = InferModel<
typeof dealMeasureApprovalEvents,
"select"
>;

View file

@ -7,6 +7,7 @@ import {
pgEnum,
integer,
bigint,
unique,
} from "drizzle-orm/pg-core";
import { user } from "./users";
import { InferModel } from "drizzle-orm";
@ -124,7 +125,43 @@ export const portfolioUsers = pgTable("portfolioUsers", {
.notNull(),
});
export const PortfolioCapability: [string, ...string[]] = [
"approver",
"contractor",
];
export type PortfolioCapabilityType = "approver" | "contractor";
export const portfolioCapabilityEnum = pgEnum(
"portfolio_capability",
PortfolioCapability as [string, ...string[]],
);
export const portfolioCapabilities = pgTable(
"portfolio_capabilities",
{
id: bigserial("id", { mode: "bigint" }).primaryKey(),
userId: bigint("user_id", { mode: "bigint" })
.notNull()
.references(() => user.id),
portfolioId: bigint("portfolio_id", { mode: "bigint" })
.notNull()
.references(() => portfolio.id),
capability: portfolioCapabilityEnum("capability").notNull(),
createdAt: timestamp("created_at", { precision: 6, withTimezone: true })
.defaultNow()
.notNull(),
updatedAt: timestamp("updated_at", { precision: 6, withTimezone: true })
.defaultNow()
.notNull(),
},
(table) => [unique().on(table.userId, table.portfolioId, table.capability)],
);
export type Portfolio = InferModel<typeof portfolio, "select">;
export type NewPortfolio = InferModel<typeof portfolio, "insert">;
export type PortfolioUsers = InferModel<typeof portfolioUsers, "select">;
export type NewPortfolioUsers = InferModel<typeof portfolioUsers, "insert">;
export type PortfolioCapabilities = InferModel<
typeof portfolioCapabilities,
"select"
>;

View file

@ -0,0 +1,46 @@
import {
bigserial,
text,
timestamp,
pgTable,
bigint,
index,
} from "drizzle-orm/pg-core";
import { user } from "./users";
import { portfolio } from "./portfolio";
import { InferModel } from "drizzle-orm";
// One row per removal request. A property can have multiple requests over time
// (e.g. request → declined → new request). Query by hubspotDealId to get history.
export const propertyRemovalRequests = pgTable(
"property_removal_requests",
{
id: bigserial("id", { mode: "bigint" }).primaryKey(),
hubspotDealId: text("hubspot_deal_id").notNull(),
portfolioId: bigint("portfolio_id", { mode: "bigint" })
.notNull()
.references(() => portfolio.id),
reason: text("reason").notNull(),
// 'pending' | 'approved' | 'declined'
status: text("status").notNull().default("pending"),
requestedBy: bigint("requested_by", { mode: "bigint" })
.notNull()
.references(() => user.id),
requestedAt: timestamp("requested_at", { withTimezone: true })
.defaultNow()
.notNull(),
reviewedBy: bigint("reviewed_by", { mode: "bigint" }).references(
() => user.id,
),
reviewedAt: timestamp("reviewed_at", { withTimezone: true }),
},
(table) => [
index("idx_removal_requests_deal_id").on(table.hubspotDealId),
index("idx_removal_requests_portfolio_id").on(table.portfolioId),
],
);
export type PropertyRemovalRequest = InferModel<
typeof propertyRemovalRequests,
"select"
>;

View file

@ -1,4 +1,5 @@
import { bigint, bigserial, pgEnum, pgTable, text, timestamp } from "drizzle-orm/pg-core";
import { user } from "./users";
export const fileType = pgEnum("file_type", [
"photo_pack",
@ -12,14 +13,48 @@ export const fileType = pgEnum("file_type", [
"pas_2023_occupancy",
"ecmk_site_note",
"ecmk_rd_sap_site_note",
"ecmk_survey_xml"
"ecmk_survey_xml",
// Contractor install documentation
// Photos
"pre_photo",
"mid_photo",
"post_photo",
"loft_hatch_photo",
"dmev_photos",
"door_undercut_photos",
"trickle_vent_photos",
// Pre-installation
"pre_installation_building_inspection",
"point_of_work_risk_assessment",
// Compliance & lodgement
"claim_of_compliance",
"mcs_compliance_certificate",
"certificate_of_conformity",
"minor_works_electrical_certificate",
"trustmark_licence_numbers",
"operative_competency",
// Ventilation
"ventilation_assessment_checklist",
"anemometer_readings",
"commissioning_records",
"part_f_ventilation_document",
// Handover & warranties
"handover_pack",
"insurance_guarantee",
"workmanship_warranty",
"g98_notification",
// Qualifications & other
"installer_qualifications",
"installer_feedback",
"contractor_other",
]);
export const fileSource = pgEnum("file_source", [
"pas hub",
"sharepoint",
"hubspot",
"ecmk"
"ecmk",
"contractor",
]);
export const uploadedFiles = pgTable(
@ -36,6 +71,8 @@ export const uploadedFiles = pgTable(
hubsotDealId: text("hubspot_deal_id"),
hubspotListingId: bigint("hubspot_listing_id", { mode: "bigint" }),
fileType: fileType("file_type"),
source: fileSource("file_source")
source: fileSource("file_source"),
measureName: text("measure_name"),
uploadedBy: bigint("uploaded_by", { mode: "bigint" }).references(() => user.id),
}
);

View file

@ -0,0 +1,230 @@
"use client";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/app/shadcn_components/ui/table";
import { Button } from "@/app/shadcn_components/ui/button";
import { Badge } from "@/app/shadcn_components/ui/badge";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
type Capability = "approver" | "contractor";
type CapabilityEntry = {
id: string;
userId: string;
capability: Capability;
name: string | null;
email: string;
};
type CapabilityMap = Record<string, { name: string | null; email: string; capabilities: Capability[] }>;
async function getCapabilities(portfolioId: string): Promise<CapabilityEntry[]> {
const res = await fetch(`/api/portfolio/${portfolioId}/capabilities`);
if (!res.ok) throw new Error("Failed to fetch capabilities");
return res.json();
}
async function getCollaborators(
portfolioId: string,
): Promise<{ userId: string; name: string | null; email: string }[]> {
const res = await fetch(`/api/portfolio/${portfolioId}/colloborators`);
if (!res.ok) throw new Error("Failed to fetch collaborators");
const json = await res.json();
const users = Array.isArray(json) ? json : json.users ?? [];
return users.map((u: any) => ({
userId: String(u.userId),
name: u.name ?? null,
email: u.email ?? "",
}));
}
async function assignCapability(
portfolioId: string,
userId: string,
capability: Capability,
): Promise<void> {
const res = await fetch(`/api/portfolio/${portfolioId}/capabilities`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ userId, capability }),
});
if (!res.ok) throw new Error("Failed to assign capability");
}
async function removeCapability(
portfolioId: string,
userId: string,
capability: Capability,
): Promise<void> {
const res = await fetch(`/api/portfolio/${portfolioId}/capabilities`, {
method: "DELETE",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ userId, capability }),
});
if (!res.ok) throw new Error("Failed to remove capability");
}
export function CapabilitiesCard({ portfolioId }: { portfolioId: string }) {
const queryClient = useQueryClient();
const queryKey = ["portfolioCapabilities", portfolioId];
const { data: entries = [], isLoading: loadingCaps } = useQuery({
queryKey,
queryFn: () => getCapabilities(portfolioId),
enabled: !!portfolioId,
refetchOnWindowFocus: false,
});
const { data: collaborators = [], isLoading: loadingCollabs } = useQuery({
queryKey: ["portfolioUsers", portfolioId],
queryFn: () => getCollaborators(portfolioId),
enabled: !!portfolioId,
refetchOnWindowFocus: false,
});
const isLoading = loadingCaps || loadingCollabs;
// Build a map: userId -> { capabilities: [] }
const capMap: CapabilityMap = {};
for (const c of collaborators) {
capMap[c.userId] = { name: c.name, email: c.email, capabilities: [] };
}
for (const e of entries) {
if (capMap[e.userId]) {
capMap[e.userId].capabilities.push(e.capability);
}
}
const toggleMutation = useMutation({
mutationFn: ({
userId,
capability,
has,
}: {
userId: string;
capability: Capability;
has: boolean;
}) =>
has
? removeCapability(portfolioId, userId, capability)
: assignCapability(portfolioId, userId, capability),
onSettled: () => {
queryClient.invalidateQueries({ queryKey });
},
});
const rows = Object.entries(capMap);
return (
<div className="rounded-md border border-gray-700 mt-4">
<Table>
<TableBody>
<TableRow>
<TableHead className="text-brandblue">
Project Roles:
<p className="text-xs text-gray-500">
Assign approver or contractor capabilities to users
</p>
</TableHead>
</TableRow>
<TableRow>
<TableCell colSpan={3}>
<div className="rounded-md border border-gray-200">
<Table>
<TableHeader>
<TableRow>
<TableHead>Name</TableHead>
<TableHead>Email</TableHead>
<TableHead>Approver</TableHead>
<TableHead>Contractor</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{isLoading ? (
<TableRow>
<TableCell colSpan={4} className="text-sm text-gray-500">
Loading
</TableCell>
</TableRow>
) : rows.length === 0 ? (
<TableRow>
<TableCell colSpan={4} className="text-sm text-gray-500">
No collaborators yet. Add users in the section above first.
</TableCell>
</TableRow>
) : (
rows.map(([userId, { name, email, capabilities }]) => (
<TableRow key={userId}>
<TableCell>{name || "—"}</TableCell>
<TableCell className="text-sm text-gray-600">{email}</TableCell>
<TableCell>
<CapabilityToggle
has={capabilities.includes("approver")}
capability="approver"
isPending={toggleMutation.isPending}
onToggle={(has) =>
toggleMutation.mutate({ userId, capability: "approver", has })
}
/>
</TableCell>
<TableCell>
<CapabilityToggle
has={capabilities.includes("contractor")}
capability="contractor"
isPending={toggleMutation.isPending}
onToggle={(has) =>
toggleMutation.mutate({ userId, capability: "contractor", has })
}
/>
</TableCell>
</TableRow>
))
)}
</TableBody>
</Table>
</div>
</TableCell>
</TableRow>
</TableBody>
</Table>
</div>
);
}
function CapabilityToggle({
has,
capability,
isPending,
onToggle,
}: {
has: boolean;
capability: Capability;
isPending: boolean;
onToggle: (has: boolean) => void;
}) {
return (
<Button
size="sm"
variant={has ? "default" : "outline"}
disabled={isPending}
onClick={() => onToggle(has)}
className={has ? "bg-brandblue text-white" : ""}
>
{has ? (
<Badge className="bg-transparent text-white p-0 shadow-none">
{capability === "approver" ? "Approver" : "Contractor"}
</Badge>
) : (
<span className="text-gray-500">
Add {capability === "approver" ? "Approver" : "Contractor"}
</span>
)}
</Button>
);
}

View file

@ -0,0 +1,140 @@
"use client";
import { useState } from "react";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogDescription,
DialogFooter,
} from "@/app/shadcn_components/ui/dialog";
import { Button } from "@/app/shadcn_components/ui/button";
import { Input } from "@/app/shadcn_components/ui/input";
import { CheckCircle2, XCircle } from "lucide-react";
export type PendingDiff = {
added: string[];
removed: string[];
};
type Props = {
open: boolean;
pendingDiffs: Record<string, PendingDiff>; // dealId -> diff
dealNames: Record<string, string>; // dealId -> display name
onConfirm: () => void;
onCancel: () => void;
isPending: boolean;
};
const CONFIRM_WORD = "approve";
export function ApprovalConfirmDialog({
open,
pendingDiffs,
dealNames,
onConfirm,
onCancel,
isPending,
}: Props) {
const [typed, setTyped] = useState("");
const canConfirm = typed === CONFIRM_WORD && !isPending;
const totalAdded = Object.values(pendingDiffs).reduce(
(sum, d) => sum + d.added.length,
0,
);
const totalRemoved = Object.values(pendingDiffs).reduce(
(sum, d) => sum + d.removed.length,
0,
);
function handleOpenChange(open: boolean) {
if (!open) {
setTyped("");
onCancel();
}
}
return (
<Dialog open={open} onOpenChange={handleOpenChange}>
<DialogContent className="sm:max-w-lg">
<DialogHeader>
<DialogTitle className="text-brandblue">Confirm approval changes</DialogTitle>
<DialogDescription>
Review the changes below. This action will be recorded in the audit
log and cannot be undone automatically.
</DialogDescription>
</DialogHeader>
<div className="space-y-4 max-h-80 overflow-y-auto py-1 pr-1">
{Object.entries(pendingDiffs).map(([dealId, diff]) => {
if (diff.added.length === 0 && diff.removed.length === 0) return null;
const name = dealNames[dealId] ?? dealId;
return (
<div key={dealId} className="space-y-2">
<p className="text-sm font-semibold text-gray-700">{name}</p>
<div className="space-y-1 pl-2">
{diff.added.map((m) => (
<div key={`add-${m}`} className="flex items-center gap-2">
<CheckCircle2 className="h-4 w-4 text-emerald-500 shrink-0" />
<span className="text-sm text-emerald-700">{m}</span>
<span className="text-xs text-gray-400">will be approved</span>
</div>
))}
{diff.removed.map((m) => (
<div key={`rem-${m}`} className="flex items-center gap-2">
<XCircle className="h-4 w-4 text-red-400 shrink-0" />
<span className="text-sm text-red-600">{m}</span>
<span className="text-xs text-gray-400">will be unapproved</span>
</div>
))}
</div>
</div>
);
})}
</div>
<div className="space-y-2 pt-2 border-t border-gray-100">
<p className="text-sm text-gray-600">
To confirm{" "}
<span className="font-semibold">
{totalAdded > 0 && `${totalAdded} approval${totalAdded > 1 ? "s" : ""}`}
{totalAdded > 0 && totalRemoved > 0 && " and "}
{totalRemoved > 0 && `${totalRemoved} removal${totalRemoved > 1 ? "s" : ""}`}
</span>
, type{" "}
<code className="px-1 py-0.5 bg-gray-100 rounded text-brandblue font-mono text-xs">
{CONFIRM_WORD}
</code>{" "}
below:
</p>
<Input
value={typed}
onChange={(e) => setTyped(e.target.value)}
placeholder={`Type "${CONFIRM_WORD}" to confirm`}
className="font-mono"
autoFocus
/>
</div>
<DialogFooter>
<Button variant="secondary" onClick={onCancel} disabled={isPending}>
Cancel
</Button>
<Button
onClick={() => {
setTyped("");
onConfirm();
}}
disabled={!canConfirm}
className="bg-brandblue text-white"
>
{isPending ? "Saving…" : "Confirm"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}

View file

@ -0,0 +1,703 @@
"use client";
import { useEffect, useRef, useState } from "react";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogDescription,
DialogFooter,
} from "@/app/shadcn_components/ui/dialog";
import { Button } from "@/app/shadcn_components/ui/button";
import {
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectLabel,
SelectTrigger,
SelectValue,
} from "@/app/shadcn_components/ui/select";
import { CheckCircle2, XCircle, Upload, Loader2, Clock, ChevronDown, ChevronRight, Info } from "lucide-react";
import { uploadFileToS3 } from "@/app/utils/s3";
import type { ClassifiedDeal } from "./types";
// ── Types ─────────────────────────────────────────────────────────────────
type FileStatus = "queued" | "uploading" | "done" | "error";
type FileEntry = {
id: string; // local UUID for React key
// One of these will be set:
file?: File; // for newly picked files
existingS3Key?: string; // for pre-existing unclassified uploads
// Display
displayName: string;
displaySize?: string;
// Upload state
status: FileStatus;
errorMsg?: string;
uploadedId?: string; // DB record ID (set after recording or from existing)
// Classification
docType: string;
measureName: string;
};
type Phase = "loading" | "upload" | "classify";
type Props = {
deal: ClassifiedDeal;
portfolioId: string;
onClose: () => void;
};
// ── Constants ─────────────────────────────────────────────────────────────
const FILE_TYPE_OPTIONS: { value: string; label: string; group: string; hint?: string }[] = [
// Photos
{ value: "pre_photo", label: "Pre-Install Photos", group: "Photos", hint: "Required for ALL measures. Capture existing condition before any work begins." },
{ value: "mid_photo", label: "Mid-Install Photos", group: "Photos", hint: "Required for ALL measures. Detailed photos showing all angles and areas during installation. Insufficient pictures will result in non-lodgement." },
{ value: "post_photo", label: "Post-Install Photos", group: "Photos", hint: "Required for ALL measures. Confirm completed installation." },
{ value: "loft_hatch_photo", label: "Loft Hatch & Draft Excluder Photos",group: "Photos", hint: "Required for loft insulation. Must show loft hatch insulation, draft excluders, and hook & eye closing. Also include photos of insulation depth with a ruler showing thickness." },
{ value: "dmev_photos", label: "DMEV Photos (Wetrooms)", group: "Photos", hint: "Clear photos of all Decentralised Mechanical Extract Ventilation units installed in wetrooms." },
{ value: "door_undercut_photos",label: "Door Undercut Photos", group: "Photos", hint: "Photos of all door undercuts to demonstrate compliant ventilation paths." },
{ value: "trickle_vent_photos", label: "Trickle Vent Photos", group: "Photos", hint: "Photos of all trickle vents located in windows." },
// Pre-installation
{ value: "pre_installation_building_inspection", label: "PIBI / Tech Survey", group: "Pre-Installation", hint: "Pre-Installation Building Inspection — required per property and per measure." },
{ value: "point_of_work_risk_assessment", label: "Point of Work Risk Assessment", group: "Pre-Installation" },
// Compliance & lodgement
{ value: "claim_of_compliance", label: "DOCC 2030 (Claim of Compliance)", group: "Compliance & Lodgement", hint: "Required per property and per measure for TrustMark lodgement under PAS 2030." },
{ value: "mcs_compliance_certificate", label: "MCS Compliance Certificate", group: "Compliance & Lodgement", hint: "Required for Solar PV and Air Source Heat Pump installations." },
{ value: "certificate_of_conformity", label: "Certificate of Conformity", group: "Compliance & Lodgement" },
{ value: "minor_works_electrical_certificate", label: "Minor Works Electrical Certificate", group: "Compliance & Lodgement" },
{ value: "trustmark_licence_numbers", label: "TrustMark Licence Numbers", group: "Compliance & Lodgement", hint: "All installer and subcontractor TrustMark licence numbers. Ensure all are accredited for the correct measures under PAS 2023." },
{ value: "operative_competency", label: "Operative Competency", group: "Compliance & Lodgement", hint: "PAS 2030 installer accreditation and qualifications of individual workers, suitable for the measure(s) installed. Verify all installers/subcontractors are accredited under PAS 2023." },
// Ventilation
{ value: "ventilation_assessment_checklist", label: "Ventilation Assessment Checklist", group: "Ventilation" },
{ value: "anemometer_readings", label: "Anemometer Readings", group: "Ventilation", hint: "Required for DMEV/ventilation measures to confirm airflow compliance." },
{ value: "commissioning_records", label: "Commissioning Records", group: "Ventilation", hint: "Tests, certifications and commissioning records for all systems installed." },
{ value: "part_f_ventilation_document", label: "Approved Document Part F", group: "Ventilation", hint: "Ventilation compliance document under Approved Document Part F." },
// Handover & warranties
{ value: "handover_pack", label: "Handover Pack", group: "Handover & Warranties" },
{ value: "workmanship_warranty", label: "Workmanship Warranty", group: "Handover & Warranties", hint: "Required per property and per measure for TrustMark lodgement." },
{ value: "insurance_guarantee", label: "Insurance Backed Guarantee (IBG)", group: "Handover & Warranties", hint: "Required per property and per measure for TrustMark lodgement." },
{ value: "g98_notification", label: "G98 / G99 Notification", group: "Handover & Warranties", hint: "Required for Solar PV and other grid-connected installations." },
// Qualifications & other
{ value: "installer_qualifications", label: "Installer Qualifications", group: "Qualifications & Other" },
{ value: "installer_feedback", label: "Installer Feedback", group: "Qualifications & Other" },
{ value: "contractor_other", label: "Other", group: "Qualifications & Other" },
];
const FILE_TYPE_GROUPS = [
"Photos",
"Pre-Installation",
"Compliance & Lodgement",
"Ventilation",
"Handover & Warranties",
"Qualifications & Other",
];
// ── PAS 2030/2035 requirements summary (for guidance panel) ───────────────
const PAS_REQUIREMENTS = [
{
heading: "Required for every property & measure",
items: [
"PIBI / Tech Survey (pre-installation building inspection)",
"DOCC 2030 — Claim of Compliance (PAS 2030)",
"Insurance Backed Guarantee (IBG)",
"Workmanship Warranty",
"Pre, mid, and post-install photos (all measures)",
],
},
{
heading: "Additional for Solar PV & ASHP",
items: [
"MCS Compliance Certificate",
"G98 / G99 Notification",
],
},
{
heading: "Loft insulation",
items: [
"Loft hatch insulation, draft excluders, and hook & eye closing photos",
"Photos of insulation depth with a ruler showing thickness",
],
},
{
heading: "Ventilation measures",
items: [
"Clear DMEV photos in all wetrooms",
"Anemometer readings",
"Commissioning records",
"Approved Document Part F ventilation document",
"Door undercut photos",
"Trickle vent photos",
],
},
{
heading: "Installer / lodgement pack",
items: [
"TrustMark licence numbers for all installers & subcontractors (verify PAS 2023 accreditation)",
"Operative Competency (PAS 2030 accreditation + individual worker qualifications)",
"Minor Works Electrical Certificate (where applicable)",
],
},
];
// ── Helpers ───────────────────────────────────────────────────────────────
function formatSize(bytes: number): string {
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(0)} KB`;
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
}
function contentTypeFor(ext: string): string {
const e = ext.toLowerCase();
if (e === "pdf") return "application/pdf";
if (["jpg", "jpeg"].includes(e)) return "image/jpeg";
if (e === "png") return "image/png";
return "application/octet-stream";
}
function parseMeasures(raw: string | null | undefined): string[] {
if (!raw) return [];
return raw.split(",").map((m) => m.trim()).filter(Boolean);
}
function s3KeyBasename(key: string): string {
return key.split("/").pop() ?? key;
}
async function getPresignedUrl(path: string, contentType: string): Promise<string> {
const res = await fetch("/api/upload/retrofit-energy-assessments", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ path, contentType, expiresInSeconds: 300 }),
});
if (!res.ok) throw new Error("Failed to get presigned URL");
const { url } = await res.json();
return url;
}
async function recordUpload(payload: {
s3FileKey: string;
s3FileBucket: string;
uprn?: string;
hubspotDealId?: string;
landlordPropertyId?: string;
}): Promise<string> {
const res = await fetch("/api/upload/contractor-install", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
});
if (!res.ok) throw new Error("Failed to record upload");
const { id } = await res.json();
return id;
}
async function saveClassifications(
updates: { id: string; fileType: string; measureName?: string }[],
): Promise<void> {
const res = await fetch("/api/upload/contractor-install", {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ updates }),
});
if (!res.ok) throw new Error("Failed to save classifications");
}
// ── PAS guidance panel ────────────────────────────────────────────────────
function PasGuidancePanel() {
const [open, setOpen] = useState(false);
return (
<div className="rounded-lg border border-blue-100 bg-blue-50/50">
<button
type="button"
onClick={() => setOpen((v) => !v)}
className="flex w-full items-center gap-2 px-3 py-2.5 text-left"
>
<Info className="h-3.5 w-3.5 text-blue-500 shrink-0" />
<span className="text-xs font-medium text-blue-700 flex-1">
PAS 2030/2035 document requirements
</span>
{open
? <ChevronDown className="h-3.5 w-3.5 text-blue-400 shrink-0" />
: <ChevronRight className="h-3.5 w-3.5 text-blue-400 shrink-0" />
}
</button>
{open && (
<div className="px-3 pb-3 space-y-3 border-t border-blue-100">
{PAS_REQUIREMENTS.map((section) => (
<div key={section.heading}>
<p className="text-[10px] font-bold uppercase tracking-wide text-blue-600 mt-2.5 mb-1">
{section.heading}
</p>
<ul className="space-y-0.5">
{section.items.map((item) => (
<li key={item} className="flex items-start gap-1.5 text-xs text-blue-800">
<span className="mt-1 h-1 w-1 rounded-full bg-blue-400 shrink-0" />
{item}
</li>
))}
</ul>
</div>
))}
<p className="text-[10px] text-blue-500 mt-2 italic">
Insufficient mid-install photos will result in the Retrofit Coordinator sending back for re-submission and non-lodgement.
</p>
</div>
)}
</div>
);
}
// ── DocType select ────────────────────────────────────────────────────────
function DocTypeSelect({ value, onChange, showHint = false }: { value: string; onChange: (v: string) => void; showHint?: boolean }) {
const selected = FILE_TYPE_OPTIONS.find((o) => o.value === value);
return (
<div className="space-y-1">
<Select value={value || "__unset__"} onValueChange={(v) => onChange(v === "__unset__" ? "" : v)}>
<SelectTrigger className="h-8 text-xs w-full">
<SelectValue placeholder="Select type…" />
</SelectTrigger>
<SelectContent>
<SelectItem value="__unset__" className="text-xs text-gray-400">Select type</SelectItem>
{FILE_TYPE_GROUPS.map((group) => {
const items = FILE_TYPE_OPTIONS.filter((o) => o.group === group);
if (!items.length) return null;
return (
<SelectGroup key={group}>
<SelectLabel className="text-[10px] font-semibold uppercase tracking-wide text-gray-400 px-2 py-1">
{group}
</SelectLabel>
{items.map((o) => (
<SelectItem key={o.value} value={o.value} className="text-xs">
{o.label}
</SelectItem>
))}
</SelectGroup>
);
})}
</SelectContent>
</Select>
{showHint && selected?.hint && (
<p className="text-[10px] text-blue-600 leading-snug px-0.5">{selected.hint}</p>
)}
</div>
);
}
// ── Status icon ────────────────────────────────────────────────────────────
function StatusIcon({ status, isExisting, errorMsg }: { status: FileStatus; isExisting?: boolean; errorMsg?: string }) {
if (isExisting) return <Clock className="h-4 w-4 text-amber-400 shrink-0" aria-label="Pending classification" />;
if (status === "queued") return <span className="h-4 w-4 rounded-full border-2 border-gray-200 shrink-0 inline-block" />;
if (status === "uploading") return <Loader2 className="h-4 w-4 animate-spin text-brandblue shrink-0" />;
if (status === "done") return <CheckCircle2 className="h-4 w-4 text-emerald-500 shrink-0" />;
return <span title={errorMsg}><XCircle className="h-4 w-4 text-red-400 shrink-0" /></span>;
}
// ── Main component ─────────────────────────────────────────────────────────
export default function ContractorUploadModal({ deal, portfolioId, onClose }: Props) {
const measures = parseMeasures(deal.proposedMeasures);
const fileInputRef = useRef<HTMLInputElement>(null);
const [isDragOver, setIsDragOver] = useState(false);
const [queue, setQueue] = useState<FileEntry[]>([]);
const [phase, setPhase] = useState<Phase>("loading");
const [isUploading, setIsUploading] = useState(false);
const [isSaving, setIsSaving] = useState(false);
const [saveError, setSaveError] = useState<string | null>(null);
// ── Fetch existing unclassified files on mount ───────────────────────
useEffect(() => {
async function fetchExisting() {
const uprnParam = deal.uprn;
const propIdParam = deal.landlordPropertyId;
if (!uprnParam && !propIdParam) {
setPhase("upload");
return;
}
try {
const param = uprnParam
? `uprn=${encodeURIComponent(uprnParam)}`
: `landlordPropertyId=${encodeURIComponent(propIdParam!)}`;
const res = await fetch(`/api/live-tracking/property-documents?${param}`);
if (!res.ok) throw new Error("fetch failed");
const docs: { id: string; s3FileKey: string; docType: string | null; source: string | null }[] = await res.json();
const unclassified = docs.filter(
(d) => d.source === "contractor" && (d.docType === null || d.docType === "unknown"),
);
if (unclassified.length > 0) {
const entries: FileEntry[] = unclassified.map((d) => ({
id: crypto.randomUUID(),
existingS3Key: d.s3FileKey,
displayName: s3KeyBasename(d.s3FileKey),
status: "done",
uploadedId: d.id,
docType: "",
measureName: measures[0] ?? "",
}));
setQueue(entries);
setPhase("classify");
} else {
setPhase("upload");
}
} catch {
// If fetch fails, just proceed to upload phase
setPhase("upload");
}
}
fetchExisting();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
// ── File selection ───────────────────────────────────────────────────
function addFiles(files: FileList | File[]) {
const newEntries: FileEntry[] = Array.from(files).map((f) => ({
id: crypto.randomUUID(),
file: f,
displayName: f.name,
displaySize: formatSize(f.size),
status: "queued",
docType: "",
measureName: measures[0] ?? "",
}));
setQueue((prev) => [...prev, ...newEntries]);
}
function handleInputChange(e: React.ChangeEvent<HTMLInputElement>) {
if (e.target.files?.length) addFiles(e.target.files);
e.target.value = "";
}
function handleDrop(e: React.DragEvent) {
e.preventDefault();
setIsDragOver(false);
if (e.dataTransfer.files?.length) addFiles(e.dataTransfer.files);
}
function removeFile(id: string) {
setQueue((prev) => prev.filter((f) => f.id !== id));
}
// ── Phase 1: Upload new files ────────────────────────────────────────
async function handleUpload() {
const toUpload = queue.filter((f) => f.status === "queued");
if (toUpload.length === 0) {
// No new files to upload — go straight to classify for existing
setPhase("classify");
return;
}
if (isUploading) return;
setIsUploading(true);
setQueue((prev) =>
prev.map((f) => f.status === "queued" ? { ...f, status: "uploading" } : f),
);
const uploadResults = await Promise.allSettled(
toUpload.map(async (entry) => {
const ext = (entry.file!.name.split(".").pop() ?? "bin").toLowerCase();
const ct = contentTypeFor(ext);
const timestamp = Date.now();
const s3Key = `contractor-install/${deal.dealId}/unclassified/${timestamp}_${entry.id.slice(0, 8)}.${ext}`;
const presignedUrl = await getPresignedUrl(s3Key, ct);
await uploadFileToS3({ presignedUrl, file: entry.file!, contentType: ct });
const urlObj = new URL(presignedUrl);
const bucket = urlObj.hostname.split(".")[0];
const uploadedId = await recordUpload({
s3FileKey: s3Key,
s3FileBucket: bucket,
uprn: deal.uprn ?? undefined,
hubspotDealId: deal.dealId,
landlordPropertyId: deal.landlordPropertyId ?? undefined,
});
return { id: entry.id, uploadedId };
}),
);
const resultMap = new Map(
uploadResults.map((r, i) => [
toUpload[i].id,
r.status === "fulfilled" ? { ok: true, uploadedId: r.value.uploadedId } : { ok: false },
]),
);
setQueue((prev) =>
prev.map((f) => {
const r = resultMap.get(f.id);
if (!r) return f;
if (r.ok) return { ...f, status: "done", uploadedId: r.uploadedId };
return { ...f, status: "error", errorMsg: "Upload failed" };
}),
);
setIsUploading(false);
setPhase("classify");
}
// ── Phase 2: Classify ────────────────────────────────────────────────
function updateEntryField(id: string, field: "docType" | "measureName", value: string) {
setQueue((prev) => prev.map((f) => (f.id === id ? { ...f, [field]: value } : f)));
}
const classifiableEntries = queue.filter((f) => f.status === "done" && f.uploadedId);
const allClassified = classifiableEntries.length > 0 && classifiableEntries.every((f) => f.docType !== "");
async function handleSaveClassifications() {
setSaveError(null);
setIsSaving(true);
try {
await saveClassifications(
classifiableEntries.map((f) => ({
id: f.uploadedId!,
fileType: f.docType,
measureName: (f.measureName && f.measureName !== "__none__") ? f.measureName : undefined,
})),
);
onClose();
} catch {
setSaveError("Failed to save classifications. Please try again.");
} finally {
setIsSaving(false);
}
}
// ── Computed ─────────────────────────────────────────────────────────
const newQueuedCount = queue.filter((f) => f.status === "queued").length;
const existingCount = queue.filter((f) => f.existingS3Key && f.status === "done").length;
const propertyLabel = deal.dealname ?? deal.landlordPropertyId ?? deal.dealId;
// ── Render ───────────────────────────────────────────────────────────
return (
<Dialog open onOpenChange={onClose}>
<DialogContent className="sm:max-w-4xl max-h-[92vh] flex flex-col overflow-hidden">
<DialogHeader>
<DialogTitle>
{phase === "loading" ? "Loading…" :
phase === "upload" ? "Upload Documents" :
"Classify Documents"}
</DialogTitle>
<DialogDescription>
{phase === "loading" && "Checking for pending files…"}
{phase === "upload" && (
<>
Upload install documents for <strong>{propertyLabel}</strong>.
{existingCount > 0 && ` ${existingCount} file${existingCount !== 1 ? "s" : ""} are pending classification.`}
</>
)}
{phase === "classify" && (
<>
{classifiableEntries.length} file{classifiableEntries.length !== 1 ? "s" : ""} ready to classify.
Select a document type for each, then save.
</>
)}
</DialogDescription>
</DialogHeader>
<div className="flex-1 overflow-y-auto min-h-0 space-y-4 py-2">
{/* ── Loading ── */}
{phase === "loading" && (
<div className="flex items-center justify-center py-8">
<Loader2 className="h-5 w-5 animate-spin text-gray-400" />
</div>
)}
{/* ── Phase 1: Upload ── */}
{phase === "upload" && (
<>
{/* PAS guidance */}
<PasGuidancePanel />
{/* Existing unclassified banner */}
{existingCount > 0 && (
<div className="flex items-center gap-2 px-3 py-2.5 rounded-lg bg-amber-50 border border-amber-200 text-xs">
<Clock className="h-4 w-4 text-amber-500 shrink-0" />
<span className="text-amber-700">
<strong>{existingCount}</strong> previously uploaded file{existingCount !== 1 ? "s" : ""} {existingCount !== 1 ? "are" : "is"} waiting to be classified.
Add new files or go straight to classification.
</span>
</div>
)}
{/* Drop zone */}
<div
className={`border-2 border-dashed rounded-xl p-8 text-center cursor-pointer transition-colors ${
isDragOver ? "border-brandblue bg-brandlightblue/20" : "border-gray-200 hover:border-brandblue/40 hover:bg-gray-50"
}`}
onClick={() => fileInputRef.current?.click()}
onDragOver={(e) => { e.preventDefault(); setIsDragOver(true); }}
onDragLeave={() => setIsDragOver(false)}
onDrop={handleDrop}
>
<Upload className="h-6 w-6 text-gray-400 mx-auto mb-2" />
<p className="text-sm font-medium text-gray-600">Drop files here or click to browse</p>
<p className="text-xs text-gray-400 mt-1">PDF, JPG, PNG accepted · Multiple files OK</p>
<input
ref={fileInputRef}
type="file"
multiple
accept=".pdf,.jpg,.jpeg,.png"
className="hidden"
onChange={handleInputChange}
/>
</div>
{/* New file queue */}
{newQueuedCount > 0 && (
<div className="space-y-1">
{queue.filter((f) => f.file).map((entry) => (
<div key={entry.id} className="flex items-center gap-3 px-3 py-2 rounded-lg bg-gray-50 border border-gray-100">
<div className="flex-1 min-w-0">
<p className="text-xs font-medium text-gray-700 truncate">{entry.displayName}</p>
{entry.displaySize && <p className="text-[10px] text-gray-400">{entry.displaySize}</p>}
</div>
<StatusIcon status={entry.status} />
{entry.status === "queued" && (
<button
onClick={() => removeFile(entry.id)}
className="text-gray-300 hover:text-gray-500 text-lg leading-none shrink-0"
aria-label="Remove"
>
×
</button>
)}
</div>
))}
</div>
)}
</>
)}
{/* ── Phase 2: Classify ── */}
{phase === "classify" && (
<div className="space-y-3">
{/* PAS guidance */}
<PasGuidancePanel />
{/* Column headers */}
<div className="grid grid-cols-[1fr_260px_180px] gap-2 px-1">
<span className="text-[10px] font-semibold text-gray-400 uppercase tracking-wide">File</span>
<span className="text-[10px] font-semibold text-gray-400 uppercase tracking-wide">Document Type <span className="text-red-400">*</span></span>
<span className="text-[10px] font-semibold text-gray-400 uppercase tracking-wide">Measure</span>
</div>
{classifiableEntries.map((entry) => (
<div key={entry.id} className="grid grid-cols-[1fr_260px_180px] gap-2 items-center px-1">
<div className="flex items-center gap-2 min-w-0">
<StatusIcon status={entry.status} isExisting={!!entry.existingS3Key} />
<div className="min-w-0">
<p className="text-xs font-medium text-gray-700 truncate">{entry.displayName}</p>
{entry.displaySize && <p className="text-[10px] text-gray-400">{entry.displaySize}</p>}
{entry.existingS3Key && <p className="text-[10px] text-amber-500">Previously uploaded</p>}
</div>
</div>
<DocTypeSelect value={entry.docType} onChange={(v) => updateEntryField(entry.id, "docType", v)} showHint />
{measures.length > 0 ? (
<Select value={entry.measureName || "__none__"} onValueChange={(v) => updateEntryField(entry.id, "measureName", v === "__none__" ? "" : v)}>
<SelectTrigger className="h-8 text-xs w-full">
<SelectValue placeholder="—" />
</SelectTrigger>
<SelectContent>
<SelectItem value="__none__" className="text-xs text-gray-400"> None </SelectItem>
{measures.map((m) => (
<SelectItem key={m} value={m} className="text-xs">{m}</SelectItem>
))}
</SelectContent>
</Select>
) : (
<span className="text-xs text-gray-300"></span>
)}
</div>
))}
{/* Failed uploads (info only) */}
{queue.filter((f) => f.status === "error").length > 0 && (
<div className="p-3 rounded-lg bg-red-50 border border-red-200">
<p className="text-xs font-medium text-red-700 mb-1">
{queue.filter((f) => f.status === "error").length} file(s) failed and are excluded:
</p>
{queue.filter((f) => f.status === "error").map((f) => (
<p key={f.id} className="text-xs text-red-600">{f.displayName}</p>
))}
</div>
)}
{saveError && (
<p className="text-sm text-red-600 bg-red-50 border border-red-200 rounded-md px-3 py-2">
{saveError}
</p>
)}
</div>
)}
</div>
<DialogFooter className="pt-2 border-t border-gray-100 shrink-0">
{phase === "loading" && (
<Button variant="secondary" onClick={onClose}>Cancel</Button>
)}
{phase === "upload" && (
<>
<Button variant="secondary" onClick={onClose} disabled={isUploading}>Cancel</Button>
<Button
onClick={handleUpload}
disabled={isUploading || (newQueuedCount === 0 && existingCount === 0)}
className="bg-brandblue text-white gap-1.5"
>
{isUploading ? (
<><Loader2 className="h-3.5 w-3.5 animate-spin" /> Uploading</>
) : newQueuedCount > 0 ? (
<>Upload {newQueuedCount} file{newQueuedCount !== 1 ? "s" : ""} </>
) : (
<>Classify {existingCount} pending file{existingCount !== 1 ? "s" : ""} </>
)}
</Button>
</>
)}
{phase === "classify" && (
<>
<Button variant="secondary" onClick={onClose} disabled={isSaving}>
Skip for now
</Button>
<Button
onClick={handleSaveClassifications}
disabled={!allClassified || isSaving}
className="bg-brandblue text-white gap-1.5"
>
{isSaving ? (
<><Loader2 className="h-3.5 w-3.5 animate-spin" /> Saving</>
) : (
"Save Classifications →"
)}
</Button>
</>
)}
</DialogFooter>
</DialogContent>
</Dialog>
);
}

View file

@ -28,14 +28,18 @@ import {
} from "@/app/shadcn_components/ui/select";
import { Search, ChevronLeft, ChevronRight, Download } from "lucide-react";
import { createDocumentTableColumns } from "./DocumentTableColumns";
import type { ClassifiedDeal, DocStatusMap } from "./types";
import ContractorUploadModal from "./ContractorUploadModal";
import type { ClassifiedDeal, DocStatusMap, PortfolioCapabilityType } from "./types";
type SurveyStatusFilter = "all" | "none" | "partial" | "complete";
type RetroAssessmentFilter = "all" | "none" | "partial" | "complete";
type InstallStatusFilter = "all" | "none" | "hasDocs" | "partial" | "complete";
interface DocumentTableProps {
data: ClassifiedDeal[];
onOpenDrawer: (uprn: string | null, landlordPropertyId: string | null, dealname: string | null) => void;
docStatusMap: DocStatusMap;
portfolioId: string;
userCapability: PortfolioCapabilityType;
}
function escapeCell(value: unknown): string {
@ -49,29 +53,46 @@ function escapeCell(value: unknown): string {
: str;
}
export default function DocumentTable({ data, onOpenDrawer, docStatusMap }: DocumentTableProps) {
export default function DocumentTable({ data, onOpenDrawer, docStatusMap, portfolioId, userCapability }: DocumentTableProps) {
const [globalFilter, setGlobalFilter] = useState("");
const [surveyStatusFilter, setSurveyStatusFilter] = useState<SurveyStatusFilter>("all");
const [retroAssessmentFilter, setRetroAssessmentFilter] = useState<RetroAssessmentFilter>("all");
const [installStatusFilter, setInstallStatusFilter] = useState<InstallStatusFilter>("all");
const [sorting, setSorting] = useState<SortingState>([]);
const [pagination, setPagination] = useState<PaginationState>({
pageIndex: 0,
pageSize: 25,
});
const [uploadDeal, setUploadDeal] = useState<ClassifiedDeal | null>(null);
const filteredData = useMemo(() => {
if (surveyStatusFilter === "all") return data;
return data.filter((d) => {
const status = d.uprn ? docStatusMap[d.uprn] : undefined;
if (surveyStatusFilter === "none") return !status || !status.hasDocs;
if (surveyStatusFilter === "partial") return !!status?.hasDocs && !status.isComplete;
if (surveyStatusFilter === "complete") return !!status?.isComplete;
if (retroAssessmentFilter !== "all") {
if (retroAssessmentFilter === "none" && !(!status || !status.hasSurveyDocs)) return false;
if (retroAssessmentFilter === "partial" && !(status?.hasSurveyDocs && !status.isSurveyComplete)) return false;
if (retroAssessmentFilter === "complete" && !status?.isSurveyComplete) return false;
}
if (installStatusFilter !== "all") {
const s = status?.installStatus ?? "none";
if (installStatusFilter === "none" && s !== "none") return false;
if (installStatusFilter === "hasDocs" && s !== "hasDocs") return false;
if (installStatusFilter === "partial" && s !== "partial") return false;
if (installStatusFilter === "complete" && s !== "all") return false;
}
return true;
});
}, [data, surveyStatusFilter, docStatusMap]);
}, [data, retroAssessmentFilter, installStatusFilter, docStatusMap]);
const columns = useMemo(
() => createDocumentTableColumns(onOpenDrawer, docStatusMap),
[onOpenDrawer, docStatusMap],
() => createDocumentTableColumns(
onOpenDrawer,
docStatusMap,
userCapability.includes("contractor") ? setUploadDeal : undefined,
),
[onOpenDrawer, docStatusMap, userCapability],
);
const table = useReactTable({
@ -90,19 +111,27 @@ export default function DocumentTable({ data, onOpenDrawer, docStatusMap }: Docu
const downloadCsv = () => {
const rows = table.getFilteredRowModel().rows;
const header = "Address,Landlord ID,Survey Status";
const header = "Address,Landlord ID,Retrofit Assessment Status,Install Docs Status";
const body = rows
.map((row) => {
const status = row.original.uprn ? docStatusMap[row.original.uprn] : undefined;
const surveyStatus = status?.isComplete
const retroStatus = status?.isSurveyComplete
? "Complete"
: status?.hasDocs
: status?.hasSurveyDocs
? "Partial"
: "No Docs";
const installStatusMap: Record<string, string> = {
all: "All Measures",
partial: "Some Measures",
hasDocs: "Has Docs",
none: "No Docs",
};
const installStatus = installStatusMap[status?.installStatus ?? "none"];
return [
escapeCell(row.original.dealname),
escapeCell(row.original.landlordPropertyId),
surveyStatus,
retroStatus,
installStatus,
].join(",");
})
.join("\n");
@ -119,11 +148,19 @@ export default function DocumentTable({ data, onOpenDrawer, docStatusMap }: Docu
const currentPage = table.getState().pagination.pageIndex + 1;
const totalFiltered = table.getFilteredRowModel().rows.length;
const surveyStatusLabel: Record<SurveyStatusFilter, string> = {
all: "All statuses",
none: "No Survey Docs",
partial: "Partial Survey Docs",
complete: "Complete Survey Docs",
const retroAssessmentLabel: Record<RetroAssessmentFilter, string> = {
all: "All retrofit statuses",
none: "No Retrofit Docs",
partial: "Partial Retrofit Docs",
complete: "Complete Retrofit Docs",
};
const installStatusLabel: Record<InstallStatusFilter, string> = {
all: "All install statuses",
none: "No Install Docs",
hasDocs: "Has Install Docs",
partial: "Some Measures",
complete: "All Measures",
};
return (
@ -144,22 +181,42 @@ export default function DocumentTable({ data, onOpenDrawer, docStatusMap }: Docu
/>
</div>
{/* Survey status filter */}
{/* Retrofit assessment filter */}
<Select
value={surveyStatusFilter}
value={retroAssessmentFilter}
onValueChange={(v) => {
setSurveyStatusFilter(v as SurveyStatusFilter);
setRetroAssessmentFilter(v as RetroAssessmentFilter);
setPagination((p) => ({ ...p, pageIndex: 0 }));
}}
>
<SelectTrigger className="h-9 w-[200px] text-sm border-gray-200 shrink-0">
{surveyStatusLabel[surveyStatusFilter]}
<SelectTrigger className="h-9 w-[210px] text-sm border-gray-200 shrink-0">
{retroAssessmentLabel[retroAssessmentFilter]}
</SelectTrigger>
<SelectContent>
<SelectItem value="all">All statuses</SelectItem>
<SelectItem value="none">No Survey Docs</SelectItem>
<SelectItem value="partial">Partial Survey Docs</SelectItem>
<SelectItem value="complete">Complete Survey Docs</SelectItem>
<SelectItem value="all">All retrofit statuses</SelectItem>
<SelectItem value="none">No Retrofit Docs</SelectItem>
<SelectItem value="partial">Partial Retrofit Docs</SelectItem>
<SelectItem value="complete">Complete Retrofit Docs</SelectItem>
</SelectContent>
</Select>
{/* Install docs filter */}
<Select
value={installStatusFilter}
onValueChange={(v) => {
setInstallStatusFilter(v as InstallStatusFilter);
setPagination((p) => ({ ...p, pageIndex: 0 }));
}}
>
<SelectTrigger className="h-9 w-[190px] text-sm border-gray-200 shrink-0">
{installStatusLabel[installStatusFilter]}
</SelectTrigger>
<SelectContent>
<SelectItem value="all">All install statuses</SelectItem>
<SelectItem value="none">No Install Docs</SelectItem>
<SelectItem value="hasDocs">Has Install Docs</SelectItem>
<SelectItem value="partial">Some Measures</SelectItem>
<SelectItem value="complete">All Measures</SelectItem>
</SelectContent>
</Select>
@ -184,7 +241,7 @@ export default function DocumentTable({ data, onOpenDrawer, docStatusMap }: Docu
</span>{" "}
of{" "}
<span className="font-semibold text-gray-600">{totalFiltered}</span>{" "}
{surveyStatusFilter !== "all" ? `(${surveyStatusLabel[surveyStatusFilter].toLowerCase()}) ` : ""}
{(retroAssessmentFilter !== "all" || installStatusFilter !== "all") ? "(filtered) " : ""}
propert{totalFiltered === 1 ? "y" : "ies"}
</p>
@ -239,6 +296,15 @@ export default function DocumentTable({ data, onOpenDrawer, docStatusMap }: Docu
</div>
</div>
{/* Contractor upload modal */}
{uploadDeal && (
<ContractorUploadModal
deal={uploadDeal}
portfolioId={portfolioId}
onClose={() => setUploadDeal(null)}
/>
)}
{/* Pagination */}
{pageCount > 1 && (
<div className="flex items-center justify-between pt-1">

View file

@ -1,7 +1,7 @@
"use client";
import { ColumnDef } from "@tanstack/react-table";
import { ArrowUpDown, CheckCircle2, AlertCircle, FileX } from "lucide-react";
import { ArrowUpDown, CheckCircle2, AlertCircle, FileX, Upload, Package } from "lucide-react";
import type { ClassifiedDeal, DocStatusMap, DocStatus } from "./types";
function SortableHeader({
@ -22,8 +22,8 @@ function SortableHeader({
);
}
function SurveyStatusBadge({ status }: { status: DocStatus | undefined }) {
if (status?.isComplete) {
function RetroAssessmentBadge({ status }: { status: DocStatus | undefined }) {
if (status?.isSurveyComplete) {
return (
<span className="inline-flex items-center gap-1.5 px-2.5 py-1 rounded-full text-xs font-medium border whitespace-nowrap bg-emerald-50 text-emerald-700 border-emerald-200">
<CheckCircle2 className="h-3.5 w-3.5" />
@ -31,7 +31,7 @@ function SurveyStatusBadge({ status }: { status: DocStatus | undefined }) {
</span>
);
}
if (status?.hasDocs) {
if (status?.hasSurveyDocs) {
return (
<span className="inline-flex items-center gap-1.5 px-2.5 py-1 rounded-full text-xs font-medium border whitespace-nowrap bg-amber-50 text-amber-700 border-amber-200">
<AlertCircle className="h-3.5 w-3.5" />
@ -47,9 +47,44 @@ function SurveyStatusBadge({ status }: { status: DocStatus | undefined }) {
);
}
function InstallDocsBadge({ status }: { status: DocStatus | undefined }) {
const installStatus = status?.installStatus ?? "none";
if (installStatus === "all") {
return (
<span className="inline-flex items-center gap-1.5 px-2.5 py-1 rounded-full text-xs font-medium border whitespace-nowrap bg-emerald-50 text-emerald-700 border-emerald-200">
<CheckCircle2 className="h-3.5 w-3.5" />
All Measures
</span>
);
}
if (installStatus === "partial") {
return (
<span className="inline-flex items-center gap-1.5 px-2.5 py-1 rounded-full text-xs font-medium border whitespace-nowrap bg-amber-50 text-amber-700 border-amber-200">
<AlertCircle className="h-3.5 w-3.5" />
Some Measures
</span>
);
}
if (installStatus === "hasDocs") {
return (
<span className="inline-flex items-center gap-1.5 px-2.5 py-1 rounded-full text-xs font-medium border whitespace-nowrap bg-sky-50 text-sky-700 border-sky-200">
<Package className="h-3.5 w-3.5" />
Has Docs
</span>
);
}
return (
<span className="inline-flex items-center gap-1.5 px-2.5 py-1 rounded-full text-xs font-medium border whitespace-nowrap bg-gray-50 text-gray-400 border-gray-200">
<FileX className="h-3.5 w-3.5" />
No Docs
</span>
);
}
export function createDocumentTableColumns(
onOpenDrawer: (uprn: string | null, landlordPropertyId: string | null, dealname: string | null) => void,
docStatusMap: DocStatusMap = {},
onUpload?: (deal: ClassifiedDeal) => void,
): ColumnDef<ClassifiedDeal>[] {
return [
// ── Address ──────────────────────────────────────────────────────────
@ -80,19 +115,38 @@ export function createDocumentTableColumns(
enableHiding: false,
},
// ── Survey Status ─────────────────────────────────────────────────────
// ── Retrofit Assessment Docs Status ───────────────────────────────────
{
id: "surveyStatus",
id: "retroAssessmentStatus",
accessorFn: (row) => {
const status = row.uprn ? docStatusMap[row.uprn] : undefined;
if (status?.isComplete) return 2;
if (status?.hasDocs) return 1;
if (status?.isSurveyComplete) return 2;
if (status?.hasSurveyDocs) return 1;
return 0;
},
header: ({ column }) => <SortableHeader label="Survey Status" column={column as any} />,
header: ({ column }) => <SortableHeader label="Retrofit Assessment Docs" column={column as any} />,
cell: ({ row }) => {
const status = row.original.uprn ? docStatusMap[row.original.uprn] : undefined;
return <SurveyStatusBadge status={status} />;
return <RetroAssessmentBadge status={status} />;
},
enableHiding: false,
},
// ── Install Docs Status ───────────────────────────────────────────────
{
id: "installDocs",
accessorFn: (row) => {
const status = row.uprn ? docStatusMap[row.uprn] : undefined;
const s = status?.installStatus ?? "none";
if (s === "all") return 3;
if (s === "partial") return 2;
if (s === "hasDocs") return 1;
return 0;
},
header: ({ column }) => <SortableHeader label="Install Docs" column={column as any} />,
cell: ({ row }) => {
const status = row.original.uprn ? docStatusMap[row.original.uprn] : undefined;
return <InstallDocsBadge status={status} />;
},
enableHiding: false,
},
@ -110,11 +164,11 @@ export function createDocumentTableColumns(
let icon: React.ReactNode;
let className: string;
if (status?.isComplete) {
if (status?.isSurveyComplete) {
icon = <CheckCircle2 className="h-3.5 w-3.5" />;
className =
"inline-flex items-center gap-1.5 px-2.5 py-1.5 rounded-lg text-xs font-medium border border-emerald-200 text-emerald-700 bg-emerald-50 hover:bg-emerald-100 hover:border-emerald-300 transition-all duration-150 whitespace-nowrap";
} else if (status?.hasDocs) {
} else if (status?.hasSurveyDocs) {
icon = <AlertCircle className="h-3.5 w-3.5" />;
className =
"inline-flex items-center gap-1.5 px-2.5 py-1.5 rounded-lg text-xs font-medium border border-amber-200 text-amber-700 bg-amber-50 hover:bg-amber-100 hover:border-amber-300 transition-all duration-150 whitespace-nowrap";
@ -143,5 +197,24 @@ export function createDocumentTableColumns(
enableSorting: false,
enableHiding: false,
},
// ── Upload button (contractor only) ──────────────────────────────────
...(onUpload ? [{
id: "upload",
header: () => (
<span className="text-xs font-semibold uppercase tracking-wide text-gray-500">Upload</span>
),
cell: ({ row }: { row: { original: ClassifiedDeal } }) => (
<button
onClick={() => onUpload(row.original)}
className="inline-flex items-center gap-1.5 px-2.5 py-1.5 rounded-lg text-xs font-medium border border-brandblue/20 text-brandblue bg-brandlightblue/20 hover:bg-brandlightblue/40 hover:border-brandblue/40 transition-all duration-150 whitespace-nowrap"
>
<Upload className="h-3.5 w-3.5" />
Upload Docs
</button>
),
enableSorting: false,
enableHiding: false,
} as ColumnDef<ClassifiedDeal>] : []),
];
}

View file

@ -9,10 +9,11 @@ import {
TabsTrigger,
} from "@/app/shadcn_components/ui/tabs";
import { Card, CardContent } from "@/app/shadcn_components/ui/card";
import { BarChart2, Table2, FolderOpen } from "lucide-react";
import { BarChart2, Table2, FolderOpen, Wrench } from "lucide-react";
import DrillDownTable from "./DrillDownTable";
import PropertyTable from "./PropertyTable";
import DocumentTable from "./DocumentTable";
import MeasuresTable from "./MeasuresTable";
import type { HubspotDeal } from "./types";
import PropertyDrawer from "./PropertyDrawer";
import PropertyDetailDrawer from "./PropertyDetailDrawer";
@ -30,9 +31,14 @@ export default function LiveTracker({
totalDeals,
majorConditionDeals,
docStatusMap,
userCapability,
approvalsByDeal,
portfolioId,
userRole,
userEmail,
}: LiveTrackerProps) {
// ── Tab state ────────────────────────────────────────────────────────
const [activeTab, setActiveTab] = useState<"analytics" | "properties" | "documents">(
const [activeTab, setActiveTab] = useState<"analytics" | "properties" | "documents" | "measures">(
"analytics",
);
@ -94,7 +100,7 @@ export default function LiveTracker({
<div className="space-y-4 w-full">
<Tabs
value={activeTab}
onValueChange={(v) => setActiveTab(v as "analytics" | "properties" | "documents")}
onValueChange={(v) => setActiveTab(v as "analytics" | "properties" | "documents" | "measures")}
>
{/* Tab bar */}
<TabsList className="h-10 p-1 bg-brandlightblue/10 border border-brandblue/10 rounded-xl mb-6">
@ -119,6 +125,13 @@ export default function LiveTracker({
<FolderOpen className="h-3.5 w-3.5" />
Document Management
</TabsTrigger>
<TabsTrigger
value="measures"
className="flex items-center gap-2 rounded-lg text-sm font-medium px-4 data-[state=active]:bg-white data-[state=active]:text-brandblue data-[state=active]:shadow-sm transition-all"
>
<Wrench className="h-3.5 w-3.5" />
Measures
</TabsTrigger>
</TabsList>
{/* Analytics tab */}
@ -207,6 +220,40 @@ export default function LiveTracker({
/>
</div>
</TabsContent>
{/* Measures tab */}
<TabsContent value="measures" className="mt-0">
<div className="space-y-4">
{projects.length > 1 && (
<div className="flex items-center gap-3">
<span className="text-sm text-gray-500 shrink-0">Project:</span>
<select
value={currentProjectCode}
onChange={(e) => setCurrentProjectCode(e.target.value)}
className="px-3 py-1.5 border border-brandblue/20 rounded-lg bg-white text-sm text-gray-800 font-medium focus:ring-2 focus:ring-brandblue focus:border-brandblue focus:outline-none transition-all appearance-none pr-8"
>
{projectCodes.map((code) =>
code === "__ALL__" ? (
<option key="__ALL__" value="__ALL__" style={{ fontWeight: 700 }}>
All Projects
</option>
) : (
<option key={code} value={code}>
{code}
</option>
),
)}
</select>
</div>
)}
<MeasuresTable
data={currentProject?.allDeals ?? []}
userCapability={userCapability}
approvalsByDeal={approvalsByDeal}
portfolioId={portfolioId}
/>
</div>
</TabsContent>
</Tabs>
{/* ── Drill-down table modal ─────────────────────────────────────── */}
@ -312,6 +359,10 @@ export default function LiveTracker({
<PropertyDetailDrawer
deal={detailDeal}
onClose={() => setDetailDeal(null)}
portfolioId={portfolioId}
userRole={userRole}
userCapability={userCapability}
userEmail={userEmail}
/>
</div>
);

View file

@ -0,0 +1,469 @@
"use client";
import React, { useMemo, useState } from "react";
import { useMutation, useQuery } from "@tanstack/react-query";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/app/shadcn_components/ui/table";
import { Input } from "@/app/shadcn_components/ui/input";
import { Button } from "@/app/shadcn_components/ui/button";
import { Badge } from "@/app/shadcn_components/ui/badge";
import { Checkbox } from "@/app/shadcn_components/ui/checkbox";
import { Search, Save, ChevronDown, ChevronRight } from "lucide-react";
import { STAGE_COLORS } from "./types";
import type { ClassifiedDeal, PortfolioCapabilityType, ApprovalsByDeal } from "./types";
import { ApprovalConfirmDialog, type PendingDiff } from "./ApprovalConfirmDialog";
type AuditEvent = {
id: string;
hubspotDealId: string;
measureName: string;
action: string; // 'approved' | 'unapproved'
actedByEmail: string;
actedByName: string | null;
actedAt: string; // ISO string
};
type Props = {
data: ClassifiedDeal[];
userCapability: PortfolioCapabilityType;
approvalsByDeal: ApprovalsByDeal;
portfolioId: string;
};
function parseMeasures(raw: string | null | undefined): string[] {
if (!raw) return [];
return raw.split(",").map((m) => m.trim()).filter(Boolean);
}
function ApprovalStatus({
proposed,
approved,
}: {
proposed: string[];
approved: string[];
}) {
if (proposed.length === 0) return null;
const approvedSet = new Set(approved);
const approvedCount = proposed.filter((m) => approvedSet.has(m)).length;
if (approvedCount === 0) {
return (
<Badge className="bg-amber-50 text-amber-700 border border-amber-200 text-xs">
Pending
</Badge>
);
}
if (approvedCount === proposed.length) {
return (
<Badge className="bg-emerald-50 text-emerald-700 border border-emerald-200 text-xs">
Fully Approved
</Badge>
);
}
return (
<Badge className="bg-blue-50 text-blue-700 border border-blue-200 text-xs">
{approvedCount}/{proposed.length} Approved
</Badge>
);
}
function formatDate(iso: string) {
return new Date(iso).toLocaleString("en-GB", {
day: "numeric",
month: "short",
year: "numeric",
hour: "2-digit",
minute: "2-digit",
});
}
function ActivityLog({
dealId,
portfolioId,
}: {
dealId: string;
portfolioId: string;
}) {
const { data, isLoading } = useQuery<{ events: AuditEvent[] }>({
queryKey: ["approvalEvents", portfolioId, dealId],
queryFn: async () => {
const res = await fetch(
`/api/portfolio/${portfolioId}/approvals?dealIds=${dealId}&include=events`,
);
if (!res.ok) throw new Error("Failed to fetch events");
return res.json();
},
staleTime: 30_000,
});
if (isLoading) {
return (
<p className="text-xs text-gray-400 py-2 pl-4">Loading activity</p>
);
}
const events = data?.events ?? [];
if (events.length === 0) {
return (
<p className="text-xs text-gray-400 py-2 pl-4">No activity yet.</p>
);
}
return (
<div className="pl-4 pr-2 pb-3 space-y-1.5">
{events.map((e) => (
<div key={e.id} className="flex items-center gap-2 text-xs">
<span
className={`px-1.5 py-0.5 rounded text-xs font-medium ${
e.action === "approved"
? "bg-emerald-50 text-emerald-700"
: "bg-red-50 text-red-600"
}`}
>
{e.action === "approved" ? "Approved" : "Unapproved"}
</span>
<span className="font-medium text-gray-700">{e.measureName}</span>
<span className="text-gray-400">·</span>
<span className="text-gray-500">
{e.actedByName ?? e.actedByEmail}
</span>
<span className="text-gray-400">·</span>
<span className="text-gray-400">{formatDate(e.actedAt)}</span>
</div>
))}
</div>
);
}
async function postApprovalChanges(
portfolioId: string,
changes: { hubspotDealId: string; measureName: string; approved: boolean }[],
) {
const res = await fetch(`/api/portfolio/${portfolioId}/approvals`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ changes }),
});
if (!res.ok) throw new Error("Failed to save approvals");
}
export default function MeasuresTable({
data,
userCapability,
approvalsByDeal,
portfolioId,
}: Props) {
const [search, setSearch] = useState("");
// pendingChanges: dealId -> desired Set<measureName> (the full intended approved set)
const [pendingChanges, setPendingChanges] = useState<
Record<string, Set<string>>
>({});
const [savedApprovals, setSavedApprovals] =
useState<ApprovalsByDeal>(approvalsByDeal);
const [showConfirm, setShowConfirm] = useState(false);
const [expandedRows, setExpandedRows] = useState<Set<string>>(new Set());
// Filter to only properties with proposed measures
const dealsWithMeasures = useMemo(
() => data.filter((d) => d.proposedMeasures),
[data],
);
const filtered = useMemo(() => {
const q = search.toLowerCase();
if (!q) return dealsWithMeasures;
return dealsWithMeasures.filter(
(d) =>
d.dealname?.toLowerCase().includes(q) ||
d.landlordPropertyId?.toLowerCase().includes(q) ||
d.proposedMeasures?.toLowerCase().includes(q),
);
}, [dealsWithMeasures, search]);
const hasPendingChanges = Object.keys(pendingChanges).length > 0;
// Compute diffs: for each deal in pendingChanges, what's added vs removed vs saved
const pendingDiffs = useMemo<Record<string, PendingDiff>>(() => {
const diffs: Record<string, PendingDiff> = {};
for (const [dealId, pending] of Object.entries(pendingChanges)) {
const saved = new Set(savedApprovals[dealId] ?? []);
const added = [...pending].filter((m) => !saved.has(m));
const removed = [...saved].filter((m) => !pending.has(m));
if (added.length > 0 || removed.length > 0) {
diffs[dealId] = { added, removed };
}
}
return diffs;
}, [pendingChanges, savedApprovals]);
const dealNames = useMemo<Record<string, string>>(() => {
const map: Record<string, string> = {};
for (const d of dealsWithMeasures) {
map[d.dealId] = d.dealname ?? d.landlordPropertyId ?? d.dealId;
}
return map;
}, [dealsWithMeasures]);
const saveMutation = useMutation({
mutationFn: () => {
// Build flat list of explicit changes from diffs
const changes: { hubspotDealId: string; measureName: string; approved: boolean }[] = [];
for (const [dealId, diff] of Object.entries(pendingDiffs)) {
for (const m of diff.added) changes.push({ hubspotDealId: dealId, measureName: m, approved: true });
for (const m of diff.removed) changes.push({ hubspotDealId: dealId, measureName: m, approved: false });
}
return postApprovalChanges(portfolioId, changes);
},
onSuccess: () => {
setSavedApprovals((prev) => {
const next = { ...prev };
for (const [dealId, pending] of Object.entries(pendingChanges)) {
next[dealId] = Array.from(pending);
}
return next;
});
setPendingChanges({});
setShowConfirm(false);
},
});
function toggleMeasure(dealId: string, measure: string) {
setPendingChanges((prev) => {
const base =
prev[dealId] !== undefined
? new Set(prev[dealId])
: new Set(savedApprovals[dealId] ?? []);
if (base.has(measure)) {
base.delete(measure);
} else {
base.add(measure);
}
// If pending equals saved, remove from tracking
const saved = new Set(savedApprovals[dealId] ?? []);
const equal = base.size === saved.size && [...base].every((m) => saved.has(m));
const next = { ...prev };
if (equal) {
delete next[dealId];
} else {
next[dealId] = base;
}
return next;
});
}
function toggleRowExpand(dealId: string) {
setExpandedRows((prev) => {
const next = new Set(prev);
if (next.has(dealId)) next.delete(dealId);
else next.add(dealId);
return next;
});
}
if (dealsWithMeasures.length === 0) {
return (
<div className="rounded-xl border border-gray-100 bg-white p-12 text-center">
<p className="text-sm text-gray-400">
No properties with proposed measures found in this project.
</p>
</div>
);
}
return (
<div className="space-y-4">
{/* Toolbar */}
<div className="flex items-center justify-between gap-3 flex-wrap">
<div className="relative flex-1 min-w-48 max-w-sm">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-gray-400" />
<Input
placeholder="Search address or measure…"
value={search}
onChange={(e) => setSearch(e.target.value)}
className="pl-9 h-9 text-sm"
/>
</div>
<div className="flex items-center gap-2">
<span className="text-xs text-gray-400">
{filtered.length} of {dealsWithMeasures.length} properties
</span>
{userCapability.includes("approver") && hasPendingChanges && (
<Button
size="sm"
onClick={() => setShowConfirm(true)}
className="bg-brandblue text-white gap-1.5"
>
<Save className="h-3.5 w-3.5" />
Review changes ({Object.keys(pendingDiffs).length})
</Button>
)}
</div>
</div>
{/* Table */}
<div className="rounded-xl border border-gray-100 overflow-hidden bg-white">
<Table>
<TableHeader>
<TableRow className="bg-gray-50 border-b border-gray-100">
<TableHead className="w-6" />
<TableHead className="text-xs font-semibold text-gray-500 uppercase tracking-wide">
Address
</TableHead>
<TableHead className="text-xs font-semibold text-gray-500 uppercase tracking-wide">
Stage
</TableHead>
<TableHead className="text-xs font-semibold text-gray-500 uppercase tracking-wide">
Proposed Measures
</TableHead>
<TableHead className="text-xs font-semibold text-gray-500 uppercase tracking-wide">
Status
</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{filtered.map((deal) => {
const proposed = parseMeasures(deal.proposedMeasures);
const approvedForDeal =
pendingChanges[deal.dealId] !== undefined
? Array.from(pendingChanges[deal.dealId])
: (savedApprovals[deal.dealId] ?? []);
const approvedSet = new Set(approvedForDeal);
const stageColor = STAGE_COLORS[deal.displayStage];
const hasPending = pendingChanges[deal.dealId] !== undefined;
const isExpanded = expandedRows.has(deal.dealId);
return (
<React.Fragment key={deal.dealId}>
<TableRow
className={`border-b border-gray-50 hover:bg-gray-50/50 transition-colors ${hasPending ? "bg-amber-50/30" : ""}`}
>
{/* Expand toggle */}
<TableCell className="py-3 pl-3 pr-0 w-6">
<button
onClick={() => toggleRowExpand(deal.dealId)}
className="text-gray-400 hover:text-brandblue transition-colors"
aria-label={isExpanded ? "Collapse activity" : "Expand activity"}
>
{isExpanded ? (
<ChevronDown className="h-4 w-4" />
) : (
<ChevronRight className="h-4 w-4" />
)}
</button>
</TableCell>
{/* Address */}
<TableCell className="py-3">
<div className="font-medium text-sm text-gray-800">
{deal.dealname ?? "—"}
</div>
{deal.landlordPropertyId && (
<div className="text-xs text-gray-400 mt-0.5">
{deal.landlordPropertyId}
</div>
)}
</TableCell>
{/* Stage */}
<TableCell className="py-3">
<span
className={`inline-flex items-center gap-1.5 px-2 py-1 rounded-full text-xs font-medium border ${stageColor.bg} ${stageColor.text} ${stageColor.border}`}
>
<span className={`h-1.5 w-1.5 rounded-full ${stageColor.dot}`} />
{deal.displayStage}
</span>
</TableCell>
{/* Proposed measures */}
<TableCell className="py-3">
<div className="flex flex-wrap gap-1.5">
{proposed.map((measure) => {
const isApproved = approvedSet.has(measure);
if (userCapability.includes("approver")) {
return (
<label
key={measure}
className={`flex items-center gap-1.5 cursor-pointer px-2 py-1 rounded-full text-xs border transition-colors ${
isApproved
? "bg-emerald-50 border-emerald-200 text-emerald-700"
: "bg-gray-50 border-gray-200 text-gray-600 hover:bg-gray-100"
}`}
>
<Checkbox
checked={isApproved}
onCheckedChange={() => toggleMeasure(deal.dealId, measure)}
className="h-3 w-3"
/>
{measure}
</label>
);
}
return (
<span
key={measure}
className={`px-2 py-1 rounded-full text-xs border ${
isApproved
? "bg-emerald-50 border-emerald-200 text-emerald-700"
: "bg-gray-50 border-gray-200 text-gray-600"
}`}
>
{measure}
</span>
);
})}
</div>
</TableCell>
{/* Status */}
<TableCell className="py-3">
<ApprovalStatus proposed={proposed} approved={approvedForDeal} />
</TableCell>
</TableRow>
{/* Expandable activity log row */}
{isExpanded && (
<TableRow className="bg-gray-50/50">
<TableCell
colSpan={5}
className="p-0"
>
<div className="border-t border-gray-100">
<p className="text-xs font-semibold text-gray-400 uppercase tracking-wide px-4 pt-2 pb-1">
Activity log
</p>
<ActivityLog dealId={deal.dealId} portfolioId={portfolioId} />
</div>
</TableCell>
</TableRow>
)}
</React.Fragment>
);
})}
</TableBody>
</Table>
</div>
{/* Confirmation dialog */}
<ApprovalConfirmDialog
open={showConfirm}
pendingDiffs={pendingDiffs}
dealNames={dealNames}
onConfirm={() => saveMutation.mutate()}
onCancel={() => setShowConfirm(false)}
isPending={saveMutation.isPending}
/>
</div>
);
}

View file

@ -1,17 +1,284 @@
"use client";
import { motion, AnimatePresence } from "framer-motion";
import { X, CheckCircle2, Circle, AlertTriangle } from "lucide-react";
import { useState } from "react";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { X, CheckCircle2, Circle, AlertTriangle, ChevronRight, ChevronDown, Trash2 } from "lucide-react";
import {
Drawer,
DrawerClose,
DrawerContent,
DrawerHeader,
DrawerTitle,
DrawerDescription,
} from "@/app/shadcn_components/ui/drawer";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogFooter,
} from "@/app/shadcn_components/ui/dialog";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@/app/shadcn_components/ui/tooltip";
import { STAGE_COLORS } from "./types";
import type { ClassifiedDeal } from "./types";
import type { ClassifiedDeal, PortfolioCapabilityType, RemovalRequest } from "./types";
// -----------------------------------------------------------------------
// Removal request section
// -----------------------------------------------------------------------
const WRITE_ROLES = ["creator", "admin", "write"];
function RemovalRequestSection({
dealId,
portfolioId,
userRole,
userCapability,
}: {
dealId: string;
portfolioId: string;
userRole: string;
userCapability: PortfolioCapabilityType;
}) {
const queryClient = useQueryClient();
const [dialogOpen, setDialogOpen] = useState(false);
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 pendingRequest = data?.requests?.find((r) => r.status === "pending") ?? null;
const latestResolvedRequest = data?.requests?.find((r) => r.status !== "pending") ?? null;
async function handleSubmit() {
if (!reason.trim()) 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() }),
});
if (!res.ok) {
const json = await res.json().catch(() => ({}));
setError(json.error ?? "Failed to submit request");
return;
}
setDialogOpen(false);
setReason("");
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);
}
}
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>
)}
{/* Pending request — visible to everyone */}
{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">
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>
{/* Approver actions */}
{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"
>
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>
)}
{/* Most recent resolved request */}
{!pendingRequest && 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"
}`}>
{latestResolvedRequest.status === "approved" ? "Removal Approved" : "Removal Declined"}
</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>
)}
{/* Request button — only shown when no pending request exists */}
{!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>
)}
{/* Reason dialog */}
<Dialog open={dialogOpen} onOpenChange={(v) => { if (!v) { setDialogOpen(false); setReason(""); setError(null); } }}>
<DialogContent className="max-w-md">
<DialogHeader>
<DialogTitle className="text-base font-semibold text-gray-800">
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.
</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…"
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={() => { setDialogOpen(false); setReason(""); setError(null); }}
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 bg-red-600 text-white hover:bg-red-700 disabled:opacity-50 transition-colors"
>
{submitting ? "Submitting…" : "Submit Request"}
</button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
);
}
// -----------------------------------------------------------------------
// Approval log placeholder (expand into a real implementation as needed)
// -----------------------------------------------------------------------
function ApprovalLogSection({ dealId, portfolioId }: { dealId: string; portfolioId: string }) {
void dealId; void portfolioId;
return <p className="text-xs text-gray-400">No approvals recorded.</p>;
}
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 ""; }
}
// -----------------------------------------------------------------------
// Milestone definitions — ordered pipeline steps with their date fields
@ -142,10 +409,21 @@ function MilestoneTimeline({ deal }: { deal: ClassifiedDeal }) {
interface PropertyDetailDrawerProps {
deal: ClassifiedDeal | null;
onClose: () => void;
portfolioId: string;
userRole: string;
userCapability: PortfolioCapabilityType;
userEmail: string;
}
export default function PropertyDetailDrawer({ deal, onClose }: PropertyDetailDrawerProps) {
export default function PropertyDetailDrawer({
deal,
portfolioId,
onClose,
userRole,
userCapability,
}: PropertyDetailDrawerProps) {
const open = !!deal;
const [isLogOpen, setIsLogOpen] = useState(false);
return (
<Drawer open={open} onOpenChange={(v) => !v && onClose()} direction="right">
@ -255,6 +533,41 @@ export default function PropertyDetailDrawer({ deal, onClose }: PropertyDetailDr
<h3 className="text-xs font-bold uppercase tracking-wider text-gray-400 mb-4">Project Timeline</h3>
<MilestoneTimeline deal={deal} />
</div>
{/* Removal request */}
<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>
{/* Approval log — collapsible */}
<div className="border-t border-gray-100 pt-4">
<button
onClick={() => setIsLogOpen((v) => !v)}
className="flex items-center gap-2 w-full text-left group"
>
{isLogOpen ? (
<ChevronDown className="h-3.5 w-3.5 text-gray-400 group-hover:text-brandblue transition-colors shrink-0" />
) : (
<ChevronRight className="h-3.5 w-3.5 text-gray-400 group-hover:text-brandblue transition-colors shrink-0" />
)}
<h3 className="text-xs font-bold uppercase tracking-wider text-gray-400 group-hover:text-brandblue transition-colors">
Approval Log
</h3>
</button>
{isLogOpen && (
<div className="mt-3">
<ApprovalLogSection dealId={deal.dealId} portfolioId={portfolioId} />
</div>
)}
</div>
</div>
{/* Footer */}

View file

@ -11,6 +11,7 @@ import {
FolderOpen,
X,
ExternalLink,
HardHat,
} from "lucide-react";
import {
Drawer,
@ -21,10 +22,11 @@ import {
DrawerDescription,
} from "@/app/shadcn_components/ui/drawer";
import type { PropertyDocument } from "./types";
import { EXPECTED_SURVEY_DOC_TYPES } from "./types";
import { EXPECTED_RETROFIT_ASSESSMENT_DOC_TYPES, SURVEY_ALL_DOC_TYPES } from "./types";
// Human-readable labels for the main DB fileType enum values
// Human-readable labels for all DB fileType enum values
const DOC_TYPE_LABELS: Record<string, string> = {
// Survey / retrofit assessment docs
photo_pack: "Photo Pack",
site_note: "Site Note",
rd_sap_site_note: "RdSAP Site Note",
@ -34,13 +36,43 @@ const DOC_TYPE_LABELS: Record<string, string> = {
par_photo_pack: "PAR Photo Pack",
pas_2023_property: "PAS 2023 Property Report",
pas_2023_occupancy: "PAS 2023 Occupancy Report",
ecmk_site_note: "ECMK Site Note",
ecmk_rd_sap_site_note: "ECMK RdSAP Site Note",
ecmk_survey_xml: "ECMK Survey XML",
// Install docs — photos
pre_photo: "Pre-Install Photos",
mid_photo: "Mid-Install Photos",
post_photo: "Post-Install Photos",
loft_hatch_photo: "Loft Hatch & Draft Excluder Photos",
dmev_photos: "DMEV Photos (Wetrooms)",
door_undercut_photos: "Door Undercut Photos",
trickle_vent_photos: "Trickle Vent Photos",
// Install docs — pre-installation
pre_installation_building_inspection: "PIBI / Tech Survey",
point_of_work_risk_assessment: "Point of Work Risk Assessment",
// Install docs — compliance & lodgement
claim_of_compliance: "DOCC 2030 (Claim of Compliance)",
mcs_compliance_certificate: "MCS Compliance Certificate",
certificate_of_conformity: "Certificate of Conformity",
minor_works_electrical_certificate: "Minor Works Electrical Certificate",
trustmark_licence_numbers: "TrustMark Licence Numbers",
operative_competency: "Operative Competency",
// Install docs — ventilation
ventilation_assessment_checklist: "Ventilation Assessment Checklist",
anemometer_readings: "Anemometer Readings",
commissioning_records: "Commissioning Records",
part_f_ventilation_document: "Approved Document Part F",
// Install docs — handover & warranties
handover_pack: "Handover Pack",
insurance_guarantee: "Insurance Backed Guarantee (IBG)",
workmanship_warranty: "Workmanship Warranty",
g98_notification: "G98 / G99 Notification",
// Install docs — qualifications & other
installer_qualifications: "Installer Qualifications",
installer_feedback: "Installer Feedback",
contractor_other: "Other",
};
// All survey docs go under this group for now (extensible later)
function getDocCategory(_docType: string): string {
return "Survey Documents";
}
function formatDate(iso: string): string {
try {
return new Date(iso).toLocaleDateString("en-GB", {
@ -56,7 +88,7 @@ function formatDate(iso: string): string {
// -----------------------------------------------------------------------
// Individual document row
// -----------------------------------------------------------------------
function DocumentRow({ doc }: { doc: PropertyDocument }) {
function DocumentRow({ doc, showMeasure }: { doc: PropertyDocument; showMeasure?: boolean }) {
const label = DOC_TYPE_LABELS[doc.docType] ?? doc.docType;
const { mutate: download, isPending: signing } = useMutation({
@ -90,7 +122,10 @@ function DocumentRow({ doc }: { doc: PropertyDocument }) {
<div className="min-w-0">
<p className="text-sm font-medium text-gray-800 truncate">{label}</p>
<p className="text-xs text-gray-400 mt-0.5">
{formatDate(doc.s3UploadTimestamp)}
{showMeasure && doc.measureName
? <><span className="text-brandblue/70 font-medium">{doc.measureName}</span> · {formatDate(doc.s3UploadTimestamp)}</>
: formatDate(doc.s3UploadTimestamp)
}
</p>
</div>
</div>
@ -161,20 +196,16 @@ export default function PropertyDrawer({
}
const documents = open ? (fetchedDocuments as PropertyDocument[]) : lastDocumentsRef.current;
// Group docs by category for display
const grouped = documents.reduce<
Record<string, PropertyDocument[]>
>((acc, doc) => {
const category = getDocCategory(doc.docType);
(acc[category] ??= []).push(doc);
return acc;
}, {});
// Split documents into the two sections
const retrofitDocs = documents.filter((d) => SURVEY_ALL_DOC_TYPES.has(d.docType));
const installDocs = documents.filter((d) => !SURVEY_ALL_DOC_TYPES.has(d.docType));
const hasDocuments = documents.length > 0;
const presentTypes = new Set(documents.map((d) => d.docType));
const missingTypes = EXPECTED_SURVEY_DOC_TYPES.filter(
(t) => !presentTypes.has(t),
// Missing mandatory retrofit assessment docs (ecmk types are optional — not shown as missing)
const presentRetrofitTypes = new Set(retrofitDocs.map((d) => d.docType));
const missingRetrofitTypes = EXPECTED_RETROFIT_ASSESSMENT_DOC_TYPES.filter(
(t) => !presentRetrofitTypes.has(t),
);
return (
@ -220,7 +251,7 @@ export default function PropertyDrawer({
</DrawerHeader>
{/* Body */}
<div className="flex-1 overflow-y-auto px-6 py-4 space-y-5">
<div className="flex-1 overflow-y-auto px-6 py-4 space-y-6">
{/* Loading state */}
{isFetching && (
<div className="space-y-3 pt-2">
@ -248,7 +279,7 @@ export default function PropertyDrawer({
</div>
)}
{/* Empty state — shows all missing doc types */}
{/* Empty state */}
{!isFetching && !isError && !hasDocuments && (
<div className="space-y-4 pt-1">
<div className="flex flex-col items-center py-6 text-center">
@ -259,15 +290,14 @@ export default function PropertyDrawer({
No documents available
</p>
<p className="text-xs text-gray-400 mt-1">
All {EXPECTED_SURVEY_DOC_TYPES.length} survey documents are
outstanding.
All {EXPECTED_RETROFIT_ASSESSMENT_DOC_TYPES.length} retrofit assessment documents are outstanding.
</p>
</div>
<div className="space-y-1.5">
<h3 className="text-xs font-semibold uppercase tracking-wide text-amber-500 px-0.5">
Missing Documents ({missingTypes.length})
Missing Documents ({missingRetrofitTypes.length})
</h3>
{missingTypes.map((t) => (
{missingRetrofitTypes.map((t) => (
<div
key={t}
className="flex items-center gap-2.5 p-3 rounded-lg border border-dashed border-amber-200 bg-amber-50/40"
@ -282,58 +312,74 @@ export default function PropertyDrawer({
</div>
)}
{/* Document groups */}
<AnimatePresence>
{!isFetching &&
!isError &&
hasDocuments &&
Object.entries(grouped).map(([category, docs]) => (
{!isFetching && !isError && hasDocuments && (
<>
{/* ── Retrofit Assessment Documents ── */}
<motion.div
key={category}
key="retrofit"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
className="space-y-2"
>
<h3 className="text-xs font-semibold uppercase tracking-wide text-gray-400 px-0.5">
{category}
Retrofit Assessment Documents
</h3>
<div className="space-y-1.5">
{docs.map((doc) => (
<DocumentRow key={doc.id} doc={doc} />
))}
</div>
</motion.div>
))}
</AnimatePresence>
{/* Missing documents section — shown when some but not all docs are present */}
{!isFetching &&
!isError &&
hasDocuments &&
missingTypes.length > 0 && (
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
className="space-y-2"
>
<h3 className="text-xs font-semibold uppercase tracking-wide text-amber-500 px-0.5">
Missing Documents ({missingTypes.length})
</h3>
<div className="space-y-1.5">
{missingTypes.map((t) => (
<div
key={t}
className="flex items-center gap-2.5 p-3 rounded-lg border border-dashed border-amber-200 bg-amber-50/40"
>
<FileX className="h-3.5 w-3.5 text-amber-300 shrink-0" />
<span className="text-xs text-amber-600 font-medium">
{DOC_TYPE_LABELS[t] ?? t}
</span>
{retrofitDocs.length > 0 ? (
<div className="space-y-1.5">
{retrofitDocs.map((doc) => (
<DocumentRow key={doc.id} doc={doc} />
))}
</div>
))}
</div>
</motion.div>
) : (
<p className="text-xs text-gray-400 px-0.5">None uploaded yet.</p>
)}
{/* Missing mandatory retrofit assessment docs */}
{missingRetrofitTypes.length > 0 && (
<div className="space-y-1.5 pt-1">
<h4 className="text-xs font-semibold uppercase tracking-wide text-amber-500 px-0.5">
Missing ({missingRetrofitTypes.length})
</h4>
{missingRetrofitTypes.map((t) => (
<div
key={t}
className="flex items-center gap-2.5 p-3 rounded-lg border border-dashed border-amber-200 bg-amber-50/40"
>
<FileX className="h-3.5 w-3.5 text-amber-300 shrink-0" />
<span className="text-xs text-amber-600 font-medium">
{DOC_TYPE_LABELS[t] ?? t}
</span>
</div>
))}
</div>
)}
</motion.div>
{/* ── Install Documents ── */}
<motion.div
key="install"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
className="space-y-2"
>
<h3 className="text-xs font-semibold uppercase tracking-wide text-gray-400 px-0.5 flex items-center gap-1.5">
<HardHat className="h-3.5 w-3.5" />
Install Documents
</h3>
{installDocs.length > 0 ? (
<div className="space-y-1.5">
{installDocs.map((doc) => (
<DocumentRow key={doc.id} doc={doc} showMeasure />
))}
</div>
) : (
<p className="text-xs text-gray-400 px-0.5">No install documents uploaded yet.</p>
)}
</motion.div>
</>
)}
</AnimatePresence>
</div>
{/* Footer */}

View file

@ -126,9 +126,9 @@ export default function PropertyTable({ data, onOpenDrawer, onOpenDetail, showDo
if (docFilter !== "all") {
result = result.filter((d) => {
const status = d.uprn ? docStatusMap[d.uprn] : undefined;
if (docFilter === "none") return !status || !status.hasDocs;
if (docFilter === "has_docs") return !!status?.hasDocs;
if (docFilter === "incomplete") return !!status?.hasDocs && !status.isComplete;
if (docFilter === "none") return !status || !status.hasSurveyDocs;
if (docFilter === "has_docs") return !!status?.hasSurveyDocs;
if (docFilter === "incomplete") return !!status?.hasSurveyDocs && !status.isSurveyComplete;
return true;
});
}

View file

@ -285,8 +285,8 @@ export function createPropertyTableColumns(
cell: ({ row }) => {
const uprn = row.original.uprn ?? "";
const status = uprn ? docStatusMap[uprn] : undefined;
const isComplete = status?.isComplete;
const hasDocs = status?.hasDocs;
const isComplete = status?.isSurveyComplete;
const hasDocs = status?.hasSurveyDocs;
let icon: React.ReactNode;
let className: string;

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 } from "drizzle-orm";
import { eq, inArray, and } from "drizzle-orm";
import LiveTracker from "./LiveTracker";
import { computeLiveTrackerData } from "./transforms";
import { db } from "@/app/db/db";
@ -9,8 +9,11 @@ import { hubspotDealData } from "@/app/db/schema/crm/hubspot_deal_table";
import { uploadedFiles } from "@/app/db/schema/uploaded_files";
import { portfolioOrganisation } from "@/app/db/schema/portfolio_organisation";
import { organisation } from "@/app/db/schema/organisation";
import type { HubspotDeal, DocStatusMap, DocStatus } from "./types";
import { EXPECTED_SURVEY_DOC_TYPES } from "./types";
import { portfolioCapabilities, portfolioUsers } from "@/app/db/schema/portfolio";
import { dealMeasureApprovals } from "@/app/db/schema/approvals";
import { user as userTable } from "@/app/db/schema/users";
import type { HubspotDeal, DocStatusMap, DocStatus, PortfolioCapabilityType, ApprovalsByDeal } 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";
import { Building2 } from "lucide-react";
@ -120,6 +123,78 @@ export default async function LiveReportingPage(props: {
const deals = rawDeals.map(mapDbRowToHubspotDeal);
const trackerData = computeLiveTrackerData(deals);
// Fetch current user's portfolio capabilities (approver / contractor — can have both)
let userCapability: PortfolioCapabilityType = [];
const userEmail = user?.user?.email;
if (userEmail) {
const userRow = await db
.select({ id: userTable.id })
.from(userTable)
.where(eq(userTable.email, userEmail))
.limit(1);
if (userRow[0]) {
const capRows = await db
.select({ capability: portfolioCapabilities.capability })
.from(portfolioCapabilities)
.where(
and(
eq(portfolioCapabilities.portfolioId, BigInt(portfolioId)),
eq(portfolioCapabilities.userId, userRow[0].id),
),
);
userCapability = capRows
.map((r) => r.capability)
.filter((c): c is "approver" | "contractor" => c === "approver" || c === "contractor");
}
}
// Fetch current user's portfolio role (creator / admin / write / read)
let userRole = "read";
if (userEmail) {
const userRow = await db
.select({ id: userTable.id })
.from(userTable)
.where(eq(userTable.email, userEmail))
.limit(1);
if (userRow[0]) {
const roleRow = await db
.select({ role: portfolioUsers.role })
.from(portfolioUsers)
.where(
and(
eq(portfolioUsers.portfolioId, BigInt(portfolioId)),
eq(portfolioUsers.userId, userRow[0].id),
),
)
.limit(1);
userRole = roleRow[0]?.role ?? "read";
}
}
// Fetch currently approved measures for all deals in scope
const approvalsByDeal: ApprovalsByDeal = {};
const dealIds = deals.map((d) => d.dealId).filter(Boolean);
if (dealIds.length > 0) {
const approvalRows = await db
.select({
hubspotDealId: dealMeasureApprovals.hubspotDealId,
measureName: dealMeasureApprovals.measureName,
})
.from(dealMeasureApprovals)
.where(
and(
inArray(dealMeasureApprovals.hubspotDealId, dealIds),
eq(dealMeasureApprovals.isApproved, true),
),
);
for (const row of approvalRows) {
(approvalsByDeal[row.hubspotDealId] ??= []).push(row.measureName);
}
}
// Fetch survey document status for all properties
const uprnList = deals
.map((d) => d.uprn)
@ -133,23 +208,58 @@ export default async function LiveReportingPage(props: {
if (uprnList.length > 0) {
const docRows = await db
.select()
.select({
uprn: uploadedFiles.uprn,
fileType: uploadedFiles.fileType,
measureName: uploadedFiles.measureName,
})
.from(uploadedFiles)
.where(inArray(uploadedFiles.uprn, uprnList));
const grouped: Record<string, Set<string>> = {};
// Group docs by UPRN
const docsByUprn = new Map<string, Array<{ fileType: string; measureName: string | null }>>();
for (const row of docRows) {
if (row.uprn === null || row.fileType === null) continue;
const key = String(row.uprn);
(grouped[key] ??= new Set()).add(row.fileType);
if (!docsByUprn.has(key)) docsByUprn.set(key, []);
docsByUprn.get(key)!.push({ fileType: row.fileType, measureName: row.measureName });
}
for (const [uprn, types] of Object.entries(grouped)) {
const presentTypes = Array.from(types);
// Build measures lookup from deals (uprn → proposed measure names)
const measuresByUprn = new Map<string, string[]>();
for (const deal of deals) {
if (deal.uprn) {
const key = String(deal.uprn);
const measures = (deal.proposedMeasures ?? "")
.split(",").map((m: string) => m.trim()).filter(Boolean);
measuresByUprn.set(key, measures);
}
}
for (const [uprn, docs] of docsByUprn) {
const surveyDocs = docs.filter((d) => SURVEY_ALL_DOC_TYPES.has(d.fileType));
const installDocs = docs.filter((d) => !SURVEY_ALL_DOC_TYPES.has(d.fileType));
const surveyTypeSet = new Set(surveyDocs.map((d) => d.fileType));
const measures = measuresByUprn.get(uprn) ?? [];
let installStatus: DocStatus["installStatus"] = "none";
if (installDocs.length > 0) {
if (measures.length === 0) {
installStatus = "hasDocs";
} else {
const measuresWithDocs = new Set(
installDocs.map((d) => d.measureName).filter(Boolean),
);
installStatus = measures.every((m) => measuresWithDocs.has(m)) ? "all" : "partial";
}
}
const status: DocStatus = {
presentTypes,
hasDocs: presentTypes.length > 0,
isComplete: EXPECTED_SURVEY_DOC_TYPES.every((t) => types.has(t)),
presentSurveyTypes: Array.from(surveyTypeSet),
hasSurveyDocs: surveyDocs.length > 0,
isSurveyComplete: EXPECTED_RETROFIT_ASSESSMENT_DOC_TYPES.every((t) => surveyTypeSet.has(t)),
hasInstallDocs: installDocs.length > 0,
installStatus,
};
docStatusMap[uprn] = status;
}
@ -158,7 +268,15 @@ export default async function LiveReportingPage(props: {
return (
<div className="max-w-7xl mx-auto px-6 pb-10 space-y-4">
{pageHeader}
<LiveTracker {...trackerData} docStatusMap={docStatusMap} />
<LiveTracker
{...trackerData}
docStatusMap={docStatusMap}
userCapability={userCapability}
approvalsByDeal={approvalsByDeal}
portfolioId={portfolioId}
userRole={userRole}
userEmail={userEmail ?? ""}
/>
</div>
);
}

View file

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

View file

@ -161,6 +161,28 @@ export type ProjectData = {
allDeals: ClassifiedDeal[]; // for table drill-downs within project
};
// -----------------------------------------------------------------------
// Portfolio capability for the current viewing user
// -----------------------------------------------------------------------
export type PortfolioCapabilityType = ("approver" | "contractor")[];
// Approved measure names per HubSpot deal ID
export type ApprovalsByDeal = Record<string, string[]>;
// -----------------------------------------------------------------------
// Removal request record returned by the API
// -----------------------------------------------------------------------
export type RemovalRequest = {
id: string;
hubspotDealId: string;
status: "pending" | "approved" | "declined";
reason: string;
requestedByEmail: string;
requestedAt: string;
reviewedByEmail: string | null;
reviewedAt: string | null;
};
// -----------------------------------------------------------------------
// Top-level props for LiveTracker (client root)
// -----------------------------------------------------------------------
@ -169,6 +191,11 @@ export type LiveTrackerProps = {
totalDeals: number;
majorConditionDeals: ClassifiedDeal[]; // for Awaab's Law card
docStatusMap: DocStatusMap;
userCapability: PortfolioCapabilityType;
approvalsByDeal: ApprovalsByDeal;
portfolioId: string;
userRole: string;
userEmail: string;
};
// -----------------------------------------------------------------------
@ -194,10 +221,11 @@ export type PropertyDocument = {
s3UploadTimestamp: string; // ISO string
uprn: string | null;
landlordPropertyId: string | null;
measureName: string | null; // set for install docs
};
// All survey document types expected for a complete survey
export const EXPECTED_SURVEY_DOC_TYPES = [
// Mandatory retrofit assessment doc types (used for completeness check — ecmk types are optional)
export const EXPECTED_RETROFIT_ASSESSMENT_DOC_TYPES = [
"photo_pack",
"site_note",
"rd_sap_site_note",
@ -209,10 +237,26 @@ export const EXPECTED_SURVEY_DOC_TYPES = [
"pas_2023_occupancy",
] as const;
// All survey-adjacent types (including optional ecmk docs) — used for display categorisation
export const SURVEY_ALL_DOC_TYPES = new Set<string>([
...EXPECTED_RETROFIT_ASSESSMENT_DOC_TYPES,
"ecmk_site_note",
"ecmk_rd_sap_site_note",
"ecmk_survey_xml",
]);
export type DocStatus = {
presentTypes: string[];
hasDocs: boolean;
isComplete: boolean; // all EXPECTED_SURVEY_DOC_TYPES present
// Retrofit assessment docs
presentSurveyTypes: string[];
hasSurveyDocs: boolean;
isSurveyComplete: boolean; // all 9 EXPECTED_RETROFIT_ASSESSMENT_DOC_TYPES present (ecmk not counted)
// Install docs
hasInstallDocs: boolean;
installStatus: "none" | "partial" | "hasDocs" | "all";
// "all" = install docs exist for every proposed measure
// "partial" = some (but not all) proposed measures have docs
// "hasDocs" = has install docs but no measures defined on the deal
// "none" = no install docs at all
};
export type DocStatusMap = Record<string, DocStatus>; // keyed by UPRN string