mirror of
https://github.com/Hestia-Homes/assessment-model.git
synced 2026-06-30 12:55:02 +00:00
commit
2de093574a
69 changed files with 79630 additions and 254 deletions
|
|
@ -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
|
||||
|
|
@ -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": {
|
||||
|
|
|
|||
|
|
@ -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
102
devcontainer.sh
Normal 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
|
||||
|
|
@ -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);
|
||||
|
|
|
|||
243
src/app/api/portfolio/[portfolioId]/approvals/route.ts
Normal file
243
src/app/api/portfolio/[portfolioId]/approvals/route.ts
Normal 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 },
|
||||
);
|
||||
}
|
||||
}
|
||||
171
src/app/api/portfolio/[portfolioId]/capabilities/route.ts
Normal file
171
src/app/api/portfolio/[portfolioId]/capabilities/route.ts
Normal 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 },
|
||||
);
|
||||
}
|
||||
}
|
||||
373
src/app/api/portfolio/[portfolioId]/removal-requests/route.ts
Normal file
373
src/app/api/portfolio/[portfolioId]/removal-requests/route.ts
Normal 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 });
|
||||
}
|
||||
}
|
||||
151
src/app/api/upload/contractor-install/route.ts
Normal file
151
src/app/api/upload/contractor-install/route.ts
Normal 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 });
|
||||
}
|
||||
}
|
||||
|
|
@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
87
src/app/components/portfolio/BulkUploadComingSoonModal.tsx
Normal file
87
src/app/components/portfolio/BulkUploadComingSoonModal.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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" />
|
||||
|
|
|
|||
|
|
@ -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, {
|
||||
|
|
|
|||
11
src/app/db/migrations/0170_furry_moonstone.sql
Normal file
11
src/app/db/migrations/0170_furry_moonstone.sql
Normal 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
|
||||
);
|
||||
2
src/app/db/migrations/0171_chunky_wallow.sql
Normal file
2
src/app/db/migrations/0171_chunky_wallow.sql
Normal 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;
|
||||
59
src/app/db/migrations/0172_kind_eddie_brock.sql
Normal file
59
src/app/db/migrations/0172_kind_eddie_brock.sql
Normal 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;
|
||||
26
src/app/db/migrations/0173_neat_bastion.sql
Normal file
26
src/app/db/migrations/0173_neat_bastion.sql
Normal 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");
|
||||
1
src/app/db/migrations/0174_bulk_upload_task_id.sql
Normal file
1
src/app/db/migrations/0174_bulk_upload_task_id.sql
Normal file
|
|
@ -0,0 +1 @@
|
|||
ALTER TABLE "bulk_address_uploads" ADD COLUMN "task_id" uuid;
|
||||
1
src/app/db/migrations/0175_sweet_otto_octavius.sql
Normal file
1
src/app/db/migrations/0175_sweet_otto_octavius.sql
Normal file
|
|
@ -0,0 +1 @@
|
|||
ALTER TABLE "bulk_address_uploads" ADD COLUMN "task_id" uuid;
|
||||
|
|
@ -0,0 +1 @@
|
|||
ALTER TABLE "bulk_address_uploads" ADD COLUMN "combined_output_s3_uri" text;
|
||||
1
src/app/db/migrations/0177_wooden_dexter_bennett.sql
Normal file
1
src/app/db/migrations/0177_wooden_dexter_bennett.sql
Normal file
|
|
@ -0,0 +1 @@
|
|||
ALTER TABLE "bulk_address_uploads" ADD COLUMN "combined_output_s3_uri" text;
|
||||
25
src/app/db/migrations/0178_parched_midnight.sql
Normal file
25
src/app/db/migrations/0178_parched_midnight.sql
Normal 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");
|
||||
2
src/app/db/migrations/0179_mighty_cardiac.sql
Normal file
2
src/app/db/migrations/0179_mighty_cardiac.sql
Normal 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;
|
||||
1
src/app/db/migrations/0180_ambitious_jane_foster.sql
Normal file
1
src/app/db/migrations/0180_ambitious_jane_foster.sql
Normal file
|
|
@ -0,0 +1 @@
|
|||
ALTER TABLE "property_removal_requests" ADD COLUMN "type" text DEFAULT 'removal' NOT NULL;
|
||||
1
src/app/db/migrations/0180_removal_request_type.sql
Normal file
1
src/app/db/migrations/0180_removal_request_type.sql
Normal file
|
|
@ -0,0 +1 @@
|
|||
ALTER TABLE "property_removal_requests" ADD COLUMN IF NOT EXISTS "type" text NOT NULL DEFAULT 'removal';
|
||||
5
src/app/db/migrations/0181_concerned_electro.sql
Normal file
5
src/app/db/migrations/0181_concerned_electro.sql
Normal 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;
|
||||
|
|
@ -0,0 +1 @@
|
|||
ALTER TABLE "property_removal_requests" ADD COLUMN IF NOT EXISTS "original_batch" text;
|
||||
4
src/app/db/migrations/0182_messy_calypso.sql
Normal file
4
src/app/db/migrations/0182_messy_calypso.sql
Normal 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;
|
||||
6410
src/app/db/migrations/meta/0170_snapshot.json
Normal file
6410
src/app/db/migrations/meta/0170_snapshot.json
Normal file
File diff suppressed because it is too large
Load diff
6423
src/app/db/migrations/meta/0171_snapshot.json
Normal file
6423
src/app/db/migrations/meta/0171_snapshot.json
Normal file
File diff suppressed because it is too large
Load diff
6718
src/app/db/migrations/meta/0172_snapshot.json
Normal file
6718
src/app/db/migrations/meta/0172_snapshot.json
Normal file
File diff suppressed because it is too large
Load diff
6754
src/app/db/migrations/meta/0173_snapshot.json
Normal file
6754
src/app/db/migrations/meta/0173_snapshot.json
Normal file
File diff suppressed because it is too large
Load diff
6760
src/app/db/migrations/meta/0175_snapshot.json
Normal file
6760
src/app/db/migrations/meta/0175_snapshot.json
Normal file
File diff suppressed because it is too large
Load diff
6766
src/app/db/migrations/meta/0177_snapshot.json
Normal file
6766
src/app/db/migrations/meta/0177_snapshot.json
Normal file
File diff suppressed because it is too large
Load diff
6898
src/app/db/migrations/meta/0178_snapshot.json
Normal file
6898
src/app/db/migrations/meta/0178_snapshot.json
Normal file
File diff suppressed because it is too large
Load diff
6910
src/app/db/migrations/meta/0179_snapshot.json
Normal file
6910
src/app/db/migrations/meta/0179_snapshot.json
Normal file
File diff suppressed because it is too large
Load diff
6917
src/app/db/migrations/meta/0180_snapshot.json
Normal file
6917
src/app/db/migrations/meta/0180_snapshot.json
Normal file
File diff suppressed because it is too large
Load diff
6947
src/app/db/migrations/meta/0181_snapshot.json
Normal file
6947
src/app/db/migrations/meta/0181_snapshot.json
Normal file
File diff suppressed because it is too large
Load diff
6971
src/app/db/migrations/meta/0182_snapshot.json
Normal file
6971
src/app/db/migrations/meta/0182_snapshot.json
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -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
|
||||
}
|
||||
]
|
||||
}
|
||||
65
src/app/db/schema/approvals.ts
Normal file
65
src/app/db/schema/approvals.ts
Normal 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"
|
||||
>;
|
||||
21
src/app/db/schema/bulk_address_uploads.ts
Normal file
21
src/app/db/schema/bulk_address_uploads.ts
Normal 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()),
|
||||
});
|
||||
|
|
@ -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 }),
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
>;
|
||||
|
|
|
|||
49
src/app/db/schema/removal_requests.ts
Normal file
49
src/app/db/schema/removal_requests.ts
Normal 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"
|
||||
>;
|
||||
|
|
@ -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),
|
||||
}
|
||||
);
|
||||
14
src/app/lib/hubspot/client.ts
Normal file
14
src/app/lib/hubspot/client.ts
Normal 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;
|
||||
}
|
||||
184
src/app/lib/hubspot/dealSync.ts
Normal file
184
src/app/lib/hubspot/dealSync.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
81
src/app/lib/measureDocumentRequirements.ts
Normal file
81
src/app/lib/measureDocumentRequirements.ts
Normal 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",
|
||||
};
|
||||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
))
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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",
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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>] : []),
|
||||
];
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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 */}
|
||||
|
|
|
|||
|
|
@ -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 */}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue