trigger address2uprn

This commit is contained in:
Jun-te Kim 2026-04-17 14:05:03 +00:00
commit 16136a3d3a
34 changed files with 23119 additions and 128 deletions

View file

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

View file

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

View file

@ -0,0 +1,151 @@
import { db } from "@/app/db/db";
import { bulkAddressUploads } from "@/app/db/schema/bulk_address_uploads";
import { tasks } from "@/app/db/schema/tasks/tasks";
import { eq } from "drizzle-orm";
import { NextRequest, NextResponse } from "next/server";
import { getServerSession } from "next-auth";
import { AuthOptions } from "@/app/api/auth/[...nextauth]/authOptions";
import { z } from "zod";
import { createS3Client } from "@/app/utils/s3";
import { sendToQueue } from "@/app/utils/sqs";
const FIELD_RENAME: Record<string, string> = {
address_1: "Address 1",
address_2: "Address 2",
address_3: "Address 3",
postcode: "postcode",
internal_reference: "Internal Reference",
};
const BodySchema = z.object({
taskId: z.string().uuid(),
subTaskId: z.string().uuid(),
});
function transformCsv(
raw: string,
columnMapping: Record<string, string>
): { csv: string; error?: never } | { csv?: never; error: string } {
const lines = raw.split(/\r?\n/);
if (lines.length === 0) return { error: "Empty file" };
const sourceHeaders = lines[0].split(",").map((h) => h.trim().replace(/^"|"$/g, ""));
const outputHeaders: string[] = [];
const keepIndices: number[] = [];
for (let i = 0; i < sourceHeaders.length; i++) {
const src = sourceHeaders[i];
const mapped = columnMapping[src];
if (!mapped || mapped === "skip") continue;
const renamed = FIELD_RENAME[mapped] ?? mapped;
outputHeaders.push(renamed);
keepIndices.push(i);
}
if (!outputHeaders.includes("Address 1"))
return { error: 'Mapping must include "Address 1"' };
if (!outputHeaders.includes("postcode"))
return { error: 'Mapping must include "postcode"' };
const outputLines: string[] = [outputHeaders.join(",")];
for (let r = 1; r < lines.length; r++) {
const line = lines[r].trim();
if (!line) continue;
const cols = line.split(",").map((c) => c.trim().replace(/^"|"$/g, ""));
outputLines.push(keepIndices.map((i) => cols[i] ?? "").join(","));
}
return { csv: outputLines.join("\n") };
}
export async function POST(
request: NextRequest,
{ params }: { params: Promise<{ portfolioId: string; uploadId: string }> }
) {
const session = await getServerSession(AuthOptions);
if (!session) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
const { portfolioId, uploadId } = await params;
let body;
try {
body = BodySchema.parse(await request.json());
} catch {
return NextResponse.json({ error: "Invalid input" }, { status: 400 });
}
const [upload] = await db
.select()
.from(bulkAddressUploads)
.where(eq(bulkAddressUploads.id, uploadId))
.limit(1);
if (!upload) return NextResponse.json({ error: "Not found" }, { status: 404 });
if (upload.status !== "mapping_complete")
return NextResponse.json({ error: "Upload not ready for onboarding" }, { status: 422 });
if (!upload.columnMapping)
return NextResponse.json({ error: "Column mapping missing" }, { status: 422 });
const s3 = createS3Client();
const bucket = upload.s3Bucket;
let rawCsv: string;
try {
const obj = await s3
.getObject({ Bucket: bucket, Key: upload.s3Key })
.promise();
rawCsv = obj.Body?.toString("utf-8") ?? "";
} catch (err) {
console.error("Failed to read source CSV from S3:", err);
return NextResponse.json({ error: "Failed to read source file" }, { status: 500 });
}
const result = transformCsv(rawCsv, upload.columnMapping);
if (result.error) return NextResponse.json({ error: result.error }, { status: 422 });
const transformedKey = `bulk_onboarding_inputs/${portfolioId}/${uploadId}.csv`;
try {
await s3
.putObject({
Bucket: bucket,
Key: transformedKey,
Body: result.csv,
ContentType: "text/csv",
})
.promise();
} catch (err) {
console.error("Failed to upload transformed CSV:", err);
return NextResponse.json({ error: "Failed to store transformed file" }, { status: 500 });
}
const s3Uri = `s3://${bucket}/${transformedKey}`;
const queueName = process.env.POSTCODE_SPLITTER_QUEUE_NAME;
if (!queueName) {
console.error("POSTCODE_SPLITTER_QUEUE_NAME not set");
return NextResponse.json({ error: "Server misconfiguration" }, { status: 500 });
}
try {
await sendToQueue(
{ task_id: body.taskId, sub_task_id: body.subTaskId, s3_uri: s3Uri },
{ queueName }
);
} catch (err) {
console.error("Failed to send SQS message:", err);
return NextResponse.json({ error: "Failed to queue onboarding job" }, { status: 500 });
}
await db
.update(bulkAddressUploads)
.set({ status: "processing", taskId: body.taskId })
.where(eq(bulkAddressUploads.id, uploadId));
await db
.update(tasks)
.set({ status: "in progress" })
.where(eq(tasks.id, body.taskId));
return NextResponse.json({ taskId: body.taskId }, { status: 200 });
}

View file

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

View file

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

View file

@ -13,7 +13,7 @@ import {
import { cn } from "@/lib/utils";
import { useRouter } from "next/navigation";
import { Dispatch, SetStateAction, useState } from "react";
import BulkUploadComingSoonModal from "@/app/components/portfolio/BulkUploadComingSoonModal";
// import BulkUploadComingSoonModal from "@/app/components/portfolio/BulkUploadComingSoonModal";
interface AddNewProps {
portfolioId: string;
@ -37,112 +37,112 @@ export default function AddNew({
return (
<>
<BulkUploadComingSoonModal
{/* <BulkUploadComingSoonModal
isOpen={isBulkUploadOpen}
onClose={() => setIsBulkUploadOpen(false)}
portfolioId={portfolioId}
/>
<Menu as="div" className="relative inline-block text-left">
<MenuButton
className="
/> */}
<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
"
>
<DocumentPlusIcon className="h-4 w-4 mr-2" />
New Property
<ChevronDownIcon className="h-4 w-4 opacity-70" />
</MenuButton>
>
<DocumentPlusIcon className="h-4 w-4 mr-2" />
New Property
<ChevronDownIcon className="h-4 w-4 opacity-70" />
</MenuButton>
<MenuItems
className="
<MenuItems
className="
absolute right-0 mt-3 w-72 origin-top-right rounded-md
bg-white shadow-lg ring-1 ring-black/5 focus:outline-none
z-[9999] py-3
"
>
<div className="flex flex-col gap-2 px-3">
{/* Remote Assessment */}
<MenuItem>
{({ active }) => (
<button
onClick={handleRemoteAssessment}
className={cn(
"w-full p-3 rounded-lg text-left flex gap-3 transition-colors",
active && "bg-gray-100"
)}
>
<DocumentMagnifyingGlassIcon className="h-5 w-5 text-gray-700 mt-[2px]" />
>
<div className="flex flex-col gap-2 px-3">
{/* Remote Assessment */}
<MenuItem>
{({ active }) => (
<button
onClick={handleRemoteAssessment}
className={cn(
"w-full p-3 rounded-lg text-left flex gap-3 transition-colors",
active && "bg-gray-100",
)}
>
<DocumentMagnifyingGlassIcon 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">
Remote Assessment
{loadingRemote && (
<span className="h-3 w-3 rounded-full border-2 border-gray-400 border-t-transparent animate-spin"></span>
)}
</span>
<span className="text-xs text-gray-500 leading-snug">
Run a remote assessment for a single property.
</span>
</div>
</button>
)}
</MenuItem>
{/* CSV Upload */}
<MenuItem>
{({ active }) => (
<button
onClick={() => setIsUploadCsvOpen(!isUploadCsvOpen)}
className={cn(
"w-full p-3 rounded-lg text-left flex gap-3 transition-colors",
active && "bg-gray-100"
)}
>
<TableCellsIcon className="h-5 w-5 text-gray-700 mt-[2px]" />
<div className="flex flex-col">
<span className="text-sm font-medium text-gray-900">
File Import
</span>
<span className="text-xs text-gray-500 leading-snug">
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
<div className="flex flex-col">
<span className="text-sm font-medium text-gray-900 flex items-center gap-2">
Remote Assessment
{loadingRemote && (
<span className="h-3 w-3 rounded-full border-2 border-gray-400 border-t-transparent animate-spin"></span>
)}
</span>
</span>
<span className="text-xs text-gray-500 leading-snug">
Upload multiple addresses in one go.
</span>
</div>
</button>
)}
</MenuItem>
</div>
</MenuItems>
</Menu>
<span className="text-xs text-gray-500 leading-snug">
Run a remote assessment for a single property.
</span>
</div>
</button>
)}
</MenuItem>
{/* CSV Upload */}
<MenuItem>
{({ active }) => (
<button
onClick={() => setIsUploadCsvOpen(!isUploadCsvOpen)}
className={cn(
"w-full p-3 rounded-lg text-left flex gap-3 transition-colors",
active && "bg-gray-100",
)}
>
<TableCellsIcon className="h-5 w-5 text-gray-700 mt-[2px]" />
<div className="flex flex-col">
<span className="text-sm font-medium text-gray-900">
File Import
</span>
<span className="text-xs text-gray-500 leading-snug">
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>
)}
</MenuItem>
</div>
</MenuItems>
</Menu>
</>
);
}

View file

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

View file

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

View file

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

View file

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

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

View file

@ -1209,8 +1209,29 @@
{
"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": "0172_bulk_upload_task_id",
"tag": "0174_bulk_upload_task_id",
"breakpoints": true
},
{
"idx": 175,
"version": "7",
"when": 1776434096854,
"tag": "0175_sweet_otto_octavius",
"breakpoints": true
}
]

View file

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

View file

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

View file

@ -58,6 +58,13 @@ export const measureTypeEnum = pgEnum("measure_type", [
// Other fabric / hot water
"hot_water_tank_insulation",
"sealing_open_fireplace",
// Contractor workflow measures
"damp_mould",
"door_undercut",
"extractor_fan",
"loft_board",
"trickle_vent",
]);
export const recommendation = pgTable(

View file

@ -1,6 +1,8 @@
import { bigint, bigserial, pgEnum, pgTable, text, timestamp } from "drizzle-orm/pg-core";
import { user } from "./users";
export const fileType = pgEnum("file_type", [
// Survey documents (existing)
"photo_pack",
"site_note",
"rd_sap_site_note",
@ -12,14 +14,33 @@ 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
"pre_photo",
"mid_photo",
"post_photo",
"pre_installation_building_inspection",
"claim_of_compliance",
"handover_pack",
"insurance_guarantee",
"installer_qualifications",
"mcs_compliance_certificate",
"minor_works_electrical_certificate",
"point_of_work_risk_assessment",
"installer_feedback",
"workmanship_warranty",
"g98_notification",
"certificate_of_conformity",
"ventilation_assessment_checklist",
"contractor_other",
]);
export const fileSource = pgEnum("file_source", [
"pas hub",
"sharepoint",
"hubspot",
"ecmk"
"ecmk",
"contractor",
]);
export const uploadedFiles = pgTable(
@ -36,6 +57,8 @@ export const uploadedFiles = pgTable(
hubsotDealId: text("hubspot_deal_id"),
hubspotListingId: bigint("hubspot_listing_id", { mode: "bigint" }),
fileType: fileType("file_type"),
source: fileSource("file_source")
source: fileSource("file_source"),
measureName: text("measure_name"),
uploadedBy: bigint("uploaded_by", { mode: "bigint" }).references(() => user.id),
}
);

View file

@ -0,0 +1,109 @@
"use client";
import { useEffect, useState, useRef } from "react";
import Link from "next/link";
interface TaskData {
id: string;
taskSource: string;
status: string;
totalSubtasks: number;
completedSubtasks: number;
failedSubtasks: number;
}
interface Props {
taskId: string;
portfolioSlug: string;
}
const TERMINAL_STATUSES = new Set(["complete", "completed", "failed", "failure", "error"]);
export default function OnboardingProgress({ taskId, portfolioSlug }: Props) {
const [data, setData] = useState<TaskData | null>(null);
const [fetchError, setFetchError] = useState(false);
const intervalRef = useRef<ReturnType<typeof setInterval> | null>(null);
useEffect(() => {
async function poll() {
try {
const res = await fetch(`/api/tasks/${taskId}`);
if (!res.ok) { setFetchError(true); return; }
const json: TaskData = await res.json();
setData(json);
if (TERMINAL_STATUSES.has(json.status.toLowerCase())) {
if (intervalRef.current) clearInterval(intervalRef.current);
}
} catch {
setFetchError(true);
}
}
poll();
intervalRef.current = setInterval(poll, 3000);
return () => { if (intervalRef.current) clearInterval(intervalRef.current); };
}, [taskId]);
if (fetchError) return null;
if (!data) {
return (
<div className="mt-4 flex items-center gap-2 text-sm text-gray-400">
<span className="w-4 h-4 rounded-full border-2 border-gray-300 border-t-transparent animate-spin" />
Loading progress
</div>
);
}
const total = data.totalSubtasks;
const complete = data.completedSubtasks;
const failed = data.failedSubtasks;
const percent = total > 0 ? Math.round((complete / total) * 100) : 0;
const isDone = TERMINAL_STATUSES.has(data.status.toLowerCase());
const isFailed = ["failed", "failure", "error"].includes(data.status.toLowerCase());
return (
<div className="mt-6 space-y-3">
{/* Progress bar */}
<div className="w-full bg-gray-100 rounded-full h-2 overflow-hidden">
<div
className={`h-2 rounded-full transition-all duration-500 ${isFailed ? "bg-red-400" : "bg-[#14163d]"}`}
style={{ width: total > 0 ? `${percent}%` : "4%" }}
/>
</div>
{/* Counts */}
<div className="flex items-center gap-4 text-xs text-gray-500">
{total > 0 && (
<span>
<span className="font-semibold text-gray-700">{complete}</span> / {total} batches complete
</span>
)}
{failed > 0 && (
<span className="flex items-center gap-1 text-red-500 font-semibold">
<span className="w-1.5 h-1.5 rounded-full bg-red-400" />
{failed} failed
</span>
)}
{!isDone && (
<span className="flex items-center gap-1 text-blue-500">
<span className="w-1.5 h-1.5 rounded-full bg-blue-400 animate-pulse" />
Running
</span>
)}
{isDone && !isFailed && (
<span className="flex items-center gap-1 text-green-600 font-semibold">
<span className="w-1.5 h-1.5 rounded-full bg-green-500" />
Complete
</span>
)}
</div>
<Link
href={`/portfolio/${portfolioSlug}/settings/logs`}
className="text-xs text-gray-400 hover:text-gray-700 underline underline-offset-2 transition-colors"
>
View detailed logs
</Link>
</div>
);
}

View file

@ -0,0 +1,87 @@
"use client";
import { useState } from "react";
import { useRouter } from "next/navigation";
import { ArrowRightIcon } from "@heroicons/react/24/outline";
interface Props {
portfolioId: string;
uploadId: string;
filename: string;
}
export default function StartOnboardingButton({ portfolioId, uploadId, filename }: Props) {
const router = useRouter();
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
async function handleStart() {
setLoading(true);
setError(null);
try {
const taskRes = await fetch("/api/tasks", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
taskSource: `Address Onboarding ${filename}`,
service: "address2uprn",
source: "portfolio_id",
sourceId: portfolioId,
inputs: { bulk_upload_id: uploadId },
}),
});
if (!taskRes.ok) {
const data = await taskRes.json().catch(() => ({}));
throw new Error(data.error ?? "Failed to create task");
}
const { taskId, subTaskId } = await taskRes.json();
const onboardRes = await fetch(
`/api/portfolio/${portfolioId}/bulk-uploads/${uploadId}/onboard`,
{
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ taskId, subTaskId }),
}
);
if (!onboardRes.ok) {
const data = await onboardRes.json().catch(() => ({}));
throw new Error(data.error ?? "Failed to start onboarding");
}
router.refresh();
} catch (err) {
setError(err instanceof Error ? err.message : "Something went wrong");
setLoading(false);
}
}
return (
<div className="mt-4">
<button
onClick={handleStart}
disabled={loading}
className={`inline-flex items-center gap-2 px-5 py-2 rounded-xl bg-gradient-to-br from-[#14163d] to-[#15173e] text-white text-sm font-bold transition-opacity ${
loading ? "opacity-50 cursor-not-allowed" : "hover:opacity-90"
}`}
>
{loading ? (
<>
<span className="w-4 h-4 rounded-full border-2 border-white border-t-transparent animate-spin" />
Starting
</>
) : (
<>
Start Onboarding
<ArrowRightIcon className="h-4 w-4" />
</>
)}
</button>
{error && <p className="mt-2 text-xs text-red-500">{error}</p>}
</div>
);
}

View file

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

View file

@ -27,15 +27,13 @@ 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) => ({
portfolioUserId: String(u.portfolioUserId),
userId: String(u.userId),
name: u.name ?? null,
email: u.email ?? "",
role: u.role,
}))
? users.map((u: any) => ({
portfolioUserId: String(u.portfolioUserId),
userId: String(u.userId),
name: u.name ?? null,
email: u.email ?? "",
role: u.role,
}))
: [];
}
@ -251,12 +249,20 @@ export function UsersPermissionsCard({ portfolioId }: { portfolioId: string }) {
<TableCell>{c.name || "—"}</TableCell>
<TableCell>{c.email}</TableCell>
<TableCell className="min-w-40">
<RoleDropdown value={c.role} onChange={(r) => onChangeRole(c.portfolioUserId, r)} />
{c.role === "creator" || c.role === "admin" ? (
<span className="text-xs font-medium text-gray-500 px-2 py-1 bg-gray-100 rounded-md capitalize">
{c.role}
</span>
) : (
<RoleDropdown value={c.role as "read" | "write"} onChange={(r) => onChangeRole(c.portfolioUserId, r)} />
)}
</TableCell>
<TableCell className="text-right">
<Button variant="destructive" className="bg-red-700" onClick={() => onRemove(c.portfolioUserId)}>
Remove
</Button>
{c.role !== "creator" && (
<Button variant="destructive" className="bg-red-700" onClick={() => onRemove(c.portfolioUserId)}>
Remove
</Button>
)}
</TableCell>
</TableRow>
))

View file

@ -13,10 +13,10 @@ export type Role = typeof ROLE_OPTIONS[number];
export type Collaborator = {
portfolioUserId: string;
userId: string;
userId: string;
name?: string | null;
email: string;
role: Role;
role: Role | "creator" | "admin";
};
// Small role dropdown using shadcn Select

View file

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

View file

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

View file

@ -0,0 +1,569 @@
"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 } from "lucide-react";
import { uploadFileToS3 } from "@/app/utils/s3";
import type { ClassifiedDeal } from "./types";
// ── Types ─────────────────────────────────────────────────────────────────
type FileStatus = "queued" | "uploading" | "done" | "error";
type FileEntry = {
id: string; // local UUID for React key
// One of these will be set:
file?: File; // for newly picked files
existingS3Key?: string; // for pre-existing unclassified uploads
// Display
displayName: string;
displaySize?: string;
// Upload state
status: FileStatus;
errorMsg?: string;
uploadedId?: string; // DB record ID (set after recording or from existing)
// Classification
docType: string;
measureName: string;
};
type Phase = "loading" | "upload" | "classify";
type Props = {
deal: ClassifiedDeal;
portfolioId: string;
onClose: () => void;
};
// ── Constants ─────────────────────────────────────────────────────────────
const FILE_TYPE_OPTIONS: { value: string; label: string; group: string }[] = [
{ value: "pre_photo", label: "Pre Photo", group: "Install Photos" },
{ value: "mid_photo", label: "Mid Photo", group: "Install Photos" },
{ value: "post_photo", label: "Post Photo", group: "Install Photos" },
{ value: "pre_installation_building_inspection", label: "Pre-Installation Building Inspection (PIBI)", group: "Pre-Installation" },
{ value: "point_of_work_risk_assessment", label: "Point of Work Risk Assessment", group: "Pre-Installation" },
{ value: "claim_of_compliance", label: "Claim of Compliance (PAS 2030)", group: "Compliance" },
{ value: "mcs_compliance_certificate", label: "MCS Compliance Certificate", group: "Compliance" },
{ value: "certificate_of_conformity", label: "Certificate of Conformity", group: "Compliance" },
{ value: "minor_works_electrical_certificate", label: "Minor Works Electrical Certificate", group: "Compliance" },
{ value: "handover_pack", label: "Handover Documents / Pack", group: "Handover" },
{ value: "workmanship_warranty", label: "Workmanship Warranty", group: "Handover" },
{ value: "insurance_guarantee", label: "Insurance Backed Guarantee (IBG)", group: "Handover" },
{ value: "g98_notification", label: "G98 / G99 Notification", group: "Handover" },
{ value: "ventilation_assessment_checklist", label: "Ventilation Assessment Checklist", group: "Handover" },
{ value: "installer_qualifications", label: "Installer Qualifications", group: "Qualifications" },
{ value: "installer_feedback", label: "Installer Feedback", group: "Other" },
{ value: "contractor_other", label: "Other", group: "Other" },
];
const FILE_TYPE_GROUPS = ["Install Photos", "Pre-Installation", "Compliance", "Handover", "Qualifications", "Other"];
// ── Helpers ───────────────────────────────────────────────────────────────
function formatSize(bytes: number): string {
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(0)} KB`;
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
}
function contentTypeFor(ext: string): string {
const e = ext.toLowerCase();
if (e === "pdf") return "application/pdf";
if (["jpg", "jpeg"].includes(e)) return "image/jpeg";
if (e === "png") return "image/png";
return "application/octet-stream";
}
function parseMeasures(raw: string | null | undefined): string[] {
if (!raw) return [];
return raw.split(",").map((m) => m.trim()).filter(Boolean);
}
function s3KeyBasename(key: string): string {
return key.split("/").pop() ?? key;
}
async function getPresignedUrl(path: string, contentType: string): Promise<string> {
const res = await fetch("/api/upload/retrofit-energy-assessments", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ path, contentType, expiresInSeconds: 300 }),
});
if (!res.ok) throw new Error("Failed to get presigned URL");
const { url } = await res.json();
return url;
}
async function recordUpload(payload: {
s3FileKey: string;
s3FileBucket: string;
uprn?: string;
hubspotDealId?: string;
landlordPropertyId?: string;
}): Promise<string> {
const res = await fetch("/api/upload/contractor-install", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
});
if (!res.ok) throw new Error("Failed to record upload");
const { id } = await res.json();
return id;
}
async function saveClassifications(
updates: { id: string; fileType: string; measureName?: string }[],
): Promise<void> {
const res = await fetch("/api/upload/contractor-install", {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ updates }),
});
if (!res.ok) throw new Error("Failed to save classifications");
}
// ── DocType select ─────────────────────────────────────────────────────────
function DocTypeSelect({ value, onChange }: { value: string; onChange: (v: string) => void }) {
return (
<Select value={value} onValueChange={onChange}>
<SelectTrigger className="h-8 text-xs w-full">
<SelectValue placeholder="Select type…" />
</SelectTrigger>
<SelectContent>
{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] text-gray-400 uppercase tracking-wide">{group}</SelectLabel>
{items.map((o) => (
<SelectItem key={o.value} value={o.value} className="text-xs">{o.label}</SelectItem>
))}
</SelectGroup>
);
})}
</SelectContent>
</Select>
);
}
// ── Status icon ────────────────────────────────────────────────────────────
function StatusIcon({ status, isExisting, errorMsg }: { status: FileStatus; isExisting?: boolean; errorMsg?: string }) {
if (isExisting) return <Clock className="h-4 w-4 text-amber-400 shrink-0" aria-label="Pending classification" />;
if (status === "queued") return <span className="h-4 w-4 rounded-full border-2 border-gray-200 shrink-0 inline-block" />;
if (status === "uploading") return <Loader2 className="h-4 w-4 animate-spin text-brandblue shrink-0" />;
if (status === "done") return <CheckCircle2 className="h-4 w-4 text-emerald-500 shrink-0" />;
return <span title={errorMsg}><XCircle className="h-4 w-4 text-red-400 shrink-0" /></span>;
}
// ── Main component ─────────────────────────────────────────────────────────
export default function ContractorUploadModal({ deal, portfolioId, onClose }: Props) {
const measures = parseMeasures(deal.proposedMeasures);
const fileInputRef = useRef<HTMLInputElement>(null);
const [isDragOver, setIsDragOver] = useState(false);
const [queue, setQueue] = useState<FileEntry[]>([]);
const [phase, setPhase] = useState<Phase>("loading");
const [isUploading, setIsUploading] = useState(false);
const [isSaving, setIsSaving] = useState(false);
const [saveError, setSaveError] = useState<string | null>(null);
// ── Fetch existing unclassified files on mount ───────────────────────
useEffect(() => {
async function fetchExisting() {
const uprnParam = deal.uprn;
const propIdParam = deal.landlordPropertyId;
if (!uprnParam && !propIdParam) {
setPhase("upload");
return;
}
try {
const param = uprnParam
? `uprn=${encodeURIComponent(uprnParam)}`
: `landlordPropertyId=${encodeURIComponent(propIdParam!)}`;
const res = await fetch(`/api/live-tracking/property-documents?${param}`);
if (!res.ok) throw new Error("fetch failed");
const docs: { id: string; s3FileKey: string; docType: string | null; source: string | null }[] = await res.json();
const unclassified = docs.filter(
(d) => d.source === "contractor" && (d.docType === null || d.docType === "unknown"),
);
if (unclassified.length > 0) {
const entries: FileEntry[] = unclassified.map((d) => ({
id: crypto.randomUUID(),
existingS3Key: d.s3FileKey,
displayName: s3KeyBasename(d.s3FileKey),
status: "done",
uploadedId: d.id,
docType: "",
measureName: measures[0] ?? "",
}));
setQueue(entries);
setPhase("classify");
} else {
setPhase("upload");
}
} catch {
// If fetch fails, just proceed to upload phase
setPhase("upload");
}
}
fetchExisting();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
// ── File selection ───────────────────────────────────────────────────
function addFiles(files: FileList | File[]) {
const newEntries: FileEntry[] = Array.from(files).map((f) => ({
id: crypto.randomUUID(),
file: f,
displayName: f.name,
displaySize: formatSize(f.size),
status: "queued",
docType: "",
measureName: measures[0] ?? "",
}));
setQueue((prev) => [...prev, ...newEntries]);
}
function handleInputChange(e: React.ChangeEvent<HTMLInputElement>) {
if (e.target.files?.length) addFiles(e.target.files);
e.target.value = "";
}
function handleDrop(e: React.DragEvent) {
e.preventDefault();
setIsDragOver(false);
if (e.dataTransfer.files?.length) addFiles(e.dataTransfer.files);
}
function removeFile(id: string) {
setQueue((prev) => prev.filter((f) => f.id !== id));
}
// ── Phase 1: Upload new files ────────────────────────────────────────
async function handleUpload() {
const toUpload = queue.filter((f) => f.status === "queued");
if (toUpload.length === 0) {
// No new files to upload — go straight to classify for existing
setPhase("classify");
return;
}
if (isUploading) return;
setIsUploading(true);
setQueue((prev) =>
prev.map((f) => f.status === "queued" ? { ...f, status: "uploading" } : f),
);
const uploadResults = await Promise.allSettled(
toUpload.map(async (entry) => {
const ext = (entry.file!.name.split(".").pop() ?? "bin").toLowerCase();
const ct = contentTypeFor(ext);
const timestamp = Date.now();
const s3Key = `contractor-install/${deal.dealId}/unclassified/${timestamp}_${entry.id.slice(0, 8)}.${ext}`;
const presignedUrl = await getPresignedUrl(s3Key, ct);
await uploadFileToS3({ presignedUrl, file: entry.file!, contentType: ct });
const urlObj = new URL(presignedUrl);
const bucket = urlObj.hostname.split(".")[0];
const uploadedId = await recordUpload({
s3FileKey: s3Key,
s3FileBucket: bucket,
uprn: deal.uprn ?? undefined,
hubspotDealId: deal.dealId,
landlordPropertyId: deal.landlordPropertyId ?? undefined,
});
return { id: entry.id, uploadedId };
}),
);
const resultMap = new Map(
uploadResults.map((r, i) => [
toUpload[i].id,
r.status === "fulfilled" ? { ok: true, uploadedId: r.value.uploadedId } : { ok: false },
]),
);
setQueue((prev) =>
prev.map((f) => {
const r = resultMap.get(f.id);
if (!r) return f;
if (r.ok) return { ...f, status: "done", uploadedId: r.uploadedId };
return { ...f, status: "error", errorMsg: "Upload failed" };
}),
);
setIsUploading(false);
setPhase("classify");
}
// ── Phase 2: Classify ────────────────────────────────────────────────
function updateEntryField(id: string, field: "docType" | "measureName", value: string) {
setQueue((prev) => prev.map((f) => (f.id === id ? { ...f, [field]: value } : f)));
}
const classifiableEntries = queue.filter((f) => f.status === "done" && f.uploadedId);
const allClassified = classifiableEntries.length > 0 && classifiableEntries.every((f) => f.docType !== "");
async function handleSaveClassifications() {
setSaveError(null);
setIsSaving(true);
try {
await saveClassifications(
classifiableEntries.map((f) => ({
id: f.uploadedId!,
fileType: f.docType,
measureName: f.measureName || 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-xl max-h-[90vh] flex flex-col overflow-hidden">
<DialogHeader>
<DialogTitle>
{phase === "loading" ? "Loading…" :
phase === "upload" ? "Upload Documents" :
"Classify Documents"}
</DialogTitle>
<DialogDescription>
{phase === "loading" && "Checking for pending files…"}
{phase === "upload" && (
<>
Upload install documents for <strong>{propertyLabel}</strong>.
{existingCount > 0 && ` ${existingCount} file${existingCount !== 1 ? "s" : ""} are pending classification.`}
</>
)}
{phase === "classify" && (
<>
{classifiableEntries.length} file{classifiableEntries.length !== 1 ? "s" : ""} ready to classify.
Select a document type for each, then save.
</>
)}
</DialogDescription>
</DialogHeader>
<div className="flex-1 overflow-y-auto min-h-0 space-y-4 py-2">
{/* ── Loading ── */}
{phase === "loading" && (
<div className="flex items-center justify-center py-8">
<Loader2 className="h-5 w-5 animate-spin text-gray-400" />
</div>
)}
{/* ── Phase 1: Upload ── */}
{phase === "upload" && (
<>
{/* Existing unclassified banner */}
{existingCount > 0 && (
<div className="flex items-center gap-2 px-3 py-2.5 rounded-lg bg-amber-50 border border-amber-200 text-xs">
<Clock className="h-4 w-4 text-amber-500 shrink-0" />
<span className="text-amber-700">
<strong>{existingCount}</strong> previously uploaded file{existingCount !== 1 ? "s" : ""} {existingCount !== 1 ? "are" : "is"} waiting to be classified.
Add new files or go straight to classification.
</span>
</div>
)}
{/* Drop zone */}
<div
className={`border-2 border-dashed rounded-xl p-8 text-center cursor-pointer transition-colors ${
isDragOver ? "border-brandblue bg-brandlightblue/20" : "border-gray-200 hover:border-brandblue/40 hover:bg-gray-50"
}`}
onClick={() => fileInputRef.current?.click()}
onDragOver={(e) => { e.preventDefault(); setIsDragOver(true); }}
onDragLeave={() => setIsDragOver(false)}
onDrop={handleDrop}
>
<Upload className="h-6 w-6 text-gray-400 mx-auto mb-2" />
<p className="text-sm font-medium text-gray-600">Drop files here or click to browse</p>
<p className="text-xs text-gray-400 mt-1">PDF, JPG, PNG accepted · Multiple files OK</p>
<input
ref={fileInputRef}
type="file"
multiple
accept=".pdf,.jpg,.jpeg,.png"
className="hidden"
onChange={handleInputChange}
/>
</div>
{/* New file queue */}
{newQueuedCount > 0 && (
<div className="space-y-1">
{queue.filter((f) => f.file).map((entry) => (
<div key={entry.id} className="flex items-center gap-3 px-3 py-2 rounded-lg bg-gray-50 border border-gray-100">
<div className="flex-1 min-w-0">
<p className="text-xs font-medium text-gray-700 truncate">{entry.displayName}</p>
{entry.displaySize && <p className="text-[10px] text-gray-400">{entry.displaySize}</p>}
</div>
<StatusIcon status={entry.status} />
{entry.status === "queued" && (
<button
onClick={() => removeFile(entry.id)}
className="text-gray-300 hover:text-gray-500 text-lg leading-none shrink-0"
aria-label="Remove"
>
×
</button>
)}
</div>
))}
</div>
)}
</>
)}
{/* ── Phase 2: Classify ── */}
{phase === "classify" && (
<div className="space-y-3">
{/* Column headers */}
<div className="grid grid-cols-[1fr_180px_128px] gap-2 px-1">
<span className="text-[10px] font-semibold text-gray-400 uppercase tracking-wide">File</span>
<span className="text-[10px] font-semibold text-gray-400 uppercase tracking-wide">Document Type <span className="text-red-400">*</span></span>
<span className="text-[10px] font-semibold text-gray-400 uppercase tracking-wide">Measure</span>
</div>
{classifiableEntries.map((entry) => (
<div key={entry.id} className="grid grid-cols-[1fr_180px_128px] gap-2 items-center px-1">
<div className="flex items-center gap-2 min-w-0">
<StatusIcon status={entry.status} isExisting={!!entry.existingS3Key} />
<div className="min-w-0">
<p className="text-xs font-medium text-gray-700 truncate">{entry.displayName}</p>
{entry.displaySize && <p className="text-[10px] text-gray-400">{entry.displaySize}</p>}
{entry.existingS3Key && <p className="text-[10px] text-amber-500">Previously uploaded</p>}
</div>
</div>
<DocTypeSelect value={entry.docType} onChange={(v) => updateEntryField(entry.id, "docType", v)} />
{measures.length > 0 ? (
<Select value={entry.measureName} onValueChange={(v) => updateEntryField(entry.id, "measureName", v)}>
<SelectTrigger className="h-8 text-xs w-full">
<SelectValue placeholder="—" />
</SelectTrigger>
<SelectContent>
<SelectItem value="" className="text-xs text-gray-400"> None </SelectItem>
{measures.map((m) => (
<SelectItem key={m} value={m} className="text-xs">{m}</SelectItem>
))}
</SelectContent>
</Select>
) : (
<span className="text-xs text-gray-300"></span>
)}
</div>
))}
{/* Failed uploads (info only) */}
{queue.filter((f) => f.status === "error").length > 0 && (
<div className="p-3 rounded-lg bg-red-50 border border-red-200">
<p className="text-xs font-medium text-red-700 mb-1">
{queue.filter((f) => f.status === "error").length} file(s) failed and are excluded:
</p>
{queue.filter((f) => f.status === "error").map((f) => (
<p key={f.id} className="text-xs text-red-600">{f.displayName}</p>
))}
</div>
)}
{saveError && (
<p className="text-sm text-red-600 bg-red-50 border border-red-200 rounded-md px-3 py-2">
{saveError}
</p>
)}
</div>
)}
</div>
<DialogFooter className="pt-2 border-t border-gray-100 shrink-0">
{phase === "loading" && (
<Button variant="secondary" onClick={onClose}>Cancel</Button>
)}
{phase === "upload" && (
<>
<Button variant="secondary" onClick={onClose} disabled={isUploading}>Cancel</Button>
<Button
onClick={handleUpload}
disabled={isUploading || (newQueuedCount === 0 && existingCount === 0)}
className="bg-brandblue text-white gap-1.5"
>
{isUploading ? (
<><Loader2 className="h-3.5 w-3.5 animate-spin" /> Uploading</>
) : newQueuedCount > 0 ? (
<>Upload {newQueuedCount} file{newQueuedCount !== 1 ? "s" : ""} </>
) : (
<>Classify {existingCount} pending file{existingCount !== 1 ? "s" : ""} </>
)}
</Button>
</>
)}
{phase === "classify" && (
<>
<Button variant="secondary" onClick={onClose} disabled={isSaving}>
Skip for now
</Button>
<Button
onClick={handleSaveClassifications}
disabled={!allClassified || isSaving}
className="bg-brandblue text-white gap-1.5"
>
{isSaving ? (
<><Loader2 className="h-3.5 w-3.5 animate-spin" /> Saving</>
) : (
"Save Classifications →"
)}
</Button>
</>
)}
</DialogFooter>
</DialogContent>
</Dialog>
);
}

View file

@ -28,7 +28,8 @@ import {
} from "@/app/shadcn_components/ui/select";
import { Search, ChevronLeft, ChevronRight, Download } from "lucide-react";
import { createDocumentTableColumns } from "./DocumentTableColumns";
import type { ClassifiedDeal, DocStatusMap } from "./types";
import ContractorUploadModal from "./ContractorUploadModal";
import type { ClassifiedDeal, DocStatusMap, PortfolioCapabilityType } from "./types";
type SurveyStatusFilter = "all" | "none" | "partial" | "complete";
@ -36,6 +37,8 @@ interface DocumentTableProps {
data: ClassifiedDeal[];
onOpenDrawer: (uprn: string | null, landlordPropertyId: string | null, dealname: string | null) => void;
docStatusMap: DocStatusMap;
portfolioId: string;
userCapability: PortfolioCapabilityType;
}
function escapeCell(value: unknown): string {
@ -49,7 +52,7 @@ function escapeCell(value: unknown): string {
: str;
}
export default function DocumentTable({ data, onOpenDrawer, docStatusMap }: DocumentTableProps) {
export default function DocumentTable({ data, onOpenDrawer, docStatusMap, portfolioId, userCapability }: DocumentTableProps) {
const [globalFilter, setGlobalFilter] = useState("");
const [surveyStatusFilter, setSurveyStatusFilter] = useState<SurveyStatusFilter>("all");
const [sorting, setSorting] = useState<SortingState>([]);
@ -57,6 +60,7 @@ export default function DocumentTable({ data, onOpenDrawer, docStatusMap }: Docu
pageIndex: 0,
pageSize: 25,
});
const [uploadDeal, setUploadDeal] = useState<ClassifiedDeal | null>(null);
const filteredData = useMemo(() => {
if (surveyStatusFilter === "all") return data;
@ -70,8 +74,12 @@ export default function DocumentTable({ data, onOpenDrawer, docStatusMap }: Docu
}, [data, surveyStatusFilter, docStatusMap]);
const columns = useMemo(
() => createDocumentTableColumns(onOpenDrawer, docStatusMap),
[onOpenDrawer, docStatusMap],
() => createDocumentTableColumns(
onOpenDrawer,
docStatusMap,
userCapability.includes("contractor") ? setUploadDeal : undefined,
),
[onOpenDrawer, docStatusMap, userCapability],
);
const table = useReactTable({
@ -239,6 +247,15 @@ export default function DocumentTable({ data, onOpenDrawer, docStatusMap }: Docu
</div>
</div>
{/* Contractor upload modal */}
{uploadDeal && (
<ContractorUploadModal
deal={uploadDeal}
portfolioId={portfolioId}
onClose={() => setUploadDeal(null)}
/>
)}
{/* Pagination */}
{pageCount > 1 && (
<div className="flex items-center justify-between pt-1">

View file

@ -1,7 +1,7 @@
"use client";
import { ColumnDef } from "@tanstack/react-table";
import { ArrowUpDown, CheckCircle2, AlertCircle, FileX } from "lucide-react";
import { ArrowUpDown, CheckCircle2, AlertCircle, FileX, Upload } from "lucide-react";
import type { ClassifiedDeal, DocStatusMap, DocStatus } from "./types";
function SortableHeader({
@ -50,6 +50,7 @@ function SurveyStatusBadge({ status }: { status: DocStatus | undefined }) {
export function createDocumentTableColumns(
onOpenDrawer: (uprn: string | null, landlordPropertyId: string | null, dealname: string | null) => void,
docStatusMap: DocStatusMap = {},
onUpload?: (deal: ClassifiedDeal) => void,
): ColumnDef<ClassifiedDeal>[] {
return [
// ── Address ──────────────────────────────────────────────────────────
@ -143,5 +144,24 @@ export function createDocumentTableColumns(
enableSorting: false,
enableHiding: false,
},
// ── Upload button (contractor only) ──────────────────────────────────
...(onUpload ? [{
id: "upload",
header: () => (
<span className="text-xs font-semibold uppercase tracking-wide text-gray-500">Upload</span>
),
cell: ({ row }: { row: { original: ClassifiedDeal } }) => (
<button
onClick={() => onUpload(row.original)}
className="inline-flex items-center gap-1.5 px-2.5 py-1.5 rounded-lg text-xs font-medium border border-brandblue/20 text-brandblue bg-brandlightblue/20 hover:bg-brandlightblue/40 hover:border-brandblue/40 transition-all duration-150 whitespace-nowrap"
>
<Upload className="h-3.5 w-3.5" />
Upload Docs
</button>
),
enableSorting: false,
enableHiding: false,
} as ColumnDef<ClassifiedDeal>] : []),
];
}

View file

@ -9,10 +9,11 @@ import {
TabsTrigger,
} from "@/app/shadcn_components/ui/tabs";
import { Card, CardContent } from "@/app/shadcn_components/ui/card";
import { BarChart2, Table2, FolderOpen } from "lucide-react";
import { BarChart2, Table2, FolderOpen, Wrench } from "lucide-react";
import DrillDownTable from "./DrillDownTable";
import PropertyTable from "./PropertyTable";
import DocumentTable from "./DocumentTable";
import MeasuresTable from "./MeasuresTable";
import type { HubspotDeal } from "./types";
import PropertyDrawer from "./PropertyDrawer";
import PropertyDetailDrawer from "./PropertyDetailDrawer";
@ -30,9 +31,12 @@ export default function LiveTracker({
totalDeals,
majorConditionDeals,
docStatusMap,
userCapability,
approvalsByDeal,
portfolioId,
}: LiveTrackerProps) {
// ── Tab state ────────────────────────────────────────────────────────
const [activeTab, setActiveTab] = useState<"analytics" | "properties" | "documents">(
const [activeTab, setActiveTab] = useState<"analytics" | "properties" | "documents" | "measures">(
"analytics",
);
@ -94,7 +98,7 @@ export default function LiveTracker({
<div className="space-y-4 w-full">
<Tabs
value={activeTab}
onValueChange={(v) => setActiveTab(v as "analytics" | "properties" | "documents")}
onValueChange={(v) => setActiveTab(v as "analytics" | "properties" | "documents" | "measures")}
>
{/* Tab bar */}
<TabsList className="h-10 p-1 bg-brandlightblue/10 border border-brandblue/10 rounded-xl mb-6">
@ -119,6 +123,13 @@ export default function LiveTracker({
<FolderOpen className="h-3.5 w-3.5" />
Document Management
</TabsTrigger>
<TabsTrigger
value="measures"
className="flex items-center gap-2 rounded-lg text-sm font-medium px-4 data-[state=active]:bg-white data-[state=active]:text-brandblue data-[state=active]:shadow-sm transition-all"
>
<Wrench className="h-3.5 w-3.5" />
Measures
</TabsTrigger>
</TabsList>
{/* Analytics tab */}
@ -204,6 +215,42 @@ export default function LiveTracker({
data={currentProject?.allDeals ?? []}
onOpenDrawer={handleOpenDrawer}
docStatusMap={docStatusMap}
portfolioId={portfolioId}
userCapability={userCapability}
/>
</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>
@ -311,6 +358,7 @@ export default function LiveTracker({
{/* ── Property detail drawer ─────────────────────────────────────── */}
<PropertyDetailDrawer
deal={detailDeal}
portfolioId={portfolioId}
onClose={() => setDetailDeal(null)}
/>
</div>

View file

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

View file

@ -1,7 +1,9 @@
"use client";
import { useState } from "react";
import { useQuery } from "@tanstack/react-query";
import { motion, AnimatePresence } from "framer-motion";
import { X, CheckCircle2, Circle, AlertTriangle } from "lucide-react";
import { X, CheckCircle2, Circle, AlertTriangle, ChevronRight, ChevronDown } from "lucide-react";
import {
Drawer,
DrawerClose,
@ -13,6 +15,84 @@ import {
import { STAGE_COLORS } from "./types";
import type { ClassifiedDeal } from "./types";
// -----------------------------------------------------------------------
// Approval log types + helpers
// -----------------------------------------------------------------------
type AuditEvent = {
id: string;
measureName: string;
action: string; // 'approved' | 'unapproved'
actedByEmail: string;
actedByName: string | null;
actedAt: string;
};
function formatDateTime(iso: string) {
return new Date(iso).toLocaleString("en-GB", {
day: "numeric",
month: "short",
year: "numeric",
hour: "2-digit",
minute: "2-digit",
});
}
function ApprovalLogSection({
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">Loading activity</p>;
}
const events = data?.events ?? [];
if (events.length === 0) {
return (
<p className="text-xs text-gray-400 py-2">No approval activity yet.</p>
);
}
return (
<div className="space-y-2 pt-1">
{events.map((e) => (
<div key={e.id} className="flex items-start gap-2 text-xs">
<span
className={`mt-0.5 shrink-0 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>
<div className="min-w-0">
<span className="font-medium text-gray-700">{e.measureName}</span>
<div className="text-gray-400 mt-0.5">
{e.actedByName ?? e.actedByEmail} · {formatDateTime(e.actedAt)}
</div>
</div>
</div>
))}
</div>
);
}
// -----------------------------------------------------------------------
// Milestone definitions — ordered pipeline steps with their date fields
// -----------------------------------------------------------------------
@ -141,14 +221,16 @@ function MilestoneTimeline({ deal }: { deal: ClassifiedDeal }) {
// -----------------------------------------------------------------------
interface PropertyDetailDrawerProps {
deal: ClassifiedDeal | null;
portfolioId: string;
onClose: () => void;
}
export default function PropertyDetailDrawer({ deal, onClose }: PropertyDetailDrawerProps) {
export default function PropertyDetailDrawer({ deal, portfolioId, onClose }: PropertyDetailDrawerProps) {
const open = !!deal;
const [isLogOpen, setIsLogOpen] = useState(false);
return (
<Drawer open={open} onOpenChange={(v) => !v && onClose()} direction="right">
<Drawer open={open} onOpenChange={(v) => { if (!v) { setIsLogOpen(false); onClose(); } }} direction="right">
<DrawerContent className="fixed right-0 top-0 bottom-0 h-full w-[42vw] min-w-80 max-w-lg rounded-l-2xl rounded-r-none mt-0 flex flex-col border-l border-t-0 border-b-0 border-r-0 border-brandblue/10 bg-white shadow-2xl overflow-hidden">
<div className="hidden" />
@ -255,6 +337,28 @@ 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>
{/* Approval log — collapsible */}
<div className="border-t border-gray-100 pt-4">
<button
onClick={() => setIsLogOpen((v) => !v)}
className="flex items-center gap-2 w-full text-left group"
>
{isLogOpen ? (
<ChevronDown className="h-3.5 w-3.5 text-gray-400 group-hover:text-brandblue transition-colors shrink-0" />
) : (
<ChevronRight className="h-3.5 w-3.5 text-gray-400 group-hover:text-brandblue transition-colors shrink-0" />
)}
<h3 className="text-xs font-bold uppercase tracking-wider text-gray-400 group-hover:text-brandblue transition-colors">
Approval Log
</h3>
</button>
{isLogOpen && (
<div className="mt-3">
<ApprovalLogSection dealId={deal.dealId} portfolioId={portfolioId} />
</div>
)}
</div>
</div>
{/* Footer */}

View file

@ -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 { and, eq, inArray } from "drizzle-orm";
import LiveTracker from "./LiveTracker";
import { computeLiveTrackerData } from "./transforms";
import { db } from "@/app/db/db";
@ -9,7 +9,10 @@ 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 { portfolioCapabilities } from "@/app/db/schema/portfolio";
import { dealMeasureApprovals } from "@/app/db/schema/approvals";
import { user as userTable } from "@/app/db/schema/users";
import type { HubspotDeal, DocStatusMap, DocStatus, PortfolioCapabilityType, ApprovalsByDeal } from "./types";
import { EXPECTED_SURVEY_DOC_TYPES } from "./types";
import type { InferSelectModel } from "drizzle-orm";
import { Card, CardContent } from "@/app/shadcn_components/ui/card";
@ -120,6 +123,54 @@ export default async function LiveReportingPage(props: {
const deals = rawDeals.map(mapDbRowToHubspotDeal);
const trackerData = computeLiveTrackerData(deals);
// Fetch current user's portfolio capabilities (approver / contractor — can have both)
let userCapability: PortfolioCapabilityType = [];
const userEmail = user?.user?.email;
if (userEmail) {
const userRow = await db
.select({ id: userTable.id })
.from(userTable)
.where(eq(userTable.email, userEmail))
.limit(1);
if (userRow[0]) {
const capRows = await db
.select({ capability: portfolioCapabilities.capability })
.from(portfolioCapabilities)
.where(
and(
eq(portfolioCapabilities.portfolioId, BigInt(portfolioId)),
eq(portfolioCapabilities.userId, userRow[0].id),
),
);
userCapability = capRows
.map((r) => r.capability)
.filter((c): c is "approver" | "contractor" => c === "approver" || c === "contractor");
}
}
// Fetch currently approved measures for all deals in scope
const approvalsByDeal: ApprovalsByDeal = {};
const dealIds = deals.map((d) => d.dealId).filter(Boolean);
if (dealIds.length > 0) {
const approvalRows = await db
.select({
hubspotDealId: dealMeasureApprovals.hubspotDealId,
measureName: dealMeasureApprovals.measureName,
})
.from(dealMeasureApprovals)
.where(
and(
inArray(dealMeasureApprovals.hubspotDealId, dealIds),
eq(dealMeasureApprovals.isApproved, true),
),
);
for (const row of approvalRows) {
(approvalsByDeal[row.hubspotDealId] ??= []).push(row.measureName);
}
}
// Fetch survey document status for all properties
const uprnList = deals
.map((d) => d.uprn)
@ -158,7 +209,13 @@ export default async function LiveReportingPage(props: {
return (
<div className="max-w-7xl mx-auto px-6 pb-10 space-y-4">
{pageHeader}
<LiveTracker {...trackerData} docStatusMap={docStatusMap} />
<LiveTracker
{...trackerData}
docStatusMap={docStatusMap}
userCapability={userCapability}
approvalsByDeal={approvalsByDeal}
portfolioId={portfolioId}
/>
</div>
);
}

View file

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

View file

@ -161,6 +161,14 @@ 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[]>;
// -----------------------------------------------------------------------
// Top-level props for LiveTracker (client root)
// -----------------------------------------------------------------------
@ -169,6 +177,9 @@ export type LiveTrackerProps = {
totalDeals: number;
majorConditionDeals: ClassifiedDeal[]; // for Awaab's Law card
docStatusMap: DocStatusMap;
userCapability: PortfolioCapabilityType;
approvalsByDeal: ApprovalsByDeal;
portfolioId: string;
};
// -----------------------------------------------------------------------