Merge pull request #236 from Hestia-Homes/main

Huge dev deployment
This commit is contained in:
Jun-te Kim 2026-04-21 15:42:18 +01:00 committed by GitHub
commit 2de093574a
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
69 changed files with 79630 additions and 254 deletions

View file

@ -1,6 +1,9 @@
FROM library/python:3.12-bullseye
ARG USER=vscode
ARG USER_UID=1000
ARG USER_GID=1000
ARG DEBIAN_FRONTEND=noninteractive
# Install system dependencies in a single layer
@ -37,6 +40,15 @@ RUN curl -fsSL https://deb.nodesource.com/setup_22.x | bash - \
# RUN apt-get install terraform
# RUN terraform -install-autocomplete
# Install Claude
USER ${USER}
RUN curl -fsSL https://claude.ai/install.sh | bash \
&& export PATH="/home/${USER}/.local/bin:${PATH}" \
&& claude plugin marketplace add JuliusBrussee/caveman \
&& claude plugin install caveman@caveman
ENV PATH="/home/vscode/.local/bin:${PATH}"
USER root
# Set the working directory
WORKDIR /workspaces/assessment-model

View file

@ -5,10 +5,11 @@
"remoteUser": "vscode",
"workspaceFolder": "/workspaces/assessment-model",
"postStartCommand": "bash .devcontainer/post-install.sh",
"forwardPorts": [3000],
"forwardPorts": [3000], # For vscode
"appPort": ["3000:3000"], # For devcontainer shell
"mounts": [
// Optional, just makes getting from Downloads (local env) easier
// "source=${localEnv:HOME},target=/workspaces/home,type=bind"
"source=${localEnv:HOME},target=/workspaces/home,type=bind"
],
"customizations": {
"vscode": {

View file

@ -1,11 +1,11 @@
version: "3.8"
services:
frontend:
user: "${UID}:${GID}"
build:
context: ..
dockerfile: .devcontainer/Dockerfile
args:
USER_UID: ${UID:-1000}
USER_GID: ${GID:-1000}
command: sleep infinity
ports:
- "3000:3000"

102
devcontainer.sh Normal file
View file

@ -0,0 +1,102 @@
#!/usr/bin/env bash
#
# devcontainer.sh — devcontainer helper for this repo
#
# Usage:
# ./devcontainer.sh <command>
#
# Commands:
# up build + start the devcontainer (idempotent)
# shell attach a bash shell; auto-ups if not running
# down stop the devcontainer
# rebuild remove + rebuild from scratch, no cache
#
# Examples:
# ./devcontainer.sh shell # one-shot: up if needed, then bash
# ./devcontainer.sh rebuild
set -euo pipefail
SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" &>/dev/null && pwd)"
REPO_ROOT="${SCRIPT_DIR}"
CONFIG_PATH="${REPO_ROOT}/.devcontainer/devcontainer.json"
VALID_COMMANDS=(up shell down rebuild)
# --- helpers ---------------------------------------------------------------
usage() {
sed -n '3,15p' "${BASH_SOURCE[0]}" | sed 's/^# \{0,1\}//'
exit "${1:-0}"
}
die() {
echo "error: $*" >&2
exit 1
}
in_list() {
local needle="$1"
shift
local item
for item in "$@"; do
[[ "${item}" == "${needle}" ]] && return 0
done
return 1
}
container_id() {
# Find the running container for this repo via devcontainer labels.
docker ps -q \
--filter "label=devcontainer.local_folder=${REPO_ROOT}" \
--filter "label=devcontainer.config_file=${CONFIG_PATH}"
}
# --- argument parsing ------------------------------------------------------
[[ $# -eq 1 ]] || usage 1
COMMAND="$1"
in_list "${COMMAND}" "${VALID_COMMANDS[@]}" \
|| die "invalid command '${COMMAND}' (expected: ${VALID_COMMANDS[*]})"
[[ -f "${CONFIG_PATH}" ]] || die "config not found: ${CONFIG_PATH}"
DC_ARGS=(--workspace-folder "${REPO_ROOT}")
# --- dispatch --------------------------------------------------------------
case "${COMMAND}" in
up)
echo ">> bringing up devcontainer"
devcontainer up "${DC_ARGS[@]}"
;;
shell)
# Auto-up if not already running. `devcontainer up` is idempotent —
# it reuses an existing container, so this is cheap on warm starts.
if [[ -z "$(container_id)" ]]; then
echo ">> devcontainer not running, bringing it up first"
devcontainer up "${DC_ARGS[@]}"
fi
echo ">> attaching shell"
devcontainer exec "${DC_ARGS[@]}" bash 2>/dev/null \
|| devcontainer exec "${DC_ARGS[@]}" sh
;;
down)
cid="$(container_id)"
if [[ -z "${cid}" ]]; then
echo ">> devcontainer not running, nothing to stop"
exit 0
fi
echo ">> stopping devcontainer"
docker stop "${cid}"
;;
rebuild)
echo ">> rebuilding devcontainer from scratch"
devcontainer up "${DC_ARGS[@]}" --remove-existing-container --build-no-cache
;;
esac

View file

@ -5,22 +5,25 @@ import { uploadedFiles } from "@/app/db/schema/uploaded_files";
export async function GET(req: Request) {
const { searchParams } = new URL(req.url);
const dealIdParam = searchParams.get("dealId");
const uprnParam = searchParams.get("uprn");
const landlordPropertyIdParam = searchParams.get("landlordPropertyId");
if (!uprnParam && !landlordPropertyIdParam) {
if (!dealIdParam && !uprnParam && !landlordPropertyIdParam) {
return NextResponse.json(
{ error: "uprn or landlordPropertyId is required" },
{ error: "dealId, uprn, or landlordPropertyId is required" },
{ status: 400 },
);
}
try {
// Prefer UPRN — it's more selective and avoids an OR full-table scan.
// Only fall back to landlordPropertyId when no UPRN is available.
const condition = uprnParam
? eq(uploadedFiles.uprn, BigInt(uprnParam))
: eq(uploadedFiles.landlordPropertyId, landlordPropertyIdParam!);
// Prefer dealId — reliable even when UPRN is missing from the deal.
// Fall back to UPRN, then landlordPropertyId.
const condition = dealIdParam
? eq(uploadedFiles.hubsotDealId, dealIdParam)
: uprnParam
? eq(uploadedFiles.uprn, BigInt(uprnParam))
: eq(uploadedFiles.landlordPropertyId, landlordPropertyIdParam!);
const rows = await db
.select({
@ -29,8 +32,10 @@ export async function GET(req: Request) {
s3FileBucket: uploadedFiles.s3FileBucket,
s3UploadTimestamp: uploadedFiles.s3UploadTimestamp,
fileType: uploadedFiles.fileType,
source: uploadedFiles.source,
uprn: uploadedFiles.uprn,
landlordPropertyId: uploadedFiles.landlordPropertyId,
measureName: uploadedFiles.measureName,
})
.from(uploadedFiles)
.where(condition);
@ -40,9 +45,11 @@ export async function GET(req: Request) {
s3FileKey: row.s3FileKey,
s3FileBucket: row.s3FileBucket,
docType: row.fileType ?? "unknown",
source: row.source ?? null,
s3UploadTimestamp: row.s3UploadTimestamp.toISOString(),
uprn: row.uprn !== null ? String(row.uprn) : null,
landlordPropertyId: row.landlordPropertyId,
measureName: row.measureName ?? null,
}));
return NextResponse.json(documents);

View file

@ -0,0 +1,243 @@
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";
import { syncMeasureApprovalsToHubSpot } from "@/app/lib/hubspot/dealSync";
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,
});
}
const affectedDealIds = [...new Set(body.changes.map((c) => c.hubspotDealId))];
for (const dealId of affectedDealIds) {
const approvalRows = await db
.select({
measureName: dealMeasureApprovals.measureName,
approvedByEmail: user.email,
})
.from(dealMeasureApprovals)
.leftJoin(user, eq(user.id, dealMeasureApprovals.approvedBy))
.where(
and(
eq(dealMeasureApprovals.hubspotDealId, dealId),
eq(dealMeasureApprovals.isApproved, true),
),
);
void syncMeasureApprovalsToHubSpot({
hubspotDealId: dealId,
approvedMeasures: approvalRows.map((r) => ({
measureName: r.measureName,
approvedByEmail: r.approvedByEmail ?? "unknown",
})),
actedByEmail: session.user.email,
actedAt: now,
});
}
return NextResponse.json({ success: true });
} catch (err) {
console.error("POST /approvals error:", err);
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,373 @@
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";
import { syncRemovalRequestToHubSpot, getDealBatch } from "@/app/lib/hubspot/dealSync";
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,
type: propertyRemovalRequests.type,
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,
type: row.type,
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"),
type: z.enum(["removal", "re_addition"]).default("removal"),
});
// 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, type } = parsed.data;
try {
// Fetch the most recent request for this deal to determine current state
const [latest] = await db
.select({
status: propertyRemovalRequests.status,
type: propertyRemovalRequests.type,
})
.from(propertyRemovalRequests)
.where(
and(
eq(propertyRemovalRequests.hubspotDealId, hubspotDealId),
eq(propertyRemovalRequests.portfolioId, BigInt(portfolioId)),
),
)
.orderBy(desc(propertyRemovalRequests.requestedAt))
.limit(1);
if (latest?.status === "pending") {
return NextResponse.json(
{ error: "A pending request already exists for this property" },
{ status: 409 },
);
}
if (type === "removal" && latest?.type === "removal" && latest?.status === "approved") {
return NextResponse.json(
{ error: "This property has already been removed" },
{ status: 409 },
);
}
if (type === "re_addition") {
const isRemoved =
(latest?.type === "removal" && latest?.status === "approved") ||
(latest?.type === "re_addition" && latest?.status === "declined");
if (!isRemoved) {
return NextResponse.json(
{ error: "This property is not currently removed" },
{ status: 409 },
);
}
}
const [inserted] = await db
.insert(propertyRemovalRequests)
.values({
hubspotDealId,
portfolioId: BigInt(portfolioId),
reason,
type,
status: "pending",
requestedBy: requestingUser.id,
})
.returning();
void syncRemovalRequestToHubSpot({
hubspotDealId,
type,
status: "pending",
reason,
requestedByEmail: requestingUser.email,
});
return NextResponse.json({ success: true, id: String(inserted.id) });
} catch (err) {
console.error("[removal-requests POST]", err);
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,
type: propertyRemovalRequests.type,
status: propertyRemovalRequests.status,
hubspotDealId: propertyRemovalRequests.hubspotDealId,
reason: propertyRemovalRequests.reason,
requestedByEmail: user.email,
})
.from(propertyRemovalRequests)
.innerJoin(user, eq(user.id, propertyRemovalRequests.requestedBy))
.where(eq(propertyRemovalRequests.id, BigInt(requestId)))
.limit(1);
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 },
);
}
const requestType = (target[0].type ?? "removal") as "removal" | "re_addition";
const dealId = target[0].hubspotDealId;
// When approving a removal: fetch the current batch value and store it
// When approving a re-addition: find the stored batch from the original removal
let originalBatch: string | null = null;
let batchValue: string | null | undefined = undefined;
if (action === "approved") {
if (requestType === "removal") {
originalBatch = await getDealBatch(dealId);
} else if (requestType === "re_addition") {
const [originalRemoval] = await db
.select({ originalBatch: propertyRemovalRequests.originalBatch })
.from(propertyRemovalRequests)
.where(
and(
eq(propertyRemovalRequests.hubspotDealId, dealId),
eq(propertyRemovalRequests.type, "removal"),
eq(propertyRemovalRequests.status, "approved"),
),
)
.orderBy(desc(propertyRemovalRequests.reviewedAt))
.limit(1);
batchValue = originalRemoval?.originalBatch ?? null;
}
}
await db
.update(propertyRemovalRequests)
.set({
status: action,
reviewedBy: requestingUser.id,
reviewedAt: new Date(),
...(requestType === "removal" && action === "approved" ? { originalBatch } : {}),
})
.where(eq(propertyRemovalRequests.id, BigInt(requestId)));
void syncRemovalRequestToHubSpot({
hubspotDealId: dealId,
type: requestType,
status: action,
reason: target[0].reason,
requestedByEmail: target[0].requestedByEmail,
reviewedByEmail: requestingUser.email,
batchValue,
});
return NextResponse.json({ success: true });
} catch (err) {
console.error("[removal-requests PATCH]", err);
return NextResponse.json({ error: "Internal server error" }, { status: 500 });
}
}

View file

@ -0,0 +1,151 @@
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";
import { syncContractorDocUploadToHubSpot } from "@/app/lib/hubspot/dealSync";
import { getRequiredDocs } from "@/app/lib/measureDocumentRequirements";
function computeMeasureProgress(
proposedMeasures: string[],
dealDocs: { fileType: string | null; measureName: string | null }[],
) {
const installDocs = dealDocs.filter((d) => d.fileType !== null && d.measureName !== null);
return proposedMeasures.map((measureName) => {
const required = getRequiredDocs(measureName);
const docsForMeasure = installDocs.filter((d) => d.measureName === measureName);
const uploadedTypeSet = new Set(docsForMeasure.map((d) => d.fileType));
const uploadedCount = required.filter((r) => uploadedTypeSet.has(r)).length;
return {
measureName,
uploadedCount,
requiredCount: required.length,
isComplete: uploadedCount === required.length,
};
});
}
// 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 });
if (body.hubspotDealId) {
void syncContractorDocUploadToHubSpot({
hubspotDealId: body.hubspotDealId,
});
}
return NextResponse.json({ id: inserted.id.toString() }, { status: 201 });
} catch (err) {
console.error("POST /upload/contractor-install error:", err);
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(),
}),
),
hubspotDealId: z.string().optional(),
proposedMeasures: z.array(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)));
}
// Sync per-measure progress to HubSpot after classification
if (body.hubspotDealId && body.proposedMeasures && body.proposedMeasures.length > 0) {
const dealDocs = await db
.select({ fileType: uploadedFiles.fileType, measureName: uploadedFiles.measureName })
.from(uploadedFiles)
.where(eq(uploadedFiles.hubsotDealId, body.hubspotDealId));
const measureProgress = computeMeasureProgress(body.proposedMeasures, dealDocs);
void syncContractorDocUploadToHubSpot({ hubspotDealId: body.hubspotDealId, measureProgress });
}
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

@ -7,11 +7,13 @@ import {
DocumentMagnifyingGlassIcon,
ChevronDownIcon,
DocumentPlusIcon,
RectangleStackIcon,
} from "@heroicons/react/24/outline";
import { cn } from "@/lib/utils";
import { useRouter } from "next/navigation";
import { Dispatch, SetStateAction, useState } from "react";
import BulkUploadComingSoonModal from "@/app/components/portfolio/BulkUploadComingSoonModal";
interface AddNewProps {
portfolioId: string;
@ -26,6 +28,7 @@ export default function AddNew({
}: AddNewProps) {
const router = useRouter();
const [loadingRemote, setLoadingRemote] = useState(false);
const [isBulkUploadOpen, setIsBulkUploadOpen] = useState(false);
function handleRemoteAssessment() {
setLoadingRemote(true);
@ -33,12 +36,18 @@ export default function AddNew({
}
return (
<>
<BulkUploadComingSoonModal
isOpen={isBulkUploadOpen}
onClose={() => setIsBulkUploadOpen(false)}
portfolioId={portfolioId}
/>
<Menu as="div" className="relative inline-block text-left">
<MenuButton
className="
inline-flex items-center gap-1 px-4 py-2 rounded-md
bg-gray-50 text-gray-900 hover:bg-midblue hover:text-gray-100
transition-colors text-sm font-medium
transition-colors text-xs font-medium
"
>
<DocumentPlusIcon className="h-4 w-4 mr-2" />
@ -98,7 +107,34 @@ export default function AddNew({
File Import
</span>
<span className="text-xs text-gray-500 leading-snug">
Upload an Excel or CSV file containing multiple units.
For bulk uploads, please contact a Domna user.
</span>
</div>
</button>
)}
</MenuItem>
{/* Bulk Upload (Coming Soon) */}
<MenuItem>
{({ active }) => (
<button
onClick={() => setIsBulkUploadOpen(true)}
className={cn(
"w-full p-3 rounded-lg text-left flex gap-3 transition-colors",
active && "bg-gray-100"
)}
>
<RectangleStackIcon className="h-5 w-5 text-gray-700 mt-[2px]" />
<div className="flex flex-col">
<span className="text-sm font-medium text-gray-900 flex items-center gap-2">
new: Bulk upload
<span className="text-[10px] font-semibold text-amber-700 bg-amber-100 px-1.5 py-0.5 rounded-full leading-none">
coming soon
</span>
</span>
<span className="text-xs text-gray-500 leading-snug">
Upload multiple addresses in one go.
</span>
</div>
</button>
@ -107,5 +143,6 @@ export default function AddNew({
</div>
</MenuItems>
</Menu>
</>
);
}

View file

@ -0,0 +1,87 @@
"use client";
import {
Dialog,
DialogBackdrop,
DialogPanel,
Transition,
TransitionChild,
} from "@headlessui/react";
import { Fragment } from "react";
import { XMarkIcon, RectangleStackIcon } from "@heroicons/react/24/outline";
interface BulkUploadComingSoonModalProps {
isOpen: boolean;
onClose: () => void;
portfolioId: string;
}
export default function BulkUploadComingSoonModal({
isOpen,
onClose,
}: BulkUploadComingSoonModalProps) {
return (
<Transition show={isOpen} as={Fragment}>
<Dialog as="div" className="relative z-50" onClose={onClose}>
<TransitionChild
as={Fragment}
enter="ease-out duration-200"
enterFrom="opacity-0"
enterTo="opacity-100"
leave="ease-in duration-150"
leaveFrom="opacity-100"
leaveTo="opacity-0"
>
<DialogBackdrop className="fixed inset-0 bg-black/30 backdrop-blur-sm" />
</TransitionChild>
<div className="fixed inset-0 flex items-center justify-center p-4">
<TransitionChild
as={Fragment}
enter="ease-out duration-200"
enterFrom="opacity-0 scale-95"
enterTo="opacity-100 scale-100"
leave="ease-in duration-150"
leaveFrom="opacity-100 scale-100"
leaveTo="opacity-0 scale-95"
>
<DialogPanel className="w-full max-w-md bg-white rounded-2xl shadow-xl p-8 relative">
<button
onClick={onClose}
className="absolute top-4 right-4 p-1.5 rounded-lg text-gray-400 hover:text-gray-700 hover:bg-gray-100 transition-colors"
>
<XMarkIcon className="h-5 w-5" />
</button>
<div className="flex flex-col items-center text-center gap-4">
<div className="w-14 h-14 rounded-2xl bg-amber-50 flex items-center justify-center">
<RectangleStackIcon className="h-7 w-7 text-amber-500" />
</div>
<div>
<span className="text-[11px] font-semibold text-amber-700 bg-amber-100 px-2 py-0.5 rounded-full">
Coming Soon
</span>
<h2 className="mt-3 text-2xl font-extrabold text-gray-900 tracking-tight">
Bulk Address Upload
</h2>
<p className="mt-2 text-sm text-gray-500 leading-relaxed">
Upload multiple addresses in one go. This feature is currently in development
and will be available soon.
</p>
</div>
<button
onClick={onClose}
className="mt-2 px-6 py-2.5 rounded-xl bg-gradient-to-br from-[#14163d] to-[#15173e] text-white text-sm font-bold hover:opacity-90 transition-opacity"
>
Got it
</button>
</div>
</DialogPanel>
</TransitionChild>
</div>
</Dialog>
</Transition>
);
}

View file

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

View file

@ -15,6 +15,7 @@ import { subTasks } from "@/app/db/schema/tasks/subtask";
import * as CrmSchema from "@/app/db/schema/crm/hubspot_deal_table";
import * as UploadedFilesSchema from "@/app/db/schema/uploaded_files";
import * as PortfolioOrgSchema from "@/app/db/schema/portfolio_organisation";
import * as BulkAddressUploadsSchema from "@/app/db/schema/bulk_address_uploads";
export const pool = new Pool({
host: process.env.DB_HOST,
@ -41,6 +42,7 @@ const schema = {
...CrmSchema,
...UploadedFilesSchema,
...PortfolioOrgSchema,
...BulkAddressUploadsSchema,
};
export const db = drizzle(pool, {

View file

@ -0,0 +1,11 @@
CREATE TABLE "bulk_address_uploads" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"portfolio_id" text NOT NULL,
"user_id" text NOT NULL,
"s3_bucket" text NOT NULL,
"s3_key" text NOT NULL,
"filename" text NOT NULL,
"status" text DEFAULT 'ready_for_processing' NOT NULL,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
);

View file

@ -0,0 +1,2 @@
ALTER TABLE "bulk_address_uploads" ADD COLUMN "source_headers" text[] DEFAULT '{}' NOT NULL;--> statement-breakpoint
ALTER TABLE "bulk_address_uploads" ADD COLUMN "column_mapping" jsonb;

View file

@ -0,0 +1,59 @@
CREATE TYPE "public"."portfolio_capability" AS ENUM('approver', 'contractor');--> statement-breakpoint
ALTER TYPE "public"."measure_type" ADD VALUE 'damp_mould';--> statement-breakpoint
ALTER TYPE "public"."measure_type" ADD VALUE 'door_undercut';--> statement-breakpoint
ALTER TYPE "public"."measure_type" ADD VALUE 'extractor_fan';--> statement-breakpoint
ALTER TYPE "public"."measure_type" ADD VALUE 'loft_board';--> statement-breakpoint
ALTER TYPE "public"."measure_type" ADD VALUE 'trickle_vent';--> statement-breakpoint
ALTER TYPE "public"."file_source" ADD VALUE 'contractor';--> statement-breakpoint
ALTER TYPE "public"."file_type" ADD VALUE 'pre_photo';--> statement-breakpoint
ALTER TYPE "public"."file_type" ADD VALUE 'mid_photo';--> statement-breakpoint
ALTER TYPE "public"."file_type" ADD VALUE 'post_photo';--> statement-breakpoint
ALTER TYPE "public"."file_type" ADD VALUE 'pre_installation_building_inspection';--> statement-breakpoint
ALTER TYPE "public"."file_type" ADD VALUE 'claim_of_compliance';--> statement-breakpoint
ALTER TYPE "public"."file_type" ADD VALUE 'handover_pack';--> statement-breakpoint
ALTER TYPE "public"."file_type" ADD VALUE 'insurance_guarantee';--> statement-breakpoint
ALTER TYPE "public"."file_type" ADD VALUE 'installer_qualifications';--> statement-breakpoint
ALTER TYPE "public"."file_type" ADD VALUE 'mcs_compliance_certificate';--> statement-breakpoint
ALTER TYPE "public"."file_type" ADD VALUE 'minor_works_electrical_certificate';--> statement-breakpoint
ALTER TYPE "public"."file_type" ADD VALUE 'point_of_work_risk_assessment';--> statement-breakpoint
ALTER TYPE "public"."file_type" ADD VALUE 'installer_feedback';--> statement-breakpoint
ALTER TYPE "public"."file_type" ADD VALUE 'workmanship_warranty';--> statement-breakpoint
ALTER TYPE "public"."file_type" ADD VALUE 'g98_notification';--> statement-breakpoint
ALTER TYPE "public"."file_type" ADD VALUE 'certificate_of_conformity';--> statement-breakpoint
ALTER TYPE "public"."file_type" ADD VALUE 'ventilation_assessment_checklist';--> statement-breakpoint
ALTER TYPE "public"."file_type" ADD VALUE 'contractor_other';--> statement-breakpoint
CREATE TABLE "deal_approvals" (
"id" bigserial PRIMARY KEY NOT NULL,
"hubspot_deal_id" text NOT NULL,
"approved_by" bigint NOT NULL,
"approved_at" timestamp with time zone DEFAULT now() NOT NULL,
"notes" text,
"created_at" timestamp with time zone DEFAULT now() NOT NULL
);
--> statement-breakpoint
CREATE TABLE "deal_approved_measures" (
"id" bigserial PRIMARY KEY NOT NULL,
"deal_approval_id" bigint NOT NULL,
"measure_name" text NOT NULL,
"created_at" timestamp with time zone DEFAULT now() NOT NULL
);
--> statement-breakpoint
CREATE TABLE "portfolio_capabilities" (
"id" bigserial PRIMARY KEY NOT NULL,
"user_id" bigint NOT NULL,
"portfolio_id" bigint NOT NULL,
"capability" "portfolio_capability" NOT NULL,
"created_at" timestamp (6) with time zone DEFAULT now() NOT NULL,
"updated_at" timestamp (6) with time zone DEFAULT now() NOT NULL,
CONSTRAINT "portfolio_capabilities_user_id_portfolio_id_capability_unique" UNIQUE("user_id","portfolio_id","capability")
);
--> statement-breakpoint
ALTER TABLE "uploaded_files" ADD COLUMN "measure_name" text;--> statement-breakpoint
ALTER TABLE "uploaded_files" ADD COLUMN "uploaded_by" bigint;--> statement-breakpoint
ALTER TABLE "deal_approvals" ADD CONSTRAINT "deal_approvals_approved_by_user_id_fk" FOREIGN KEY ("approved_by") REFERENCES "public"."user"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "deal_approved_measures" ADD CONSTRAINT "deal_approved_measures_deal_approval_id_deal_approvals_id_fk" FOREIGN KEY ("deal_approval_id") REFERENCES "public"."deal_approvals"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "portfolio_capabilities" ADD CONSTRAINT "portfolio_capabilities_user_id_user_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."user"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "portfolio_capabilities" ADD CONSTRAINT "portfolio_capabilities_portfolio_id_portfolio_id_fk" FOREIGN KEY ("portfolio_id") REFERENCES "public"."portfolio"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
CREATE INDEX "idx_deal_approvals_deal_id" ON "deal_approvals" USING btree ("hubspot_deal_id");--> statement-breakpoint
CREATE INDEX "idx_deal_approved_measures_approval_id" ON "deal_approved_measures" USING btree ("deal_approval_id");--> statement-breakpoint
ALTER TABLE "uploaded_files" ADD CONSTRAINT "uploaded_files_uploaded_by_user_id_fk" FOREIGN KEY ("uploaded_by") REFERENCES "public"."user"("id") ON DELETE no action ON UPDATE no action;

View file

@ -0,0 +1,26 @@
CREATE TABLE "deal_measure_approval_events" (
"id" bigserial PRIMARY KEY NOT NULL,
"hubspot_deal_id" text NOT NULL,
"measure_name" text NOT NULL,
"action" text NOT NULL,
"acted_by" bigint NOT NULL,
"acted_at" timestamp with time zone DEFAULT now() NOT NULL
);
--> statement-breakpoint
CREATE TABLE "deal_measure_approvals" (
"id" bigserial PRIMARY KEY NOT NULL,
"hubspot_deal_id" text NOT NULL,
"measure_name" text NOT NULL,
"is_approved" boolean DEFAULT true NOT NULL,
"approved_by" bigint NOT NULL,
"approved_at" timestamp with time zone DEFAULT now() NOT NULL,
CONSTRAINT "uq_deal_measure" UNIQUE("hubspot_deal_id","measure_name")
);
--> statement-breakpoint
DROP TABLE "deal_approvals" CASCADE;--> statement-breakpoint
DROP TABLE "deal_approved_measures" CASCADE;--> statement-breakpoint
ALTER TABLE "deal_measure_approval_events" ADD CONSTRAINT "deal_measure_approval_events_acted_by_user_id_fk" FOREIGN KEY ("acted_by") REFERENCES "public"."user"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "deal_measure_approvals" ADD CONSTRAINT "deal_measure_approvals_approved_by_user_id_fk" FOREIGN KEY ("approved_by") REFERENCES "public"."user"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
CREATE INDEX "idx_deal_measure_events_deal_id" ON "deal_measure_approval_events" USING btree ("hubspot_deal_id");--> statement-breakpoint
CREATE INDEX "idx_deal_measure_events_acted_at" ON "deal_measure_approval_events" USING btree ("acted_at");--> statement-breakpoint
CREATE INDEX "idx_deal_measure_approvals_deal_id" ON "deal_measure_approvals" USING btree ("hubspot_deal_id");

View file

@ -0,0 +1 @@
ALTER TABLE "bulk_address_uploads" ADD COLUMN "task_id" uuid;

View file

@ -0,0 +1 @@
ALTER TABLE "bulk_address_uploads" ADD COLUMN "task_id" uuid;

View file

@ -0,0 +1 @@
ALTER TABLE "bulk_address_uploads" ADD COLUMN "combined_output_s3_uri" text;

View file

@ -0,0 +1 @@
ALTER TABLE "bulk_address_uploads" ADD COLUMN "combined_output_s3_uri" text;

View file

@ -0,0 +1,25 @@
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");

View file

@ -0,0 +1,2 @@
ALTER TABLE "bulk_address_uploads" ADD COLUMN "task_id" uuid;--> statement-breakpoint
ALTER TABLE "bulk_address_uploads" ADD COLUMN "combined_output_s3_uri" text;

View file

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

View file

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

View file

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

View file

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

View file

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

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

View file

@ -1191,6 +1191,97 @@
"when": 1776351792028,
"tag": "0169_freezing_moonstone",
"breakpoints": true
},
{
"idx": 170,
"version": "7",
"when": 1776357524564,
"tag": "0170_furry_moonstone",
"breakpoints": true
},
{
"idx": 171,
"version": "7",
"when": 1776361262258,
"tag": "0171_chunky_wallow",
"breakpoints": true
},
{
"idx": 172,
"version": "7",
"when": 1776374085626,
"tag": "0172_kind_eddie_brock",
"breakpoints": true
},
{
"idx": 173,
"version": "7",
"when": 1776378570612,
"tag": "0173_neat_bastion",
"breakpoints": true
},
{
"idx": 174,
"version": "7",
"when": 1776900000000,
"tag": "0174_bulk_upload_task_id",
"breakpoints": true
},
{
"idx": 175,
"version": "7",
"when": 1776434096854,
"tag": "0175_sweet_otto_octavius",
"breakpoints": true
},
{
"idx": 176,
"version": "7",
"when": 1776900120000,
"tag": "0176_bulk_upload_combined_output",
"breakpoints": true
},
{
"idx": 177,
"version": "7",
"when": 1776451871348,
"tag": "0177_wooden_dexter_bennett",
"breakpoints": true
},
{
"idx": 178,
"version": "7",
"when": 1776458454019,
"tag": "0178_parched_midnight",
"breakpoints": true
},
{
"idx": 179,
"version": "7",
"when": 1776459924335,
"tag": "0179_mighty_cardiac",
"breakpoints": true
},
{
"idx": 180,
"version": "7",
"when": 1776683523665,
"tag": "0180_ambitious_jane_foster",
"breakpoints": true
},
{
"idx": 181,
"version": "7",
"when": 1776697748194,
"tag": "0181_concerned_electro",
"breakpoints": true
},
{
"idx": 182,
"version": "7",
"when": 1776699608018,
"tag": "0182_messy_calypso",
"breakpoints": true
}
]
}

View file

@ -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

@ -0,0 +1,21 @@
import { pgTable, uuid, text, timestamp, jsonb } from "drizzle-orm/pg-core";
import { sql } from "drizzle-orm";
export const bulkAddressUploads = pgTable("bulk_address_uploads", {
id: uuid("id").defaultRandom().primaryKey(),
portfolioId: text("portfolio_id").notNull(),
userId: text("user_id").notNull(),
s3Bucket: text("s3_bucket").notNull(),
s3Key: text("s3_key").notNull(),
filename: text("filename").notNull(),
status: text("status").notNull().default("ready_for_processing"),
sourceHeaders: text("source_headers").array().notNull().default(sql`'{}'`),
columnMapping: jsonb("column_mapping").$type<Record<string, string>>(),
taskId: uuid("task_id"),
combinedOutputS3Uri: text("combined_output_s3_uri"),
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
updatedAt: timestamp("updated_at", { withTimezone: true })
.notNull()
.defaultNow()
.$onUpdate(() => new Date()),
});

View file

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

View file

@ -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,49 @@
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(),
// 'removal' | 're_addition'
type: text("type").notNull().default("removal"),
// '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 }),
originalBatch: text("original_batch"),
},
(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,14 @@
import { Client } from "@hubspot/api-client";
let _client: Client | null = null;
export function getHubSpotClient(): Client {
if (!_client) {
const accessToken = process.env.HUBSPOT_API_KEY;
if (!accessToken) {
throw new Error("HUBSPOT_API_KEY environment variable is not set");
}
_client = new Client({ accessToken });
}
return _client;
}

View file

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

View file

@ -0,0 +1,81 @@
/**
* Measure Document Requirements
* Maps HubSpot measure names to the required installer document types (fileType enum values).
* Used to compute per-measure upload completion and guide contractors in the upload modal.
*/
// Required for every measure
const BASE_DOCS = [
"pre_photo",
"mid_photo",
"post_photo",
"pre_installation_building_inspection",
"claim_of_compliance",
"insurance_guarantee",
"workmanship_warranty",
] as const;
// MCS-accredited measures require MCS certification in addition to base docs
const MCS_EXTRA = ["mcs_compliance_certificate"] as const;
export const MEASURE_DOC_REQUIREMENTS: Record<string, string[]> = {
ASHP: [...BASE_DOCS, ...MCS_EXTRA, "commissioning_records"],
"Solar PV": [...BASE_DOCS, ...MCS_EXTRA, "g98_notification"],
DMevs: [
...BASE_DOCS,
"dmev_photos",
"anemometer_readings",
"commissioning_records",
"part_f_ventilation_document",
"door_undercut_photos",
"trickle_vent_photos",
"ventilation_assessment_checklist",
"minor_works_electrical_certificate",
],
"Loft insulation": [...BASE_DOCS, "loft_hatch_photo"],
// All remaining measures require BASE_DOCS only:
// CWI, EWI, IWI, "Flat roof", RIR, UFI, HW, Windows, "Ext. doors",
// TRVs, "Heating controls", "New boiler", HHRSH, Battery, LEL,
// "Listed building", "Removal 2nd heating", Others
};
/**
* Returns the required document types for a given measure name.
* Falls back to BASE_DOCS for any measure not explicitly listed.
*/
export function getRequiredDocs(measureName: string): string[] {
return MEASURE_DOC_REQUIREMENTS[measureName] ?? [...BASE_DOCS];
}
/**
* Human-readable label for a fileType enum value.
* Matches the labels used in ContractorUploadModal FILE_TYPE_OPTIONS.
*/
export const FILE_TYPE_LABELS: Record<string, string> = {
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",
pre_installation_building_inspection: "PIBI / Tech Survey",
point_of_work_risk_assessment: "Point of Work Risk Assessment",
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",
ventilation_assessment_checklist: "Ventilation Assessment Checklist",
anemometer_readings: "Anemometer Readings",
commissioning_records: "Commissioning Records",
part_f_ventilation_document: "Approved Document Part F",
handover_pack: "Handover Pack",
workmanship_warranty: "Workmanship Warranty",
insurance_guarantee: "Insurance Backed Guarantee (IBG)",
g98_notification: "G98 / G99 Notification",
installer_qualifications: "Installer Qualifications",
installer_feedback: "Installer Feedback",
contractor_other: "Other",
};

View file

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

View file

@ -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

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

View file

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

View file

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

View file

@ -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,954 @@
"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, DocStatusMap } from "./types";
import { getRequiredDocs } from "@/app/lib/measureDocumentRequirements";
// ── 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" | "measure-select" | "upload" | "classify";
type Props = {
deal: ClassifiedDeal;
portfolioId: string;
onClose: () => void;
docStatusMap?: DocStatusMap;
approvedMeasures?: string[]; // if non-empty, used instead of proposedMeasures
};
// ── 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 }[],
hubspotDealId?: string,
proposedMeasures?: string[],
): Promise<void> {
const res = await fetch("/api/upload/contractor-install", {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ updates, hubspotDealId, proposedMeasures }),
});
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 ────────────────────────────────────────────────────────
// ── DocType button grid — shown when a measure is selected ───────────────
function DocTypeButtonGrid({
value,
onChange,
requiredDocs,
uploadedDocs,
}: {
value: string;
onChange: (v: string) => void;
requiredDocs: string[];
uploadedDocs: string[];
}) {
const [showOther, setShowOther] = useState(false);
const uploadedSet = new Set(uploadedDocs);
const requiredSet = new Set(requiredDocs);
const isOtherSelected = value !== "" && !requiredSet.has(value);
return (
<div className="space-y-2">
{/* Required doc type buttons */}
<div className="flex flex-wrap gap-1.5">
{requiredDocs.map((docType) => {
const option = FILE_TYPE_OPTIONS.find((o) => o.value === docType);
const label = option?.label ?? docType;
const alreadyUploaded = uploadedSet.has(docType);
const isSelected = value === docType;
return (
<button
key={docType}
type="button"
onClick={() => { onChange(docType); setShowOther(false); }}
title={alreadyUploaded ? `${label} — already uploaded` : label}
className={`inline-flex items-center gap-1 px-2.5 py-1.5 rounded-lg border text-xs font-medium transition-all duration-100 ${
isSelected
? "bg-brandblue text-white border-brandblue shadow-sm"
: alreadyUploaded
? "bg-emerald-50 text-emerald-700 border-emerald-200 hover:border-emerald-400"
: "bg-white text-gray-700 border-gray-200 hover:border-brandblue/50 hover:bg-brandlightblue/10"
}`}
>
{alreadyUploaded && !isSelected && (
<svg className="h-3 w-3 text-emerald-500 shrink-0" viewBox="0 0 12 12" fill="none">
<path d="M2 6l3 3 5-5" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"/>
</svg>
)}
{label}
</button>
);
})}
{/* Other button */}
<button
type="button"
onClick={() => setShowOther((v) => !v)}
className={`inline-flex items-center gap-1 px-2.5 py-1.5 rounded-lg border text-xs font-medium transition-all duration-100 ${
isOtherSelected || showOther
? "bg-gray-100 text-gray-700 border-gray-300"
: "bg-white text-gray-400 border-gray-200 hover:border-gray-400 hover:text-gray-600"
}`}
>
Other {showOther ? "▲" : "▼"}
</button>
</div>
{/* Other: dropdown for non-required types */}
{(showOther || isOtherSelected) && (
<Select
value={isOtherSelected ? value : "__unset__"}
onValueChange={(v) => { onChange(v === "__unset__" ? "" : v); }}
>
<SelectTrigger className="h-8 text-xs w-full">
<SelectValue placeholder="Select other type…" />
</SelectTrigger>
<SelectContent>
<SelectItem value="__unset__" className="text-xs text-gray-400">Select other type</SelectItem>
{FILE_TYPE_GROUPS.map((group) => {
const items = FILE_TYPE_OPTIONS.filter(
(o) => o.group === group && !requiredSet.has(o.value),
);
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>
)}
{/* Show hint for selected type */}
{value && (() => {
const hint = FILE_TYPE_OPTIONS.find((o) => o.value === value)?.hint;
return hint ? <p className="text-[10px] text-blue-600 leading-snug">{hint}</p> : null;
})()}
</div>
);
}
// ── DocType select — fallback when no measure selected ────────────────────
function DocTypeSelect({
value,
onChange,
}: {
value: string;
onChange: (v: string) => void;
}) {
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>
{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, docStatusMap, approvedMeasures }: Props) {
// Use approved measures when available; fall back to all proposed measures
const measures = (approvedMeasures && approvedMeasures.length > 0)
? approvedMeasures
: 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);
// The measure selected in the measure-select phase (empty = "not measure-specific")
const [selectedMeasure, setSelectedMeasure] = useState<string>("");
// ── Fetch existing unclassified files on mount ───────────────────────
useEffect(() => {
async function fetchExisting() {
const uprnParam = deal.uprn;
const propIdParam = deal.landlordPropertyId;
if (!uprnParam && !propIdParam) {
setPhase(measures.length > 0 ? "measure-select" : "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 if (measures.length > 0) {
setPhase("measure-select");
} else {
setPhase("upload");
}
} catch {
// If fetch fails, just proceed to measure-select (or upload if no measures)
setPhase(measures.length > 0 ? "measure-select" : "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: selectedMeasure,
}));
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,
})),
deal.dealId,
measures.length > 0 ? measures : 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 === "measure-select" ? "Select Measure" :
phase === "upload" ? "Upload Documents" :
"Classify Documents"}
</DialogTitle>
<DialogDescription>
{phase === "loading" && "Checking for pending files…"}
{phase === "measure-select" && (
<>
Which measure are you uploading documents for?{" "}
<strong>{propertyLabel}</strong>
</>
)}
{phase === "upload" && (
<>
{selectedMeasure
? <>Uploading documents for <strong>{selectedMeasure}</strong> <strong>{propertyLabel}</strong>.</>
: <>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>
)}
{/* ── Measure Select ── */}
{phase === "measure-select" && (() => {
const docStatus = docStatusMap?.[deal.dealId];
const measureProgressMap = new Map(
(docStatus?.measureProgress ?? []).map((m) => [m.measureName, m]),
);
return (
<div className="space-y-3">
<p className="text-xs text-gray-500">
Select the measure you are uploading documents for. This helps track completion against required documents.
</p>
<div className="grid grid-cols-2 gap-2 sm:grid-cols-3">
{measures.map((measure) => {
const progress = measureProgressMap.get(measure);
const isComplete = progress?.isComplete ?? false;
const uploaded = progress?.uploadedCount ?? 0;
const required = progress?.requiredCount ?? getRequiredDocs(measure).length;
return (
<button
key={measure}
type="button"
onClick={() => { setSelectedMeasure(measure); setPhase("upload"); }}
className={`flex flex-col items-start gap-1 rounded-lg border px-3 py-2.5 text-left transition-colors hover:border-brandblue/40 hover:bg-brandlightblue/10 ${
isComplete
? "border-emerald-200 bg-emerald-50/60"
: uploaded > 0
? "border-amber-200 bg-amber-50/50"
: "border-gray-200 bg-white"
}`}
>
<span className="text-xs font-semibold text-gray-800 leading-tight">{measure}</span>
<span className={`text-[10px] font-medium ${isComplete ? "text-emerald-600" : uploaded > 0 ? "text-amber-600" : "text-gray-400"}`}>
{isComplete ? "✓ Complete" : `${uploaded} / ${required} docs`}
</span>
</button>
);
})}
</div>
<button
type="button"
onClick={() => { setSelectedMeasure(""); setPhase("upload"); }}
className="text-xs text-gray-400 hover:text-gray-600 underline underline-offset-2 mt-1"
>
Not measure-specific / other
</button>
</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" && (() => {
const docStatus = docStatusMap?.[deal.dealId];
const measureProgressMap = new Map(
(docStatus?.measureProgress ?? []).map((m) => [m.measureName, m]),
);
return (
<div className="space-y-4">
{/* PAS guidance */}
<PasGuidancePanel />
{/* Measure context banner */}
{selectedMeasure && (
<div className="flex items-center justify-between gap-2 px-3 py-2 rounded-lg bg-brandlightblue/20 border border-brandblue/20">
<div className="flex items-center gap-2">
<span className="text-xs font-semibold text-brandblue">{selectedMeasure}</span>
{(() => {
const mp = measureProgressMap.get(selectedMeasure);
if (!mp) return null;
return (
<span className={`text-[10px] font-medium px-1.5 py-0.5 rounded-full border ${
mp.isComplete ? "bg-emerald-50 text-emerald-700 border-emerald-200" : "bg-amber-50 text-amber-700 border-amber-200"
}`}>
{mp.uploadedCount}/{mp.requiredCount} docs uploaded
</span>
);
})()}
</div>
<button
type="button"
onClick={() => setPhase("measure-select")}
className="text-[10px] text-brandblue/60 hover:text-brandblue underline underline-offset-2"
>
Change
</button>
</div>
)}
{/* File list with classification */}
<div className="space-y-3">
{classifiableEntries.map((entry) => {
const entryMeasure = entry.measureName && entry.measureName !== "__none__" ? entry.measureName : null;
const requiredDocs = entryMeasure ? getRequiredDocs(entryMeasure) : null;
const uploadedDocs = entryMeasure ? (measureProgressMap.get(entryMeasure)?.uploaded ?? []) : [];
return (
<div key={entry.id} className="rounded-lg border border-gray-100 bg-gray-50/50 p-3 space-y-2.5">
{/* File info row */}
<div className="flex items-center gap-2 min-w-0">
<StatusIcon status={entry.status} isExisting={!!entry.existingS3Key} />
<div className="min-w-0 flex-1">
<p className="text-xs font-medium text-gray-700 truncate">{entry.displayName}</p>
<p className="text-[10px] text-gray-400">
{entry.existingS3Key ? "Previously uploaded · " : ""}
{entry.displaySize ?? ""}
{entryMeasure && !selectedMeasure && (
<span className="ml-1 text-brandblue/70">{entryMeasure}</span>
)}
</p>
</div>
{/* Measure selector — only shown if no pre-selected measure */}
{!selectedMeasure && measures.length > 0 && (
<Select
value={entry.measureName || "__none__"}
onValueChange={(v) => updateEntryField(entry.id, "measureName", v === "__none__" ? "" : v)}
>
<SelectTrigger className="h-7 text-[10px] w-36 shrink-0">
<SelectValue placeholder="Measure…" />
</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>
)}
</div>
{/* Doc type selector */}
<div>
<p className="text-[10px] font-semibold text-gray-400 uppercase tracking-wide mb-1.5">
Document type <span className="text-red-400">*</span>
</p>
{requiredDocs ? (
<DocTypeButtonGrid
value={entry.docType}
onChange={(v) => updateEntryField(entry.id, "docType", v)}
requiredDocs={requiredDocs}
uploadedDocs={uploadedDocs}
/>
) : (
<DocTypeSelect
value={entry.docType}
onChange={(v) => updateEntryField(entry.id, "docType", v)}
/>
)}
</div>
</div>
);
})}
</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 === "measure-select" && (
<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

@ -121,6 +121,7 @@ export default function DampMouldRiskPanel({
"dealname",
"landlordPropertyId",
"dampMouldFlag",
"dampMouldAndRepairComments",
"coordinator",
];
@ -128,6 +129,7 @@ export default function DampMouldRiskPanel({
dealname: "Address",
landlordPropertyId: "Property Ref",
dampMouldFlag: "Coordinator Flag",
dampMouldAndRepairComments: "Comments",
coordinator: "Coordinator",
};

View file

@ -28,14 +28,19 @@ 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, ApprovalsByDeal } 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;
onOpenDrawer: (dealId: string, uprn: string | null, landlordPropertyId: string | null, dealname: string | null) => void;
docStatusMap: DocStatusMap;
portfolioId: string;
userCapability: PortfolioCapabilityType;
approvalsByDeal?: ApprovalsByDeal;
}
function escapeCell(value: unknown): string {
@ -49,29 +54,46 @@ function escapeCell(value: unknown): string {
: str;
}
export default function DocumentTable({ data, onOpenDrawer, docStatusMap }: DocumentTableProps) {
export default function DocumentTable({ data, onOpenDrawer, docStatusMap, portfolioId, userCapability, approvalsByDeal }: 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;
const status = docStatusMap[d.dealId];
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 +112,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 status = docStatusMap[row.original.dealId];
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 +149,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 +182,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 +242,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 +297,17 @@ export default function DocumentTable({ data, onOpenDrawer, docStatusMap }: Docu
</div>
</div>
{/* Contractor upload modal */}
{uploadDeal && (
<ContractorUploadModal
deal={uploadDeal}
portfolioId={portfolioId}
onClose={() => setUploadDeal(null)}
docStatusMap={docStatusMap}
approvedMeasures={approvalsByDeal?.[uploadDeal.dealId] ?? []}
/>
)}
{/* 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,63 @@ function SurveyStatusBadge({ status }: { status: DocStatus | undefined }) {
);
}
function InstallDocsBadge({ status }: { status: DocStatus | undefined }) {
const installStatus = status?.installStatus ?? "none";
const measureProgress = status?.measureProgress ?? [];
// Build a tooltip showing per-measure doc counts (e.g. "ASHP: 5/9, CWI: 7/7")
const tooltip =
measureProgress.length > 0
? measureProgress
.map((m) => `${m.measureName}: ${m.uploadedCount}/${m.requiredCount}`)
.join(" | ")
: undefined;
if (installStatus === "all") {
return (
<span
title={tooltip}
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") {
const completedCount = measureProgress.filter((m) => m.isComplete).length;
const totalCount = measureProgress.length;
const label = totalCount > 0 ? `${completedCount} / ${totalCount} measures` : "Some Measures";
return (
<span
title={tooltip}
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" />
{label}
</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,
onOpenDrawer: (dealId: string, uprn: string | null, landlordPropertyId: string | null, dealname: string | null) => void,
docStatusMap: DocStatusMap = {},
onUpload?: (deal: ClassifiedDeal) => void,
): ColumnDef<ClassifiedDeal>[] {
return [
// ── Address ──────────────────────────────────────────────────────────
@ -80,19 +134,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;
const status = docStatusMap[row.dealId];
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} />;
const status = docStatusMap[row.original.dealId];
return <RetroAssessmentBadge status={status} />;
},
enableHiding: false,
},
// ── Install Docs Status ───────────────────────────────────────────────
{
id: "installDocs",
accessorFn: (row) => {
const status = docStatusMap[row.dealId];
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 = docStatusMap[row.original.dealId];
return <InstallDocsBadge status={status} />;
},
enableHiding: false,
},
@ -104,17 +177,16 @@ export function createDocumentTableColumns(
<span className="text-xs font-semibold uppercase tracking-wide text-gray-500">Docs</span>
),
cell: ({ row }) => {
const uprn = row.original.uprn ?? "";
const status = uprn ? docStatusMap[uprn] : undefined;
const status = docStatusMap[row.original.dealId];
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";
@ -128,6 +200,7 @@ export function createDocumentTableColumns(
<button
onClick={() =>
onOpenDrawer(
row.original.dealId,
row.original.uprn,
row.original.landlordPropertyId,
row.original.dealname,
@ -143,5 +216,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,17 @@ 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,
AlertTriangle,
} 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";
@ -23,6 +30,7 @@ import type {
ClassifiedDeal,
DocumentDrawerState,
DocStatusMap,
RemovalStatusByDeal,
} from "./types";
export default function LiveTracker({
@ -30,11 +38,17 @@ export default function LiveTracker({
totalDeals,
majorConditionDeals,
docStatusMap,
userCapability,
approvalsByDeal,
removalStatusByDeal,
portfolioId,
userRole,
userEmail,
}: LiveTrackerProps) {
// ── Tab state ────────────────────────────────────────────────────────
const [activeTab, setActiveTab] = useState<"analytics" | "properties" | "documents">(
"analytics",
);
const [activeTab, setActiveTab] = useState<
"analytics" | "properties" | "documents" | "measures"
>("analytics");
// ── Project selector (shared across both tabs) ───────────────────────
const projectCodes = projects.map((p) => p.projectCode);
@ -43,12 +57,19 @@ export default function LiveTracker({
(p) => p.projectCode === currentProjectCode,
);
// ── Pending removal count for current project ────────────────────────
const pendingRemovalCount = (currentProject?.allDeals ?? []).filter((d) => {
const state = d.dealId ? removalStatusByDeal[d.dealId] : undefined;
return state === "pending_removal" || state === "pending_re_addition";
}).length;
// ── Drill-down table modal (used by AnalyticsView) ───────────────────
const [openTable, setOpenTable] = useState<TableModal | null>(null);
// ── Document drawer (used by PropertyTable) ──────────────────────────
const [drawerState, setDrawerState] = useState<DocumentDrawerState>({
open: false,
dealId: null,
uprn: null,
landlordPropertyId: null,
dealname: null,
@ -67,7 +88,10 @@ export default function LiveTracker({
setOpenTable({
stage,
data: filteredDeals,
columns: (columns || ["dealname", "landlordPropertyId"]) as (keyof ClassifiedDeal)[],
columns: (columns || [
"dealname",
"landlordPropertyId",
]) as (keyof ClassifiedDeal)[],
columnLabels: (columnLabels || {
dealname: "Address Ref.",
landlordPropertyId: "Property Ref.",
@ -76,8 +100,13 @@ export default function LiveTracker({
});
};
const handleOpenDrawer = (uprn: string | null, landlordPropertyId: string | null, dealname: string | null) => {
setDrawerState({ open: true, uprn, landlordPropertyId, dealname });
const handleOpenDrawer = (
dealId: string,
uprn: string | null,
landlordPropertyId: string | null,
dealname: string | null,
) => {
setDrawerState({ open: true, dealId, uprn, landlordPropertyId, dealname });
};
if (!totalDeals) {
@ -94,7 +123,11 @@ 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">
@ -111,13 +144,30 @@ export default function LiveTracker({
>
<Table2 className="h-3.5 w-3.5" />
Properties
<span
className={`ml-1 min-w-[18px] h-[18px] px-1 rounded-full bg-amber-500 text-white text-[10px] font-bold flex items-center justify-center leading-none transition-opacity ${
pendingRemovalCount > 0
? "opacity-100"
: "opacity-0 pointer-events-none"
}`}
aria-hidden={pendingRemovalCount === 0}
>
{pendingRemovalCount || ""}
</span>
</TabsTrigger>
<TabsTrigger
value="documents"
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"
>
<FolderOpen className="h-3.5 w-3.5" />
Document Management
Documents
</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>
@ -167,11 +217,22 @@ export default function LiveTracker({
</div>
)}
<div
className={`flex items-center gap-2.5 px-4 py-3 rounded-xl border border-amber-200 bg-amber-50 text-amber-800 text-sm ${pendingRemovalCount === 0 ? "hidden" : ""}`}
>
<AlertTriangle className="h-4 w-4 text-amber-500 shrink-0" />
<span>
<span className="font-semibold">{pendingRemovalCount}</span>{" "}
{pendingRemovalCount === 1 ? "property has" : "properties have"}{" "}
an outstanding removal request
</span>
</div>
<PropertyTable
data={currentProject?.allDeals ?? []}
onOpenDrawer={handleOpenDrawer}
onOpenDetail={setDetailDeal}
docStatusMap={docStatusMap}
removalStatusByDeal={removalStatusByDeal}
/>
</div>
</TabsContent>
@ -188,7 +249,11 @@ export default function LiveTracker({
>
{projectCodes.map((code) =>
code === "__ALL__" ? (
<option key="__ALL__" value="__ALL__" style={{ fontWeight: 700 }}>
<option
key="__ALL__"
value="__ALL__"
style={{ fontWeight: 700 }}
>
All Projects
</option>
) : (
@ -204,6 +269,47 @@ export default function LiveTracker({
data={currentProject?.allDeals ?? []}
onOpenDrawer={handleOpenDrawer}
docStatusMap={docStatusMap}
portfolioId={portfolioId}
userCapability={userCapability}
approvalsByDeal={approvalsByDeal}
/>
</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>
@ -300,11 +406,21 @@ export default function LiveTracker({
{/* ── Document drawer ────────────────────────────────────────────── */}
<PropertyDrawer
open={drawerState.open}
dealId={drawerState.dealId}
uprn={drawerState.uprn}
landlordPropertyId={drawerState.landlordPropertyId}
dealname={drawerState.dealname}
docStatus={
drawerState.dealId ? docStatusMap[drawerState.dealId] : undefined
}
onClose={() =>
setDrawerState({ open: false, uprn: null, landlordPropertyId: null, dealname: null })
setDrawerState({
open: false,
dealId: null,
uprn: null,
landlordPropertyId: null,
dealname: null,
})
}
/>
@ -312,6 +428,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,347 @@
"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, RotateCcw } 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 [dialogType, setDialogType] = useState<"removal" | "re_addition" | null>(null);
const [reason, setReason] = useState("");
const [submitting, setSubmitting] = useState(false);
const [reviewing, setReviewing] = useState(false);
const [error, setError] = useState<string | null>(null);
const canRequest = WRITE_ROLES.includes(userRole);
const isApprover = userCapability.includes("approver");
const { data, isLoading } = useQuery<{ requests: RemovalRequest[] }>({
queryKey: ["removalRequests", portfolioId, dealId],
queryFn: async () => {
const res = await fetch(
`/api/portfolio/${portfolioId}/removal-requests?dealId=${dealId}`,
);
if (!res.ok) throw new Error("Failed to fetch removal requests");
return res.json();
},
staleTime: 30_000,
});
const latest = data?.requests?.[0] ?? null;
// Derive effective state from the most recent request
type EffectiveState = "active" | "pending_removal" | "removed" | "pending_re_addition";
const effectiveState: EffectiveState = (() => {
if (!latest) return "active";
if (latest.status === "pending") {
return latest.type === "re_addition" ? "pending_re_addition" : "pending_removal";
}
if (latest.type === "removal" && latest.status === "approved") return "removed";
if (latest.type === "re_addition" && latest.status === "declined") return "removed";
return "active";
})();
const pendingRequest = latest?.status === "pending" ? latest : null;
const latestResolvedRequest = latest?.status !== "pending" ? latest : null;
function closeDialog() {
setDialogType(null);
setReason("");
setError(null);
}
async function handleSubmit() {
if (!reason.trim() || !dialogType) return;
setSubmitting(true);
setError(null);
try {
const res = await fetch(`/api/portfolio/${portfolioId}/removal-requests`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ hubspotDealId: dealId, reason: reason.trim(), type: dialogType }),
});
if (!res.ok) {
const json = await res.json().catch(() => ({}));
setError(json.error ?? "Failed to submit request");
return;
}
closeDialog();
queryClient.invalidateQueries({ queryKey: ["removalRequests", portfolioId, dealId] });
} finally {
setSubmitting(false);
}
}
async function handleReview(requestId: string, action: "approved" | "declined") {
setReviewing(true);
setError(null);
try {
const res = await fetch(`/api/portfolio/${portfolioId}/removal-requests`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ requestId: Number(requestId), action }),
});
if (!res.ok) {
const json = await res.json().catch(() => ({}));
setError(json.error ?? "Failed to review request");
return;
}
queryClient.invalidateQueries({ queryKey: ["removalRequests", portfolioId, dealId] });
} finally {
setReviewing(false);
}
}
function resolvedLabel(req: RemovalRequest): string {
if (req.type === "re_addition") {
return req.status === "approved" ? "Re-addition Approved" : "Re-addition Declined";
}
return req.status === "approved" ? "Removal Approved" : "Removal Declined";
}
if (isLoading) {
return <p className="text-xs text-gray-400 py-2">Loading</p>;
}
return (
<div className="space-y-3">
{error && (
<p className="text-xs text-red-600 bg-red-50 px-3 py-2 rounded-lg">{error}</p>
)}
{/* 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">
{pendingRequest.type === "re_addition" ? "Pending Re-addition Request" : "Pending Removal Request"}
</span>
</div>
<p className="text-xs text-gray-700 leading-relaxed">{pendingRequest.reason}</p>
<p className="text-[11px] text-gray-400">
Requested by <span className="font-medium text-gray-600">{pendingRequest.requestedByEmail}</span>
{" · "}
{formatDateTime(pendingRequest.requestedAt)}
</p>
{isApprover && (
<div className="flex gap-2 pt-1">
<button
onClick={() => handleReview(pendingRequest.id, "approved")}
disabled={reviewing}
className="flex-1 text-xs font-medium px-3 py-1.5 rounded-lg bg-emerald-600 text-white hover:bg-emerald-700 disabled:opacity-50 transition-colors"
>
{pendingRequest.type === "re_addition" ? "Approve Re-addition" : "Approve Removal"}
</button>
<button
onClick={() => handleReview(pendingRequest.id, "declined")}
disabled={reviewing}
className="flex-1 text-xs font-medium px-3 py-1.5 rounded-lg border border-gray-200 text-gray-600 hover:bg-gray-100 disabled:opacity-50 transition-colors"
>
Decline
</button>
</div>
)}
</div>
)}
{/* Most recent resolved request */}
{latestResolvedRequest && (
<div className={`rounded-xl border p-3.5 space-y-1.5 ${
latestResolvedRequest.status === "approved"
? "border-emerald-200 bg-emerald-50"
: "border-gray-200 bg-gray-50"
}`}>
<div className="flex items-center gap-2">
<span className={`text-xs font-semibold px-2 py-0.5 rounded-full border ${
latestResolvedRequest.status === "approved"
? "text-emerald-700 bg-emerald-100 border-emerald-200"
: "text-gray-600 bg-gray-100 border-gray-200"
}`}>
{resolvedLabel(latestResolvedRequest)}
</span>
</div>
<p className="text-xs text-gray-600 leading-relaxed">{latestResolvedRequest.reason}</p>
<p className="text-[11px] text-gray-400">
Requested by <span className="font-medium text-gray-600">{latestResolvedRequest.requestedByEmail}</span>
{" · "}
{formatDateTime(latestResolvedRequest.requestedAt)}
</p>
{latestResolvedRequest.reviewedByEmail && (
<p className="text-[11px] text-gray-400">
{latestResolvedRequest.status === "approved" ? "Approved" : "Declined"} by{" "}
<span className="font-medium text-gray-600">{latestResolvedRequest.reviewedByEmail}</span>
{latestResolvedRequest.reviewedAt && ` · ${formatDateTime(latestResolvedRequest.reviewedAt)}`}
</p>
)}
</div>
)}
{/* Action buttons — shown when no pending request */}
{!pendingRequest && (
<>
{effectiveState === "active" && (
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<span className="inline-block w-full">
<button
onClick={() => { if (canRequest) setDialogType("removal"); }}
disabled={!canRequest}
className={`w-full flex items-center justify-center gap-2 text-xs font-medium px-3 py-2 rounded-lg border transition-colors ${
canRequest
? "border-red-200 text-red-600 hover:bg-red-50 bg-white"
: "border-gray-100 text-gray-300 bg-gray-50 cursor-not-allowed"
}`}
>
<Trash2 className="h-3.5 w-3.5" />
Request Removal from Project
</button>
</span>
</TooltipTrigger>
{!canRequest && (
<TooltipContent side="top" className="text-xs">
Not available with read-only permissions
</TooltipContent>
)}
</Tooltip>
</TooltipProvider>
)}
{effectiveState === "removed" && (
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<span className="inline-block w-full">
<button
onClick={() => { if (canRequest) setDialogType("re_addition"); }}
disabled={!canRequest}
className={`w-full flex items-center justify-center gap-2 text-xs font-medium px-3 py-2 rounded-lg border transition-colors ${
canRequest
? "border-blue-200 text-blue-600 hover:bg-blue-50 bg-white"
: "border-gray-100 text-gray-300 bg-gray-50 cursor-not-allowed"
}`}
>
<RotateCcw className="h-3.5 w-3.5" />
Request Re-addition to Project
</button>
</span>
</TooltipTrigger>
{!canRequest && (
<TooltipContent side="top" className="text-xs">
Not available with read-only permissions
</TooltipContent>
)}
</Tooltip>
</TooltipProvider>
)}
</>
)}
{/* Shared dialog for removal and re-addition requests */}
<Dialog open={dialogType !== null} onOpenChange={(v) => { if (!v) closeDialog(); }}>
<DialogContent className="max-w-md">
<DialogHeader>
<DialogTitle className="text-base font-semibold text-gray-800">
{dialogType === "re_addition" ? "Request Re-addition to Project" : "Request Removal from Project"}
</DialogTitle>
</DialogHeader>
<div className="space-y-3 py-2">
<p className="text-xs text-gray-500 leading-relaxed">
{dialogType === "re_addition"
? "Please provide a reason why this property should be re-added to the project. This will be recorded for audit purposes."
: "Please provide a reason why this property should be removed from the project. This will be recorded for audit purposes."}
</p>
<textarea
className="w-full rounded-lg border border-gray-200 px-3 py-2 text-sm text-gray-800 placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-blue-200 focus:border-blue-300 resize-none"
placeholder={dialogType === "re_addition" ? "Reason for re-addition…" : "Reason for removal…"}
rows={4}
value={reason}
onChange={(e) => setReason(e.target.value)}
/>
{error && <p className="text-xs text-red-600">{error}</p>}
</div>
<DialogFooter className="gap-2">
<button
onClick={closeDialog}
className="text-xs font-medium px-4 py-2 rounded-lg border border-gray-200 text-gray-600 hover:bg-gray-50 transition-colors"
>
Cancel
</button>
<button
onClick={handleSubmit}
disabled={!reason.trim() || submitting}
className={`text-xs font-medium px-4 py-2 rounded-lg text-white disabled:opacity-50 transition-colors ${
dialogType === "re_addition"
? "bg-blue-600 hover:bg-blue-700"
: "bg-red-600 hover:bg-red-700"
}`}
>
{submitting ? "Submitting…" : "Submit Request"}
</button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
);
}
// -----------------------------------------------------------------------
// 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 +472,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 +596,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,
@ -20,11 +21,12 @@ import {
DrawerTitle,
DrawerDescription,
} from "@/app/shadcn_components/ui/drawer";
import type { PropertyDocument } from "./types";
import { EXPECTED_SURVEY_DOC_TYPES } from "./types";
import type { PropertyDocument, DocStatus } 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", {
@ -54,11 +86,9 @@ function formatDate(iso: string): string {
}
// -----------------------------------------------------------------------
// Individual document row
// Reusable download button — encapsulates the presigned URL mutation
// -----------------------------------------------------------------------
function DocumentRow({ doc }: { doc: PropertyDocument }) {
const label = DOC_TYPE_LABELS[doc.docType] ?? doc.docType;
function DownloadDocButton({ doc }: { doc: PropertyDocument }) {
const { mutate: download, isPending: signing } = useMutation({
mutationFn: async () => {
const res = await fetch("/api/sign-document-url", {
@ -75,6 +105,28 @@ function DocumentRow({ doc }: { doc: PropertyDocument }) {
},
});
return (
<button
onClick={() => download()}
disabled={signing}
className="shrink-0 inline-flex items-center gap-1.5 px-3 py-1.5 rounded-lg bg-brandblue text-white text-xs font-medium hover:bg-brandblue/90 transition-colors disabled:opacity-60 disabled:cursor-not-allowed"
>
{signing ? (
<Loader2 className="h-3.5 w-3.5 animate-spin" />
) : (
<FileDown className="h-3.5 w-3.5" />
)}
{signing ? "Preparing…" : "Download"}
</button>
);
}
// -----------------------------------------------------------------------
// Individual document row — used in retrofit section and install fallback
// -----------------------------------------------------------------------
function DocumentRow({ doc, showMeasure }: { doc: PropertyDocument; showMeasure?: boolean }) {
const label = DOC_TYPE_LABELS[doc.docType] ?? doc.docType;
return (
<motion.div
layout
@ -90,24 +142,15 @@ 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>
{/* Right: download button */}
<button
onClick={() => download()}
disabled={signing}
className="shrink-0 inline-flex items-center gap-1.5 px-3 py-1.5 rounded-lg bg-brandblue text-white text-xs font-medium hover:bg-brandblue/90 transition-colors disabled:opacity-60 disabled:cursor-not-allowed"
>
{signing ? (
<Loader2 className="h-3.5 w-3.5 animate-spin" />
) : (
<FileDown className="h-3.5 w-3.5" />
)}
{signing ? "Preparing…" : "Download"}
</button>
<DownloadDocButton doc={doc} />
</motion.div>
);
}
@ -117,29 +160,34 @@ function DocumentRow({ doc }: { doc: PropertyDocument }) {
// -----------------------------------------------------------------------
interface PropertyDrawerProps {
open: boolean;
dealId?: string | null;
uprn: string | null;
landlordPropertyId: string | null;
dealname: string | null;
docStatus?: DocStatus;
onClose: () => void;
}
export default function PropertyDrawer({
open,
dealId,
uprn,
landlordPropertyId,
dealname,
docStatus,
onClose,
}: PropertyDrawerProps) {
const canQuery = !!(uprn || landlordPropertyId);
const canQuery = !!(dealId || uprn || landlordPropertyId);
const {
data: fetchedDocuments = [],
isFetching,
isError,
} = useQuery({
queryKey: ["property-documents", uprn, landlordPropertyId],
queryKey: ["property-documents", dealId, uprn, landlordPropertyId],
queryFn: async () => {
const params = new URLSearchParams();
if (uprn) params.set("uprn", uprn);
if (dealId) params.set("dealId", dealId);
else if (uprn) params.set("uprn", uprn);
else if (landlordPropertyId)
params.set("landlordPropertyId", landlordPropertyId);
const res = await fetch(
@ -161,20 +209,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 +264,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 +292,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 +303,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 +325,173 @@ 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-3"
>
<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>
{docStatus?.measureProgress && docStatus.measureProgress.length > 0 ? (
// ── Per-measure checklist ──
<div className="space-y-4">
{docStatus.measureProgress.map((mp) => {
const measureDocs = installDocs.filter((d) => d.measureName === mp.measureName);
const uploadedTypeSet = new Set(measureDocs.map((d) => d.docType));
const missingTypes = mp.required.filter((t) => !uploadedTypeSet.has(t));
return (
<div key={mp.measureName} className="rounded-xl border border-gray-100 bg-gray-50/40 overflow-hidden">
{/* Measure header */}
<div className="flex items-center justify-between px-3 py-2.5 border-b border-gray-100 bg-white">
<span className="text-xs font-semibold text-gray-800">{mp.measureName}</span>
<span className={`inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-[10px] font-medium border ${
mp.isComplete
? "bg-emerald-50 text-emerald-700 border-emerald-200"
: mp.uploadedCount > 0
? "bg-amber-50 text-amber-700 border-amber-200"
: "bg-gray-100 text-gray-500 border-gray-200"
}`}>
{mp.uploadedCount} / {mp.requiredCount} docs
</span>
</div>
<div className="px-3 py-2.5 space-y-1.5">
{/* Uploaded required docs */}
{mp.uploaded.map((docType) => {
const doc = measureDocs.find((d) => d.docType === docType);
if (!doc) return null;
return (
<div key={docType} className="flex items-center justify-between gap-3 px-3 py-2 rounded-lg border border-emerald-100 bg-emerald-50/50">
<div className="flex items-center gap-2 min-w-0">
<div className="shrink-0 w-5 h-5 rounded-full bg-emerald-100 border border-emerald-200 flex items-center justify-center">
<svg className="h-3 w-3 text-emerald-600" viewBox="0 0 12 12" fill="none">
<path d="M2 6l3 3 5-5" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"/>
</svg>
</div>
<div className="min-w-0">
<p className="text-xs font-medium text-gray-800 truncate">{DOC_TYPE_LABELS[docType] ?? docType}</p>
<p className="text-[10px] text-gray-400">{formatDate(doc.s3UploadTimestamp)}</p>
</div>
</div>
<DownloadDocButton doc={doc} />
</div>
);
})}
{/* Missing required docs */}
{missingTypes.map((docType) => (
<div key={docType} className="flex items-center gap-2.5 px-3 py-2 rounded-lg border border-dashed border-amber-200 bg-amber-50/30">
<div className="shrink-0 w-5 h-5 rounded-full border-2 border-dashed border-amber-300 flex items-center justify-center">
<FileX className="h-2.5 w-2.5 text-amber-400" />
</div>
<p className="text-xs text-amber-700 font-medium">{DOC_TYPE_LABELS[docType] ?? docType}</p>
</div>
))}
{/* Extra docs uploaded for this measure (not in required list) */}
{measureDocs
.filter((d) => !mp.required.includes(d.docType))
.map((doc) => (
<div key={doc.id} className="flex items-center justify-between gap-3 px-3 py-2 rounded-lg border border-gray-100 bg-white">
<div className="flex items-center gap-2 min-w-0">
<div className="shrink-0 w-5 h-5 rounded-full bg-sky-50 border border-sky-200 flex items-center justify-center">
<FileText className="h-3 w-3 text-sky-500" />
</div>
<div className="min-w-0">
<p className="text-xs font-medium text-gray-800 truncate">{DOC_TYPE_LABELS[doc.docType] ?? doc.docType}</p>
<p className="text-[10px] text-gray-400">{formatDate(doc.s3UploadTimestamp)}</p>
</div>
</div>
<DownloadDocButton doc={doc} />
</div>
))
}
</div>
</div>
);
})}
{/* Unassigned / no-measure install docs */}
{(() => {
const knownMeasures = new Set(docStatus.measureProgress.map((m) => m.measureName));
const unassigned = installDocs.filter(
(d) => !d.measureName || !knownMeasures.has(d.measureName),
);
if (unassigned.length === 0) return null;
return (
<div className="space-y-1.5">
<h4 className="text-[10px] font-semibold uppercase tracking-wide text-gray-400 px-0.5">Other</h4>
{unassigned.map((doc) => (
<DocumentRow key={doc.id} doc={doc} showMeasure />
))}
</div>
);
})()}
</div>
) : installDocs.length > 0 ? (
// ── Fallback: flat list (no measure progress data) ──
<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

@ -38,7 +38,7 @@ import {
import { Search, SlidersHorizontal, ChevronLeft, ChevronRight, Download } from "lucide-react";
import { createPropertyTableColumns } from "./PropertyTableColumns";
import { STAGE_ORDER } from "./types";
import type { ClassifiedDeal, DocStatusMap } from "./types";
import type { ClassifiedDeal, DocStatusMap, RemovalStatusByDeal, EffectiveRemovalState } from "./types";
// Human-readable labels for toggle dropdown
const COLUMN_LABELS: Record<string, string> = {
@ -52,19 +52,25 @@ const COLUMN_LABELS: Record<string, string> = {
approvedPackage: "Approved Package",
actualMeasuresInstalled: "Installed Measures",
preSapScore: "Pre-SAP",
eiScore: "EI Score",
eiScorePotential: "EI Score (Potential)",
epcSapScore: "EPC SAP Score",
epcSapScorePotential: "EPC SAP (Potential)",
lodgementStatus: "Lodgement Status",
designDate: "Design Date",
fullLodgementDate: "Lodgement Date",
};
type DocFilter = "all" | "has_docs" | "incomplete" | "none";
type RemovalFilter = "all" | "pending_removal" | "removed" | "pending_re_addition";
interface PropertyTableProps {
data: ClassifiedDeal[];
onOpenDrawer: (uprn: string | null, landlordPropertyId: string | null, dealname: string | null) => void;
onOpenDrawer: (dealId: string, uprn: string | null, landlordPropertyId: string | null, dealname: string | null) => void;
onOpenDetail?: (deal: ClassifiedDeal) => void;
showDocuments?: boolean;
docStatusMap?: DocStatusMap;
removalStatusByDeal?: RemovalStatusByDeal;
}
const CSV_FIELDS: { key: keyof ClassifiedDeal; label: string }[] = [
@ -80,6 +86,10 @@ const CSV_FIELDS: { key: keyof ClassifiedDeal; label: string }[] = [
{ key: "approvedPackage", label: "Approved Package" },
{ key: "actualMeasuresInstalled", label: "Installed Measures" },
{ key: "preSapScore", label: "Pre-SAP" },
{ key: "eiScore", label: "EI Score" },
{ key: "eiScorePotential", label: "EI Score (Potential)" },
{ key: "epcSapScore", label: "EPC SAP Score" },
{ key: "epcSapScorePotential", label: "EPC SAP (Potential)" },
{ key: "lodgementStatus", label: "Lodgement Status" },
{ key: "designDate", label: "Design Date" },
{ key: "fullLodgementDate", label: "Lodgement Date" },
@ -96,10 +106,11 @@ function escapeCell(value: unknown): string {
: str;
}
export default function PropertyTable({ data, onOpenDrawer, onOpenDetail, showDocuments = false, docStatusMap = {} }: PropertyTableProps) {
export default function PropertyTable({ data, onOpenDrawer, onOpenDetail, showDocuments = false, docStatusMap = {}, removalStatusByDeal = {} }: PropertyTableProps) {
const [globalFilter, setGlobalFilter] = useState("");
const [stageFilter, setStageFilter] = useState<string>("all");
const [docFilter, setDocFilter] = useState<DocFilter>("all");
const [removalFilter, setRemovalFilter] = useState<RemovalFilter>("all");
const [sorting, setSorting] = useState<SortingState>([]);
const [pagination, setPagination] = useState<PaginationState>({
pageIndex: 0,
@ -112,12 +123,16 @@ export default function PropertyTable({ data, onOpenDrawer, onOpenDetail, showDo
approvedPackage: false,
actualMeasuresInstalled: false,
preSapScore: false,
eiScore: false,
eiScorePotential: false,
epcSapScore: false,
epcSapScorePotential: false,
lodgementStatus: false,
designDate: false,
fullLodgementDate: false,
});
// Pre-filter by stage and doc status before TanStack gets it
// Pre-filter by stage, doc status, and removal status before TanStack gets it
const filteredData = useMemo(() => {
let result = data;
if (stageFilter !== "all") {
@ -125,19 +140,25 @@ 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;
const status = docStatusMap[d.dealId];
if (docFilter === "none") return !status || !status.hasSurveyDocs;
if (docFilter === "has_docs") return !!status?.hasSurveyDocs;
if (docFilter === "incomplete") return !!status?.hasSurveyDocs && !status.isSurveyComplete;
return true;
});
}
if (removalFilter !== "all") {
result = result.filter((d) => {
const state: EffectiveRemovalState = (d.dealId ? removalStatusByDeal[d.dealId] : undefined) ?? "none";
return state === removalFilter;
});
}
return result;
}, [data, stageFilter, docFilter, docStatusMap]);
}, [data, stageFilter, docFilter, docStatusMap, removalFilter, removalStatusByDeal]);
const columns = useMemo(
() => createPropertyTableColumns(onOpenDrawer, showDocuments, docStatusMap, onOpenDetail),
[onOpenDrawer, showDocuments, docStatusMap, onOpenDetail]
() => createPropertyTableColumns(onOpenDrawer, showDocuments, docStatusMap, onOpenDetail, removalStatusByDeal),
[onOpenDrawer, showDocuments, docStatusMap, onOpenDetail, removalStatusByDeal]
);
const table = useReactTable({
@ -227,6 +248,31 @@ export default function PropertyTable({ data, onOpenDrawer, onOpenDetail, showDo
</SelectContent>
</Select>
{/* Removal status filter */}
<Select
value={removalFilter}
onValueChange={(v) => {
setRemovalFilter(v as RemovalFilter);
setPagination((p) => ({ ...p, pageIndex: 0 }));
}}
>
<SelectTrigger className="h-9 w-[180px] text-sm border-gray-200 shrink-0">
{removalFilter === "all"
? "All properties"
: removalFilter === "pending_removal"
? "Pending removal"
: removalFilter === "removed"
? "Removed"
: "Pending re-addition"}
</SelectTrigger>
<SelectContent>
<SelectItem value="all">All properties</SelectItem>
<SelectItem value="pending_removal">Pending removal</SelectItem>
<SelectItem value="removed">Removed</SelectItem>
<SelectItem value="pending_re_addition">Pending re-addition</SelectItem>
</SelectContent>
</Select>
{/* Docs filter */}
{showDocuments && (
<Select

View file

@ -3,7 +3,7 @@
import { ColumnDef } from "@tanstack/react-table";
import { ArrowUpDown, CheckCircle2, AlertCircle, FileX } from "lucide-react";
import { STAGE_COLORS } from "./types";
import type { ClassifiedDeal, DisplayStage, DocStatusMap } from "./types";
import type { ClassifiedDeal, DisplayStage, DocStatusMap, RemovalStatusByDeal } from "./types";
// -----------------------------------------------------------------------
// Stage badge — consistent pill rendering
@ -42,13 +42,14 @@ function SortableHeader({
// -----------------------------------------------------------------------
// Column factory — takes onOpenDrawer so the Documents button can trigger it
// showDocuments controls whether the Docs action column is included
// docStatusMap provides per-UPRN document status for status indicators
// docStatusMap provides per-deal document status for status indicators
// -----------------------------------------------------------------------
export function createPropertyTableColumns(
onOpenDrawer: (uprn: string | null, landlordPropertyId: string | null, dealname: string | null) => void,
onOpenDrawer: (dealId: string, uprn: string | null, landlordPropertyId: string | null, dealname: string | null) => void,
showDocuments: boolean = false,
docStatusMap: DocStatusMap = {},
onOpenDetail?: (deal: ClassifiedDeal) => void,
removalStatusByDeal: RemovalStatusByDeal = {},
): ColumnDef<ClassifiedDeal>[] {
const columns: ColumnDef<ClassifiedDeal>[] = [
// ── Address ──────────────────────────────────────────────────────────
@ -56,22 +57,29 @@ export function createPropertyTableColumns(
accessorKey: "dealname",
id: "dealname",
header: ({ column }) => <SortableHeader label="Address" column={column as any} />,
cell: ({ row }) => (
<div className="max-w-[220px]">
{onOpenDetail ? (
<button
onClick={() => onOpenDetail(row.original)}
className="text-sm font-medium text-brandblue hover:text-brandmidblue hover:underline underline-offset-2 leading-tight text-left truncate w-full transition-colors"
>
{row.original.dealname ?? "—"}
</button>
) : (
<p className="text-sm font-medium text-gray-900 leading-tight truncate">
{row.original.dealname ?? "—"}
</p>
)}
</div>
),
cell: ({ row }) => {
const removalState = row.original.dealId ? removalStatusByDeal[row.original.dealId] : undefined;
const hasPending = removalState === "pending_removal" || removalState === "pending_re_addition";
return (
<div className="max-w-[220px] flex items-center gap-1.5">
{hasPending && (
<span className="shrink-0 w-2 h-2 rounded-full bg-amber-400" title="Outstanding removal request" />
)}
{onOpenDetail ? (
<button
onClick={() => onOpenDetail(row.original)}
className="text-sm font-medium text-brandblue hover:text-brandmidblue hover:underline underline-offset-2 leading-tight text-left truncate transition-colors"
>
{row.original.dealname ?? "—"}
</button>
) : (
<p className="text-sm font-medium text-gray-900 leading-tight truncate">
{row.original.dealname ?? "—"}
</p>
)}
</div>
);
},
enableHiding: false,
},
@ -232,6 +240,54 @@ export function createPropertyTableColumns(
},
// ── EI score ─────────────────────────────────────────────────────────
{
accessorKey: "eiScore",
id: "eiScore",
header: ({ column }) => <SortableHeader label="EI Score" column={column as any} />,
cell: ({ row }) => (
<span className="text-xs font-mono text-gray-600">
{row.original.eiScore ?? <span className="text-gray-300"></span>}
</span>
),
},
// ── EI score (potential) ──────────────────────────────────────────────
{
accessorKey: "eiScorePotential",
id: "eiScorePotential",
header: ({ column }) => <SortableHeader label="EI Score (Potential)" column={column as any} />,
cell: ({ row }) => (
<span className="text-xs font-mono text-gray-600">
{row.original.eiScorePotential ?? <span className="text-gray-300"></span>}
</span>
),
},
// ── EPC SAP score ─────────────────────────────────────────────────────
{
accessorKey: "epcSapScore",
id: "epcSapScore",
header: ({ column }) => <SortableHeader label="EPC SAP Score" column={column as any} />,
cell: ({ row }) => (
<span className="text-xs font-mono text-gray-600">
{row.original.epcSapScore ?? <span className="text-gray-300"></span>}
</span>
),
},
// ── EPC SAP score (potential) ─────────────────────────────────────────
{
accessorKey: "epcSapScorePotential",
id: "epcSapScorePotential",
header: ({ column }) => <SortableHeader label="EPC SAP (Potential)" column={column as any} />,
cell: ({ row }) => (
<span className="text-xs font-mono text-gray-600">
{row.original.epcSapScorePotential ?? <span className="text-gray-300"></span>}
</span>
),
},
// ── Lodgement status ─────────────────────────────────────────────────
{
accessorKey: "lodgementStatus",
@ -283,10 +339,9 @@ export function createPropertyTableColumns(
<span className="text-xs font-semibold uppercase tracking-wide text-gray-500">Docs</span>
),
cell: ({ row }) => {
const uprn = row.original.uprn ?? "";
const status = uprn ? docStatusMap[uprn] : undefined;
const isComplete = status?.isComplete;
const hasDocs = status?.hasDocs;
const status = docStatusMap[row.original.dealId];
const isComplete = status?.isSurveyComplete;
const hasDocs = status?.hasSurveyDocs;
let icon: React.ReactNode;
let className: string;
@ -307,7 +362,7 @@ export function createPropertyTableColumns(
return (
<button
onClick={() => onOpenDrawer(row.original.uprn, row.original.landlordPropertyId, row.original.dealname)}
onClick={() => onOpenDrawer(row.original.dealId, row.original.uprn, row.original.landlordPropertyId, row.original.dealname)}
className={className}
>
{icon}

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, desc } from "drizzle-orm";
import LiveTracker from "./LiveTracker";
import { computeLiveTrackerData } from "./transforms";
import { db } from "@/app/db/db";
@ -9,8 +9,13 @@ 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 { propertyRemovalRequests } from "@/app/db/schema/removal_requests";
import { user as userTable } from "@/app/db/schema/users";
import type { HubspotDeal, DocStatusMap, DocStatus, MeasureDocProgress, PortfolioCapabilityType, ApprovalsByDeal, RemovalStatusByDeal, EffectiveRemovalState } from "./types";
import { EXPECTED_RETROFIT_ASSESSMENT_DOC_TYPES, SURVEY_ALL_DOC_TYPES } from "./types";
import { getRequiredDocs } from "@/app/lib/measureDocumentRequirements";
import type { InferSelectModel } from "drizzle-orm";
import { Card, CardContent } from "@/app/shadcn_components/ui/card";
import { Building2 } from "lucide-react";
@ -37,6 +42,7 @@ function mapDbRowToHubspotDeal(row: DbDeal): HubspotDeal {
pashubLink: row.pashubLink,
sharepointLink: row.sharepointLink,
dampMouldFlag: row.dampmouldGrowth,
dampMouldAndRepairComments: row.damnpMouldAndRepairComments,
preSapScore: row.preSap,
coordinator: row.coordinator,
ioeV1Date: row.mtpCompletionDate,
@ -55,6 +61,10 @@ function mapDbRowToHubspotDeal(row: DbDeal): HubspotDeal {
confirmedSurveyDate: row.confirmedSurveyDate,
surveyedDate: row.surveyedDate,
designType: row.dealType,
eiScore: row.eiScore,
eiScorePotential: row.eiScorePotential,
epcSapScore: row.epcSapScore,
epcSapScorePotential: row.epcSapScorePotential,
createdAt: row.createdAt,
updatedAt: row.updatedAt,
};
@ -120,45 +130,237 @@ export default async function LiveReportingPage(props: {
const deals = rawDeals.map(mapDbRowToHubspotDeal);
const trackerData = computeLiveTrackerData(deals);
// Fetch survey document status for all properties
const uprnList = 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);
}
}
// Compute effective removal state per deal
const removalStatusByDeal: RemovalStatusByDeal = {};
const removalRows = await db
.select({
hubspotDealId: propertyRemovalRequests.hubspotDealId,
type: propertyRemovalRequests.type,
status: propertyRemovalRequests.status,
})
.from(propertyRemovalRequests)
.where(eq(propertyRemovalRequests.portfolioId, BigInt(portfolioId)))
.orderBy(desc(propertyRemovalRequests.requestedAt));
// Keep only the most recent row per deal, then derive effective state
const seenDeals = new Set<string>();
for (const row of removalRows) {
if (seenDeals.has(row.hubspotDealId)) continue;
seenDeals.add(row.hubspotDealId);
let state: EffectiveRemovalState = "none";
if (row.status === "pending") {
state = row.type === "re_addition" ? "pending_re_addition" : "pending_removal";
} else if (row.type === "removal" && row.status === "approved") {
state = "removed";
} else if (row.type === "re_addition" && row.status === "declined") {
state = "removed";
}
if (state !== "none") removalStatusByDeal[row.hubspotDealId] = state;
}
// Fetch document status for all deals — two-phase strategy:
// Phase 1: query by dealId (reliable even when UPRN is missing from hubspot_deal_data)
// Phase 2: UPRN fallback only for deals that returned no results in phase 1
const docsByDealId = new Map<string, Array<{ fileType: string; measureName: string | null }>>();
if (dealIds.length > 0) {
const phase1Rows = await db
.select({
hubsotDealId: uploadedFiles.hubsotDealId,
fileType: uploadedFiles.fileType,
measureName: uploadedFiles.measureName,
})
.from(uploadedFiles)
.where(inArray(uploadedFiles.hubsotDealId, dealIds));
for (const row of phase1Rows) {
if (!row.hubsotDealId || row.fileType === null) continue;
if (!docsByDealId.has(row.hubsotDealId)) docsByDealId.set(row.hubsotDealId, []);
docsByDealId.get(row.hubsotDealId)!.push({ fileType: row.fileType, measureName: row.measureName });
}
}
// Phase 2: for deals with no docs from phase 1 that have a UPRN, try UPRN lookup
const dealsWithoutDocs = deals.filter((d) => !docsByDealId.has(d.dealId));
const fallbackUprns = dealsWithoutDocs
.map((d) => d.uprn)
.filter((u): u is string => !!u)
.map((u) => {
try { return BigInt(u); } catch { return null; }
})
.map((u) => { try { return BigInt(u); } catch { return null; } })
.filter((u): u is bigint => u !== null);
let docStatusMap: DocStatusMap = {};
if (uprnList.length > 0) {
const docRows = await db
.select()
if (fallbackUprns.length > 0) {
const phase2Rows = await db
.select({
uprn: uploadedFiles.uprn,
fileType: uploadedFiles.fileType,
measureName: uploadedFiles.measureName,
})
.from(uploadedFiles)
.where(inArray(uploadedFiles.uprn, uprnList));
.where(inArray(uploadedFiles.uprn, fallbackUprns));
const grouped: Record<string, Set<string>> = {};
for (const row of docRows) {
// Map phase 2 UPRN results back to dealId
const uprnToDealId = new Map<string, string>(
dealsWithoutDocs
.filter((d) => d.uprn)
.map((d) => {
try { return [String(BigInt(d.uprn!)), d.dealId] as [string, string]; } catch { return null; }
})
.filter((e): e is [string, string] => e !== null),
);
for (const row of phase2Rows) {
if (row.uprn === null || row.fileType === null) continue;
const key = String(row.uprn);
(grouped[key] ??= new Set()).add(row.fileType);
const dealId = uprnToDealId.get(String(row.uprn));
if (!dealId) continue;
if (!docsByDealId.has(dealId)) docsByDealId.set(dealId, []);
docsByDealId.get(dealId)!.push({ fileType: row.fileType, measureName: row.measureName });
}
}
// Build measures lookup by dealId (approved measures, falling back to proposed)
const measuresByDealId = new Map<string, string[]>();
for (const deal of deals) {
const approved = approvalsByDeal[deal.dealId] ?? [];
const measures = approved.length > 0
? approved
: (deal.proposedMeasures ?? "").split(",").map((m: string) => m.trim()).filter(Boolean);
measuresByDealId.set(deal.dealId, measures);
}
// Build docStatusMap keyed by dealId
const docStatusMap: DocStatusMap = {};
for (const [dealId, docs] of docsByDealId) {
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 = measuresByDealId.get(dealId) ?? [];
// Compute per-measure document progress against the requirements matrix
const measureProgress: MeasureDocProgress[] = measures.map((measureName) => {
const required = getRequiredDocs(measureName);
const docsForMeasure = installDocs.filter((d) => d.measureName === measureName);
const uploadedTypeSet = new Set(docsForMeasure.map((d) => d.fileType));
const uploaded = required.filter((r) => uploadedTypeSet.has(r));
return {
measureName,
required,
uploaded,
isComplete: uploaded.length === required.length,
uploadedCount: uploaded.length,
requiredCount: required.length,
};
});
let installStatus: DocStatus["installStatus"] = "none";
if (installDocs.length > 0) {
if (measures.length === 0) {
installStatus = "hasDocs";
} else {
installStatus = measureProgress.every((m) => m.isComplete)
? "all"
: measureProgress.some((m) => m.uploadedCount > 0)
? "partial"
: "none";
}
}
for (const [uprn, types] of Object.entries(grouped)) {
const presentTypes = Array.from(types);
const status: DocStatus = {
presentTypes,
hasDocs: presentTypes.length > 0,
isComplete: EXPECTED_SURVEY_DOC_TYPES.every((t) => types.has(t)),
};
docStatusMap[uprn] = status;
}
docStatusMap[dealId] = {
presentSurveyTypes: Array.from(surveyTypeSet),
hasSurveyDocs: surveyDocs.length > 0,
isSurveyComplete: EXPECTED_RETROFIT_ASSESSMENT_DOC_TYPES.every((t) => surveyTypeSet.has(t)),
hasInstallDocs: installDocs.length > 0,
installStatus,
measureProgress,
};
}
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}
removalStatusByDeal={removalStatusByDeal}
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" | "removalStatusByDeal" | "portfolioId" | "userRole" | "userEmail"> {
// Classify all deals (add displayStage field)
const classified = classifyDeals(rawDeals);

View file

@ -28,6 +28,7 @@ export type HubspotDeal = {
pashubLink: string | null;
sharepointLink: string | null;
dampMouldFlag: string | null; // coordinator-stage damp/mould flag
dampMouldAndRepairComments: string | null; // coordinator damp/mould comments
preSapScore: string | null; // kept as text (HubSpot returns strings)
coordinator: string | null;
ioeV1Date: Date | null;
@ -46,6 +47,10 @@ export type HubspotDeal = {
confirmedSurveyDate: Date | null;
surveyedDate: Date | null;
designType: string | null;
eiScore: string | null;
eiScorePotential: string | null;
epcSapScore: string | null;
epcSapScorePotential: string | null;
createdAt: Date;
updatedAt: Date;
@ -161,6 +166,32 @@ 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[]>;
export type EffectiveRemovalState = "none" | "pending_removal" | "removed" | "pending_re_addition";
export type RemovalStatusByDeal = Record<string, EffectiveRemovalState>;
// -----------------------------------------------------------------------
// Removal request record returned by the API
// -----------------------------------------------------------------------
export type RemovalRequest = {
id: string;
hubspotDealId: string;
type: "removal" | "re_addition";
status: "pending" | "approved" | "declined";
reason: string;
requestedByEmail: string;
requestedAt: string;
reviewedByEmail: string | null;
reviewedAt: string | null;
};
// -----------------------------------------------------------------------
// Top-level props for LiveTracker (client root)
// -----------------------------------------------------------------------
@ -169,6 +200,12 @@ export type LiveTrackerProps = {
totalDeals: number;
majorConditionDeals: ClassifiedDeal[]; // for Awaab's Law card
docStatusMap: DocStatusMap;
userCapability: PortfolioCapabilityType;
approvalsByDeal: ApprovalsByDeal;
removalStatusByDeal: RemovalStatusByDeal;
portfolioId: string;
userRole: string;
userEmail: string;
};
// -----------------------------------------------------------------------
@ -194,10 +231,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,16 +247,44 @@ export const EXPECTED_SURVEY_DOC_TYPES = [
"pas_2023_occupancy",
] as const;
export type DocStatus = {
presentTypes: string[];
hasDocs: boolean;
isComplete: boolean; // all EXPECTED_SURVEY_DOC_TYPES present
// 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",
]);
// Per-measure document upload progress
export type MeasureDocProgress = {
measureName: string;
required: string[]; // required fileType values for this measure
uploaded: string[]; // required fileType values that have been uploaded
isComplete: boolean;
uploadedCount: number;
requiredCount: number;
};
export type DocStatusMap = Record<string, DocStatus>; // keyed by UPRN string
export type DocStatus = {
// 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" = all required docs uploaded for every proposed measure
// "partial" = some (but not all) proposed measures have complete docs
// "hasDocs" = has install docs but no measures defined on the deal
// "none" = no install docs at all
measureProgress: MeasureDocProgress[]; // one entry per proposed measure
};
export type DocStatusMap = Record<string, DocStatus>; // keyed by dealId string
export type DocumentDrawerState = {
open: boolean;
dealId: string | null;
uprn: string | null;
landlordPropertyId: string | null;
dealname: string | null;

View file

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