Merge branch 'main' into feature/add_rc_comments

This commit is contained in:
Jun-te Kim 2026-04-08 14:32:20 +00:00
commit b2cd473c9d
49 changed files with 18994 additions and 1767 deletions

View file

@ -0,0 +1,56 @@
import { NextResponse } from "next/server";
import { eq } from "drizzle-orm";
import { db } from "@/app/db/db";
import { uploadedFiles } from "@/app/db/schema/uploaded_files";
export async function GET(req: Request) {
const { searchParams } = new URL(req.url);
const uprnParam = searchParams.get("uprn");
const landlordPropertyIdParam = searchParams.get("landlordPropertyId");
if (!uprnParam && !landlordPropertyIdParam) {
return NextResponse.json(
{ error: "uprn or landlordPropertyId is required" },
{ status: 400 },
);
}
try {
// Prefer UPRN — it's more selective and avoids an OR full-table scan.
// Only fall back to landlordPropertyId when no UPRN is available.
const condition = uprnParam
? eq(uploadedFiles.uprn, BigInt(uprnParam))
: eq(uploadedFiles.landlordPropertyId, landlordPropertyIdParam!);
const rows = await db
.select({
id: uploadedFiles.id,
s3FileKey: uploadedFiles.s3FileKey,
s3FileBucket: uploadedFiles.s3FileBucket,
s3UploadTimestamp: uploadedFiles.s3UploadTimestamp,
fileType: uploadedFiles.fileType,
uprn: uploadedFiles.uprn,
landlordPropertyId: uploadedFiles.landlordPropertyId,
})
.from(uploadedFiles)
.where(condition);
const documents = rows.map((row) => ({
id: String(row.id),
s3FileKey: row.s3FileKey,
s3FileBucket: row.s3FileBucket,
docType: row.fileType ?? "unknown",
s3UploadTimestamp: row.s3UploadTimestamp.toISOString(),
uprn: row.uprn !== null ? String(row.uprn) : null,
landlordPropertyId: row.landlordPropertyId,
}));
return NextResponse.json(documents);
} catch (error) {
console.error("Error fetching property documents:", error);
return NextResponse.json(
{ error: "Failed to fetch documents" },
{ status: 500 },
);
}
}

View file

@ -0,0 +1,24 @@
import { NextRequest, NextResponse } from "next/server";
import { getServerSession } from "next-auth";
import { AuthOptions } from "@/app/api/auth/[...nextauth]/authOptions";
import { db } from "@/app/db/db";
import { organisation } from "@/app/db/schema/organisation";
import { asc } from "drizzle-orm";
export async function GET(_req: NextRequest) {
const session = await getServerSession(AuthOptions);
if (!session?.user?.email?.endsWith("@domna.homes")) {
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
}
const rows = await db
.select({
id: organisation.id,
name: organisation.name,
hubspotCompanyId: organisation.hubspotCompanyId,
})
.from(organisation)
.orderBy(asc(organisation.name));
return NextResponse.json(rows);
}

View file

@ -0,0 +1,94 @@
import { NextRequest, NextResponse } from "next/server";
import { getServerSession } from "next-auth";
import { AuthOptions } from "@/app/api/auth/[...nextauth]/authOptions";
import { eq } from "drizzle-orm";
import { db } from "@/app/db/db";
import { portfolioOrganisation } from "@/app/db/schema/portfolio_organisation";
import { organisation } from "@/app/db/schema/organisation";
function isDomnaUser(email: string | null | undefined): boolean {
return !!email?.endsWith("@domna.homes");
}
// GET — fetch the current linked organisation for this portfolio
export async function GET(
_req: NextRequest,
{ params }: { params: Promise<{ portfolioId: string }> },
) {
const { portfolioId } = await params;
const rows = await db
.select({
id: organisation.id,
name: organisation.name,
hubspotCompanyId: organisation.hubspotCompanyId,
})
.from(portfolioOrganisation)
.innerJoin(organisation, eq(portfolioOrganisation.organisationId, organisation.id))
.where(eq(portfolioOrganisation.portfolioId, BigInt(portfolioId)))
.limit(1);
return NextResponse.json(rows[0] ?? null);
}
// POST — connect an organisation to this portfolio (Domna only)
export async function POST(
req: NextRequest,
{ params }: { params: Promise<{ portfolioId: string }> },
) {
const session = await getServerSession(AuthOptions);
if (!isDomnaUser(session?.user?.email)) {
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
}
const { portfolioId } = await params;
const body = await req.json();
const { organisationId } = body as { organisationId: string };
if (!organisationId) {
return NextResponse.json({ error: "organisationId required" }, { status: 400 });
}
// Upsert: delete any existing link then insert fresh
await db
.delete(portfolioOrganisation)
.where(eq(portfolioOrganisation.portfolioId, BigInt(portfolioId)));
await db.insert(portfolioOrganisation).values({
portfolioId: BigInt(portfolioId),
organisationId,
});
// Return the newly linked org
const rows = await db
.select({
id: organisation.id,
name: organisation.name,
hubspotCompanyId: organisation.hubspotCompanyId,
})
.from(portfolioOrganisation)
.innerJoin(organisation, eq(portfolioOrganisation.organisationId, organisation.id))
.where(eq(portfolioOrganisation.portfolioId, BigInt(portfolioId)))
.limit(1);
return NextResponse.json(rows[0] ?? null);
}
// DELETE — disconnect the organisation from this portfolio (Domna only)
export async function DELETE(
_req: NextRequest,
{ params }: { params: Promise<{ portfolioId: string }> },
) {
const session = await getServerSession(AuthOptions);
if (!isDomnaUser(session?.user?.email)) {
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
}
const { portfolioId } = await params;
await db
.delete(portfolioOrganisation)
.where(eq(portfolioOrganisation.portfolioId, BigInt(portfolioId)));
return NextResponse.json({ success: true });
}

View file

@ -0,0 +1,53 @@
import { db } from "@/app/db/db";
import { tasks } from "@/app/db/schema/tasks/tasks";
import { subTasks } from "@/app/db/schema/tasks/subtask";
import { eq, desc, count, sql } from "drizzle-orm";
import { NextRequest, NextResponse } from "next/server";
export async function GET(
request: NextRequest,
{ params }: { params: Promise<{ portfolioId: string }> }
) {
try {
const { portfolioId } = await params;
const { searchParams } = new URL(request.url);
const limit = parseInt(searchParams.get("limit") || "20");
const offset = parseInt(searchParams.get("offset") || "0");
const rows = await db
.select({
id: tasks.id,
taskSource: tasks.taskSource,
jobStarted: tasks.jobStarted,
jobCompleted: tasks.jobCompleted,
status: tasks.status,
service: tasks.service,
updatedAt: tasks.updatedAt,
totalSubtasks: count(subTasks.id),
completedSubtasks: sql<number>`count(case when lower(${subTasks.status}) in ('completed', 'complete') then 1 end)::int`,
failedSubtasks: sql<number>`count(case when lower(${subTasks.status}) in ('failed', 'failure', 'error') then 1 end)::int`,
})
.from(tasks)
.leftJoin(subTasks, eq(subTasks.taskId, tasks.id))
.where(eq(tasks.sourceId, portfolioId))
.groupBy(tasks.id)
.orderBy(desc(tasks.updatedAt))
.limit(limit)
.offset(offset);
const countResult = await db
.select({ count: count() })
.from(tasks)
.where(eq(tasks.sourceId, portfolioId));
const total = countResult[0].count;
return NextResponse.json({ tasks: rows, total, limit, offset });
} catch (error) {
console.error("Error fetching portfolio tasks:", error);
return NextResponse.json(
{ error: "Failed to fetch portfolio tasks" },
{ status: 500 }
);
}
}

View file

@ -0,0 +1,38 @@
import { NextResponse } from "next/server";
import { S3Client, GetObjectCommand } from "@aws-sdk/client-s3";
import { getSignedUrl } from "@aws-sdk/s3-request-presigner";
const energyAssessmentsS3 = new S3Client({
region: process.env.PRESIGN_AWS_REGION,
credentials: {
accessKeyId: process.env.RETROFIT_ENERGY_ASSESSMENTS_AWS_ACCESS_KEY!,
secretAccessKey: process.env.ENERGY_ASSESSMENTS_AWS_SECRET!,
},
});
const retrofitDataS3 = new S3Client({
region: process.env.RETROFIT_DATA_DEV_REGION,
credentials: {
accessKeyId: process.env.RETROFIT_DATA_DEV_ACCESS_KEY!,
secretAccessKey: process.env.RETROFIT_DATA_DEV_SECRET_KEY!,
},
});
export async function POST(req: Request) {
try {
const { key, bucket } = await req.json();
if (!key || !bucket)
return NextResponse.json({ error: "Missing key or bucket" }, { status: 400 });
const isEnergyAssessments = bucket === process.env.RETROFIT_ENERGY_ASSESSMENTS_BUCKET;
const s3Client = isEnergyAssessments ? energyAssessmentsS3 : retrofitDataS3;
const command = new GetObjectCommand({ Bucket: bucket, Key: key });
const signedUrl = await getSignedUrl(s3Client, command, { expiresIn: 1800 });
return NextResponse.json({ url: signedUrl });
} catch (error) {
console.error("Error generating signed URL:", error);
return NextResponse.json({ error: "Failed to sign URL" }, { status: 500 });
}
}

View file

@ -0,0 +1,26 @@
import { db } from "@/app/db/db";
import { subTasks } from "@/app/db/schema/tasks/subtask";
import { eq } from "drizzle-orm";
import { NextRequest, NextResponse } from "next/server";
export async function GET(
request: NextRequest,
{ params }: { params: Promise<{ taskId: string }> }
) {
try {
const { taskId } = await params;
const taskSubTasks = await db
.select()
.from(subTasks)
.where(eq(subTasks.taskId, taskId))
.orderBy(subTasks.updatedAt);
return NextResponse.json(taskSubTasks);
} catch (error) {
console.error("Error fetching subtasks:", error);
return NextResponse.json(
{ error: "Failed to fetch subtasks" },
{ status: 500 }
);
}
}

View file

@ -0,0 +1,37 @@
import { db } from "@/app/db/db";
import { tasks } from "@/app/db/schema/tasks/tasks";
import { desc, count } from "drizzle-orm";
import { NextRequest, NextResponse } from "next/server";
export async function GET(request: NextRequest) {
try {
const { searchParams } = new URL(request.url);
const limit = parseInt(searchParams.get("limit") || "20");
const offset = parseInt(searchParams.get("offset") || "0");
const allTasks = await db
.select()
.from(tasks)
.orderBy(desc(tasks.updatedAt))
.limit(limit)
.offset(offset);
const countResult = await db
.select({ count: count() })
.from(tasks);
const total = countResult[0].count;
return NextResponse.json({
tasks: allTasks,
total,
limit,
offset,
});
} catch (error) {
console.error("Error fetching tasks:", error);
return NextResponse.json(
{ error: "Failed to fetch tasks" },
{ status: 500 }
);
}
}

View file

@ -10,6 +10,11 @@ import * as EnergyAssessmentsSchema from "@/app/db/schema/energy_assessments";
import * as FundingSchema from "@/app/db/schema/funding";
import * as Relations from "@/app/db/schema/relations";
import * as Users from "@/app/db/schema/users";
import { tasks } from "@/app/db/schema/tasks/tasks";
import { subTasks } from "@/app/db/schema/tasks/subtask";
import * as CrmSchema from "@/app/db/schema/crm/hubspot_deal_table";
import * as UploadedFilesSchema from "@/app/db/schema/uploaded_files";
import * as PortfolioOrgSchema from "@/app/db/schema/portfolio_organisation";
export const pool = new Pool({
host: process.env.DB_HOST,
@ -31,6 +36,11 @@ const schema = {
...EnergyAssessmentsSchema,
...FundingSchema,
...Users,
tasks,
subTasks,
...CrmSchema,
...UploadedFilesSchema,
...PortfolioOrgSchema,
};
export const db = drizzle(pool, {

View file

@ -0,0 +1,11 @@
CREATE TABLE "portfolio_organisation" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"portfolio_id" bigint NOT NULL,
"organisation_id" uuid 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_organisation_portfolio_id_unique" UNIQUE("portfolio_id")
);
--> statement-breakpoint
ALTER TABLE "portfolio_organisation" ADD CONSTRAINT "portfolio_organisation_portfolio_id_portfolio_id_fk" FOREIGN KEY ("portfolio_id") REFERENCES "public"."portfolio"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "portfolio_organisation" ADD CONSTRAINT "portfolio_organisation_organisation_id_organisation_id_fk" FOREIGN KEY ("organisation_id") REFERENCES "public"."organisation"("id") ON DELETE cascade ON UPDATE no action;

View file

@ -0,0 +1,3 @@
ALTER TYPE "public"."file_source" ADD VALUE 'ecmk';--> statement-breakpoint
ALTER TYPE "public"."file_type" ADD VALUE 'ecmk_site_note';--> statement-breakpoint
ALTER TYPE "public"."file_type" ADD VALUE 'ecmk_rd_sap_site_note';

View file

@ -48,9 +48,7 @@
"postcode_search_postcode_unique": {
"name": "postcode_search_postcode_unique",
"nullsNotDistinct": false,
"columns": [
"postcode"
]
"columns": ["postcode"]
}
},
"policies": {},
@ -123,12 +121,8 @@
"name": "aspect_condition_element_id_element_id_fk",
"tableFrom": "aspect_condition",
"tableTo": "element",
"columnsFrom": [
"element_id"
],
"columnsTo": [
"id"
],
"columnsFrom": ["element_id"],
"columnsTo": ["id"],
"onDelete": "no action",
"onUpdate": "no action"
}
@ -175,12 +169,8 @@
"name": "element_survey_id_property_condition_survey_id_fk",
"tableFrom": "element",
"tableTo": "property_condition_survey",
"columnsFrom": [
"survey_id"
],
"columnsTo": [
"id"
],
"columnsFrom": ["survey_id"],
"columnsTo": ["id"],
"onDelete": "no action",
"onUpdate": "no action"
}
@ -604,12 +594,8 @@
"name": "property_status_tracker_property_id_property_id_fk",
"tableFrom": "property_status_tracker",
"tableTo": "property",
"columnsFrom": [
"property_id"
],
"columnsTo": [
"id"
],
"columnsFrom": ["property_id"],
"columnsTo": ["id"],
"onDelete": "cascade",
"onUpdate": "no action"
},
@ -617,12 +603,8 @@
"name": "property_status_tracker_portfolio_id_portfolio_id_fk",
"tableFrom": "property_status_tracker",
"tableTo": "portfolio",
"columnsFrom": [
"portfolio_id"
],
"columnsTo": [
"id"
],
"columnsFrom": ["portfolio_id"],
"columnsTo": ["id"],
"onDelete": "cascade",
"onUpdate": "no action"
}
@ -1397,12 +1379,8 @@
"name": "energy_assessment_documents_energy_assessment_id_energy_assessments_id_fk",
"tableFrom": "energy_assessment_documents",
"tableTo": "energy_assessments",
"columnsFrom": [
"energy_assessment_id"
],
"columnsTo": [
"id"
],
"columnsFrom": ["energy_assessment_id"],
"columnsTo": ["id"],
"onDelete": "no action",
"onUpdate": "no action"
},
@ -1410,12 +1388,8 @@
"name": "energy_assessment_documents_scenario_id_energy_assessment_scenarios_id_fk",
"tableFrom": "energy_assessment_documents",
"tableTo": "energy_assessment_scenarios",
"columnsFrom": [
"scenario_id"
],
"columnsTo": [
"id"
],
"columnsFrom": ["scenario_id"],
"columnsTo": ["id"],
"onDelete": "no action",
"onUpdate": "no action"
}
@ -1455,12 +1429,8 @@
"name": "energy_assessment_scenarios_energy_assessment_id_energy_assessments_id_fk",
"tableFrom": "energy_assessment_scenarios",
"tableTo": "energy_assessments",
"columnsFrom": [
"energy_assessment_id"
],
"columnsTo": [
"id"
],
"columnsFrom": ["energy_assessment_id"],
"columnsTo": ["id"],
"onDelete": "no action",
"onUpdate": "no action"
}
@ -1585,12 +1555,8 @@
"name": "files_from_surveyor_portfolio_id_portfolio_id_fk",
"tableFrom": "files_from_surveyor",
"tableTo": "portfolio",
"columnsFrom": [
"portfolio_id"
],
"columnsTo": [
"id"
],
"columnsFrom": ["portfolio_id"],
"columnsTo": ["id"],
"onDelete": "no action",
"onUpdate": "no action"
},
@ -1598,12 +1564,8 @@
"name": "files_from_surveyor_property_id_property_id_fk",
"tableFrom": "files_from_surveyor",
"tableTo": "property",
"columnsFrom": [
"property_id"
],
"columnsTo": [
"id"
],
"columnsFrom": ["property_id"],
"columnsTo": ["id"],
"onDelete": "no action",
"onUpdate": "no action"
}
@ -1681,12 +1643,8 @@
"name": "funding_package_plan_id_plan_id_fk",
"tableFrom": "funding_package",
"tableTo": "plan",
"columnsFrom": [
"plan_id"
],
"columnsTo": [
"id"
],
"columnsFrom": ["plan_id"],
"columnsTo": ["id"],
"onDelete": "no action",
"onUpdate": "no action"
}
@ -1751,12 +1709,8 @@
"name": "funding_package_measures_funding_package_id_funding_package_id_fk",
"tableFrom": "funding_package_measures",
"tableTo": "funding_package",
"columnsFrom": [
"funding_package_id"
],
"columnsTo": [
"id"
],
"columnsFrom": ["funding_package_id"],
"columnsTo": ["id"],
"onDelete": "no action",
"onUpdate": "no action"
},
@ -1764,12 +1718,8 @@
"name": "funding_package_measures_material_id_material_id_fk",
"tableFrom": "funding_package_measures",
"tableTo": "material",
"columnsFrom": [
"material_id"
],
"columnsTo": [
"id"
],
"columnsFrom": ["material_id"],
"columnsTo": ["id"],
"onDelete": "no action",
"onUpdate": "no action"
}
@ -1905,12 +1855,8 @@
"name": "inspections_property_id_property_id_fk",
"tableFrom": "inspections",
"tableTo": "property",
"columnsFrom": [
"property_id"
],
"columnsTo": [
"id"
],
"columnsFrom": ["property_id"],
"columnsTo": ["id"],
"onDelete": "no action",
"onUpdate": "no action"
}
@ -2407,12 +2353,8 @@
"name": "portfolioUsers_user_id_user_id_fk",
"tableFrom": "portfolioUsers",
"tableTo": "user",
"columnsFrom": [
"user_id"
],
"columnsTo": [
"id"
],
"columnsFrom": ["user_id"],
"columnsTo": ["id"],
"onDelete": "no action",
"onUpdate": "no action"
},
@ -2420,12 +2362,8 @@
"name": "portfolioUsers_portfolio_id_portfolio_id_fk",
"tableFrom": "portfolioUsers",
"tableTo": "portfolio",
"columnsFrom": [
"portfolio_id"
],
"columnsTo": [
"id"
],
"columnsFrom": ["portfolio_id"],
"columnsTo": ["id"],
"onDelete": "no action",
"onUpdate": "no action"
}
@ -2508,12 +2446,8 @@
"name": "non_intrusive_survey_notes_survey_id_non_intrusive_survey_id_fk",
"tableFrom": "non_intrusive_survey_notes",
"tableTo": "non_intrusive_survey",
"columnsFrom": [
"survey_id"
],
"columnsTo": [
"id"
],
"columnsFrom": ["survey_id"],
"columnsTo": ["id"],
"onDelete": "no action",
"onUpdate": "no action"
}
@ -2733,12 +2667,8 @@
"name": "property_portfolio_id_portfolio_id_fk",
"tableFrom": "property",
"tableTo": "portfolio",
"columnsFrom": [
"portfolio_id"
],
"columnsTo": [
"id"
],
"columnsFrom": ["portfolio_id"],
"columnsTo": ["id"],
"onDelete": "no action",
"onUpdate": "no action"
}
@ -3165,12 +3095,8 @@
"name": "property_details_epc_property_id_property_id_fk",
"tableFrom": "property_details_epc",
"tableTo": "property",
"columnsFrom": [
"property_id"
],
"columnsTo": [
"id"
],
"columnsFrom": ["property_id"],
"columnsTo": ["id"],
"onDelete": "no action",
"onUpdate": "no action"
},
@ -3178,12 +3104,8 @@
"name": "property_details_epc_portfolio_id_portfolio_id_fk",
"tableFrom": "property_details_epc",
"tableTo": "portfolio",
"columnsFrom": [
"portfolio_id"
],
"columnsTo": [
"id"
],
"columnsFrom": ["portfolio_id"],
"columnsTo": ["id"],
"onDelete": "no action",
"onUpdate": "no action"
}
@ -3381,12 +3303,8 @@
"name": "property_targets_property_id_property_id_fk",
"tableFrom": "property_targets",
"tableTo": "property",
"columnsFrom": [
"property_id"
],
"columnsTo": [
"id"
],
"columnsFrom": ["property_id"],
"columnsTo": ["id"],
"onDelete": "no action",
"onUpdate": "no action"
},
@ -3394,12 +3312,8 @@
"name": "property_targets_portfolio_id_portfolio_id_fk",
"tableFrom": "property_targets",
"tableTo": "portfolio",
"columnsFrom": [
"portfolio_id"
],
"columnsTo": [
"id"
],
"columnsFrom": ["portfolio_id"],
"columnsTo": ["id"],
"onDelete": "no action",
"onUpdate": "no action"
}
@ -3768,12 +3682,8 @@
"name": "plan_portfolio_id_portfolio_id_fk",
"tableFrom": "plan",
"tableTo": "portfolio",
"columnsFrom": [
"portfolio_id"
],
"columnsTo": [
"id"
],
"columnsFrom": ["portfolio_id"],
"columnsTo": ["id"],
"onDelete": "no action",
"onUpdate": "no action"
},
@ -3781,12 +3691,8 @@
"name": "plan_property_id_property_id_fk",
"tableFrom": "plan",
"tableTo": "property",
"columnsFrom": [
"property_id"
],
"columnsTo": [
"id"
],
"columnsFrom": ["property_id"],
"columnsTo": ["id"],
"onDelete": "no action",
"onUpdate": "no action"
},
@ -3794,12 +3700,8 @@
"name": "plan_scenario_id_scenario_id_fk",
"tableFrom": "plan",
"tableTo": "scenario",
"columnsFrom": [
"scenario_id"
],
"columnsTo": [
"id"
],
"columnsFrom": ["scenario_id"],
"columnsTo": ["id"],
"onDelete": "no action",
"onUpdate": "no action"
}
@ -3876,12 +3778,8 @@
"name": "plan_recommendations_plan_id_plan_id_fk",
"tableFrom": "plan_recommendations",
"tableTo": "plan",
"columnsFrom": [
"plan_id"
],
"columnsTo": [
"id"
],
"columnsFrom": ["plan_id"],
"columnsTo": ["id"],
"onDelete": "no action",
"onUpdate": "no action"
},
@ -3889,12 +3787,8 @@
"name": "plan_recommendations_recommendation_id_recommendation_id_fk",
"tableFrom": "plan_recommendations",
"tableTo": "recommendation",
"columnsFrom": [
"recommendation_id"
],
"columnsTo": [
"id"
],
"columnsFrom": ["recommendation_id"],
"columnsTo": ["id"],
"onDelete": "no action",
"onUpdate": "no action"
}
@ -4104,12 +3998,8 @@
"name": "recommendation_property_id_property_id_fk",
"tableFrom": "recommendation",
"tableTo": "property",
"columnsFrom": [
"property_id"
],
"columnsTo": [
"id"
],
"columnsFrom": ["property_id"],
"columnsTo": ["id"],
"onDelete": "no action",
"onUpdate": "no action"
}
@ -4197,12 +4087,8 @@
"name": "recommendation_materials_recommendation_id_recommendation_id_fk",
"tableFrom": "recommendation_materials",
"tableTo": "recommendation",
"columnsFrom": [
"recommendation_id"
],
"columnsTo": [
"id"
],
"columnsFrom": ["recommendation_id"],
"columnsTo": ["id"],
"onDelete": "cascade",
"onUpdate": "no action"
},
@ -4210,12 +4096,8 @@
"name": "recommendation_materials_material_id_material_id_fk",
"tableFrom": "recommendation_materials",
"tableTo": "material",
"columnsFrom": [
"material_id"
],
"columnsTo": [
"id"
],
"columnsFrom": ["material_id"],
"columnsTo": ["id"],
"onDelete": "no action",
"onUpdate": "no action"
}
@ -4481,12 +4363,8 @@
"name": "scenario_portfolio_id_portfolio_id_fk",
"tableFrom": "scenario",
"tableTo": "portfolio",
"columnsFrom": [
"portfolio_id"
],
"columnsTo": [
"id"
],
"columnsFrom": ["portfolio_id"],
"columnsTo": ["id"],
"onDelete": "no action",
"onUpdate": "no action"
}
@ -4644,12 +4522,8 @@
"name": "solar_scenario_solar_id_solar_id_fk",
"tableFrom": "solar_scenario",
"tableTo": "solar",
"columnsFrom": [
"solar_id"
],
"columnsTo": [
"id"
],
"columnsFrom": ["solar_id"],
"columnsTo": ["id"],
"onDelete": "no action",
"onUpdate": "no action"
}
@ -4728,12 +4602,8 @@
"name": "sub_task_task_id_tasks_id_fk",
"tableFrom": "sub_task",
"tableTo": "tasks",
"columnsFrom": [
"task_id"
],
"columnsTo": [
"id"
],
"columnsFrom": ["task_id"],
"columnsTo": ["id"],
"onDelete": "cascade",
"onUpdate": "no action"
}
@ -4859,12 +4729,8 @@
"name": "team_org_id_organisation_id_fk",
"tableFrom": "team",
"tableTo": "organisation",
"columnsFrom": [
"org_id"
],
"columnsTo": [
"id"
],
"columnsFrom": ["org_id"],
"columnsTo": ["id"],
"onDelete": "no action",
"onUpdate": "no action"
}
@ -4919,12 +4785,8 @@
"name": "team_members_user_id_user_id_fk",
"tableFrom": "team_members",
"tableTo": "user",
"columnsFrom": [
"user_id"
],
"columnsTo": [
"id"
],
"columnsFrom": ["user_id"],
"columnsTo": ["id"],
"onDelete": "no action",
"onUpdate": "no action"
},
@ -4932,12 +4794,8 @@
"name": "team_members_team_id_team_id_fk",
"tableFrom": "team_members",
"tableTo": "team",
"columnsFrom": [
"team_id"
],
"columnsTo": [
"id"
],
"columnsFrom": ["team_id"],
"columnsTo": ["id"],
"onDelete": "no action",
"onUpdate": "no action"
}
@ -4999,12 +4857,8 @@
"name": "team_portfolio_permissions_team_id_team_id_fk",
"tableFrom": "team_portfolio_permissions",
"tableTo": "team",
"columnsFrom": [
"team_id"
],
"columnsTo": [
"id"
],
"columnsFrom": ["team_id"],
"columnsTo": ["id"],
"onDelete": "no action",
"onUpdate": "no action"
},
@ -5012,12 +4866,8 @@
"name": "team_portfolio_permissions_portfolio_id_portfolio_id_fk",
"tableFrom": "team_portfolio_permissions",
"tableTo": "portfolio",
"columnsFrom": [
"portfolio_id"
],
"columnsTo": [
"id"
],
"columnsFrom": ["portfolio_id"],
"columnsTo": ["id"],
"onDelete": "no action",
"onUpdate": "no action"
}
@ -5174,12 +5024,8 @@
"name": "account_userId_user_id_fk",
"tableFrom": "account",
"tableTo": "user",
"columnsFrom": [
"userId"
],
"columnsTo": [
"id"
],
"columnsFrom": ["userId"],
"columnsTo": ["id"],
"onDelete": "cascade",
"onUpdate": "no action"
}
@ -5187,10 +5033,7 @@
"compositePrimaryKeys": {
"account_provider_providerAccountId_pk": {
"name": "account_provider_providerAccountId_pk",
"columns": [
"provider",
"providerAccountId"
]
"columns": ["provider", "providerAccountId"]
}
},
"uniqueConstraints": {},
@ -5227,12 +5070,8 @@
"name": "session_userId_user_id_fk",
"tableFrom": "session",
"tableTo": "user",
"columnsFrom": [
"userId"
],
"columnsTo": [
"id"
],
"columnsFrom": ["userId"],
"columnsTo": ["id"],
"onDelete": "cascade",
"onUpdate": "no action"
}
@ -5324,9 +5163,7 @@
"user_email_unique": {
"name": "user_email_unique",
"nullsNotDistinct": false,
"columns": [
"email"
]
"columns": ["email"]
}
},
"policies": {},
@ -5441,12 +5278,8 @@
"name": "user_profiles_user_id_user_id_fk",
"tableFrom": "user_profiles",
"tableTo": "user",
"columnsFrom": [
"user_id"
],
"columnsTo": [
"id"
],
"columnsFrom": ["user_id"],
"columnsTo": ["id"],
"onDelete": "cascade",
"onUpdate": "no action"
}
@ -5485,10 +5318,7 @@
"compositePrimaryKeys": {
"verificationToken_identifier_token_pk": {
"name": "verificationToken_identifier_token_pk",
"columns": [
"identifier",
"token"
]
"columns": ["identifier", "token"]
}
},
"uniqueConstraints": {},
@ -5782,12 +5612,7 @@
"public.scheme": {
"name": "scheme",
"schema": "public",
"values": [
"eco4",
"gbis",
"whlg",
"none"
]
"values": ["eco4", "gbis", "whlg", "none"]
},
"public.inspection_archetype_2": {
"name": "inspection_archetype_2",
@ -5804,22 +5629,12 @@
"public.inspection_archetype": {
"name": "inspection_archetype",
"schema": "public",
"values": [
"Bungalow",
"Flat",
"Maisonette",
"House",
"non-domestic"
]
"values": ["Bungalow", "Flat", "Maisonette", "House", "non-domestic"]
},
"public.inspection_borescoped": {
"name": "inspection_borescoped",
"schema": "public",
"values": [
"yes",
"no",
"refused"
]
"values": ["yes", "no", "refused"]
},
"public.inspections_access_issues": {
"name": "inspections_access_issues",
@ -5901,11 +5716,7 @@
"public.inspections_tile_hung": {
"name": "inspections_tile_hung",
"schema": "public",
"values": [
"yes",
"no",
"first floor flats are tile hung"
]
"values": ["yes", "no", "first floor flats are tile hung"]
},
"public.inspections_wall_construction": {
"name": "inspections_wall_construction",
@ -5941,19 +5752,12 @@
"public.cost_unit": {
"name": "cost_unit",
"schema": "public",
"values": [
"gbp_sq_meter",
"gbp_per_unit",
"gbp_per_m2",
"gbp_per_m"
]
"values": ["gbp_sq_meter", "gbp_per_unit", "gbp_per_m2", "gbp_per_m"]
},
"public.depth_unit": {
"name": "depth_unit",
"schema": "public",
"values": [
"mm"
]
"values": ["mm"]
},
"public.type": {
"name": "type",
@ -6006,26 +5810,17 @@
"public.r_value_unit": {
"name": "r_value_unit",
"schema": "public",
"values": [
"square_meter_kelvin_per_watt"
]
"values": ["square_meter_kelvin_per_watt"]
},
"public.size_unit": {
"name": "size_unit",
"schema": "public",
"values": [
"kWp",
"kW",
"watt",
"storey"
]
"values": ["kWp", "kW", "watt", "storey"]
},
"public.thermal_conductivity_unit": {
"name": "thermal_conductivity_unit",
"schema": "public",
"values": [
"watt_per_meter_kelvin"
]
"values": ["watt_per_meter_kelvin"]
},
"public.goal": {
"name": "goal",
@ -6041,12 +5836,7 @@
"public.role": {
"name": "role",
"schema": "public",
"values": [
"creator",
"admin",
"read",
"write"
]
"values": ["creator", "admin", "read", "write"]
},
"public.status": {
"name": "status",
@ -6067,32 +5857,17 @@
"public.epc": {
"name": "epc",
"schema": "public",
"values": [
"A",
"B",
"C",
"D",
"E",
"F",
"G"
]
"values": ["A", "B", "C", "D", "E", "F", "G"]
},
"public.creation_status": {
"name": "creation_status",
"schema": "public",
"values": [
"LOADING",
"READY",
"ERROR"
]
"values": ["LOADING", "READY", "ERROR"]
},
"public.housing_type": {
"name": "housing_type",
"schema": "public",
"values": [
"Private",
"Social"
]
"values": ["Private", "Social"]
},
"public.measure_type": {
"name": "measure_type",
@ -6138,35 +5913,22 @@
"public.unit_quantity": {
"name": "unit_quantity",
"schema": "public",
"values": [
"m2",
"part",
"kwp"
]
"values": ["m2", "part", "kwp"]
},
"public.scenario_type": {
"name": "scenario_type",
"schema": "public",
"values": [
"unit",
"building"
]
"values": ["unit", "building"]
},
"public.source": {
"name": "source",
"schema": "public",
"values": [
"portfolio_id"
]
"values": ["portfolio_id"]
},
"public.file_source": {
"name": "file_source",
"schema": "public",
"values": [
"pas hub",
"sharepoint",
"hubspot"
]
"values": ["pas hub", "sharepoint", "hubspot"]
},
"public.file_type": {
"name": "file_type",
@ -6233,4 +5995,4 @@
"schemas": {},
"tables": {}
}
}
}

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

View file

@ -1149,6 +1149,20 @@
"when": 1775123235194,
"tag": "0163_cultured_madripoor",
"breakpoints": true
},
{
"idx": 164,
"version": "7",
"when": 1775310006908,
"tag": "0164_high_sumo",
"breakpoints": true
},
{
"idx": 165,
"version": "7",
"when": 1775577933185,
"tag": "0165_small_khan",
"breakpoints": true
}
]
}

View file

@ -0,0 +1,24 @@
import { pgTable, bigint, uuid, timestamp } from "drizzle-orm/pg-core";
import { portfolio } from "./portfolio";
import { organisation } from "./organisation";
import { InferModel } from "drizzle-orm";
export const portfolioOrganisation = pgTable("portfolio_organisation", {
id: uuid("id").defaultRandom().primaryKey(),
portfolioId: bigint("portfolio_id", { mode: "bigint" })
.notNull()
.references(() => portfolio.id, { onDelete: "cascade" })
.unique(), // one organisation per portfolio
organisationId: uuid("organisation_id")
.notNull()
.references(() => organisation.id, { onDelete: "cascade" }),
createdAt: timestamp("created_at", { precision: 6, withTimezone: true })
.defaultNow()
.notNull(),
updatedAt: timestamp("updated_at", { precision: 6, withTimezone: true })
.defaultNow()
.notNull(),
});
export type PortfolioOrganisation = InferModel<typeof portfolioOrganisation, "select">;
export type NewPortfolioOrganisation = InferModel<typeof portfolioOrganisation, "insert">;

View file

@ -9,13 +9,16 @@ export const fileType = pgEnum("file_type", [
"pas_significance",
"par_photo_pack",
"pas_2023_property",
"pas_2023_occupancy"
"pas_2023_occupancy",
"ecmk_site_note",
"ecmk_rd_sap_site_note"
]);
export const fileSource = pgEnum("file_source", [
"pas hub",
"sharepoint",
"hubspot"
"hubspot",
"ecmk"
]);
export const uploadedFiles = pgTable(

View file

@ -0,0 +1,280 @@
"use client";
import { useState, useMemo } from "react";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { Building2, CheckCircle2, Link2, Link2Off, AlertTriangle, Search } from "lucide-react";
import { Button } from "@/app/shadcn_components/ui/button";
import { Input } from "@/app/shadcn_components/ui/input";
import {
Dialog,
DialogContent,
DialogTitle,
DialogFooter,
} from "@/app/shadcn_components/ui/dialog";
type OrgSummary = {
id: string;
name: string | null;
hubspotCompanyId: string | null;
};
async function fetchCurrentOrg(portfolioId: string): Promise<OrgSummary | null> {
const res = await fetch(`/api/portfolio/${portfolioId}/organisation`);
if (!res.ok) throw new Error("Failed to fetch linked organisation");
return res.json();
}
async function fetchAllOrgs(): Promise<OrgSummary[]> {
const res = await fetch("/api/organisations");
if (!res.ok) throw new Error("Failed to fetch organisations");
return res.json();
}
export default function OrganisationLinkCard({ portfolioId }: { portfolioId: string }) {
const queryClient = useQueryClient();
const [connectOpen, setConnectOpen] = useState(false);
const [disconnectOpen, setDisconnectOpen] = useState(false);
const [selectedOrgId, setSelectedOrgId] = useState<string | null>(null);
const [searchQuery, setSearchQuery] = useState("");
const [confirmed, setConfirmed] = useState(false);
// Current linked org
const { data: currentOrg, isLoading: loadingCurrent } = useQuery({
queryKey: ["portfolio-org", portfolioId],
queryFn: () => fetchCurrentOrg(portfolioId),
});
// All orgs — only fetched when connect modal is open
const { data: allOrgs = [], isLoading: loadingOrgs } = useQuery({
queryKey: ["all-organisations"],
queryFn: fetchAllOrgs,
enabled: connectOpen,
});
const connectMutation = useMutation({
mutationFn: async (organisationId: string) => {
const res = await fetch(`/api/portfolio/${portfolioId}/organisation`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ organisationId }),
});
if (!res.ok) throw new Error("Failed to connect organisation");
return res.json();
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["portfolio-org", portfolioId] });
setConnectOpen(false);
setSelectedOrgId(null);
setConfirmed(false);
setSearchQuery("");
},
});
const disconnectMutation = useMutation({
mutationFn: async () => {
const res = await fetch(`/api/portfolio/${portfolioId}/organisation`, {
method: "DELETE",
});
if (!res.ok) throw new Error("Failed to disconnect organisation");
return res.json();
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["portfolio-org", portfolioId] });
setDisconnectOpen(false);
},
});
const filteredOrgs = useMemo(
() =>
allOrgs.filter((o) =>
(o.name ?? "").toLowerCase().includes(searchQuery.toLowerCase()),
),
[allOrgs, searchQuery],
);
const selectedOrg = allOrgs.find((o) => o.id === selectedOrgId) ?? null;
return (
<div className="rounded-xl border border-brandblue/15 bg-white shadow-sm mt-4 overflow-hidden">
{/* Header */}
<div className="flex items-center gap-3 px-5 py-4 border-b border-gray-100 bg-brandlightblue/20">
<div className="p-2 rounded-lg bg-brandblue/10">
<Building2 className="h-4 w-4 text-brandblue" />
</div>
<div>
<p className="text-sm font-semibold text-brandblue">Organisation Link</p>
<p className="text-xs text-gray-500 mt-0.5">
Connect this portfolio to an organisation to enable live project tracking
</p>
</div>
</div>
{/* Body */}
<div className="px-5 py-4">
{loadingCurrent ? (
<div className="h-10 bg-gray-100 rounded-lg animate-pulse w-48" />
) : currentOrg ? (
<div className="flex items-center justify-between gap-4">
<div className="flex items-center gap-3">
<CheckCircle2 className="h-5 w-5 text-emerald-500 shrink-0" />
<div>
<p className="text-sm font-semibold text-gray-800">{currentOrg.name ?? "Unnamed organisation"}</p>
<p className="text-xs text-gray-400 mt-0.5">
Connected · HubSpot ID: {currentOrg.hubspotCompanyId ?? "—"}
</p>
</div>
</div>
<div className="flex items-center gap-2">
<Button
size="sm"
variant="outline"
className="h-8 text-xs border-brandblue/20 text-brandblue hover:bg-brandlightblue/30"
onClick={() => {
setConnectOpen(true);
setSelectedOrgId(null);
setConfirmed(false);
setSearchQuery("");
}}
>
<Link2 className="h-3.5 w-3.5 mr-1.5" />
Change
</Button>
<Button
size="sm"
variant="outline"
className="h-8 text-xs border-red-200 text-red-600 hover:bg-red-50"
onClick={() => setDisconnectOpen(true)}
>
<Link2Off className="h-3.5 w-3.5 mr-1.5" />
Disconnect
</Button>
</div>
</div>
) : (
<div className="flex items-center justify-between gap-4">
<div className="flex items-center gap-3">
<div className="w-8 h-8 rounded-full bg-gray-100 flex items-center justify-center">
<Building2 className="h-4 w-4 text-gray-400" />
</div>
<p className="text-sm text-gray-500">No organisation linked</p>
</div>
<Button
size="sm"
className="h-8 text-xs bg-brandblue hover:bg-brandmidblue"
onClick={() => {
setConnectOpen(true);
setSelectedOrgId(null);
setConfirmed(false);
setSearchQuery("");
}}
>
<Link2 className="h-3.5 w-3.5 mr-1.5" />
Connect Organisation
</Button>
</div>
)}
</div>
{/* ── Connect modal ─────────────────────────────────────────────── */}
<Dialog open={connectOpen} onOpenChange={(v) => { setConnectOpen(v); if (!v) { setSelectedOrgId(null); setConfirmed(false); setSearchQuery(""); } }}>
<DialogContent className="max-w-md">
<DialogTitle className="text-brandblue">Connect Organisation</DialogTitle>
{/* Search */}
<div className="relative mt-1">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-gray-400 pointer-events-none" />
<Input
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
placeholder="Search organisations…"
className="pl-9 h-9 text-sm border-gray-200"
/>
</div>
{/* Org list */}
<div className="mt-2 max-h-56 overflow-y-auto rounded-lg border border-gray-200 divide-y divide-gray-100">
{loadingOrgs ? (
<div className="p-4 text-sm text-gray-400 text-center">Loading</div>
) : filteredOrgs.length === 0 ? (
<div className="p-4 text-sm text-gray-400 text-center">No organisations found</div>
) : (
filteredOrgs.map((org) => (
<button
key={org.id}
onClick={() => setSelectedOrgId(org.id)}
className={`w-full text-left px-4 py-2.5 transition-colors text-sm ${
selectedOrgId === org.id
? "bg-brandlightblue/50 text-brandblue font-medium"
: "hover:bg-gray-50 text-gray-700"
}`}
>
<span className="block font-medium">{org.name ?? "Unnamed"}</span>
{org.hubspotCompanyId && (
<span className="text-xs text-gray-400">HubSpot ID: {org.hubspotCompanyId}</span>
)}
</button>
))
)}
</div>
{/* Warning */}
<div className="flex items-start gap-2.5 p-3 rounded-lg bg-amber-50 border border-amber-200 mt-1">
<AlertTriangle className="h-4 w-4 text-amber-500 mt-0.5 shrink-0" />
<p className="text-xs text-amber-700 leading-relaxed">
Viewers of this portfolio will be able to see <strong>live project tracking data</strong> associated with the selected organisation.
</p>
</div>
{/* Confirmation checkbox */}
<label className="flex items-center gap-2.5 cursor-pointer select-none mt-1">
<input
type="checkbox"
checked={confirmed}
onChange={(e) => setConfirmed(e.target.checked)}
className="h-4 w-4 rounded border-gray-300 text-brandblue accent-brandblue"
/>
<span className="text-sm text-gray-700">I understand and want to connect this organisation</span>
</label>
<DialogFooter className="mt-2">
<Button variant="outline" onClick={() => setConnectOpen(false)} className="text-sm">
Cancel
</Button>
<Button
onClick={() => selectedOrgId && connectMutation.mutate(selectedOrgId)}
disabled={!selectedOrgId || !confirmed || connectMutation.isPending}
className="bg-brandblue hover:bg-brandmidblue text-sm"
>
{connectMutation.isPending ? "Connecting…" : `Connect${selectedOrg ? ` "${selectedOrg.name}"` : ""}`}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* ── Disconnect confirm dialog ──────────────────────────────────── */}
<Dialog open={disconnectOpen} onOpenChange={setDisconnectOpen}>
<DialogContent className="max-w-sm">
<DialogTitle className="text-gray-800">Disconnect organisation?</DialogTitle>
<p className="text-sm text-gray-600 leading-relaxed">
Are you sure you want to disconnect{" "}
<strong>{currentOrg?.name ?? "this organisation"}</strong>?
Live project tracking data will no longer be visible to portfolio viewers.
</p>
<DialogFooter className="mt-2">
<Button variant="outline" onClick={() => setDisconnectOpen(false)} className="text-sm">
Cancel
</Button>
<Button
onClick={() => disconnectMutation.mutate()}
disabled={disconnectMutation.isPending}
className="bg-red-600 hover:bg-red-700 text-white text-sm"
>
{disconnectMutation.isPending ? "Disconnecting…" : "Disconnect"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
);
}

View file

@ -1,544 +0,0 @@
"use client";
import { useState } from "react";
import { useMutation } from "@tanstack/react-query";
import { PortfolioSettingsType } from "../../utils";
import { Button } from "@/app/shadcn_components/ui/button";
import { Input } from "@/app/shadcn_components/ui/input";
import { useRouter } from "next/navigation";
import { handleNumericKeyDown } from "@/app/utils";
import {
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectLabel,
SelectTrigger,
SelectValue,
} from "@/app/shadcn_components/ui/select";
import {
Dialog,
DialogContent,
DialogTitle,
DialogFooter,
} from "@/app/shadcn_components/ui/dialog";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/app/shadcn_components/ui/table";
import { PortfolioStatus as PortfolioStatusOptions } from "@/app/db/schema/portfolio";
import { PortfolioGoal as PortfolioGoalOptions } from "@/app/db/schema/portfolio";
import { useSession } from "next-auth/react";
import PortfolioPlanTable from "@/app/components/portfolio/measures/PlanTable";
import { UsersPermissionsCard } from "./UsersPermissionsCard";
// dropdown selection component for both goal and status
export function SettingsDropdown({
startingValue,
options,
setOption,
className,
}: {
startingValue: string;
options: string[];
setOption: (option: string) => void;
className?: string;
}) {
function handleValueChange(newValue: string) {
setOption(newValue);
}
return (
<Select onValueChange={(newValue) => handleValueChange(newValue)}>
<SelectTrigger className="w-full">
<SelectValue placeholder={startingValue} />
</SelectTrigger>
<SelectContent>
<SelectGroup>
{options.map((option, idx) => (
<SelectItem value={option} key={idx}>
{option}
</SelectItem>
))}
</SelectGroup>
</SelectContent>
</Select>
);
}
type updateSettingsArgs = {
userId: bigint;
portfolioId: string;
name: string | null;
budget: number | string | undefined | null;
goal: (typeof PortfolioGoalOptions)[number] | null;
status: (typeof PortfolioStatusOptions)[number] | null;
};
type bodyType = {
name?: string;
budget?: number | string;
goal?: string;
status?: string;
};
const updateSettings = async ({
userId,
portfolioId,
name,
budget,
goal,
status,
}: updateSettingsArgs) => {
const permissionsReponse = await fetch(
`/api/portfolio/${portfolioId}/permissions`,
{
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
userId: userId.toString(),
action: "update",
}),
},
);
const permissionsData = await permissionsReponse.json();
const permitted = permissionsData.permitted;
console.log("USER IS PERMITTED TO DO THIS!!!!");
// If the user is not permitted to delete the portfolio, we'll throw an error
if (!permitted) {
throw new Error("User is not permitted to update this portfolio");
}
// We convert the the bigint to a string since big ints are not serialisable and we don't want to loose precision
// We will create a js object with the starting values
// We will then update the values that are not null
const body: bodyType = {};
if (name) {
body.name = name;
}
if (budget) {
body.budget = budget;
}
if (goal) {
body.goal = goal;
}
if (status) {
body.status = status;
}
const requestBody = JSON.stringify(body);
const response = await fetch(`/api/portfolio/${portfolioId}`, {
method: "PUT",
headers: {
"Content-Type": "application/json",
},
body: requestBody,
});
if (!response.ok) {
throw new Error("Network response was not ok");
}
return response.json();
};
async function deletePortfolio({
userId,
portfolioId,
}: {
userId: bigint;
portfolioId: string;
}) {
try {
console.log("Attempting to DELETE portfolio by calling API:", {
userId,
portfolioId,
});
// We'll check if the user is authorized to delete this portfolio
const permissionsReponse = await fetch(
`/api/portfolio/${portfolioId}/permissions`,
{
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
userId: userId.toString(),
action: "delete",
}),
},
);
const permissionsData = await permissionsReponse.json();
const permitted = permissionsData.permitted;
// If the user is not permitted to delete the portfolio, we'll throw an error
if (!permitted) {
throw new Error("User is not permitted to delete this portfolio");
}
const response = await fetch(`/api/portfolio/${portfolioId}`, {
method: "DELETE",
headers: {
"Content-Type": "application/json",
},
});
if (!response.ok) {
throw new Error(
"deletePortfolio has been called into action but utterly failed to do the API handoff",
);
}
return await response.json();
} catch (error) {
console.error("Error after failing to the try to get a response:", error);
throw error;
}
}
export default function PortfolioSettings({
portfolioId,
portfolioSettingsData,
}: {
portfolioId: string;
portfolioSettingsData: PortfolioSettingsType;
}) {
// This is a client component so we can access the session directly
const session = useSession();
const router = useRouter();
const { mutate, isLoading } = useMutation(updateSettings, {
onSuccess: () => {
router.refresh();
},
onError: (error) => {
// handle error
console.log(error);
},
});
const { mutate: mutateDelete } = useMutation(deletePortfolio, {
onSuccess: () => {
setIsDeleteModalOpen(false);
router.push("/home");
},
onError: (error) => {
console.error(
"Because the API hand off failed, we're right back here at the mutation station",
error,
);
},
});
const [portfolioName, setPortfolioName] = useState(
portfolioSettingsData.name,
);
const [portfolioBudget, setPortfolioBudget] = useState<
number | string | null
>(portfolioSettingsData.budget);
const [portfolioGoal, setPortfolioGoal] = useState(
portfolioSettingsData.goal,
);
const [portfolioStatus, setPortfolioStatus] = useState(
portfolioSettingsData.status,
);
// Set up state for deleteModal and deleteConfirmation
const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false);
const [deleteConfirmationByName, setDeleteConfirmationByName] = useState("");
if (session.status === "loading") {
// You can return a loading spinner or placeholder here
return <div>Loading...</div>;
}
if (!session.data) {
// The user is not logged in, redirect them to sign in
return null;
}
const userId = session.data.user.dbId;
function handleOpenDeleteModal() {
setDeleteConfirmationByName("");
setIsDeleteModalOpen(true);
}
async function handleDeleteConfirmation() {
if (deleteConfirmationByName !== portfolioSettingsData.name) {
console.warn("Delete confirmation name does not match");
return;
}
try {
console.log("[DELETE] starting delete mutation");
await mutateDelete({
userId,
portfolioId,
});
console.log("[DELETE] mutation completed successfully");
// Refresh table / page data
router.refresh();
} catch (err) {
console.error("[DELETE] mutation failed", err);
}
}
// Change NAME functionality - changing state
function handlePortfolioNameChange(e: React.ChangeEvent<HTMLInputElement>) {
setPortfolioName(e.target.value);
}
// The onClick function called to update the NAME in the DB
function handleRename() {
mutate({
userId,
portfolioId,
name: portfolioName,
budget: null,
goal: null,
status: null,
});
}
// BUDGET CHANGING FUNCTIONS
// Change BUDGET functionality - changing state
function handlePortfolioBudgetUpdate(e: React.ChangeEvent<HTMLInputElement>) {
setPortfolioBudget(Number(e.target.value));
}
// The onClick function called to update the BUDGET in the DB
function handleBudgetUpdate() {
mutate({
userId,
portfolioId,
name: null,
budget: portfolioBudget,
goal: null,
status: null,
});
}
// CHANGING GOAL AND STATUS FUNCTIONALITY
// The onClick function called to update the GOAL in the DB
function handleGoalUpdate() {
mutate({
userId,
portfolioId,
name: null,
budget: null,
goal: portfolioGoal,
status: null,
});
}
// The onClick function called to update the BUDGET in the DB
function handleStatusUpdate() {
mutate({
userId,
portfolioId,
name: null,
budget: null,
goal: null,
status: portfolioStatus,
});
}
// HTML to render the page
// TODO: 1) Set up the useMutate hook
// 2) Set up the api functions
// 3) add the call to mutate() so that when we submit the form, the data is updated in the DB
// 4) Create the API
return (
<div className="w-auto mt-4 p-4 bg-gray-50 rounded-lg text-brandblue">
<div className="rounded-md border border-gray-700">
<Table>
<TableBody>
<TableRow>
<TableHead className="text-brandblue">
Rename the Portfolio:
<p className="text-xs text-gray-500">
Permanently change the name of your portfolio
</p>
</TableHead>
<TableCell>
<Input
value={portfolioName}
onChange={handlePortfolioNameChange}
/>
</TableCell>
<TableCell>
<Button className="w-28" onClick={handleRename}>
Rename
</Button>
</TableCell>
</TableRow>
<TableRow>
<TableHead className="text-brandblue">
Change the Portfolio Budget:
<p className="text-xs text-gray-500">
The total budget across ALL properties. Works aim to stay
within this budget
</p>
</TableHead>
<TableCell>
<Input
type="number"
value={portfolioBudget ?? undefined}
onChange={handlePortfolioBudgetUpdate}
onKeyDown={(e) => handleNumericKeyDown(e)}
/>
</TableCell>
<TableCell>
<Button className="w-28" onClick={handleBudgetUpdate}>
Update
</Button>
</TableCell>
</TableRow>
<TableRow>
<TableHead className="text-brandblue">
Change the Portfolio Goal:
<p className="text-xs text-gray-500">
Adjust the overall aim of the works conducted on this
portfolio
</p>
</TableHead>
<TableCell>
<SettingsDropdown
className="w-full"
startingValue={portfolioGoal}
options={PortfolioGoalOptions}
setOption={setPortfolioGoal}
/>
</TableCell>
<TableCell>
<Button className="w-28" onClick={handleGoalUpdate}>
Update
</Button>
</TableCell>
</TableRow>
<TableRow>
<TableHead className="text-brandblue">
Change the Status of the Portfolio:
<p className="text-xs text-gray-500">
Adjust where the portfolio stands in the works pipeline
</p>
</TableHead>
<TableCell>
<SettingsDropdown
className="w-full"
startingValue={portfolioStatus}
options={PortfolioStatusOptions}
setOption={setPortfolioStatus}
/>
</TableCell>
<TableCell>
<Button className="w-28" onClick={handleStatusUpdate}>
Update
</Button>
</TableCell>
</TableRow>
</TableBody>
</Table>
</div>
<UsersPermissionsCard portfolioId={portfolioId} />
<div className="rounded-md border border-red-500 mt-2">
<Table>
<TableHeader>
<TableRow>
<TableHead colSpan={2} className="text-lg text-brandblue">
Danger Zone:
</TableHead>
</TableRow>
</TableHeader>
<TableBody>
<TableRow>
<TableHead className="text-brandblue">
Delete the Portfolio:
<p className="text-xs text-gray-500">
Permanently delete the portfolio and all property data
assigned to this portfolio
</p>
</TableHead>
<TableCell className="flex justify-end">
<Button
className="bg-red-700 w-42"
onClick={handleOpenDeleteModal}
>
Delete Portfolio
</Button>
</TableCell>
</TableRow>
</TableBody>
</Table>
<Dialog open={isDeleteModalOpen} onOpenChange={setIsDeleteModalOpen}>
<DialogContent>
<DialogTitle>Are you sure?</DialogTitle>
<p>
To confirm, please type the name of the portfolio (
<strong>{portfolioSettingsData.name}</strong>)
</p>
<input
type="text"
value={deleteConfirmationByName}
onChange={(e) => setDeleteConfirmationByName(e.target.value)}
placeholder="Type portfolio name"
/>
<DialogFooter>
<Button
className="bg-green-600"
onClick={() => setIsDeleteModalOpen(false)}
>
Cancel
</Button>
<Button
className="bg-red-700"
onClick={handleDeleteConfirmation}
disabled={
deleteConfirmationByName !== portfolioSettingsData.name
}
>
Delete
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
</div>
);
}

View file

@ -0,0 +1,53 @@
"use client";
import { usePathname, useRouter } from "next/navigation";
import { useTransition } from "react";
import { cn } from "@/lib/utils";
export function SettingsSidebarLink({
href,
icon,
children,
}: {
href: string;
icon: React.ReactNode;
children: React.ReactNode;
}) {
const pathname = usePathname();
const router = useRouter();
const isActive = pathname === href;
const [isPending, startTransition] = useTransition();
function handleClick(e: React.MouseEvent) {
e.preventDefault();
if (isActive) return;
startTransition(() => router.push(href));
}
return (
<button
onClick={handleClick}
disabled={isPending}
className={cn(
"w-full flex items-center gap-2.5 px-3 py-1.5 rounded-md text-sm transition-colors disabled:opacity-50 disabled:cursor-not-allowed",
isActive
? "bg-gray-200/70 text-gray-900 font-medium"
: "text-gray-600 font-normal hover:bg-gray-100 hover:text-gray-900"
)}
>
<span
className={cn(
"shrink-0",
isActive ? "text-gray-700" : "text-gray-400"
)}
>
{isPending ? (
<span className="animate-spin h-4 w-4 border-2 border-gray-400 border-t-transparent rounded-full block" />
) : (
icon
)}
</span>
{children}
</button>
);
}

View file

@ -11,7 +11,7 @@ import {
import { Input } from "@/app/shadcn_components/ui/input";
import { Button } from "@/app/shadcn_components/ui/button";
import { useState, useEffect } from "react";
import { useState } from "react";
import { Role, RoleDropdown, Collaborator } from "@/app/portfolio/[slug]/(portfolio)/settings/roles";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";

View file

@ -0,0 +1,22 @@
import { redirect } from "next/navigation";
import { getServerSession } from "next-auth";
import { AuthOptions } from "@/app/api/auth/[...nextauth]/authOptions";
import OrganisationLinkCard from "../OrganisationLinkCard";
export default async function ConnectedOrganisationPage(props: {
params: Promise<{ slug: string }>;
}) {
const { slug } = await props.params;
const session = await getServerSession(AuthOptions);
const isDomnaUser = !!session?.user?.email?.endsWith("@domna.homes");
if (!isDomnaUser) {
redirect(`/portfolio/${slug}/settings/general`);
}
return (
<div>
<OrganisationLinkCard portfolioId={slug} />
</div>
);
}

View file

@ -0,0 +1,151 @@
"use client";
import { useState } from "react";
import { useMutation } from "@tanstack/react-query";
import { Button } from "@/app/shadcn_components/ui/button";
import { useRouter } from "next/navigation";
import {
Dialog,
DialogContent,
DialogTitle,
DialogFooter,
} from "@/app/shadcn_components/ui/dialog";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/app/shadcn_components/ui/table";
import { useSession } from "next-auth/react";
async function deletePortfolio({
userId,
portfolioId,
}: {
userId: bigint;
portfolioId: string;
}) {
const permissionsReponse = await fetch(
`/api/portfolio/${portfolioId}/permissions`,
{
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ userId: userId.toString(), action: "delete" }),
},
);
const permissionsData = await permissionsReponse.json();
if (!permissionsData.permitted) {
throw new Error("User is not permitted to delete this portfolio");
}
const response = await fetch(`/api/portfolio/${portfolioId}`, {
method: "DELETE",
headers: { "Content-Type": "application/json" },
});
if (!response.ok) {
throw new Error("Failed to delete portfolio");
}
return await response.json();
}
export default function DangerZone({
portfolioId,
portfolioName,
}: {
portfolioId: string;
portfolioName: string;
}) {
const session = useSession();
const router = useRouter();
const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false);
const [deleteConfirmationByName, setDeleteConfirmationByName] = useState("");
const { mutate: mutateDelete } = useMutation(deletePortfolio, {
onSuccess: () => {
setIsDeleteModalOpen(false);
router.push("/home");
},
onError: (error) => console.error("Delete failed", error),
});
if (session.status === "loading") return null;
if (!session.data) return null;
const userId = session.data.user.dbId;
async function handleDeleteConfirmation() {
if (deleteConfirmationByName !== portfolioName) return;
mutateDelete({ userId, portfolioId });
}
return (
<div className="rounded-md border border-red-500 mt-4">
<Table>
<TableHeader>
<TableRow>
<TableHead colSpan={2} className="text-lg text-brandblue">
Danger Zone:
</TableHead>
</TableRow>
</TableHeader>
<TableBody>
<TableRow>
<TableHead className="text-brandblue">
Delete the Portfolio:
<p className="text-xs text-gray-500">
Permanently delete the portfolio and all property data assigned to this portfolio
</p>
</TableHead>
<TableCell className="flex justify-end">
<Button
className="bg-red-700 w-42"
onClick={() => {
setDeleteConfirmationByName("");
setIsDeleteModalOpen(true);
}}
>
Delete Portfolio
</Button>
</TableCell>
</TableRow>
</TableBody>
</Table>
<Dialog open={isDeleteModalOpen} onOpenChange={setIsDeleteModalOpen}>
<DialogContent>
<DialogTitle>Are you sure?</DialogTitle>
<p>
To confirm, please type the name of the portfolio (
<strong>{portfolioName}</strong>)
</p>
<input
type="text"
value={deleteConfirmationByName}
onChange={(e) => setDeleteConfirmationByName(e.target.value)}
placeholder="Type portfolio name"
/>
<DialogFooter>
<Button
className="bg-green-600"
onClick={() => setIsDeleteModalOpen(false)}
>
Cancel
</Button>
<Button
className="bg-red-700"
onClick={handleDeleteConfirmation}
disabled={deleteConfirmationByName !== portfolioName}
>
Delete
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
);
}

View file

@ -0,0 +1,238 @@
"use client";
import { useState } from "react";
import { useMutation } from "@tanstack/react-query";
import { PortfolioSettingsType } from "../../../utils";
import { Button } from "@/app/shadcn_components/ui/button";
import { Input } from "@/app/shadcn_components/ui/input";
import { useRouter } from "next/navigation";
import { handleNumericKeyDown } from "@/app/utils";
import {
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/app/shadcn_components/ui/select";
import { PortfolioStatus as PortfolioStatusOptions } from "@/app/db/schema/portfolio";
import { PortfolioGoal as PortfolioGoalOptions } from "@/app/db/schema/portfolio";
import { useSession } from "next-auth/react";
function SettingsDropdown({
startingValue,
options,
setOption,
}: {
startingValue: string;
options: string[];
setOption: (option: string) => void;
}) {
return (
<Select onValueChange={(newValue) => setOption(newValue)}>
<SelectTrigger className="w-56">
<SelectValue placeholder={startingValue} />
</SelectTrigger>
<SelectContent>
<SelectGroup>
{options.map((option, idx) => (
<SelectItem value={option} key={idx}>
{option}
</SelectItem>
))}
</SelectGroup>
</SelectContent>
</Select>
);
}
type updateSettingsArgs = {
userId: bigint;
portfolioId: string;
name: string | null;
budget: number | string | undefined | null;
goal: (typeof PortfolioGoalOptions)[number] | null;
status: (typeof PortfolioStatusOptions)[number] | null;
};
type bodyType = {
name?: string;
budget?: number | string;
goal?: string;
status?: string;
};
const updateSettings = async ({
userId,
portfolioId,
name,
budget,
goal,
status,
}: updateSettingsArgs) => {
const permissionsReponse = await fetch(
`/api/portfolio/${portfolioId}/permissions`,
{
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ userId: userId.toString(), action: "update" }),
},
);
const permissionsData = await permissionsReponse.json();
if (!permissionsData.permitted) {
throw new Error("User is not permitted to update this portfolio");
}
const body: bodyType = {};
if (name) body.name = name;
if (budget) body.budget = budget;
if (goal) body.goal = goal;
if (status) body.status = status;
const response = await fetch(`/api/portfolio/${portfolioId}`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
});
if (!response.ok) throw new Error("Network response was not ok");
return response.json();
};
function SettingRow({
label,
description,
children,
}: {
label: string;
description: string;
children: React.ReactNode;
}) {
return (
<div className="py-4 border-b border-gray-100 last:border-0">
<div className="flex items-start justify-between gap-6">
<div className="min-w-0 flex-1">
<p className="text-sm font-medium text-gray-900">{label}</p>
<p className="text-sm text-gray-500 mt-0.5">{description}</p>
</div>
<div className="flex items-center gap-2 shrink-0">{children}</div>
</div>
</div>
);
}
export default function GeneralSettingsForm({
portfolioId,
portfolioSettingsData,
}: {
portfolioId: string;
portfolioSettingsData: PortfolioSettingsType;
}) {
const session = useSession();
const router = useRouter();
const { mutate } = useMutation(updateSettings, {
onSuccess: () => router.refresh(),
onError: (error) => console.log(error),
});
const [portfolioName, setPortfolioName] = useState(portfolioSettingsData.name);
const [portfolioBudget, setPortfolioBudget] = useState<number | string | null>(
portfolioSettingsData.budget,
);
const [portfolioGoal, setPortfolioGoal] = useState(portfolioSettingsData.goal);
const [portfolioStatus, setPortfolioStatus] = useState(portfolioSettingsData.status);
if (session.status === "loading") return <div>Loading...</div>;
if (!session.data) return null;
const userId = session.data.user.dbId;
return (
<div>
<h2 className="text-base font-semibold text-gray-900 mb-1">General</h2>
<p className="text-sm text-gray-500 mb-4">Manage your portfolio settings.</p>
<div className="border border-gray-200 rounded-lg bg-white px-4">
<SettingRow
label="Portfolio Name"
description="Permanently change the name of your portfolio."
>
<Input
value={portfolioName}
onChange={(e) => setPortfolioName(e.target.value)}
className="w-48"
/>
<Button
size="sm"
onClick={() =>
mutate({ userId, portfolioId, name: portfolioName, budget: null, goal: null, status: null })
}
>
Save
</Button>
</SettingRow>
<SettingRow
label="Budget"
description="The total budget across all properties. Works aim to stay within this budget."
>
<Input
type="number"
value={portfolioBudget ?? undefined}
onChange={(e) => setPortfolioBudget(Number(e.target.value))}
onKeyDown={(e) => handleNumericKeyDown(e)}
className="w-48"
/>
<Button
size="sm"
onClick={() =>
mutate({ userId, portfolioId, name: null, budget: portfolioBudget, goal: null, status: null })
}
>
Save
</Button>
</SettingRow>
<SettingRow
label="Goal"
description="The overall aim of the works conducted on this portfolio."
>
<SettingsDropdown
startingValue={portfolioGoal}
options={PortfolioGoalOptions}
setOption={setPortfolioGoal}
/>
<Button
size="sm"
onClick={() =>
mutate({ userId, portfolioId, name: null, budget: null, goal: portfolioGoal, status: null })
}
>
Save
</Button>
</SettingRow>
<SettingRow
label="Status"
description="Where the portfolio stands in the works pipeline."
>
<SettingsDropdown
startingValue={portfolioStatus}
options={PortfolioStatusOptions}
setOption={setPortfolioStatus}
/>
<Button
size="sm"
onClick={() =>
mutate({ userId, portfolioId, name: null, budget: null, goal: null, status: portfolioStatus })
}
>
Save
</Button>
</SettingRow>
</div>
</div>
);
}

View file

@ -0,0 +1,20 @@
import { getPortfolioSettings } from "../../../utils";
import GeneralSettingsForm from "./GeneralSettingsForm";
import DangerZone from "./DangerZone";
export default async function GeneralSettingsPage(props: {
params: Promise<{ slug: string }>;
}) {
const { slug } = await props.params;
const portfolioSettingsData = await getPortfolioSettings(slug);
return (
<div className="text-brandblue">
<GeneralSettingsForm
portfolioId={slug}
portfolioSettingsData={portfolioSettingsData}
/>
<DangerZone portfolioId={slug} portfolioName={portfolioSettingsData.name} />
</div>
);
}

View file

@ -0,0 +1,58 @@
import { getServerSession } from "next-auth";
import { AuthOptions } from "@/app/api/auth/[...nextauth]/authOptions";
import { SettingsSidebarLink } from "./SettingsSidebarLink";
import { Settings2, Users, Building2, ScrollText } from "lucide-react";
export default async function SettingsLayout({
children,
params,
}: {
children: React.ReactNode;
params: Promise<{ slug: string }>;
}) {
const { slug } = await params;
const session = await getServerSession(AuthOptions);
const isDomnaUser = !!session?.user?.email?.endsWith("@domna.homes");
return (
<div className="flex max-w-8xl mx-auto mt-6 px-4 gap-8 mb-8">
<aside className="w-56 shrink-0">
<p className="px-3 mb-1 text-xs font-semibold text-gray-400 uppercase tracking-widest">
Settings
</p>
<nav className="space-y-0.5">
<SettingsSidebarLink
href={`/portfolio/${slug}/settings/general`}
icon={<Settings2 size={16} />}
>
General
</SettingsSidebarLink>
<SettingsSidebarLink
href={`/portfolio/${slug}/settings/user-access`}
icon={<Users size={16} />}
>
User Access
</SettingsSidebarLink>
{isDomnaUser && (
<SettingsSidebarLink
href={`/portfolio/${slug}/settings/connected-organisation`}
icon={<Building2 size={16} />}
>
Connected Organisation
</SettingsSidebarLink>
)}
{isDomnaUser && (
<SettingsSidebarLink
href={`/portfolio/${slug}/settings/logs`}
icon={<ScrollText size={16} />}
>
Logs
</SettingsSidebarLink>
)}
</nav>
</aside>
<div className="flex-1 min-w-0">{children}</div>
</div>
);
}

View file

@ -0,0 +1,120 @@
"use client";
import { useState } from "react";
import { useInfiniteQuery, useQuery } from "@tanstack/react-query";
import PortfolioTaskList from "./PortfolioTaskList";
import PortfolioSubtaskDetails from "./PortfolioSubtaskDetails";
export interface Task {
id: string;
taskSource: string;
jobStarted: string | null;
jobCompleted: string | null;
status: string;
service: string | null;
updatedAt: string;
}
export interface PortfolioTask extends Task {
totalSubtasks: number;
completedSubtasks: number;
failedSubtasks: number;
}
export interface SubTask {
id: string;
taskId: string;
jobStarted: string | null;
jobCompleted: string | null;
status: string;
inputs: string | null;
outputs: string | null;
cloudLogsURL: string | null;
updatedAt: string;
}
interface TasksResponse {
tasks: PortfolioTask[];
total: number;
limit: number;
offset: number;
}
export default function PortfolioLogs({ portfolioId }: { portfolioId: string }) {
const [selectedTaskId, setSelectedTaskId] = useState<string | null>(null);
const {
data: tasksData,
isLoading,
isFetchingNextPage,
isError,
error: tasksError,
fetchNextPage,
refetch,
} = useInfiniteQuery<TasksResponse>({
queryKey: ["portfolioTasks", portfolioId],
queryFn: async ({ pageParam = 0 }) => {
const response = await fetch(
`/api/portfolio/${portfolioId}/tasks?limit=20&offset=${pageParam}`
);
if (!response.ok) throw new Error("Failed to fetch tasks");
return response.json();
},
getNextPageParam: (lastPage) => {
const nextOffset = lastPage.offset + lastPage.tasks.length;
return nextOffset < lastPage.total ? nextOffset : undefined;
},
enabled: !!portfolioId,
});
const tasks = tasksData?.pages.flatMap((p) => p.tasks) ?? [];
const total = tasksData?.pages[0]?.total ?? 0;
const errorMessage = isError
? (tasksError instanceof Error ? tasksError.message : "An error occurred")
: null;
const { data: subtasks = [] } = useQuery<SubTask[]>({
queryKey: ["taskSubtasks", selectedTaskId],
queryFn: async () => {
const response = await fetch(`/api/tasks/${selectedTaskId}`);
if (!response.ok) throw new Error("Failed to fetch subtasks");
return response.json();
},
enabled: !!selectedTaskId,
});
const selectedTask = tasks.find((t) => t.id === selectedTaskId);
return (
<div className="flex min-h-[600px] max-h-[calc(100vh-220px)] border border-gray-200 rounded-lg overflow-hidden bg-gray-50">
{/* Left sidebar - Task list */}
<div className="w-80 border-r border-gray-200 bg-white shrink-0">
<PortfolioTaskList
tasks={tasks}
selectedTaskId={selectedTaskId}
onSelectTask={setSelectedTaskId}
loading={isLoading}
loadingMore={isFetchingNextPage}
error={errorMessage}
total={total}
onLoadMore={() => fetchNextPage()}
onRefresh={() => refetch()}
/>
</div>
{/* Right side - Subtask details */}
<div className="flex-1 overflow-auto">
{selectedTaskId ? (
<PortfolioSubtaskDetails
subtasks={subtasks}
task={selectedTask}
/>
) : (
<div className="flex items-center justify-center h-full text-gray-500 text-sm">
Select a task to view its subtasks
</div>
)}
</div>
</div>
);
}

View file

@ -0,0 +1,305 @@
"use client";
import { useState, useEffect } from "react";
import { SubTask, PortfolioTask } from "./PortfolioLogs";
import { ScrollArea } from "@/app/shadcn_components/ui/scroll-area";
import { Card } from "@/app/shadcn_components/ui/card";
import { Button } from "@/app/shadcn_components/ui/button";
import { ChevronDown, AlertTriangle } from "lucide-react";
import { cn } from "@/lib/utils";
interface PortfolioSubtaskDetailsProps {
subtasks: SubTask[];
task?: PortfolioTask;
}
function StatusPill({ status }: { status: string }) {
const s = status.toLowerCase();
const isComplete = s === "completed" || s === "complete";
const isInProgress = s === "in progress";
const isFailed = s === "failed" || s === "failure" || s === "error";
return (
<span
className={cn(
"inline-flex items-center rounded-full px-2.5 py-0.5 text-xs font-medium",
isComplete && "bg-green-100 text-green-700",
isInProgress && "bg-blue-100 text-blue-700",
isFailed && "bg-red-100 text-red-700",
!isComplete && !isInProgress && !isFailed && "bg-gray-100 text-gray-600"
)}
>
{status}
</span>
);
}
function formatJson(jsonString: string | null): string {
if (!jsonString) return "N/A";
try {
return JSON.stringify(JSON.parse(jsonString), null, 2);
} catch {
return jsonString;
}
}
function CopyableCodeBlock({ content, label }: { content: string; label: string }) {
const [copied, setCopied] = useState(false);
const handleCopy = async () => {
try {
await navigator.clipboard.writeText(content);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
} catch (err) {
console.error("Failed to copy:", err);
}
};
return (
<div>
<div className="flex items-center justify-between mb-2">
<p className="text-sm font-medium text-gray-900">{label}</p>
<Button variant="ghost" size="sm" onClick={handleCopy} className="h-6 px-2 text-xs">
{copied ? "✓ Copied" : "Copy"}
</Button>
</div>
<pre className="bg-gray-50 p-3 rounded text-xs overflow-x-auto border border-gray-200 text-gray-700">
{content}
</pre>
</div>
);
}
function ExpandableSubtaskTile({
subtask,
index,
isExpanded,
onToggle,
}: {
subtask: SubTask;
index: number;
isExpanded: boolean;
onToggle: () => void;
}) {
const isFailed = subtask.status.toLowerCase() === "failed";
return (
<Card className={`overflow-hidden ${isFailed ? "border-red-200" : ""}`}>
<button
onClick={onToggle}
className="w-full p-4 flex items-center justify-between hover:bg-gray-50 transition-colors"
>
<div className="flex-1 text-left">
<p className="text-sm font-semibold text-gray-900">Subtask {index + 1}</p>
<code className="text-xs font-mono text-gray-600">{subtask.id}</code>
</div>
<div className="flex items-center gap-3">
<StatusPill status={subtask.status} />
<ChevronDown
size={20}
className={`text-gray-500 transition-transform ${isExpanded ? "rotate-180" : ""}`}
/>
</div>
</button>
{isExpanded && (
<div className="border-t border-gray-200 p-4 space-y-4 bg-gray-50">
{/* Failure callout */}
{isFailed && (
<div className="flex items-start gap-2 p-3 rounded-lg bg-red-50 border border-red-200">
<AlertTriangle className="h-4 w-4 text-red-500 mt-0.5 shrink-0" />
<div>
<p className="text-xs text-red-700 font-semibold">This subtask failed.</p>
{subtask.cloudLogsURL && (
<a
href={subtask.cloudLogsURL}
target="_blank"
rel="noopener noreferrer"
className="text-xs text-red-600 hover:text-red-800 underline mt-1 block"
>
View error logs
</a>
)}
</div>
</div>
)}
{/* Timeline */}
{(subtask.jobStarted || subtask.jobCompleted) && (
<div className="grid grid-cols-2 gap-3 text-sm">
{subtask.jobStarted && (
<div>
<p className="text-gray-600 text-xs font-medium">Started</p>
<p className="text-gray-900 text-xs">
{new Date(subtask.jobStarted).toLocaleString()}
</p>
</div>
)}
{subtask.jobCompleted && (
<div>
<p className="text-gray-600 text-xs font-medium">Completed</p>
<p className="text-gray-900 text-xs">
{new Date(subtask.jobCompleted).toLocaleString()}
</p>
</div>
)}
</div>
)}
{/* Inputs */}
{subtask.inputs && (
<CopyableCodeBlock content={formatJson(subtask.inputs)} label="Inputs" />
)}
{/* Outputs */}
{subtask.outputs && (
<CopyableCodeBlock content={formatJson(subtask.outputs)} label="Outputs" />
)}
{/* Cloud Logs (for non-failed subtasks) */}
{subtask.cloudLogsURL && !isFailed && (
<div>
<p className="text-sm font-medium text-gray-900 mb-2">Cloud Logs</p>
<a
href={subtask.cloudLogsURL}
target="_blank"
rel="noopener noreferrer"
className="text-blue-600 hover:text-blue-800 text-xs break-all"
>
{subtask.cloudLogsURL}
</a>
</div>
)}
<p className="text-xs text-gray-500">
Updated: {new Date(subtask.updatedAt).toLocaleString()}
</p>
</div>
)}
</Card>
);
}
export default function PortfolioSubtaskDetails({
subtasks,
task,
}: PortfolioSubtaskDetailsProps) {
const [expandedSubtasks, setExpandedSubtasks] = useState<Record<string, boolean>>({});
useEffect(() => {
setExpandedSubtasks({});
}, [task?.id]);
const toggleSubtask = (subtaskId: string) => {
setExpandedSubtasks((prev) => ({ ...prev, [subtaskId]: !prev[subtaskId] }));
};
const total = Number(task?.totalSubtasks ?? 0);
const completed = Number(task?.completedSubtasks ?? 0);
const failed = Number(task?.failedSubtasks ?? 0);
const completionPct = total > 0 ? Math.round((completed / total) * 100) : 0;
const remainingCount = total - completed;
const isAllDone = total > 0 && completed === total;
return (
<div className="flex flex-col h-full">
{/* Task Header */}
{task && (
<div className="p-6 border-b border-gray-200 bg-white">
<div className="space-y-3">
<div className="flex items-start justify-between gap-3">
<h2 className="text-base font-semibold text-gray-900 break-all">{task.taskSource}</h2>
<StatusPill status={task.status} />
</div>
{/* Enriched stats */}
{total > 0 && (
<>
<div className="w-full bg-gray-200 rounded-full h-2">
<div
className={cn(
"h-2 rounded-full transition-all",
isAllDone ? "bg-green-500" : failed > 0 ? "bg-red-500" : "bg-blue-500"
)}
style={{ width: `${completionPct}%` }}
/>
</div>
<div className="flex items-center gap-3 text-sm">
<span className={isAllDone ? "text-green-600 font-medium" : "text-gray-600"}>
{completionPct}% complete
</span>
<span className="text-gray-400">·</span>
<span className="text-gray-600">{remainingCount} remaining</span>
{failed > 0 && (
<>
<span className="text-gray-400">·</span>
<span className="text-red-600 font-medium flex items-center gap-1">
<AlertTriangle className="h-3.5 w-3.5" />
{failed} failed
</span>
</>
)}
</div>
</>
)}
<div className="grid grid-cols-2 gap-4 text-sm">
<div>
<p className="text-gray-600">Task ID</p>
<code className="text-xs font-mono text-gray-900 break-all">{task.id}</code>
</div>
{task.service && (
<div>
<p className="text-gray-600">Service</p>
<p className="text-gray-900">{task.service}</p>
</div>
)}
{task.jobStarted && (
<div>
<p className="text-gray-600">Job Started</p>
<p className="text-gray-900 text-xs">
{new Date(task.jobStarted).toLocaleString()}
</p>
</div>
)}
{task.jobCompleted && (
<div>
<p className="text-gray-600">Job Completed</p>
<p className="text-gray-900 text-xs">
{new Date(task.jobCompleted).toLocaleString()}
</p>
</div>
)}
</div>
</div>
</div>
)}
{/* Subtasks List */}
<ScrollArea key={task?.id} className="flex-1 p-6">
<div className="space-y-4">
<h3 className="text-base font-semibold text-gray-900">
Subtasks ({subtasks.length})
</h3>
{subtasks.length === 0 && (
<div className="text-center py-8 text-gray-500 text-sm">No subtasks found</div>
)}
<div className="space-y-3">
{subtasks.map((subtask, index) => (
<ExpandableSubtaskTile
key={subtask.id}
subtask={subtask}
index={index}
isExpanded={expandedSubtasks[subtask.id] || false}
onToggle={() => toggleSubtask(subtask.id)}
/>
))}
</div>
</div>
</ScrollArea>
</div>
);
}

View file

@ -0,0 +1,339 @@
"use client";
import { useState, useMemo } from "react";
import { PortfolioTask } from "./PortfolioLogs";
import { cn } from "@/lib/utils";
import { Button } from "@/app/shadcn_components/ui/button";
import { Input } from "@/app/shadcn_components/ui/input";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/app/shadcn_components/ui/select";
interface PortfolioTaskListProps {
tasks: PortfolioTask[];
selectedTaskId: string | null;
onSelectTask: (taskId: string) => void;
loading: boolean;
loadingMore: boolean;
error: string | null;
total: number;
onLoadMore: () => void;
onRefresh: () => void;
}
type SortOption = "recent" | "oldest" | "status" | "service";
function StatusPill({ status }: { status: string }) {
const s = status.toLowerCase();
const isComplete = s === "completed" || s === "complete";
const isInProgress = s === "in progress";
const isFailed = s === "failed" || s === "failure" || s === "error";
return (
<span
className={cn(
"inline-flex items-center rounded-full px-2 py-0.5 text-xs font-medium",
isComplete && "bg-green-100 text-green-700",
isInProgress && "bg-blue-100 text-blue-700",
isFailed && "bg-red-100 text-red-700",
!isComplete &&
!isInProgress &&
!isFailed &&
"bg-gray-100 text-gray-600",
)}
>
{status}
</span>
);
}
export default function PortfolioTaskList({
tasks,
selectedTaskId,
onSelectTask,
loading,
loadingMore,
error,
total,
onLoadMore,
onRefresh,
}: PortfolioTaskListProps) {
const [searchQuery, setSearchQuery] = useState("");
const [statusFilter, setStatusFilter] = useState<string>("all");
const [serviceFilter, setServiceFilter] = useState<string>("all");
const [sortBy, setSortBy] = useState<SortOption>("recent");
const uniqueStatuses = useMemo(
() => Array.from(new Set(tasks.map((t) => t.status))).sort(),
[tasks],
);
const uniqueServices = useMemo(
() =>
Array.from(
new Set(tasks.map((t) => t.service).filter(Boolean)),
).sort() as string[],
[tasks],
);
const filteredTasks = useMemo(() => {
let result = tasks;
if (statusFilter !== "all") {
result = result.filter((t) => t.status === statusFilter);
}
if (serviceFilter !== "all") {
result = result.filter((t) => t.service === serviceFilter);
}
if (searchQuery) {
const query = searchQuery.toLowerCase();
result = result.filter(
(t) =>
t.id.toLowerCase().includes(query) ||
t.taskSource.toLowerCase().includes(query) ||
(t.service?.toLowerCase().includes(query) ?? false),
);
}
const sorted = [...result];
switch (sortBy) {
case "recent":
sorted.sort(
(a, b) =>
new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime(),
);
break;
case "oldest":
sorted.sort(
(a, b) =>
new Date(a.updatedAt).getTime() - new Date(b.updatedAt).getTime(),
);
break;
case "status":
sorted.sort((a, b) => a.status.localeCompare(b.status));
break;
case "service":
sorted.sort((a, b) => (a.service ?? "").localeCompare(b.service ?? ""));
break;
}
return sorted;
}, [tasks, statusFilter, serviceFilter, searchQuery, sortBy]);
return (
<div className="flex flex-col h-full">
{/* Header */}
<div className="p-4 border-b border-gray-200 space-y-2">
<div className="flex items-center justify-between">
<div>
<h2 className="text-base font-semibold text-gray-900">Tasks</h2>
<p className="text-xs text-gray-600">
{filteredTasks.length} of {tasks.length} (Total: {total})
</p>
</div>
<Button
variant="outline"
size="sm"
onClick={onRefresh}
disabled={loading}
className="text-xs"
>
Refresh
</Button>
</div>
</div>
{/* Filters */}
<div className="p-3 border-b border-gray-200 space-y-2 bg-gray-50">
<Input
placeholder="Search by ID, source, or service..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="text-xs h-8"
/>
<Select
value={sortBy}
onValueChange={(value) => setSortBy(value as SortOption)}
>
<SelectTrigger className="text-xs h-8">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="recent">Most Recent</SelectItem>
<SelectItem value="oldest">Oldest First</SelectItem>
<SelectItem value="status">By Status</SelectItem>
<SelectItem value="service">By Service</SelectItem>
</SelectContent>
</Select>
{uniqueStatuses.length > 0 && (
<Select value={statusFilter} onValueChange={setStatusFilter}>
<SelectTrigger className="text-xs h-8">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">All Statuses</SelectItem>
{uniqueStatuses.map((status) => (
<SelectItem key={status} value={status}>
{status}
</SelectItem>
))}
</SelectContent>
</Select>
)}
{uniqueServices.length > 0 && (
<Select value={serviceFilter} onValueChange={setServiceFilter}>
<SelectTrigger className="text-xs h-8">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">All Services</SelectItem>
{uniqueServices.map((service) => (
<SelectItem key={service} value={service}>
{service}
</SelectItem>
))}
</SelectContent>
</Select>
)}
{(searchQuery || statusFilter !== "all" || serviceFilter !== "all") && (
<Button
variant="outline"
size="sm"
onClick={() => {
setSearchQuery("");
setStatusFilter("all");
setServiceFilter("all");
}}
className="w-full text-xs h-7"
>
Clear Filters
</Button>
)}
</div>
{/* Content */}
<div className="flex-1 overflow-y-auto">
{error && (
<div className="p-4 m-4 bg-red-50 border border-red-200 rounded text-red-700 text-xs">
{error}
</div>
)}
{loading && (
<div className="p-4 text-center text-gray-500 text-sm">
Loading tasks...
</div>
)}
{!loading && !error && tasks.length === 0 && (
<div className="p-4 text-center text-gray-500 text-sm">
No tasks found for this portfolio
</div>
)}
<div className="divide-y divide-gray-200 pb-4">
{filteredTasks.map((task) => {
const total = Number(task.totalSubtasks);
const completed = Number(task.completedSubtasks);
const failed = Number(task.failedSubtasks);
const completionPct =
total > 0 ? Math.round((completed / total) * 100) : 0;
const remainingCount = total - completed;
const isAllDone = total > 0 && completed === total;
return (
<button
key={task.id}
onClick={() => onSelectTask(task.id)}
className={cn(
"w-full min-w-0 overflow-hidden text-left p-4 transition-colors hover:bg-gray-50",
selectedTaskId === task.id
? "bg-blue-50 border-l-4 border-blue-500"
: "border-l-4 border-transparent",
)}
>
<div className="space-y-1.5 min-w-0 pr-2">
{/* Status badge at top */}
<StatusPill status={task.status} />
{/* Route name — truncated with full text on hover */}
<p
className="font-medium text-gray-900 text-sm truncate"
title={task.taskSource}
>
{task.taskSource}
</p>
{task.service && (
<p
className="text-xs text-gray-500 truncate"
title={task.service}
>
{task.service}
</p>
)}
{/* Completion progress */}
{total > 0 && (
<>
<div className="bg-gray-200 rounded-full h-1.5">
<div
className={cn(
"h-1.5 rounded-full transition-all",
isAllDone
? "bg-green-500"
: failed > 0
? "bg-red-500"
: "bg-blue-500",
)}
style={{ width: `${completionPct}%` }}
/>
</div>
<p
className={cn(
"text-xs",
isAllDone
? "text-green-600 font-medium"
: "text-gray-500",
)}
>
{completionPct}% complete &middot; {remainingCount}{" "}
remaining
</p>
</>
)}
{/* Failure indicator */}
{failed > 0 && (
<p className="text-xs text-red-600 font-medium">
{failed} failed subtask{failed > 1 ? "s" : ""}
</p>
)}
<p className="text-xs text-gray-400">
{new Date(task.updatedAt).toLocaleString()}
</p>
</div>
</button>
);
})}
{tasks.length < total && (
<div className="p-4 flex justify-center border-t border-gray-200">
<Button
variant="outline"
size="sm"
onClick={onLoadMore}
disabled={loadingMore}
className="text-xs"
>
{loadingMore ? "Loading..." : "Load More"}
</Button>
</div>
)}
</div>
</div>
</div>
);
}

View file

@ -0,0 +1,22 @@
import { redirect } from "next/navigation";
import { getServerSession } from "next-auth";
import { AuthOptions } from "@/app/api/auth/[...nextauth]/authOptions";
import PortfolioLogs from "./PortfolioLogs";
export default async function LogsPage(props: {
params: Promise<{ slug: string }>;
}) {
const { slug } = await props.params;
const session = await getServerSession(AuthOptions);
const isDomnaUser = !!session?.user?.email?.endsWith("@domna.homes");
if (!isDomnaUser) {
redirect(`/portfolio/${slug}/settings/general`);
}
return (
<div>
<PortfolioLogs portfolioId={slug} />
</div>
);
}

View file

@ -1,23 +1,8 @@
import { getPortfolioSettings } from "../../utils";
import PortfolioSettings from "./PortfolioSettings";
import { redirect } from "next/navigation";
export default async function PortfolioSettingsPage(
props: {
params: Promise<{ slug: string }>;
}
) {
const params = await props.params;
const portfolioId = params.slug;
const portfolioSettingsData = await getPortfolioSettings(portfolioId);
return (
<>
<div className="flex justify-center">
<PortfolioSettings
portfolioId={portfolioId}
portfolioSettingsData={portfolioSettingsData}
/>
</div>
</>
);
export default async function SettingsRootPage(props: {
params: Promise<{ slug: string }>;
}) {
const { slug } = await props.params;
redirect(`/portfolio/${slug}/settings/general`);
}

View file

@ -0,0 +1,13 @@
import { UsersPermissionsCard } from "../UsersPermissionsCard";
export default async function UserAccessPage(props: {
params: Promise<{ slug: string }>;
}) {
const { slug } = await props.params;
return (
<div>
<UsersPermissionsCard portfolioId={slug} />
</div>
);
}

View file

@ -0,0 +1,411 @@
"use client";
import { useState } from "react";
import { motion } from "framer-motion";
import { Home, AlertTriangle, ToggleLeft, ToggleRight } from "lucide-react";
import { Card, CardContent } from "@/app/shadcn_components/ui/card";
import SurveyedResultsPieChart from "./SurveyedResultsPieChart";
import DampMouldRiskPanel from "./DampMouldRiskPanel";
import CompletionTrendsChart from "./CompletionTrendsChart";
import SurveyIssuesPanel from "./SurveyIssuesPanel";
import { STAGE_COLORS, STAGE_ORDER } from "./types";
import type {
ProjectData,
ClassifiedDeal,
TableModal,
FunnelStage,
DisplayStage,
} from "./types";
// -----------------------------------------------------------------------
// Stat card (reused from original LiveTracker)
// -----------------------------------------------------------------------
function StatCard({
icon: Icon,
title,
value,
subtitle,
onClick,
accent = "brandblue",
}: {
icon: React.ElementType;
title: string;
value: string | number;
subtitle?: string;
onClick: () => void;
accent?: "brandblue" | "red" | "bright-red";
}) {
const accentConfig = {
brandblue: {
gradient: "from-brandlightblue/30 to-brandlightblue/10",
border: "border-brandblue/20",
text: "text-brandblue",
value: "text-brandblue",
hover: "hover:border-brandblue/40 hover:shadow-lg",
icon: "text-brandblue",
},
red: {
gradient: "from-red-100/30 to-red-50/20",
border: "border-red-300/40",
text: "text-red-500",
value: "text-red-500",
hover: "hover:border-red-300/60 hover:shadow-lg",
icon: "text-red-500",
},
"bright-red": {
gradient: "from-red-100 to-red-50",
border: "border-red-500",
text: "text-red-700",
value: "text-red-900",
hover: "hover:border-red-600 hover:shadow-lg",
icon: "text-red-700",
},
};
const config = accentConfig[accent];
return (
<motion.button
onClick={onClick}
whileHover={{ scale: 1.02 }}
className={`group relative text-left border rounded-xl bg-gradient-to-br ${config.gradient} ${config.border} transition-all duration-300 shadow-sm ${config.hover} p-6`}
>
<div className="flex items-center justify-between">
<div>
<p
className={`text-xs uppercase tracking-wide font-semibold ${config.text} opacity-70 mb-3`}
>
{title}
</p>
<p
className={`text-3xl font-bold ${config.value} opacity-50 group-hover:opacity-75 transition-opacity`}
>
{value}
{subtitle && (
<span className="text-base font-medium text-gray-600 ml-2">
{subtitle}
</span>
)}
</p>
</div>
<Icon
className={`h-8 w-8 ${config.icon} opacity-40 group-hover:opacity-70 transition-all duration-300`}
/>
</div>
</motion.button>
);
}
// -----------------------------------------------------------------------
// Per-stage column config for the drill-down table
// -----------------------------------------------------------------------
type StageTableConfig = {
cols: (keyof ClassifiedDeal)[];
labels: Partial<Record<keyof ClassifiedDeal, string>>;
};
const STAGE_TABLE_CONFIG: Record<string, StageTableConfig> = {
"Booking in Progress": {
cols: ["dealname", "landlordPropertyId", "confirmedSurveyDate", "ioeV1Date"],
labels: {
dealname: "Address",
landlordPropertyId: "Ref",
confirmedSurveyDate: "Confirmed Survey Date",
ioeV1Date: "Expected Commencement",
},
},
"Assessment in Progress": {
cols: ["dealname", "landlordPropertyId", "confirmedSurveyDate", "ioeV1Date", "outcome", "coordinator"],
labels: {
dealname: "Address",
landlordPropertyId: "Ref",
confirmedSurveyDate: "Confirmed Survey Date",
ioeV1Date: "Expected Commencement",
outcome: "Outcome",
coordinator: "Surveyor",
},
},
"Coordination in Progress": {
cols: ["dealname", "landlordPropertyId", "coordinator", "preSapScore", "coordinationStatus"],
labels: {
dealname: "Address",
landlordPropertyId: "Ref",
coordinator: "Coordinator",
preSapScore: "Pre-SAP Score",
coordinationStatus: "Coordination Status",
},
},
"Design in Progress": {
cols: ["dealname", "landlordPropertyId", "designer", "proposedMeasures", "designType"],
labels: {
dealname: "Address",
landlordPropertyId: "Ref",
designer: "Designer",
proposedMeasures: "Proposed Measures",
designType: "Design Type",
},
},
_default: {
cols: ["dealname", "landlordPropertyId", "displayStage", "installer"],
labels: {
dealname: "Address",
landlordPropertyId: "Ref",
displayStage: "Stage",
installer: "Installer",
},
},
};
// -----------------------------------------------------------------------
// Pipeline Funnel — rich card rows
// -----------------------------------------------------------------------
function PipelineFunnel({
funnelStages,
allDeals,
onOpenTable,
}: {
funnelStages: FunnelStage[];
allDeals: ClassifiedDeal[];
onOpenTable: (
stage: string,
deals: ClassifiedDeal[],
columns?: (keyof ClassifiedDeal)[],
columnLabels?: Partial<Record<keyof ClassifiedDeal, string>>,
breakdown?: Record<string, ClassifiedDeal[]>,
title?: string,
description?: string,
reason?: string,
) => void;
}) {
const [mode, setMode] = useState<"current" | "cumulative">("current");
const ALWAYS_VISIBLE: DisplayStage[] = ["At Lodgement", "Project Complete"];
const visibleStages = funnelStages.filter(
(s) => s.currentCount > 0 || s.cumulativeCount > 0 || ALWAYS_VISIBLE.includes(s.stage),
);
const maxCount = Math.max(
...visibleStages.map((s) =>
mode === "current" ? s.currentCount : s.cumulativeCount,
),
1,
);
return (
<Card className="border border-brandblue/10 shadow-sm">
<CardContent className="p-6">
<div className="flex items-center justify-between mb-5">
<div>
<h3 className="text-base font-semibold text-brandblue">
Pipeline Overview
</h3>
<p className="text-sm text-gray-500 mt-0.5">
{mode === "cumulative"
? "Properties that have reached each stage or beyond"
: "Properties currently at each stage"}
</p>
</div>
<button
onClick={() =>
setMode((m) => (m === "current" ? "cumulative" : "current"))
}
className="inline-flex items-center gap-2 px-3 py-1.5 rounded-lg border border-brandblue/20 bg-brandblue/5 text-xs font-medium text-brandblue hover:bg-brandblue/10 transition-colors"
>
{mode === "cumulative" ? (
<ToggleRight className="h-3.5 w-3.5" />
) : (
<ToggleLeft className="h-3.5 w-3.5" />
)}
{mode === "cumulative" ? "Cumulative" : "Point-in-time"}
</button>
</div>
<div className="space-y-2">
{visibleStages.map((s) => {
const count =
mode === "current" ? s.currentCount : s.cumulativeCount;
const pct = mode === "current" ? s.currentPct : s.cumulativePct;
const pastCount = s.cumulativeCount - s.currentCount;
const barWidth = maxCount > 0 ? (count / maxCount) * 100 : 0;
const c = STAGE_COLORS[s.stage];
const deals = allDeals.filter((d) =>
mode === "current"
? d.displayStage === s.stage
: STAGE_ORDER.indexOf(d.displayStage) >=
STAGE_ORDER.indexOf(s.stage),
);
return (
<motion.button
key={s.stage}
whileHover={{ scale: 1.01, y: -1 }}
transition={{ duration: 0.15 }}
onClick={() => {
const { cols, labels } = STAGE_TABLE_CONFIG[s.stage] ?? STAGE_TABLE_CONFIG._default;
onOpenTable(`Pipeline — ${s.stage}`, deals, cols, labels);
}}
className={`w-full text-left rounded-xl border ${c.border} ${c.bg} p-4 shadow-sm hover:shadow-md transition-shadow`}
type="button"
>
{/* Header row: dot + name + pct badge */}
<div className="flex items-center justify-between mb-3">
<div className="flex items-center gap-2">
<span className={`w-2.5 h-2.5 rounded-full shrink-0 ${c.dot}`} />
<span className={`text-sm font-semibold ${c.text}`}>
{s.stage}
</span>
</div>
<span className={`text-xs font-medium px-2 py-0.5 rounded-full bg-white/60 ${c.text}`}>
{pct.toFixed(0)}%
</span>
</div>
{/* Progress bar */}
<div className="h-2 bg-white/50 rounded-full overflow-hidden mb-3">
<motion.div
initial={{ width: 0 }}
animate={{ width: `${barWidth}%` }}
transition={{ duration: 0.7, ease: "easeOut" }}
className={`h-full rounded-full ${c.dot}`}
style={{ minWidth: count > 0 ? "0.5rem" : 0 }}
/>
</div>
{/* Stats row */}
<div className="flex items-center gap-4">
<div>
<span className={`text-2xl font-bold ${c.text}`}>{count}</span>
<span className={`text-xs ml-1.5 ${c.text} opacity-70`}>
{mode === "current" ? "here now" : "reached stage"}
</span>
</div>
{mode === "cumulative" && pastCount > 0 && (
<div className={`text-xs ${c.text} opacity-60 border-l border-current/20 pl-4`}>
<span className="font-semibold">{pastCount}</span>
{" past this stage"}
</div>
)}
</div>
</motion.button>
);
})}
</div>
</CardContent>
</Card>
);
}
// -----------------------------------------------------------------------
// AnalyticsView — props
// -----------------------------------------------------------------------
interface AnalyticsViewProps {
projects: { projectCode: string }[];
currentProject: ProjectData;
currentProjectCode: string;
onProjectChange: (code: string) => void;
onOpenTable: (
stage: string,
deals: ClassifiedDeal[],
columns?: (keyof ClassifiedDeal)[],
columnLabels?: Partial<Record<keyof ClassifiedDeal, string>>,
breakdown?: Record<string, ClassifiedDeal[]>,
) => void;
majorConditionDeals: ClassifiedDeal[];
totalDeals: number;
}
export default function AnalyticsView({
projects,
currentProject,
currentProjectCode,
onProjectChange,
onOpenTable,
majorConditionDeals,
totalDeals,
}: AnalyticsViewProps) {
return (
<div className="space-y-6">
{/* Row 1: project selector + stat card (Properties in project) */}
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
{/* Project selector */}
<Card className="flex flex-col justify-center items-center border border-brandblue/10 bg-gradient-to-br from-brandlightblue/20 to-white shadow-sm hover:shadow-md transition-shadow p-5">
<div className="w-full flex flex-col">
<p className="text-xs uppercase tracking-wide text-gray-600 mb-3 font-semibold">
Select Project
</p>
<div className="relative">
<select
value={currentProjectCode}
onChange={(e) => onProjectChange(e.target.value)}
className="w-full px-4 py-2.5 pr-10 border border-brandblue/20 rounded-lg bg-white text-gray-800 font-medium text-center focus:ring-2 focus:ring-brandblue focus:border-brandblue focus:outline-none transition-all appearance-none"
>
{projects.map((p) =>
p.projectCode === "__ALL__" ? (
<option key="__ALL__" value="__ALL__" style={{ fontWeight: 700 }}>
All Projects
</option>
) : (
<option key={p.projectCode} value={p.projectCode}>
{p.projectCode}
</option>
)
)}
</select>
</div>
</div>
</Card>
{/* Properties in project */}
<StatCard
icon={Home}
title="Properties in Project"
value={currentProject.allDeals.length}
onClick={() =>
onOpenTable(
currentProjectCode === "__ALL__" ? "All Properties" : `${currentProjectCode} — All Properties`,
currentProject.allDeals,
["dealname", "landlordPropertyId"],
{ dealname: "Address Ref.", landlordPropertyId: "Property Ref." },
)
}
accent="brandblue"
/>
</div>
{/* Row 1.5: Completion trends chart */}
<CompletionTrendsChart
deals={currentProject.allDeals}
onOpenTable={onOpenTable}
/>
{/* Row 2: section header */}
<div className="pb-3 border-b border-brandblue/10 text-center">
<h2 className="text-base font-bold text-brandblue">
Project Insights {" "}
<span className="text-brandmidblue">
{currentProjectCode === "__ALL__" ? "All Projects" : currentProjectCode}
</span>
</h2>
</div>
{/* Row 4: Pipeline Funnel */}
<PipelineFunnel
funnelStages={currentProject.progress.funnelStages}
allDeals={currentProject.allDeals}
onOpenTable={onOpenTable}
/>
{/* Row 5: Damp & Mould Risk (moved up) */}
<DampMouldRiskPanel
risk={currentProject.progress.dampMouldRisk}
onOpenTable={onOpenTable}
/>
{/* Row 6: Survey Issues */}
<SurveyIssuesPanel
deals={currentProject.allDeals}
onOpenTable={onOpenTable}
/>
</div>
);
}

View file

@ -0,0 +1,480 @@
"use client";
import { useState } from "react";
import { AlertCircle } from "lucide-react";
import { Card, Title } from "@tremor/react";
import {
BarChart as RechartsBarChart,
Bar,
XAxis,
YAxis,
CartesianGrid,
Tooltip,
LabelList,
ResponsiveContainer,
Legend as RechartsLegend,
} from "recharts";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
} from "@/app/shadcn_components/ui/select";
import type { ClassifiedDeal } from "./types";
interface CompletionTrendsChartProps {
deals: ClassifiedDeal[];
projectCode?: string;
onOpenTable?: (
stage: string,
deals: ClassifiedDeal[],
columns?: (keyof ClassifiedDeal)[],
columnLabels?: Partial<Record<keyof ClassifiedDeal, string>>,
) => void;
}
const METRICS = [
{ key: "bookings", label: "Bookings", dateField: "confirmedSurveyDate" },
{
key: "assessments",
label: "Completed Assessments",
dateField: "surveyedDate",
},
{
key: "coordination",
label: "Completed Coordination",
dateField: "ioeV1Date",
},
{ key: "design", label: "Completed Designs", dateField: "designDate" },
{
key: "lodgement",
label: "Completed Lodgements",
dateField: "fullLodgementDate",
},
];
// Brand colour palette
const C = {
blue: "#5d6be0",
midblue: "#3943b7",
lightblue: "#8b96e9",
paleblue: "#b8bef4",
brown: "#c4a47c",
};
function ChartTooltip({
payload,
active,
label,
}: {
payload?: { name: string; value: number; color: string }[];
active?: boolean;
label?: string;
}) {
if (!active || !payload?.length) return null;
// Filter out the internal _total key
const visible = payload.filter((p) => p.name !== "_total");
if (!visible.length) return null;
return (
<div className="bg-white border border-gray-200 rounded-lg shadow-lg px-3 py-2 text-sm min-w-[140px]">
<p className="font-semibold text-gray-700 mb-1.5 border-b border-gray-100 pb-1">{label}</p>
{visible.map((item, i) => (
<div key={i} className="flex items-center justify-between gap-3 py-0.5">
<div className="flex items-center gap-1.5">
<span
className="w-2.5 h-2.5 rounded-sm shrink-0"
style={{ backgroundColor: item.color }}
/>
<span className="text-gray-600 text-xs">{item.name}</span>
</div>
<span className="font-semibold text-gray-800 text-xs">{item.value}</span>
</div>
))}
</div>
);
}
// Client-facing design type labels
const DESIGN_TYPE_LABELS: Record<string, string> = {
"Archetype (Complex)": "Bespoke (Complex)",
"Archetype (Simple)": "Bespoke (Simple)",
"Repetitive (Complex)": "Standard (Complex)",
"Repetitive (Simple)": "Standard (Simple)",
};
const DESIGN_TYPE_ORDER = [
"Bespoke (Complex)",
"Bespoke (Simple)",
"Standard (Complex)",
"Standard (Simple)",
];
function getMondayOfWeek(date: Date): string {
const d = new Date(date);
const day = d.getDay();
d.setDate(d.getDate() - (day === 0 ? 6 : day - 1));
d.setHours(0, 0, 0, 0);
return d.toISOString().split("T")[0];
}
function formatMonday(isoDate: string): string {
return new Date(isoDate).toLocaleDateString("en-GB", {
day: "numeric",
month: "short",
year: "numeric",
});
}
function fillWeekGaps(keys: string[]): string[] {
if (keys.length === 0) return [];
const sorted = [...keys].sort();
const result: string[] = [];
const current = new Date(sorted[0]);
const end = new Date(sorted[sorted.length - 1]);
while (current <= end) {
result.push(current.toISOString().split("T")[0]);
current.setDate(current.getDate() + 7);
}
return result;
}
function aggregateByWeek(
deals: ClassifiedDeal[],
dateField: string,
filter?: (deal: ClassifiedDeal) => boolean,
) {
const weekCounts: Record<string, number> = {};
for (const deal of deals) {
if (filter && !filter(deal)) continue;
const date = deal[dateField as keyof ClassifiedDeal] as string | Date | null;
if (!date) continue;
const d = new Date(date);
if (isNaN(d.getTime())) continue;
const key = getMondayOfWeek(d);
weekCounts[key] = (weekCounts[key] || 0) + 1;
}
const allKeys = fillWeekGaps(Object.keys(weekCounts));
return allKeys.map((isoKey) => ({
week: formatMonday(isoKey),
value: weekCounts[isoKey] ?? 0,
}));
}
function aggregateCoordinationByWeek(
deals: ClassifiedDeal[],
): Array<{ week: string; "V1 (MTP)": number; "V2 (Re-model)": number; _total: number }> {
const v1Counts: Record<string, number> = {};
const v2Counts: Record<string, number> = {};
for (const deal of deals) {
const status = (deal.coordinationStatus ?? "").toUpperCase();
if (status.includes("(V1) IOE/MTP COMPLETE") && deal.ioeV1Date) {
const d = new Date(deal.ioeV1Date);
if (!isNaN(d.getTime())) {
const key = getMondayOfWeek(d);
v1Counts[key] = (v1Counts[key] || 0) + 1;
}
}
if (status.includes("(V2) IOE/MTP COMPLETE") && deal.ioeV2Date) {
const d = new Date(deal.ioeV2Date);
if (!isNaN(d.getTime())) {
const key = getMondayOfWeek(d);
v2Counts[key] = (v2Counts[key] || 0) + 1;
}
}
}
const allKeys = fillWeekGaps(
Array.from(new Set([...Object.keys(v1Counts), ...Object.keys(v2Counts)])),
);
return allKeys.map((isoKey) => {
const v1 = v1Counts[isoKey] ?? 0;
const v2 = v2Counts[isoKey] ?? 0;
return { week: formatMonday(isoKey), "V1 (MTP)": v1, "V2 (Re-model)": v2, _total: v1 + v2 };
});
}
function aggregateAssessmentsByWeek(
deals: ClassifiedDeal[],
): Array<{ week: string; "Retrofit Assessment": number; EPC: number; _total: number }> {
const retrofitCounts: Record<string, number> = {};
const epcCounts: Record<string, number> = {};
for (const deal of deals) {
const o = deal.outcome ?? "";
const isRetrofit = o === "Surveyed" || o === "Surveyed - Pending Upload";
const isEpc = o === "EPC Completed";
if (!isRetrofit && !isEpc) continue;
if (!deal.surveyedDate) continue;
const d = new Date(deal.surveyedDate);
if (isNaN(d.getTime())) continue;
const key = getMondayOfWeek(d);
if (isRetrofit) retrofitCounts[key] = (retrofitCounts[key] || 0) + 1;
if (isEpc) epcCounts[key] = (epcCounts[key] || 0) + 1;
}
const allKeys = fillWeekGaps(
Array.from(new Set([...Object.keys(retrofitCounts), ...Object.keys(epcCounts)])),
);
return allKeys.map((isoKey) => {
const r = retrofitCounts[isoKey] ?? 0;
const e = epcCounts[isoKey] ?? 0;
return { week: formatMonday(isoKey), "Retrofit Assessment": r, EPC: e, _total: r + e };
});
}
function aggregateLodgementsByWeek(
deals: ClassifiedDeal[],
): Array<{ week: string; "Stage 1 Lodgement": number; "Lodged Measures": number; _total: number }> {
const stageCounts: Record<string, number> = {};
const measuresCounts: Record<string, number> = {};
for (const deal of deals) {
if (deal.fullLodgementDate) {
const d = new Date(deal.fullLodgementDate);
if (!isNaN(d.getTime())) {
const key = getMondayOfWeek(d);
stageCounts[key] = (stageCounts[key] || 0) + 1;
}
}
if (deal.measuresLodgementDate) {
const d = new Date(deal.measuresLodgementDate);
if (!isNaN(d.getTime())) {
const key = getMondayOfWeek(d);
measuresCounts[key] = (measuresCounts[key] || 0) + 1;
}
}
}
const allKeys = fillWeekGaps(
Array.from(new Set([...Object.keys(stageCounts), ...Object.keys(measuresCounts)])),
);
return allKeys.map((isoKey) => {
const s = stageCounts[isoKey] ?? 0;
const m = measuresCounts[isoKey] ?? 0;
return { week: formatMonday(isoKey), "Stage 1 Lodgement": s, "Lodged Measures": m, _total: s + m };
});
}
function aggregateDesignsByWeek(
deals: ClassifiedDeal[],
): Array<Record<string, string | number>> {
const counts: Record<string, Record<string, number>> = {};
for (const deal of deals) {
if (deal.designStatus?.toUpperCase() !== "UPLOADED") continue;
if (!deal.designDate) continue;
const d = new Date(deal.designDate);
if (isNaN(d.getTime())) continue;
const key = getMondayOfWeek(d);
const rawType = deal.designType ?? "Unknown";
const label = DESIGN_TYPE_LABELS[rawType] ?? rawType;
if (!counts[key]) counts[key] = {};
counts[key][label] = (counts[key][label] || 0) + 1;
}
const allKeys = fillWeekGaps(Object.keys(counts));
return allKeys.map((isoKey) => {
const entry: Record<string, string | number> = { week: formatMonday(isoKey) };
let total = 0;
for (const label of DESIGN_TYPE_ORDER) {
const v = counts[isoKey]?.[label] ?? 0;
entry[label] = v;
total += v;
}
entry._total = total;
return entry;
});
}
// Compute total completed count for metrics that support it
function computeTotalCompleted(
metric: string,
chartData: Record<string, string | number>[],
categories: string[],
): number | null {
if (!["bookings", "assessments", "coordination", "design"].includes(metric)) return null;
return chartData.reduce((sum, row) => {
return sum + categories.reduce((s, cat) => s + ((row[cat] as number) || 0), 0);
}, 0);
}
export default function CompletionTrendsChart({
deals,
onOpenTable,
}: CompletionTrendsChartProps) {
const [metric, setMetric] = useState(METRICS[0].key);
const selectedMetric = METRICS.find((m) => m.key === metric)!;
const isCoordination = metric === "coordination";
const isAssessments = metric === "assessments";
const isLodgement = metric === "lodgement";
const isDesign = metric === "design";
const isStacked = isCoordination || isAssessments || isLodgement || isDesign;
// External assessments with no date
const undatedAssessments = isAssessments
? deals.filter((d) => {
const o = d.outcome ?? "";
return (o === "Surveyed" || o === "Surveyed - Pending Upload") && !d.surveyedDate;
})
: [];
// Build chart data
let chartData: Record<string, string | number>[];
let categories: string[];
let colors: string[];
if (isCoordination) {
chartData = aggregateCoordinationByWeek(deals);
categories = ["V1 (MTP)", "V2 (Re-model)"];
colors = [C.blue, C.midblue];
} else if (isAssessments) {
chartData = aggregateAssessmentsByWeek(deals);
categories = ["Retrofit Assessment", "EPC"];
colors = [C.blue, C.midblue];
} else if (isLodgement) {
chartData = aggregateLodgementsByWeek(deals);
categories = ["Stage 1 Lodgement", "Lodged Measures"];
colors = [C.blue, C.lightblue];
} else if (isDesign) {
chartData = aggregateDesignsByWeek(deals);
categories = DESIGN_TYPE_ORDER;
colors = [C.midblue, C.blue, C.lightblue, C.paleblue];
} else {
const singleData = aggregateByWeek(deals, selectedMetric.dateField);
chartData = singleData.map((d) => ({
week: d.week,
[selectedMetric.label]: d.value,
}));
categories = [selectedMetric.label];
colors = [C.blue];
}
const totalCompleted = computeTotalCompleted(metric, chartData, categories);
return (
<Card className="p-6 border border-brandblue/10 bg-white shadow-sm">
{/* Header row */}
<div className="flex flex-col sm:flex-row sm:items-start sm:justify-between gap-4 mb-4">
<div className="flex flex-col gap-2">
<Title className="text-brandblue text-lg font-bold">
Trends Over Time
</Title>
{totalCompleted !== null && (
<div className="inline-flex items-center gap-2 self-start px-3 py-1.5 rounded-full bg-gradient-to-r from-brandmidblue/10 to-brandlightblue/50 border border-brandblue/20 shadow-sm">
<span className="text-brandmidblue font-bold text-base leading-none" suppressHydrationWarning>{totalCompleted}</span>
<span className="text-xs text-brandblue font-medium">
{metric === "bookings" ? "booked to date" : "completed to date"}
</span>
<span className="text-brandmidblue text-xs leading-none"></span>
</div>
)}
</div>
<div className="flex gap-2 items-start">
<Select value={metric} onValueChange={setMetric}>
<SelectTrigger className="w-56 h-9 text-sm border-gray-200">
{METRICS.find((m) => m.key === metric)?.label}
</SelectTrigger>
<SelectContent>
{METRICS.map((m) => (
<SelectItem key={m.key} value={m.key} className="text-sm">
{m.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
{/* Undated external assessments — shown above the chart */}
{isAssessments && undatedAssessments.length > 0 && (
<div className="mb-4 flex items-center justify-between gap-3 p-3 rounded-lg border border-amber-200 bg-amber-50/60">
<div className="flex items-center gap-2 min-w-0">
<AlertCircle className="h-4 w-4 text-amber-500 shrink-0" />
<span className="text-sm text-amber-700">
<span className="font-semibold">{undatedAssessments.length}</span>{" "}
external assessment{undatedAssessments.length !== 1 ? "s" : ""} have no date recorded
</span>
</div>
{onOpenTable && (
<button
onClick={() =>
onOpenTable(
"Undated External Assessments",
undatedAssessments,
["dealname", "landlordPropertyId", "coordinator"],
{
dealname: "Address",
landlordPropertyId: "Property Ref.",
coordinator: "Surveyor",
},
)
}
className="shrink-0 text-xs font-semibold text-amber-700 underline underline-offset-2 hover:text-amber-900 transition-colors"
>
View properties
</button>
)}
</div>
)}
{/* Chart */}
<ResponsiveContainer width="100%" height={288}>
<RechartsBarChart data={chartData} margin={{ top: 20, right: 16, left: 0, bottom: 0 }}>
<CartesianGrid strokeDasharray="3 3" stroke="#f0f0f0" vertical={false} />
<XAxis
dataKey="week"
tick={{ fontSize: 10, fill: "#9ca3af" }}
axisLine={false}
tickLine={false}
/>
<YAxis
width={36}
tick={{ fontSize: 10, fill: "#9ca3af" }}
axisLine={false}
tickLine={false}
allowDecimals={false}
/>
<Tooltip content={<ChartTooltip />} cursor={{ fill: "rgba(89,107,224,0.06)" }} />
{categories.map((cat, i) => (
<Bar
key={cat}
dataKey={cat}
stackId={isStacked ? "stack" : undefined}
fill={colors[i]}
radius={i === categories.length - 1 || !isStacked ? [3, 3, 0, 0] : [0, 0, 0, 0]}
>
{/* For stacked bars: show total on the top (last) bar only via _total.
For non-stacked: show each bar's own value. */}
{i === categories.length - 1 && (
<LabelList
dataKey={isStacked ? "_total" : cat}
position="top"
style={{ fontSize: 10, fill: "#6b7280", fontWeight: 500 }}
formatter={(v: number) => (v === 0 ? "" : v)}
/>
)}
</Bar>
))}
</RechartsBarChart>
</ResponsiveContainer>
{/* Legend for stacked charts */}
{isStacked && (
<RechartsLegend
wrapperStyle={{ paddingTop: "12px", fontSize: "12px", color: "#6b7280" }}
iconType="square"
iconSize={10}
payload={categories.map((cat, i) => ({
value: cat,
type: "square" as const,
color: colors[i],
}))}
/>
)}
</Card>
);
}

View file

@ -0,0 +1,222 @@
"use client";
import { motion } from "framer-motion";
import { Droplets, AlertTriangle, ShieldAlert } from "lucide-react";
import { Card, CardContent } from "@/app/shadcn_components/ui/card";
import type { DampMouldRiskData, ClassifiedDeal } from "./types";
interface DampMouldRiskPanelProps {
risk: DampMouldRiskData;
onOpenTable: (
stage: string,
deals: ClassifiedDeal[],
columns?: (keyof ClassifiedDeal)[],
columnLabels?: Partial<Record<keyof ClassifiedDeal, string>>
) => void;
}
function RiskStatCard({
label,
subtitle,
count,
total,
icon: Icon,
color,
onClick,
}: {
label: string;
subtitle: string;
count: number;
total: number;
icon: React.ElementType;
color: "amber" | "orange" | "red";
onClick: () => void;
}) {
const pct = total > 0 ? ((count / total) * 100).toFixed(1) : "0.0";
const styles = {
amber: {
gradient: "from-amber-50 to-amber-50/30",
border: "border-amber-200",
hover: "hover:border-amber-300 hover:shadow-md",
icon: "text-amber-500",
badge: "bg-amber-100 text-amber-700",
bar: "bg-amber-400",
value: "text-amber-700",
},
orange: {
gradient: "from-orange-50 to-orange-50/30",
border: "border-orange-200",
hover: "hover:border-orange-300 hover:shadow-md",
icon: "text-orange-500",
badge: "bg-orange-100 text-orange-700",
bar: "bg-orange-400",
value: "text-orange-700",
},
red: {
gradient: "from-red-50 to-red-50/30",
border: "border-red-300",
hover: "hover:border-red-400 hover:shadow-md",
icon: "text-red-500",
badge: "bg-red-100 text-red-700",
bar: "bg-red-500",
value: "text-red-700",
},
};
const s = styles[color];
return (
<motion.button
whileHover={{ scale: 1.02 }}
onClick={onClick}
disabled={count === 0}
className={`group w-full text-left rounded-xl border bg-gradient-to-br ${s.gradient} ${s.border} ${s.hover} p-5 transition-all duration-200 shadow-sm disabled:opacity-50 disabled:cursor-default`}
>
<div className="flex items-start justify-between mb-3">
<div className="p-2 rounded-lg bg-white/70">
<Icon className={`h-4 w-4 ${s.icon}`} />
</div>
<span className={`text-xs font-semibold px-2 py-0.5 rounded-full ${s.badge}`}>
{pct}%
</span>
</div>
<p className={`text-2xl font-bold ${s.value} mb-0.5`}>{count}</p>
<p className="text-sm font-medium text-gray-700">{label}</p>
<p className="text-xs text-gray-500 mt-0.5">{subtitle}</p>
{/* Mini progress bar */}
<div className="mt-3 h-1 bg-gray-200 rounded-full overflow-hidden">
<div
className={`h-full ${s.bar} rounded-full transition-all duration-700`}
style={{ width: `${Math.min(Number(pct), 100)}%` }}
/>
</div>
</motion.button>
);
}
export default function DampMouldRiskPanel({
risk,
onOpenTable,
}: DampMouldRiskPanelProps) {
const { totalDeals } = risk;
const surveyColumns: (keyof ClassifiedDeal)[] = [
"dealname",
"landlordPropertyId",
"majorConditionIssueDescription",
"majorConditionIssuePhotosS3",
];
const surveyLabels: Partial<Record<keyof ClassifiedDeal, string>> = {
dealname: "Address",
landlordPropertyId: "Property Ref",
majorConditionIssueDescription: "Surveyor Notes",
majorConditionIssuePhotosS3: "Photo Evidence",
};
const coordColumns: (keyof ClassifiedDeal)[] = [
"dealname",
"landlordPropertyId",
"dampMouldFlag",
"coordinator",
];
const coordLabels: Partial<Record<keyof ClassifiedDeal, string>> = {
dealname: "Address",
landlordPropertyId: "Property Ref",
dampMouldFlag: "Coordinator Flag",
coordinator: "Coordinator",
};
const noRisk =
risk.surveyFlagCount === 0 &&
risk.coordinatorFlagCount === 0;
return (
<Card className="border border-amber-200/60 bg-gradient-to-br from-amber-50/40 to-white shadow-sm">
<CardContent className="p-6">
{/* Header */}
<div className="flex items-start gap-3 mb-5">
<div className="p-2.5 rounded-xl bg-amber-100 border border-amber-200">
<Droplets className="h-5 w-5 text-amber-600" />
</div>
<div>
<h3 className="text-base font-semibold text-gray-800">
Awaab&apos;s Law Damp & Mould Risk
</h3>
<p className="text-sm text-gray-500 mt-0.5">
Comparison of flags raised at survey vs coordination stage
</p>
</div>
</div>
{noRisk ? (
<div className="flex items-center gap-3 py-4 px-4 rounded-xl bg-emerald-50 border border-emerald-200">
<div className="p-1.5 rounded-lg bg-emerald-100">
<ShieldAlert className="h-4 w-4 text-emerald-600" />
</div>
<p className="text-sm font-medium text-emerald-700">
No damp or mould flags recorded for this project.
</p>
</div>
) : (
<>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3 mb-4">
<RiskStatCard
label="Flagged at Survey"
subtitle="Identified by assessor"
count={risk.surveyFlagCount}
total={totalDeals}
icon={AlertTriangle}
color="red"
onClick={() =>
onOpenTable(
"Damp & Mould — Survey Stage Flags",
risk.surveyFlagDeals,
surveyColumns,
surveyLabels
)
}
/>
<RiskStatCard
label="Flagged at Coordination"
subtitle="Identified after survey"
count={risk.coordinatorFlagCount}
total={totalDeals}
icon={Droplets}
color="red"
onClick={() =>
onOpenTable(
"Damp & Mould — Coordination Stage Flags",
risk.coordinatorFlagDeals,
coordColumns,
coordLabels
)
}
/>
</div>
{/* Missed risk callout */}
{risk.coordinatorFlagCount > risk.surveyFlagCount && (
<div className="flex items-start gap-2.5 p-3.5 rounded-lg bg-orange-50 border border-orange-200">
<AlertTriangle className="h-4 w-4 text-orange-500 mt-0.5 shrink-0" />
<p className="text-xs text-orange-700 leading-relaxed">
<span className="font-semibold">
{risk.coordinatorFlagCount - risk.surveyFlagCount} additional{" "}
{risk.coordinatorFlagCount - risk.surveyFlagCount === 1 ? "property was" : "properties were"}{" "}
</span>
flagged for damp & mould at the coordination stage that{" "}
{risk.coordinatorFlagCount - risk.surveyFlagCount === 1 ? "was" : "were"} not
identified during the initial survey.
</p>
</div>
)}
</>
)}
</CardContent>
</Card>
);
}

View file

@ -0,0 +1,289 @@
"use client";
import { useMemo, useState } from "react";
import {
useReactTable,
getCoreRowModel,
getFilteredRowModel,
getSortedRowModel,
getPaginationRowModel,
flexRender,
type SortingState,
type PaginationState,
} from "@tanstack/react-table";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/app/shadcn_components/ui/table";
import { Input } from "@/app/shadcn_components/ui/input";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
} from "@/app/shadcn_components/ui/select";
import { Search, ChevronLeft, ChevronRight, Download } from "lucide-react";
import { createDocumentTableColumns } from "./DocumentTableColumns";
import type { ClassifiedDeal, DocStatusMap } from "./types";
type SurveyStatusFilter = "all" | "none" | "partial" | "complete";
interface DocumentTableProps {
data: ClassifiedDeal[];
onOpenDrawer: (uprn: string | null, landlordPropertyId: string | null, dealname: string | null) => void;
docStatusMap: DocStatusMap;
}
function escapeCell(value: unknown): string {
if (value === null || value === undefined) return "";
const str =
value instanceof Date
? value.toLocaleDateString("en-GB")
: String(value);
return str.includes(",") || str.includes('"') || str.includes("\n")
? `"${str.replace(/"/g, '""')}"`
: str;
}
export default function DocumentTable({ data, onOpenDrawer, docStatusMap }: DocumentTableProps) {
const [globalFilter, setGlobalFilter] = useState("");
const [surveyStatusFilter, setSurveyStatusFilter] = useState<SurveyStatusFilter>("all");
const [sorting, setSorting] = useState<SortingState>([]);
const [pagination, setPagination] = useState<PaginationState>({
pageIndex: 0,
pageSize: 25,
});
const filteredData = useMemo(() => {
if (surveyStatusFilter === "all") return data;
return data.filter((d) => {
const status = d.uprn ? docStatusMap[d.uprn] : undefined;
if (surveyStatusFilter === "none") return !status || !status.hasDocs;
if (surveyStatusFilter === "partial") return !!status?.hasDocs && !status.isComplete;
if (surveyStatusFilter === "complete") return !!status?.isComplete;
return true;
});
}, [data, surveyStatusFilter, docStatusMap]);
const columns = useMemo(
() => createDocumentTableColumns(onOpenDrawer, docStatusMap),
[onOpenDrawer, docStatusMap],
);
const table = useReactTable({
data: filteredData,
columns,
state: { globalFilter, sorting, pagination },
onGlobalFilterChange: setGlobalFilter,
onSortingChange: setSorting,
onPaginationChange: setPagination,
getCoreRowModel: getCoreRowModel(),
getFilteredRowModel: getFilteredRowModel(),
getSortedRowModel: getSortedRowModel(),
getPaginationRowModel: getPaginationRowModel(),
globalFilterFn: "includesString",
});
const downloadCsv = () => {
const rows = table.getFilteredRowModel().rows;
const header = "Address,Landlord ID,Survey Status";
const body = rows
.map((row) => {
const status = row.original.uprn ? docStatusMap[row.original.uprn] : undefined;
const surveyStatus = status?.isComplete
? "Complete"
: status?.hasDocs
? "Partial"
: "No Docs";
return [
escapeCell(row.original.dealname),
escapeCell(row.original.landlordPropertyId),
surveyStatus,
].join(",");
})
.join("\n");
const blob = new Blob([header + "\n" + body], { type: "text/csv;charset=utf-8;" });
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = "document-management.csv";
a.click();
URL.revokeObjectURL(url);
};
const pageCount = table.getPageCount();
const currentPage = table.getState().pagination.pageIndex + 1;
const totalFiltered = table.getFilteredRowModel().rows.length;
const surveyStatusLabel: Record<SurveyStatusFilter, string> = {
all: "All statuses",
none: "No Survey Docs",
partial: "Partial Survey Docs",
complete: "Complete Survey Docs",
};
return (
<div className="space-y-3">
{/* Toolbar */}
<div className="flex flex-col sm:flex-row gap-3 items-start sm:items-center">
{/* Search */}
<div className="relative flex-1 min-w-0">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-gray-400 pointer-events-none" />
<Input
value={globalFilter}
onChange={(e) => {
setGlobalFilter(e.target.value);
setPagination((p) => ({ ...p, pageIndex: 0 }));
}}
placeholder="Search address, landlord ID…"
className="pl-9 h-9 text-sm border-gray-200 focus:border-brandblue/40 focus:ring-brandblue/20"
/>
</div>
{/* Survey status filter */}
<Select
value={surveyStatusFilter}
onValueChange={(v) => {
setSurveyStatusFilter(v as SurveyStatusFilter);
setPagination((p) => ({ ...p, pageIndex: 0 }));
}}
>
<SelectTrigger className="h-9 w-[200px] text-sm border-gray-200 shrink-0">
{surveyStatusLabel[surveyStatusFilter]}
</SelectTrigger>
<SelectContent>
<SelectItem value="all">All statuses</SelectItem>
<SelectItem value="none">No Survey Docs</SelectItem>
<SelectItem value="partial">Partial Survey Docs</SelectItem>
<SelectItem value="complete">Complete Survey Docs</SelectItem>
</SelectContent>
</Select>
{/* Download CSV */}
<button
onClick={downloadCsv}
className="inline-flex items-center gap-2 h-9 px-3 rounded-lg border border-gray-200 bg-white text-sm font-medium text-gray-600 hover:border-brandblue/30 hover:text-brandblue transition-colors shrink-0"
>
<Download className="h-3.5 w-3.5" />
CSV
</button>
</div>
{/* Result count */}
<p className="text-xs text-gray-400">
Showing{" "}
<span className="font-semibold text-gray-600">
{Math.min(
table.getState().pagination.pageSize,
totalFiltered - table.getState().pagination.pageIndex * table.getState().pagination.pageSize,
)}
</span>{" "}
of{" "}
<span className="font-semibold text-gray-600">{totalFiltered}</span>{" "}
{surveyStatusFilter !== "all" ? `(${surveyStatusLabel[surveyStatusFilter].toLowerCase()}) ` : ""}
propert{totalFiltered === 1 ? "y" : "ies"}
</p>
{/* Table */}
<div className="rounded-xl border border-gray-200 overflow-hidden shadow-sm">
<div className="overflow-x-auto">
<Table>
<TableHeader>
{table.getHeaderGroups().map((headerGroup) => (
<TableRow
key={headerGroup.id}
className="bg-gray-50/80 hover:bg-gray-50/80 border-b border-gray-200"
>
{headerGroup.headers.map((header) => (
<TableHead key={header.id} className="h-10 px-4 first:pl-5 last:pr-5">
{header.isPlaceholder
? null
: flexRender(header.column.columnDef.header, header.getContext())}
</TableHead>
))}
</TableRow>
))}
</TableHeader>
<TableBody>
{table.getRowModel().rows.length ? (
table.getRowModel().rows.map((row, i) => (
<TableRow
key={row.id}
className={`border-b border-gray-100 transition-colors hover:bg-brandlightblue/10 ${
i % 2 === 0 ? "bg-white" : "bg-gray-50/30"
}`}
>
{row.getVisibleCells().map((cell) => (
<TableCell key={cell.id} className="py-3 px-4 first:pl-5 last:pr-5">
{flexRender(cell.column.columnDef.cell, cell.getContext())}
</TableCell>
))}
</TableRow>
))
) : (
<TableRow>
<TableCell
colSpan={columns.length}
className="h-32 text-center text-sm text-gray-400"
>
No properties match the current filters.
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
</div>
</div>
{/* Pagination */}
{pageCount > 1 && (
<div className="flex items-center justify-between pt-1">
<div className="flex items-center gap-2">
<span className="text-xs text-gray-500">Rows per page:</span>
<Select
value={String(table.getState().pagination.pageSize)}
onValueChange={(v) => table.setPageSize(Number(v))}
>
<SelectTrigger className="h-7 w-16 text-xs border-gray-200">
{table.getState().pagination.pageSize}
</SelectTrigger>
<SelectContent>
{[10, 25, 50, 100].map((n) => (
<SelectItem key={n} value={String(n)} className="text-xs">
{n}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="flex items-center gap-3">
<span className="text-xs text-gray-500">
Page {currentPage} of {pageCount}
</span>
<div className="flex gap-1">
<button
onClick={() => table.previousPage()}
disabled={!table.getCanPreviousPage()}
className="h-7 w-7 flex items-center justify-center rounded-lg border border-gray-200 text-gray-500 hover:border-brandblue/30 hover:text-brandblue disabled:opacity-30 disabled:cursor-not-allowed transition-colors"
>
<ChevronLeft className="h-3.5 w-3.5" />
</button>
<button
onClick={() => table.nextPage()}
disabled={!table.getCanNextPage()}
className="h-7 w-7 flex items-center justify-center rounded-lg border border-gray-200 text-gray-500 hover:border-brandblue/30 hover:text-brandblue disabled:opacity-30 disabled:cursor-not-allowed transition-colors"
>
<ChevronRight className="h-3.5 w-3.5" />
</button>
</div>
</div>
</div>
)}
</div>
);
}

View file

@ -0,0 +1,147 @@
"use client";
import { ColumnDef } from "@tanstack/react-table";
import { ArrowUpDown, CheckCircle2, AlertCircle, FileX } from "lucide-react";
import type { ClassifiedDeal, DocStatusMap, DocStatus } from "./types";
function SortableHeader({
label,
column,
}: {
label: string;
column: { toggleSorting: (desc: boolean) => void; getIsSorted: () => false | "asc" | "desc" };
}) {
return (
<button
className="flex items-center gap-1 text-xs font-semibold uppercase tracking-wide text-gray-500 hover:text-brandblue transition-colors group"
onClick={() => column.toggleSorting(column.getIsSorted() === "asc")}
>
{label}
<ArrowUpDown className="h-3 w-3 opacity-40 group-hover:opacity-70 transition-opacity" />
</button>
);
}
function SurveyStatusBadge({ status }: { status: DocStatus | undefined }) {
if (status?.isComplete) {
return (
<span className="inline-flex items-center gap-1.5 px-2.5 py-1 rounded-full text-xs font-medium border whitespace-nowrap bg-emerald-50 text-emerald-700 border-emerald-200">
<CheckCircle2 className="h-3.5 w-3.5" />
Complete
</span>
);
}
if (status?.hasDocs) {
return (
<span className="inline-flex items-center gap-1.5 px-2.5 py-1 rounded-full text-xs font-medium border whitespace-nowrap bg-amber-50 text-amber-700 border-amber-200">
<AlertCircle className="h-3.5 w-3.5" />
Partial
</span>
);
}
return (
<span className="inline-flex items-center gap-1.5 px-2.5 py-1 rounded-full text-xs font-medium border whitespace-nowrap bg-gray-50 text-gray-400 border-gray-200">
<FileX className="h-3.5 w-3.5" />
No Docs
</span>
);
}
export function createDocumentTableColumns(
onOpenDrawer: (uprn: string | null, landlordPropertyId: string | null, dealname: string | null) => void,
docStatusMap: DocStatusMap = {},
): ColumnDef<ClassifiedDeal>[] {
return [
// ── Address ──────────────────────────────────────────────────────────
{
accessorKey: "dealname",
id: "dealname",
header: ({ column }) => <SortableHeader label="Address" column={column as any} />,
cell: ({ row }) => (
<div className="max-w-[260px]">
<p className="text-sm font-medium text-gray-900 leading-tight truncate">
{row.original.dealname ?? "—"}
</p>
</div>
),
enableHiding: false,
},
// ── Landlord ID ──────────────────────────────────────────────────────
{
accessorKey: "landlordPropertyId",
id: "landlordPropertyId",
header: ({ column }) => <SortableHeader label="Landlord ID" column={column as any} />,
cell: ({ row }) => (
<span className="text-xs font-mono text-gray-500">
{row.original.landlordPropertyId ?? "—"}
</span>
),
enableHiding: false,
},
// ── Survey Status ─────────────────────────────────────────────────────
{
id: "surveyStatus",
accessorFn: (row) => {
const status = row.uprn ? docStatusMap[row.uprn] : undefined;
if (status?.isComplete) return 2;
if (status?.hasDocs) return 1;
return 0;
},
header: ({ column }) => <SortableHeader label="Survey Status" column={column as any} />,
cell: ({ row }) => {
const status = row.original.uprn ? docStatusMap[row.original.uprn] : undefined;
return <SurveyStatusBadge status={status} />;
},
enableHiding: false,
},
// ── Documents button ─────────────────────────────────────────────────
{
id: "documents",
header: () => (
<span className="text-xs font-semibold uppercase tracking-wide text-gray-500">Docs</span>
),
cell: ({ row }) => {
const uprn = row.original.uprn ?? "";
const status = uprn ? docStatusMap[uprn] : undefined;
let icon: React.ReactNode;
let className: string;
if (status?.isComplete) {
icon = <CheckCircle2 className="h-3.5 w-3.5" />;
className =
"inline-flex items-center gap-1.5 px-2.5 py-1.5 rounded-lg text-xs font-medium border border-emerald-200 text-emerald-700 bg-emerald-50 hover:bg-emerald-100 hover:border-emerald-300 transition-all duration-150 whitespace-nowrap";
} else if (status?.hasDocs) {
icon = <AlertCircle className="h-3.5 w-3.5" />;
className =
"inline-flex items-center gap-1.5 px-2.5 py-1.5 rounded-lg text-xs font-medium border border-amber-200 text-amber-700 bg-amber-50 hover:bg-amber-100 hover:border-amber-300 transition-all duration-150 whitespace-nowrap";
} else {
icon = <FileX className="h-3.5 w-3.5" />;
className =
"inline-flex items-center gap-1.5 px-2.5 py-1.5 rounded-lg text-xs font-medium border border-gray-200 text-gray-400 bg-gray-50 hover:bg-gray-100 hover:border-gray-300 transition-all duration-150 whitespace-nowrap";
}
return (
<button
onClick={() =>
onOpenDrawer(
row.original.uprn,
row.original.landlordPropertyId,
row.original.dealname,
)
}
className={className}
>
{icon}
Docs
</button>
);
},
enableSorting: false,
enableHiding: false,
},
];
}

View file

@ -0,0 +1,304 @@
"use client";
import { useMemo, useState } from "react";
import { useMutation } from "@tanstack/react-query";
import {
useReactTable,
getCoreRowModel,
getFilteredRowModel,
getSortedRowModel,
getPaginationRowModel,
flexRender,
type SortingState,
type PaginationState,
type ColumnDef,
} from "@tanstack/react-table";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/app/shadcn_components/ui/table";
import { Input } from "@/app/shadcn_components/ui/input";
import { Search, Download, ChevronLeft, ChevronRight } from "lucide-react";
import type { ClassifiedDeal, HubspotDeal } from "./types";
interface DrillDownTableProps {
data: ClassifiedDeal[];
columns?: (keyof HubspotDeal)[];
columnLabels?: Partial<Record<keyof HubspotDeal, string>>;
}
function escapeCell(value: unknown): string {
if (value === null || value === undefined) return "";
const str =
value instanceof Date ? value.toLocaleDateString("en-GB") : String(value);
return str.includes(",") || str.includes('"') || str.includes("\n")
? `"${str.replace(/"/g, '""')}"`
: str;
}
function PhotoDownloadButton({ url }: { url: string }) {
const { mutate: download, isPending } = useMutation({
mutationFn: async () => {
const key = url.split(".amazonaws.com/")[1];
if (!key) throw new Error("Invalid S3 key");
const res = await fetch("/api/sign-s3-url", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ key }),
});
if (!res.ok) throw new Error("Failed to get signed URL");
const data = await res.json();
return data.url as string;
},
onSuccess: (signedUrl) => {
window.open(signedUrl, "_blank");
},
});
return (
<button
onClick={() => download()}
disabled={isPending}
className="flex items-center gap-1.5 px-2.5 py-1.5 bg-brandblue/5 text-brandblue text-xs font-medium rounded-lg hover:bg-brandblue/10 border border-brandblue/20 hover:border-brandblue/40 transition-all duration-150 active:scale-95 disabled:opacity-60 disabled:cursor-not-allowed"
>
<Download className="w-3.5 h-3.5" />
{isPending ? "Preparing…" : "Download"}
</button>
);
}
function PhotoDownloadCell({ value }: { value: unknown }) {
let urls: string[] = [];
if (typeof value === "string") {
try {
const parsed = JSON.parse(value);
urls = Array.isArray(parsed) ? parsed : [value];
} catch {
urls = value.split(/[\s,]+/).filter((u) => u.startsWith("http"));
}
} else if (Array.isArray(value)) {
urls = value as string[];
}
if (urls.length === 0) return <span className="text-gray-400 text-xs">No photos</span>;
return (
<div className="flex flex-wrap gap-1.5">
{urls.map((url, idx) => (
<PhotoDownloadButton key={idx} url={url} />
))}
</div>
);
}
export default function DrillDownTable({
data,
columns: columnKeys,
columnLabels,
}: DrillDownTableProps) {
const [globalFilter, setGlobalFilter] = useState("");
const [sorting, setSorting] = useState<SortingState>([]);
const [pagination, setPagination] = useState<PaginationState>({
pageIndex: 0,
pageSize: 25,
});
const visibleKeys: (keyof HubspotDeal)[] = columnKeys?.length
? columnKeys
: (Object.keys(data?.[0] || {}) as (keyof HubspotDeal)[]);
const columns = useMemo<ColumnDef<ClassifiedDeal>[]>(
() =>
visibleKeys.map((key) => ({
accessorKey: key as string,
id: key as string,
header: () => (
<span className="text-xs font-semibold uppercase tracking-wide text-gray-500">
{columnLabels?.[key] ?? (key as string)}
</span>
),
cell: ({ row }) => {
const value = row.original[key as keyof ClassifiedDeal];
if (key === "majorConditionIssuePhotosS3") {
return <PhotoDownloadCell value={value} />;
}
return (
<span className="text-sm text-gray-800">
{value != null ? String(value) : (
<span className="text-gray-300"></span>
)}
</span>
);
},
})),
// eslint-disable-next-line react-hooks/exhaustive-deps
[visibleKeys.join(","), columnLabels],
);
const table = useReactTable({
data,
columns,
state: { globalFilter, sorting, pagination },
onGlobalFilterChange: setGlobalFilter,
onSortingChange: setSorting,
onPaginationChange: setPagination,
getCoreRowModel: getCoreRowModel(),
getFilteredRowModel: getFilteredRowModel(),
getSortedRowModel: getSortedRowModel(),
getPaginationRowModel: getPaginationRowModel(),
globalFilterFn: "includesString",
});
const downloadCsv = () => {
const rows = table.getFilteredRowModel().rows;
const exportKeys = visibleKeys.filter((k) => k !== "majorConditionIssuePhotosS3");
const header = exportKeys
.map((k) => columnLabels?.[k] ?? (k as string))
.join(",");
const body = rows
.map((row) =>
exportKeys.map((k) => escapeCell(row.original[k as keyof ClassifiedDeal])).join(","),
)
.join("\n");
const blob = new Blob([header + "\n" + body], {
type: "text/csv;charset=utf-8;",
});
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = "data.csv";
a.click();
URL.revokeObjectURL(url);
};
const pageCount = table.getPageCount();
const currentPage = table.getState().pagination.pageIndex + 1;
const totalFiltered = table.getFilteredRowModel().rows.length;
return (
<div className="space-y-3">
{/* Toolbar */}
<div className="flex gap-3 items-center">
<div className="relative flex-1 min-w-0">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-gray-400 pointer-events-none" />
<Input
value={globalFilter}
onChange={(e) => {
setGlobalFilter(e.target.value);
setPagination((p) => ({ ...p, pageIndex: 0 }));
}}
placeholder="Search…"
className="pl-9 h-9 text-sm border-gray-200 focus:border-brandblue/40 focus:ring-brandblue/20"
/>
</div>
<button
onClick={downloadCsv}
className="inline-flex items-center gap-2 h-9 px-3 rounded-lg border border-gray-200 bg-white text-sm font-medium text-gray-600 hover:border-brandblue/30 hover:text-brandblue transition-colors shrink-0"
>
<Download className="h-3.5 w-3.5" />
CSV
</button>
</div>
{/* Row count */}
<p className="text-xs text-gray-400">
Showing{" "}
<span className="font-semibold text-gray-600">{totalFiltered}</span>{" "}
{totalFiltered === 1 ? "row" : "rows"}
</p>
{/* Table */}
<div className="rounded-xl border border-gray-200 overflow-hidden shadow-sm">
<div className="overflow-x-auto">
<Table>
<TableHeader>
{table.getHeaderGroups().map((headerGroup) => (
<TableRow
key={headerGroup.id}
className="bg-gray-50/80 hover:bg-gray-50/80 border-b border-gray-200"
>
{headerGroup.headers.map((header) => (
<TableHead
key={header.id}
className="h-10 px-4 first:pl-5 last:pr-5"
>
{header.isPlaceholder
? null
: flexRender(
header.column.columnDef.header,
header.getContext(),
)}
</TableHead>
))}
</TableRow>
))}
</TableHeader>
<TableBody>
{table.getRowModel().rows.length ? (
table.getRowModel().rows.map((row, i) => (
<TableRow
key={row.id}
className={`border-b border-gray-100 transition-colors hover:bg-brandlightblue/10 ${
i % 2 === 0 ? "bg-white" : "bg-gray-50/30"
}`}
>
{row.getVisibleCells().map((cell) => (
<TableCell
key={cell.id}
className="py-3 px-4 first:pl-5 last:pr-5"
>
{flexRender(
cell.column.columnDef.cell,
cell.getContext(),
)}
</TableCell>
))}
</TableRow>
))
) : (
<TableRow>
<TableCell
colSpan={columns.length}
className="h-24 text-center text-sm text-gray-400"
>
No results found.
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
</div>
</div>
{/* Pagination */}
{pageCount > 1 && (
<div className="flex items-center justify-between pt-1">
<span className="text-xs text-gray-500">
Page {currentPage} of {pageCount}
</span>
<div className="flex gap-1">
<button
onClick={() => table.previousPage()}
disabled={!table.getCanPreviousPage()}
className="h-7 w-7 flex items-center justify-center rounded-lg border border-gray-200 text-gray-500 hover:border-brandblue/30 hover:text-brandblue disabled:opacity-30 disabled:cursor-not-allowed transition-colors"
>
<ChevronLeft className="h-3.5 w-3.5" />
</button>
<button
onClick={() => table.nextPage()}
disabled={!table.getCanNextPage()}
className="h-7 w-7 flex items-center justify-center rounded-lg border border-gray-200 text-gray-500 hover:border-brandblue/30 hover:text-brandblue disabled:opacity-30 disabled:cursor-not-allowed transition-colors"
>
<ChevronRight className="h-3.5 w-3.5" />
</button>
</div>
</div>
)}
</div>
);
}

View file

@ -1,61 +1,85 @@
"use client";
import { useState } from "react";
import ProgressOverview from "./ProgressOverview";
import SurveyedResultsPieChart from "./SurveyedResultsPieChart";
import TableViewer from "./TableViewer";
import { Card, CardContent } from "@/app/shadcn_components/ui/card";
import { Home, AlertTriangle } from "lucide-react";
import { motion } from "framer-motion";
import type { LiveTrackerProps, TableModal, ClassifiedDeal, HubspotDeal } from "./types";
import {
Tabs,
TabsContent,
TabsList,
TabsTrigger,
} from "@/app/shadcn_components/ui/tabs";
import { Card, CardContent } from "@/app/shadcn_components/ui/card";
import { BarChart2, Table2, FolderOpen } from "lucide-react";
import DrillDownTable from "./DrillDownTable";
import PropertyTable from "./PropertyTable";
import DocumentTable from "./DocumentTable";
import type { HubspotDeal } from "./types";
import PropertyDrawer from "./PropertyDrawer";
import PropertyDetailDrawer from "./PropertyDetailDrawer";
import AnalyticsView from "./AnalyticsView";
import type {
LiveTrackerProps,
TableModal,
ClassifiedDeal,
DocumentDrawerState,
DocStatusMap,
} from "./types";
export default function LiveTracker({
projects,
totalDeals,
majorConditionDeals,
docStatusMap,
}: LiveTrackerProps) {
// UI State: which table modal is open
const [openTable, setOpenTable] = useState<TableModal | null>(null);
// ── Tab state ────────────────────────────────────────────────────────
const [activeTab, setActiveTab] = useState<"analytics" | "properties" | "documents">(
"analytics",
);
// UI State: which project tab is selected
// ── Project selector (shared across both tabs) ───────────────────────
const projectCodes = projects.map((p) => p.projectCode);
const [currentProjectCode, setCurrentProjectCode] = useState(projectCodes[0]);
const currentProject = projects.find(
(p) => p.projectCode === currentProjectCode
(p) => p.projectCode === currentProjectCode,
);
// Compute minor stuff inline (not data processing)
const majorIssues = majorConditionDeals.length;
const majorPercent = ((majorIssues / totalDeals) * 100).toFixed(1);
const hasSurveyData = (currentProject?.outcomePieSlices.length ?? 0) > 0;
// ── Drill-down table modal (used by AnalyticsView) ───────────────────
const [openTable, setOpenTable] = useState<TableModal | null>(null);
// Group allDeals by outcome for pie chart click handler
const dealsByOutcome: Record<string, ClassifiedDeal[]> = {};
for (const deal of currentProject?.allDeals ?? []) {
if (deal.outcome) {
(dealsByOutcome[deal.outcome] ??= []).push(deal);
}
}
// ── Document drawer (used by PropertyTable) ──────────────────────────
const [drawerState, setDrawerState] = useState<DocumentDrawerState>({
open: false,
uprn: null,
landlordPropertyId: null,
dealname: null,
});
// ── Property detail drawer ───────────────────────────────────────────
const [detailDeal, setDetailDeal] = useState<ClassifiedDeal | null>(null);
const handleOpenTable = (
stage: string,
filteredDeals: ClassifiedDeal[],
columns?: (keyof HubspotDeal)[],
columnLabels?: Partial<Record<keyof HubspotDeal, string>>,
breakdown?: Record<string, ClassifiedDeal[]>
columns?: (keyof ClassifiedDeal)[],
columnLabels?: Partial<Record<keyof ClassifiedDeal, string>>,
breakdown?: Record<string, ClassifiedDeal[]>,
) => {
setOpenTable({
stage,
data: filteredDeals,
columns: columns || ["dealname", "landlordPropertyId"],
columnLabels: columnLabels || {
columns: (columns || ["dealname", "landlordPropertyId"]) as (keyof ClassifiedDeal)[],
columnLabels: (columnLabels || {
dealname: "Address Ref.",
landlordPropertyId: "Property Ref.",
},
}) as Partial<Record<keyof ClassifiedDeal, string>>,
breakdown,
});
};
const handleOpenDrawer = (uprn: string | null, landlordPropertyId: string | null, dealname: string | null) => {
setDrawerState({ open: true, uprn, landlordPropertyId, dealname });
};
if (!totalDeals) {
return (
<Card className="p-8 text-center bg-gradient-to-br from-brandlightblue/30 to-white border border-brandblue/10 shadow-sm">
@ -67,123 +91,125 @@ export default function LiveTracker({
}
return (
<div className="space-y-6 w-full">
{/* 🌍 Global Overview */}
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4">
{/* Project Selector */}
<Card className="flex flex-col justify-center items-center border border-brandblue/10 bg-gradient-to-br from-brandlightblue/20 to-white shadow-sm hover:shadow-md transition-shadow p-5">
<div className="w-full flex flex-col">
<p className="text-xs uppercase tracking-wide text-gray-600 mb-3 font-semibold">
Select Project
</p>
<div className="relative">
<select
id="projectSelect"
value={currentProjectCode}
onChange={(e) => setCurrentProjectCode(e.target.value)}
className="w-full px-4 py-2.5 pr-10 border border-brandblue/20 rounded-lg bg-white text-gray-800 font-medium text-center focus:ring-2 focus:ring-brandblue focus:border-brandblue focus:outline-none transition-all appearance-none"
>
{projectCodes.map((code) => (
<option key={code} value={code}>
{code}
</option>
))}
</select>
</div>
</div>
</Card>
{/* Total Properties per Project */}
<StatCard
icon={Home}
title="Properties in Project"
value={currentProject?.allDeals.length ?? 0}
onClick={() =>
handleOpenTable(
`${currentProjectCode} — All Properties`,
currentProject?.allDeals ?? [],
["dealname", "landlordPropertyId"],
{
dealname: "Address Ref.",
landlordPropertyId: "Property Ref.",
}
)
}
accent="brandblue"
/>
{/* Major Issues */}
<StatCard
icon={AlertTriangle}
title="Awaab's Law Reporting"
value={`${majorIssues}`}
subtitle={`(${majorPercent}% across all projects)`}
onClick={() =>
handleOpenTable(
"Awaab's Law Reporting",
majorConditionDeals,
[
"dealname",
"landlordPropertyId",
"majorConditionIssueDescription",
"majorConditionIssuePhotosS3",
],
{
dealname: "Address Ref.",
landlordPropertyId: "Property Ref.",
majorConditionIssueDescription: "Surveyor's Notes",
majorConditionIssuePhotosS3: "Photo Evidence",
}
)
}
accent={majorIssues > 0 ? "bright-red" : "red"}
/>
</div>
{/* 📊 Project Insights */}
{currentProject && (
<div>
<div className="mb-6 pb-4 border-b border-brandblue/20 text-center">
<h2 className="text-lg font-bold text-brandblue break-words">
Project-Level Insights {" "}
<span className="text-brandmidblue">{currentProjectCode}</span>
</h2>
</div>
<div
className={`grid gap-6 ${
hasSurveyData
? "grid-cols-1 md:grid-cols-2"
: "grid-cols-1 max-w-3xl mx-auto"
}`}
<div className="space-y-4 w-full">
<Tabs
value={activeTab}
onValueChange={(v) => setActiveTab(v as "analytics" | "properties" | "documents")}
>
{/* Tab bar */}
<TabsList className="h-10 p-1 bg-brandlightblue/10 border border-brandblue/10 rounded-xl mb-6">
<TabsTrigger
value="analytics"
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"
>
<motion.div
whileHover={{ scale: 1.01 }}
className="transition-all duration-300"
>
<ProgressOverview
data={currentProject.progress}
onOpenTable={handleOpenTable}
/>
</motion.div>
<BarChart2 className="h-3.5 w-3.5" />
Analytics
</TabsTrigger>
<TabsTrigger
value="properties"
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"
>
<Table2 className="h-3.5 w-3.5" />
Properties
</TabsTrigger>
<TabsTrigger
value="documents"
className="flex items-center gap-2 rounded-lg text-sm font-medium px-4 data-[state=active]:bg-white data-[state=active]:text-brandblue data-[state=active]:shadow-sm transition-all"
>
<FolderOpen className="h-3.5 w-3.5" />
Document Management
</TabsTrigger>
</TabsList>
{hasSurveyData && (
<motion.div
whileHover={{ scale: 1.01 }}
className="transition-all duration-300"
>
<SurveyedResultsPieChart
slices={currentProject.outcomePieSlices}
dealsByOutcome={dealsByOutcome}
onOpenTable={handleOpenTable}
/>
</motion.div>
{/* Analytics tab */}
<TabsContent value="analytics" className="mt-0">
{currentProject && (
<AnalyticsView
projects={projects}
currentProject={currentProject}
currentProjectCode={currentProjectCode}
onProjectChange={setCurrentProjectCode}
onOpenTable={handleOpenTable}
majorConditionDeals={majorConditionDeals}
totalDeals={totalDeals}
/>
)}
</TabsContent>
{/* Properties tab */}
<TabsContent value="properties" className="mt-0">
<div className="space-y-4">
{/* Project selector — mirrors analytics tab */}
{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>
)}
</div>
</div>
)}
{/* 🔹 Table Modal */}
<PropertyTable
data={currentProject?.allDeals ?? []}
onOpenDrawer={handleOpenDrawer}
onOpenDetail={setDetailDeal}
docStatusMap={docStatusMap}
/>
</div>
</TabsContent>
{/* Document Management tab */}
<TabsContent value="documents" 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>
)}
<DocumentTable
data={currentProject?.allDeals ?? []}
onOpenDrawer={handleOpenDrawer}
docStatusMap={docStatusMap}
/>
</div>
</TabsContent>
</Tabs>
{/* ── Drill-down table modal ─────────────────────────────────────── */}
{openTable && (
<div
className="fixed inset-0 z-50 flex items-center justify-center bg-black/40 backdrop-blur-md transition-opacity"
@ -208,67 +234,61 @@ export default function LiveTracker({
properties
</p>
{/* Breakdown Stats */}
{openTable.breakdown && (
<div className="grid grid-cols-2 gap-3">
{Object.entries(openTable.breakdown).map(([category, items]) => {
const isCompleted = category.includes("Completed");
const bgColor = isCompleted
? "bg-gradient-to-br from-brandblue/25 to-brandblue/15"
: "bg-gradient-to-br from-amber-100/40 to-amber-50/30";
const borderColor = isCompleted
? "border-brandblue/40"
: "border-amber-200/50";
const textColor = isCompleted
? "text-brandblue"
: "text-amber-600";
const labelColor = isCompleted
? "text-brandblue"
: "text-amber-600/70";
{Object.entries(openTable.breakdown).map(
([category, items]) => {
const isCompleted = category.includes("Complete");
const bgColor = isCompleted
? "bg-gradient-to-br from-brandblue/25 to-brandblue/15"
: "bg-gradient-to-br from-amber-100/40 to-amber-50/30";
const borderColor = isCompleted
? "border-brandblue/40"
: "border-amber-200/50";
const textColor = isCompleted
? "text-brandblue"
: "text-amber-600";
const labelColor = isCompleted
? "text-brandblue"
: "text-amber-600/70";
return (
<div
key={category}
className={`${bgColor} rounded-lg p-3 border ${borderColor}`}
>
<p
className={`text-xs uppercase tracking-wide font-semibold ${labelColor} mb-1`}
return (
<div
key={category}
className={`${bgColor} rounded-lg p-3 border ${borderColor}`}
>
{category}
</p>
<p className={`text-2xl font-bold ${textColor}`}>
{items.length}
</p>
<p className="text-xs text-gray-500 mt-1">
{(
((items.length / openTable.data.length) * 100) |
0
)}
% of total
</p>
</div>
);
})}
<p
className={`text-xs uppercase tracking-wide font-semibold ${labelColor} mb-1`}
>
{category}
</p>
<p className={`text-2xl font-bold ${textColor}`}>
{items.length}
</p>
<p className="text-xs text-gray-500 mt-1">
{((items.length / openTable.data.length) * 100) | 0}
% of total
</p>
</div>
);
},
)}
</div>
)}
</div>
<div className="flex-1 overflow-auto rounded-lg border border-gray-100">
<TableViewer
<div className="flex-1 overflow-auto rounded-lg border border-gray-100 bg-white p-4">
<DrillDownTable
data={openTable.data}
columns={openTable.columns}
columns={openTable.columns as (keyof HubspotDeal)[]}
columnLabels={openTable.columnLabels}
breakdown={openTable.breakdown}
/>
</div>
<div className="mt-6 flex justify-end gap-3">
<button
onClick={(e) => {
e.stopPropagation();
setOpenTable(null);
}}
className="px-6 py-2.5 bg-gradient-to-r from-brandblue to-brandmidblue text-white font-medium rounded-lg hover:shadow-md transition-all duration-200 hover:opacity-90"
onClick={() => setOpenTable(null)}
className="px-6 py-2.5 bg-slate-100 text-slate-600 font-medium rounded-lg border border-slate-200 hover:bg-slate-200 transition-all duration-200"
>
Close
</button>
@ -276,83 +296,23 @@ export default function LiveTracker({
</motion.div>
</div>
)}
{/* ── Document drawer ────────────────────────────────────────────── */}
<PropertyDrawer
open={drawerState.open}
uprn={drawerState.uprn}
landlordPropertyId={drawerState.landlordPropertyId}
dealname={drawerState.dealname}
onClose={() =>
setDrawerState({ open: false, uprn: null, landlordPropertyId: null, dealname: null })
}
/>
{/* ── Property detail drawer ─────────────────────────────────────── */}
<PropertyDetailDrawer
deal={detailDeal}
onClose={() => setDetailDeal(null)}
/>
</div>
);
}
/** 🔸Small stat card component */
function StatCard({
icon: Icon,
title,
value,
subtitle,
onClick,
accent = "brandblue",
}: {
icon: any;
title: string;
value: string | number;
subtitle?: string;
onClick: () => void;
accent?: "brandblue" | "red" | "bright-red";
}) {
const accentConfig = {
brandblue: {
gradient: "from-brandlightblue/30 to-brandlightblue/10",
border: "border-brandblue/20",
text: "text-brandblue",
value: "text-brandblue",
hover: "hover:border-brandblue/40 hover:shadow-lg",
icon: "text-brandblue",
},
red: {
gradient: "from-red-100/30 to-red-50/20",
border: "border-red-300/40",
text: "text-red-500",
value: "text-red-500",
hover: "hover:border-red-300/60 hover:shadow-lg",
icon: "text-red-500",
},
"bright-red": {
gradient: "from-red-100 to-red-50",
border: "border-red-500",
text: "text-red-700",
value: "text-red-900",
hover: "hover:border-red-600 hover:shadow-lg",
icon: "text-red-700",
},
};
const config = accentConfig[accent];
return (
<motion.button
onClick={onClick}
whileHover={{ scale: 1.02 }}
className={`group relative text-left border rounded-xl bg-gradient-to-br ${config.gradient} ${config.border} transition-all duration-300 shadow-sm ${config.hover} p-6`}
>
<div className="flex items-center justify-between">
<div>
<p
className={`text-xs uppercase tracking-wide font-semibold ${config.text} opacity-70 mb-3`}
>
{title}
</p>
<p
className={`text-3xl font-bold ${config.value} opacity-50 group-hover:opacity-75 transition-opacity`}
>
{value}
{subtitle && (
<span className="text-base font-medium text-gray-600 ml-2">
{subtitle}
</span>
)}
</p>
</div>
<Icon
className={`h-8 w-8 ${config.icon} opacity-40 group-hover:opacity-70 transition-all duration-300`}
/>
</div>
</motion.button>
);
}

View file

@ -1,19 +1,81 @@
"use client";
import { Card } from "@tremor/react";
import { AlertCircle } from "lucide-react";
import { motion } from "framer-motion";
import ExpandableCountBar from "./ExpandableCountBar";
import type { ProjectProgressData, ClassifiedDeal, HubspotDeal } from "./types";
import { CheckCircle2, ArrowRight } from "lucide-react";
import { Card, CardContent } from "@/app/shadcn_components/ui/card";
import { STAGE_COLORS } from "./types";
import type {
ProjectProgressData,
ClassifiedDeal,
} from "./types";
const EARLY_COLUMNS: (keyof ClassifiedDeal)[] = [
"dealname",
"landlordPropertyId",
"displayStage",
"preSapScore",
"outcome",
];
const EARLY_LABELS: Partial<Record<keyof ClassifiedDeal, string>> = {
dealname: "Address",
landlordPropertyId: "Ref",
displayStage: "Current Stage",
preSapScore: "Pre-SAP Score",
outcome: "Survey Outcome",
};
// -----------------------------------------------------------------------
// Circular progress ring (SVG)
// -----------------------------------------------------------------------
function RingProgress({
pct,
color = "#14163d",
size = 80,
}: {
pct: number;
color?: string;
size?: number;
}) {
const r = 34;
const circ = 2 * Math.PI * r;
const offset = circ - (Math.min(pct, 100) / 100) * circ;
return (
<svg width={size} height={size} viewBox="0 0 80 80" className="-rotate-90">
<circle
cx="40"
cy="40"
r={r}
fill="none"
stroke="#e5e7eb"
strokeWidth="6"
/>
<circle
cx="40"
cy="40"
r={r}
fill="none"
stroke={color}
strokeWidth="6"
strokeDasharray={circ}
strokeDashoffset={offset}
strokeLinecap="round"
style={{ transition: "stroke-dashoffset 0.8s ease" }}
/>
</svg>
);
}
// -----------------------------------------------------------------------
// Main component
// -----------------------------------------------------------------------
interface ProgressOverviewProps {
data: ProjectProgressData;
onOpenTable?: (
stage: string,
deals: ClassifiedDeal[],
columns?: (keyof HubspotDeal)[],
columnLabels?: Partial<Record<keyof HubspotDeal, string>>,
breakdown?: Record<string, ClassifiedDeal[]>
columns?: (keyof ClassifiedDeal)[],
columnLabels?: Partial<Record<keyof ClassifiedDeal, string>>,
breakdown?: Record<string, ClassifiedDeal[]>,
) => void;
}
@ -21,319 +83,125 @@ export default function ProgressOverview({
data,
onOpenTable,
}: ProgressOverviewProps) {
// Pre-computed values from props
const {
completedPercentage,
completedCount,
totalDeals,
queriesDeals,
coordination,
design,
nonQueryTotal,
stageProgress,
} = data;
// SVG circle calculations (pure, no memo needed)
const radius = 40;
const circumference = 2 * Math.PI * radius;
const strokeDashoffset = circumference - (completedPercentage / 100) * circumference;
const handleCompletedClick = () => {
if (onOpenTable) {
onOpenTable(
"Completed Properties",
data.completedDeals,
["dealname", "landlordPropertyId"],
{
dealname: "Address Ref.",
landlordPropertyId: "Property Ref.",
}
);
}
};
const handleCoordinationClick = () => {
if (onOpenTable) {
const coordinationBreakdown = {
"Coordination Completed": coordination.completedDeals,
"Coordination in Progress": coordination.inProgressDeals,
};
const allCoordDeals = [
...coordination.completedDeals,
...coordination.inProgressDeals,
];
onOpenTable(
"Coordination Status",
allCoordDeals,
undefined,
undefined,
coordinationBreakdown
);
}
};
const handleDesignClick = () => {
if (onOpenTable) {
const designBreakdown = {
"Design Completed": design.completedDeals,
"Design in Progress": design.inProgressDeals,
};
const allDesignDeals = [
...design.completedDeals,
...design.inProgressDeals,
];
onOpenTable(
"Design Status",
allDesignDeals,
undefined,
undefined,
designBreakdown
);
}
};
const handleQueriesClick = () => {
if (onOpenTable && queriesDeals.length > 0) {
onOpenTable(
"Properties Needing Attention",
queriesDeals,
["dealname", "landlordPropertyId", "coordinationStatus"],
{
dealname: "Address Ref.",
landlordPropertyId: "Property Ref.",
coordinationStatus: "Issue",
}
);
}
};
// Early-stage rows (scope / booking / assessment)
const earlyStages = [
"Scope & Planning",
"Booking in Progress",
"Assessment in Progress",
];
const earlyItems = stageProgress.filter(
(s) => earlyStages.includes(s.stage) && s.count > 0,
);
return (
<div className="space-y-6">
{/* Work Completed - Full Width Overview at Top */}
<motion.button
onClick={handleCompletedClick}
whileHover={{ scale: 1.02 }}
className="group relative text-left w-full"
>
<Card className="bg-gradient-to-br from-emerald-50/80 to-emerald-50/40 border-2 border-emerald-300/60 rounded-xl shadow-sm hover:shadow-md transition-all duration-300 p-8 hover:border-emerald-400">
<div className="space-y-6">
{/* Header with Circular Progress */}
<div className="flex items-center justify-between gap-6">
<div className="flex-1">
<p className="text-3xl font-semibold text-emerald-900 uppercase tracking-wide mb-2">
<Card className="border border-brandblue/10 shadow-md rounded-2xl bg-white">
<CardContent className="p-6 space-y-5">
{/* ── Completion header ──────────────────────────────────────────── */}
<motion.button
whileHover={{ scale: 1.01 }}
onClick={() =>
onOpenTable?.(
"Completed Properties",
data.completedDeals,
[
"dealname",
"landlordPropertyId",
"displayStage",
"actualMeasuresInstalled",
"fullLodgementDate",
],
{
dealname: "Address",
landlordPropertyId: "Ref",
displayStage: "Stage",
actualMeasuresInstalled: "Measures Installed",
fullLodgementDate: "Lodgement Date",
},
)
}
className="group w-full text-left rounded-xl border border-emerald-200 bg-gradient-to-r from-emerald-50 to-white p-5 hover:border-emerald-300 hover:shadow-md transition-all duration-200"
>
<div className="flex items-center gap-4">
<div className="relative shrink-0">
<RingProgress
pct={completedPercentage}
color="#059669"
size={72}
/>
<div className="absolute inset-0 flex items-center justify-center">
<span className="text-sm font-bold text-emerald-700">
{completedPercentage.toFixed(0)}%
</span>
</div>
</div>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-1">
<CheckCircle2 className="h-4 w-4 text-emerald-600" />
<span className="text-sm font-semibold text-emerald-800">
Work Completed
</p>
<p className="text-lg text-emerald-700">
End-to-end project overview
</p>
</div>
{/* Circular Progress */}
<div className="relative w-32 h-32 flex-shrink-0">
<svg
className="w-full h-full transform -rotate-90"
viewBox="0 0 100 100"
>
{/* Background circle */}
<circle
cx="50"
cy="50"
r={radius}
fill="none"
stroke="currentColor"
strokeWidth="3"
className="text-emerald-200"
/>
{/* Progress circle */}
<circle
cx="50"
cy="50"
r={radius}
fill="none"
stroke="url(#completedGradient)"
strokeWidth="4"
strokeDasharray={circumference}
strokeDashoffset={strokeDashoffset}
strokeLinecap="round"
className="transition-all duration-700 ease-out"
/>
<defs>
<linearGradient
id="completedGradient"
x1="0%"
y1="0%"
x2="100%"
y2="100%"
>
<stop offset="0%" stopColor="#059669" />
<stop offset="100%" stopColor="#10b981" />
</linearGradient>
</defs>
</svg>
{/* Center text */}
<div className="absolute inset-0 flex flex-col items-center justify-center">
<span className="text-3xl font-bold text-emerald-700">
{completedPercentage.toFixed(0)}%
</span>
<span className="text-sm text-emerald-600 font-semibold mt-1">
{completedCount}/{totalDeals}
</span>
</div>
</span>
</div>
<p className="text-2xl font-bold text-emerald-700">
{completedCount}
<span className="text-sm font-medium text-emerald-600/70 ml-1">
/ {nonQueryTotal}
</span>
</p>
<p className="text-xs text-emerald-600/80 mt-0.5">
Properties fully lodged and funded
</p>
</div>
{/* CTA */}
<div className="flex items-center gap-2 text-emerald-700 group-hover:text-emerald-900 transition-colors pt-2 border-t border-emerald-200/50">
<span className="text-sm font-semibold">View Completed Properties</span>
<span className="text-lg"></span>
</div>
<ArrowRight className="h-4 w-4 text-emerald-400 group-hover:text-emerald-600 group-hover:translate-x-0.5 transition-all shrink-0" />
</div>
</Card>
</motion.button>
</motion.button>
{/* Early Stage Cards - Scope, Booking, Assessment */}
{(() => {
const earlyStages = [
"Scope & Planning",
"Booking in Progress",
"Assessment in Progress",
];
const earlyStageItems = data.stageProgress.filter((s) =>
earlyStages.includes(s.stage)
);
return earlyStageItems.length > 0 ? (
<div className="grid grid-cols-3 gap-3">
{earlyStageItems.map((item) => (
<motion.button
key={item.stage}
onClick={() => {
if (onOpenTable) {
onOpenTable(
{/* ── Early stage chips ─────────────────────────────────────────── */}
{earlyItems.length > 0 && (
<div className="grid grid-cols-3 gap-2">
{earlyItems.map((item) => {
const c = STAGE_COLORS[item.stage];
return (
<motion.button
key={item.stage}
whileHover={{ scale: 1.03 }}
onClick={() =>
onOpenTable?.(
item.stage,
item.deals,
["dealname", "landlordPropertyId"],
{
dealname: "Address Ref.",
landlordPropertyId: "Property Ref.",
}
);
EARLY_COLUMNS,
EARLY_LABELS,
)
}
}}
whileHover={{ scale: 1.02 }}
className="group relative text-left"
>
<Card className="bg-gradient-to-br from-blue-50/80 to-blue-50/40 border-2 border-blue-300/60 rounded-xl shadow-sm hover:shadow-md transition-all duration-300 p-4 hover:border-blue-400 h-full flex flex-col">
<div className="space-y-3 flex-1 flex flex-col justify-between">
<div>
<p className="text-sm font-semibold text-blue-900 uppercase tracking-wide mb-1">
{item.stage}
</p>
<p className="text-2xl font-bold text-blue-700">
{item.count}
</p>
</div>
<div className="border-t border-blue-200/50 pt-3">
<p className="text-xs text-blue-600 font-semibold">
{item.percentage.toFixed(0)}% of total
</p>
</div>
<div className="flex items-center gap-2 text-blue-600 group-hover:text-blue-800 transition-colors">
<span className="text-xs font-semibold">View</span>
<span className="text-sm"></span>
</div>
className={`group text-left rounded-xl border p-3 transition-all duration-200 hover:shadow-md ${c.bg} ${c.border} hover:opacity-95`}
>
<div className={`flex items-center gap-1 mb-1.5`}>
<span
className={`w-1.5 h-1.5 rounded-full shrink-0 ${c.dot}`}
/>
<span
className={`text-xs font-semibold ${c.text} truncate leading-tight`}
>
{item.stage}
</span>
</div>
</Card>
</motion.button>
))}
<p className={`text-xl font-bold ${c.text}`}>{item.count}</p>
<p className={`text-xs ${c.text} opacity-70 mt-0.5`}>
{item.percentage.toFixed(0)}% of total
</p>
</motion.button>
);
})}
</div>
) : null;
})()}
)}
{/* Project Summary Cards - Coordination & Design */}
<div className="grid grid-cols-2 gap-3">
<ExpandableCountBar
title="Coordination Completed"
count={coordination.completedCount}
percentage={coordination.completedPercentage}
inProgressPercentage={coordination.inProgressPercentage}
total={coordination.total}
secondaryStats={[
{
label: "Coordination Completed",
count: coordination.completedCount,
},
{
label: "Coordination in Progress",
count: coordination.inProgressCount,
},
]}
items={[
...coordination.completedDeals,
...coordination.inProgressDeals,
]}
onClick={handleCoordinationClick}
/>
<ExpandableCountBar
title="Design Completed"
count={design.completedCount}
percentage={design.completedPercentage}
inProgressPercentage={design.inProgressPercentage}
total={design.total}
secondaryStats={[
{ label: "Design Completed", count: design.completedCount },
{ label: "Design in Progress", count: design.inProgressCount },
]}
items={[...design.completedDeals, ...design.inProgressDeals]}
onClick={handleDesignClick}
/>
</div>
{/* Queries / Attention Required Section */}
{queriesDeals.length > 0 && (
<motion.button
onClick={handleQueriesClick}
whileHover={{ scale: 1.02 }}
className="group relative text-left w-full"
>
<Card className="bg-gradient-to-br from-amber-50/80 to-amber-50/40 border-2 border-amber-300/60 rounded-xl shadow-sm hover:shadow-md transition-all duration-300 p-6 hover:border-amber-400">
<div className="space-y-4">
{/* Header with Alert */}
<div className="flex items-start gap-3">
<div className="flex-shrink-0 mt-0.5">
<AlertCircle className="w-6 h-6 text-amber-600 animate-pulse" />
</div>
<div className="flex-1">
<p className="text-sm font-bold text-amber-900 uppercase tracking-wide">
Requires Your Input
</p>
<p className="text-xs text-amber-700 mt-1">
These properties need your feedback or assistance to progress
</p>
</div>
</div>
{/* Count Display */}
<div className="pt-3 border-t border-amber-200/50">
<p className="text-4xl font-black text-amber-600 mb-1">
{queriesDeals.length}
</p>
<p className="text-xs font-semibold text-amber-700 opacity-70">
{queriesDeals.length === 1 ? "property" : "properties"}{" "}
awaiting action
</p>
</div>
{/* CTA */}
<div className="flex items-center gap-2 text-amber-700 group-hover:text-amber-900 transition-colors">
<span className="text-sm font-semibold">Review Details</span>
<span className="text-lg"></span>
</div>
</div>
</Card>
</motion.button>
)}
</div>
</CardContent>
</Card>
);
}

View file

@ -0,0 +1,271 @@
"use client";
import { motion, AnimatePresence } from "framer-motion";
import { X, CheckCircle2, Circle, AlertTriangle } from "lucide-react";
import {
Drawer,
DrawerClose,
DrawerContent,
DrawerHeader,
DrawerTitle,
DrawerDescription,
} from "@/app/shadcn_components/ui/drawer";
import { STAGE_COLORS } from "./types";
import type { ClassifiedDeal } from "./types";
// -----------------------------------------------------------------------
// Milestone definitions — ordered pipeline steps with their date fields
// -----------------------------------------------------------------------
const MILESTONES: { label: string; field: keyof ClassifiedDeal; sublabel?: string }[] = [
{ label: "Booking Confirmed", field: "confirmedSurveyDate" },
{ label: "Assessment Completed", field: "surveyedDate" },
{ label: "Coordination (V1)", field: "ioeV1Date", sublabel: "IOE/MTP V1" },
{ label: "Coordination (V2)", field: "ioeV2Date", sublabel: "IOE/MTP V2" },
{ label: "Design Completed", field: "designDate" },
{ label: "Measures Lodged", field: "measuresLodgementDate" },
{ label: "Stage 1 Lodgement", field: "fullLodgementDate" },
];
function formatDate(d: Date | string | null | undefined): string | null {
if (!d) return null;
try {
return new Date(d).toLocaleDateString("en-GB", {
day: "numeric",
month: "short",
year: "numeric",
});
} catch {
return null;
}
}
// -----------------------------------------------------------------------
// Mini info row
// -----------------------------------------------------------------------
function InfoRow({ label, value }: { label: string; value: React.ReactNode }) {
if (!value) return null;
return (
<div className="flex items-start gap-3 py-2 border-b border-gray-50 last:border-0">
<span className="text-xs text-gray-400 font-medium w-32 shrink-0 pt-0.5">{label}</span>
<span className="text-xs text-gray-700 flex-1 leading-relaxed">{value}</span>
</div>
);
}
// -----------------------------------------------------------------------
// Stage badge
// -----------------------------------------------------------------------
function StageBadge({ stage }: { stage: ClassifiedDeal["displayStage"] }) {
const c = STAGE_COLORS[stage] ?? STAGE_COLORS["Unknown Stage"];
return (
<span className={`inline-flex items-center gap-1.5 px-2.5 py-1 rounded-full text-xs font-semibold border ${c.bg} ${c.text} ${c.border}`}>
<span className={`w-1.5 h-1.5 rounded-full ${c.dot}`} />
{stage}
</span>
);
}
// -----------------------------------------------------------------------
// Vertical milestone timeline
// -----------------------------------------------------------------------
function MilestoneTimeline({ deal }: { deal: ClassifiedDeal }) {
const milestones = MILESTONES.map((m) => ({
...m,
date: formatDate(deal[m.field] as Date | string | null),
}));
// Find last completed index
const lastCompletedIdx = milestones.reduce((acc, m, i) => (m.date ? i : acc), -1);
return (
<div className="relative">
{milestones.map((m, i) => {
const completed = !!m.date;
const isLast = i === milestones.length - 1;
return (
<div key={m.field} className="flex items-stretch gap-3">
{/* Left: dot + connecting line */}
<div className="flex flex-col items-center w-5 shrink-0">
<div className={`relative z-10 flex items-center justify-center w-5 h-5 rounded-full border-2 mt-0.5 transition-all duration-300 ${
completed
? "bg-brandmidblue border-brandmidblue"
: i <= lastCompletedIdx + 1
? "bg-white border-brandblue/30"
: "bg-white border-gray-200"
}`}>
{completed ? (
<CheckCircle2 className="h-3 w-3 text-white" />
) : (
<Circle className={`h-2 w-2 ${i <= lastCompletedIdx + 1 ? "text-brandblue/40" : "text-gray-300"}`} />
)}
</div>
{!isLast && (
<div className={`w-0.5 flex-1 my-0.5 ${
completed && milestones[i + 1]?.date ? "bg-brandmidblue/40" : "bg-gray-100"
}`} />
)}
</div>
{/* Right: label + date */}
<div className={`pb-4 flex-1 min-w-0 ${isLast ? "pb-0" : ""}`}>
<div className="flex items-start justify-between gap-2">
<div>
<p className={`text-xs font-semibold leading-tight ${
completed ? "text-gray-800" : "text-gray-400"
}`}>
{m.label}
</p>
{m.sublabel && (
<p className="text-[10px] text-gray-400 mt-0.5">{m.sublabel}</p>
)}
</div>
{m.date ? (
<span className="text-[11px] font-medium text-brandmidblue bg-brandlightblue/60 px-2 py-0.5 rounded-full shrink-0 whitespace-nowrap">
{m.date}
</span>
) : (
<span className="text-[11px] text-gray-300 shrink-0">Pending</span>
)}
</div>
</div>
</div>
);
})}
</div>
);
}
// -----------------------------------------------------------------------
// PropertyDetailDrawer — main component
// -----------------------------------------------------------------------
interface PropertyDetailDrawerProps {
deal: ClassifiedDeal | null;
onClose: () => void;
}
export default function PropertyDetailDrawer({ deal, onClose }: PropertyDetailDrawerProps) {
const open = !!deal;
return (
<Drawer open={open} onOpenChange={(v) => !v && 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" />
{deal && (
<>
{/* Header */}
<DrawerHeader className="shrink-0 px-6 pt-6 pb-4 border-b border-gray-100">
<div className="flex items-start justify-between gap-3">
<div className="min-w-0 flex-1">
<DrawerTitle className="text-base font-semibold text-brandblue leading-snug line-clamp-2 mb-2">
{deal.dealname ?? "Property Details"}
</DrawerTitle>
<div className="flex flex-wrap items-center gap-2">
<StageBadge stage={deal.displayStage} />
{deal.landlordPropertyId && (
<span className="text-xs font-mono text-gray-400 bg-gray-50 px-2 py-0.5 rounded border border-gray-200">
{deal.landlordPropertyId}
</span>
)}
{deal.projectCode && (
<span className="text-xs font-medium text-gray-500 bg-gray-100 px-2 py-0.5 rounded">
{deal.projectCode}
</span>
)}
</div>
</div>
<DrawerClose asChild>
<button
onClick={onClose}
className="shrink-0 p-1.5 rounded-lg text-gray-400 hover:text-gray-600 hover:bg-gray-100 transition-colors"
>
<X className="h-4 w-4" />
</button>
</DrawerClose>
</div>
</DrawerHeader>
{/* Scrollable body */}
<div className="flex-1 overflow-y-auto px-6 py-5 space-y-6">
{/* Damp & mould alert */}
{(deal.dampMouldFlag || deal.majorConditionIssuePhotosS3) && (
<div className="flex items-start gap-2.5 p-3.5 rounded-xl bg-red-50 border border-red-200">
<AlertTriangle className="h-4 w-4 text-red-500 mt-0.5 shrink-0" />
<div>
<p className="text-xs font-semibold text-red-700">Damp & Mould Flag</p>
{deal.dampMouldFlag && (
<p className="text-xs text-red-600 mt-0.5">{deal.dampMouldFlag}</p>
)}
{deal.majorConditionIssueDescription && (
<p className="text-xs text-red-600 mt-0.5 italic">{deal.majorConditionIssueDescription}</p>
)}
</div>
</div>
)}
{/* Key details */}
<div>
<h3 className="text-xs font-bold uppercase tracking-wider text-gray-400 mb-3">Property Details</h3>
<div className="divide-y divide-gray-50">
<InfoRow label="Coordinator" value={deal.coordinator} />
<InfoRow label="Designer" value={deal.designer} />
<InfoRow label="Installer" value={deal.installer} />
<InfoRow
label="Pre-SAP Score"
value={
deal.preSapScore
? <span className={`font-semibold px-1.5 py-0.5 rounded text-xs ${
Number(deal.preSapScore) < 30
? "text-red-600 bg-red-50"
: Number(deal.preSapScore) < 50
? "text-amber-700 bg-amber-50"
: "text-emerald-700 bg-emerald-50"
}`}>{deal.preSapScore}</span>
: null
}
/>
<InfoRow label="Outcome" value={deal.outcome} />
{deal.outcomeNotes && (
<InfoRow label="Outcome Notes" value={deal.outcomeNotes} />
)}
<InfoRow label="Coordination" value={deal.coordinationStatus} />
<InfoRow label="Design Status" value={deal.designStatus} />
<InfoRow label="Design Type" value={deal.designType} />
</div>
</div>
{/* Measures */}
{(deal.proposedMeasures || deal.approvedPackage || deal.actualMeasuresInstalled) && (
<div>
<h3 className="text-xs font-bold uppercase tracking-wider text-gray-400 mb-3">Measures</h3>
<div className="divide-y divide-gray-50">
<InfoRow label="Proposed" value={deal.proposedMeasures} />
<InfoRow label="Approved Package" value={deal.approvedPackage} />
<InfoRow label="Installed" value={deal.actualMeasuresInstalled} />
<InfoRow label="Lodgement Status" value={deal.lodgementStatus} />
</div>
</div>
)}
{/* Timeline */}
<div>
<h3 className="text-xs font-bold uppercase tracking-wider text-gray-400 mb-4">Project Timeline</h3>
<MilestoneTimeline deal={deal} />
</div>
</div>
{/* Footer */}
<div className="shrink-0 px-6 py-4 border-t border-gray-100 bg-gray-50/50">
{deal.uprn && (
<p className="text-xs text-gray-400 font-mono">UPRN: {deal.uprn}</p>
)}
</div>
</>
)}
</DrawerContent>
</Drawer>
);
}

View file

@ -0,0 +1,349 @@
"use client";
import { useQuery, useMutation } from "@tanstack/react-query";
import { useRef } from "react";
import { motion, AnimatePresence } from "framer-motion";
import {
FileDown,
FileText,
FileX,
Loader2,
FolderOpen,
X,
ExternalLink,
} from "lucide-react";
import {
Drawer,
DrawerClose,
DrawerContent,
DrawerHeader,
DrawerTitle,
DrawerDescription,
} from "@/app/shadcn_components/ui/drawer";
import type { PropertyDocument } from "./types";
import { EXPECTED_SURVEY_DOC_TYPES } from "./types";
// Human-readable labels for the main DB fileType enum values
const DOC_TYPE_LABELS: Record<string, string> = {
photo_pack: "Photo Pack",
site_note: "Site Note",
rd_sap_site_note: "RdSAP Site Note",
pas_2023_ventilation: "PAS 2023 Ventilation",
pas_2023_condition: "PAS 2023 Condition Report",
pas_significance: "PAS Significance",
par_photo_pack: "PAR Photo Pack",
pas_2023_property: "PAS 2023 Property Report",
pas_2023_occupancy: "PAS 2023 Occupancy Report",
};
// All survey docs go under this group for now (extensible later)
function getDocCategory(_docType: string): string {
return "Survey Documents";
}
function formatDate(iso: string): string {
try {
return new Date(iso).toLocaleDateString("en-GB", {
day: "numeric",
month: "short",
year: "numeric",
});
} catch {
return iso;
}
}
// -----------------------------------------------------------------------
// Individual document row
// -----------------------------------------------------------------------
function DocumentRow({ doc }: { doc: PropertyDocument }) {
const label = DOC_TYPE_LABELS[doc.docType] ?? doc.docType;
const { mutate: download, isPending: signing } = useMutation({
mutationFn: async () => {
const res = await fetch("/api/sign-document-url", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ key: doc.s3FileKey, bucket: doc.s3FileBucket }),
});
if (!res.ok) throw new Error("Failed to get signed URL");
const data = await res.json();
return data.url as string;
},
onSuccess: (url) => {
window.open(url, "_blank");
},
});
return (
<motion.div
layout
initial={{ opacity: 0, y: 4 }}
animate={{ opacity: 1, y: 0 }}
className="flex items-center justify-between gap-4 px-4 py-3 rounded-lg border border-gray-100 bg-white hover:border-brandblue/20 hover:shadow-sm transition-all duration-150"
>
{/* Left: icon + label + date stacked */}
<div className="flex items-center gap-3 min-w-0">
<div className="shrink-0 w-8 h-8 rounded-lg bg-sky-50 border border-sky-200 flex items-center justify-center">
<FileText className="h-4 w-4 text-sky-600" />
</div>
<div className="min-w-0">
<p className="text-sm font-medium text-gray-800 truncate">{label}</p>
<p className="text-xs text-gray-400 mt-0.5">
{formatDate(doc.s3UploadTimestamp)}
</p>
</div>
</div>
{/* Right: download button */}
<button
onClick={() => download()}
disabled={signing}
className="shrink-0 inline-flex items-center gap-1.5 px-3 py-1.5 rounded-lg bg-brandblue text-white text-xs font-medium hover:bg-brandblue/90 transition-colors disabled:opacity-60 disabled:cursor-not-allowed"
>
{signing ? (
<Loader2 className="h-3.5 w-3.5 animate-spin" />
) : (
<FileDown className="h-3.5 w-3.5" />
)}
{signing ? "Preparing…" : "Download"}
</button>
</motion.div>
);
}
// -----------------------------------------------------------------------
// PropertyDrawer — main component
// -----------------------------------------------------------------------
interface PropertyDrawerProps {
open: boolean;
uprn: string | null;
landlordPropertyId: string | null;
dealname: string | null;
onClose: () => void;
}
export default function PropertyDrawer({
open,
uprn,
landlordPropertyId,
dealname,
onClose,
}: PropertyDrawerProps) {
const canQuery = !!(uprn || landlordPropertyId);
const {
data: fetchedDocuments = [],
isFetching,
isError,
} = useQuery({
queryKey: ["property-documents", uprn, landlordPropertyId],
queryFn: async () => {
const params = new URLSearchParams();
if (uprn) params.set("uprn", uprn);
else if (landlordPropertyId)
params.set("landlordPropertyId", landlordPropertyId);
const res = await fetch(
`/api/live-tracking/property-documents?${params}`,
);
if (!res.ok) throw new Error("Failed to load documents");
return res.json() as Promise<PropertyDocument[]>;
},
enabled: open && canQuery,
staleTime: 30_000,
});
// Keep the last successfully fetched result so the closing animation doesn't
// flash the empty state (the parent nulls out uprn/landlordPropertyId on close,
// which disables the query and resets fetchedDocuments to [] mid-animation).
const lastDocumentsRef = useRef<PropertyDocument[]>([]);
if (open && !isFetching && !isError) {
lastDocumentsRef.current = fetchedDocuments as PropertyDocument[];
}
const documents = open ? (fetchedDocuments as PropertyDocument[]) : lastDocumentsRef.current;
// Group docs by category for display
const grouped = documents.reduce<
Record<string, PropertyDocument[]>
>((acc, doc) => {
const category = getDocCategory(doc.docType);
(acc[category] ??= []).push(doc);
return acc;
}, {});
const hasDocuments = documents.length > 0;
const presentTypes = new Set(documents.map((d) => d.docType));
const missingTypes = EXPECTED_SURVEY_DOC_TYPES.filter(
(t) => !presentTypes.has(t),
);
return (
<Drawer open={open} onOpenChange={(v) => !v && onClose()} direction="right">
<DrawerContent className="fixed right-0 top-0 bottom-0 h-full w-[40vw] min-w-80 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">
{/* Remove the default drag handle */}
<div className="hidden" />
<DrawerHeader className="shrink-0 px-6 pt-6 pb-4 border-b border-gray-100">
<div className="flex items-start justify-between gap-3">
<div className="min-w-0 flex-1">
<DrawerTitle className="text-lg font-semibold text-brandblue leading-tight line-clamp-2">
{dealname ?? "Property Documents"}
</DrawerTitle>
{uprn ? (
<DrawerDescription className="text-xs text-gray-500 mt-0.5 font-mono truncate">
UPRN: {uprn}
</DrawerDescription>
) : landlordPropertyId ? (
<DrawerDescription className="text-xs text-gray-500 mt-0.5 font-mono truncate">
Ref: {landlordPropertyId}
</DrawerDescription>
) : null}
</div>
<DrawerClose asChild>
<button
onClick={onClose}
className="shrink-0 p-1.5 rounded-lg text-gray-400 hover:text-gray-600 hover:bg-gray-100 transition-colors"
>
<X className="h-4 w-4" />
</button>
</DrawerClose>
</div>
{hasDocuments && !isFetching && (
<div className="mt-3 inline-flex items-center gap-1.5 px-2.5 py-1 rounded-full bg-brandblue/10 border border-brandblue/20">
<FileDown className="h-3.5 w-3.5 text-brandblue" />
<span className="text-xs font-medium text-brandblue">
{documents.length} document{documents.length !== 1 ? "s" : ""}
</span>
</div>
)}
</DrawerHeader>
{/* Body */}
<div className="flex-1 overflow-y-auto px-6 py-4 space-y-5">
{/* Loading state */}
{isFetching && (
<div className="space-y-3 pt-2">
{[1, 2, 3].map((i) => (
<div
key={i}
className="h-14 rounded-lg bg-gray-100 animate-pulse"
/>
))}
</div>
)}
{/* Error state */}
{isError && !isFetching && (
<div className="flex flex-col items-center justify-center py-12 text-center">
<div className="w-10 h-10 rounded-full bg-red-50 flex items-center justify-center mb-3">
<ExternalLink className="h-5 w-5 text-red-400" />
</div>
<p className="text-sm font-medium text-gray-700">
Could not load documents
</p>
<p className="text-xs text-gray-500 mt-1">
Please try again later.
</p>
</div>
)}
{/* Empty state — shows all missing doc types */}
{!isFetching && !isError && !hasDocuments && (
<div className="space-y-4 pt-1">
<div className="flex flex-col items-center py-6 text-center">
<div className="w-12 h-12 rounded-full bg-amber-50 border border-amber-200 flex items-center justify-center mb-3">
<FolderOpen className="h-6 w-6 text-amber-400" />
</div>
<p className="text-sm font-medium text-gray-700">
No documents available
</p>
<p className="text-xs text-gray-400 mt-1">
All {EXPECTED_SURVEY_DOC_TYPES.length} survey documents are
outstanding.
</p>
</div>
<div className="space-y-1.5">
<h3 className="text-xs font-semibold uppercase tracking-wide text-amber-500 px-0.5">
Missing Documents ({missingTypes.length})
</h3>
{missingTypes.map((t) => (
<div
key={t}
className="flex items-center gap-2.5 p-3 rounded-lg border border-dashed border-amber-200 bg-amber-50/40"
>
<FileX className="h-3.5 w-3.5 text-amber-300 shrink-0" />
<span className="text-xs text-amber-600 font-medium">
{DOC_TYPE_LABELS[t] ?? t}
</span>
</div>
))}
</div>
</div>
)}
{/* Document groups */}
<AnimatePresence>
{!isFetching &&
!isError &&
hasDocuments &&
Object.entries(grouped).map(([category, docs]) => (
<motion.div
key={category}
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
className="space-y-2"
>
<h3 className="text-xs font-semibold uppercase tracking-wide text-gray-400 px-0.5">
{category}
</h3>
<div className="space-y-1.5">
{docs.map((doc) => (
<DocumentRow key={doc.id} doc={doc} />
))}
</div>
</motion.div>
))}
</AnimatePresence>
{/* Missing documents section — shown when some but not all docs are present */}
{!isFetching &&
!isError &&
hasDocuments &&
missingTypes.length > 0 && (
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
className="space-y-2"
>
<h3 className="text-xs font-semibold uppercase tracking-wide text-amber-500 px-0.5">
Missing Documents ({missingTypes.length})
</h3>
<div className="space-y-1.5">
{missingTypes.map((t) => (
<div
key={t}
className="flex items-center gap-2.5 p-3 rounded-lg border border-dashed border-amber-200 bg-amber-50/40"
>
<FileX className="h-3.5 w-3.5 text-amber-300 shrink-0" />
<span className="text-xs text-amber-600 font-medium">
{DOC_TYPE_LABELS[t] ?? t}
</span>
</div>
))}
</div>
</motion.div>
)}
</div>
{/* Footer */}
<div className="px-6 py-4 border-t border-gray-100 bg-gray-50/50">
<p className="text-xs text-gray-400">
Download links expire after 30 minutes. Refresh to generate a new
link.
</p>
</div>
</DrawerContent>
</Drawer>
);
}

View file

@ -0,0 +1,421 @@
"use client";
import { useMemo, useState } from "react";
import {
useReactTable,
getCoreRowModel,
getFilteredRowModel,
getSortedRowModel,
getPaginationRowModel,
flexRender,
type SortingState,
type VisibilityState,
type PaginationState,
} from "@tanstack/react-table";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/app/shadcn_components/ui/table";
import { Input } from "@/app/shadcn_components/ui/input";
import {
DropdownMenu,
DropdownMenuCheckboxItem,
DropdownMenuContent,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/app/shadcn_components/ui/dropdown-menu";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
} from "@/app/shadcn_components/ui/select";
import { Search, SlidersHorizontal, ChevronLeft, ChevronRight, Download } from "lucide-react";
import { createPropertyTableColumns } from "./PropertyTableColumns";
import { STAGE_ORDER } from "./types";
import type { ClassifiedDeal, DocStatusMap } from "./types";
// Human-readable labels for toggle dropdown
const COLUMN_LABELS: Record<string, string> = {
landlordPropertyId: "Property Ref",
uprn: "UPRN",
projectCode: "Project",
coordinator: "Coordinator",
designer: "Designer",
installer: "Installer",
proposedMeasures: "Proposed Measures",
approvedPackage: "Approved Package",
actualMeasuresInstalled: "Installed Measures",
preSapScore: "Pre-SAP",
lodgementStatus: "Lodgement Status",
designDate: "Design Date",
fullLodgementDate: "Lodgement Date",
};
type DocFilter = "all" | "has_docs" | "incomplete" | "none";
interface PropertyTableProps {
data: ClassifiedDeal[];
onOpenDrawer: (uprn: string | null, landlordPropertyId: string | null, dealname: string | null) => void;
onOpenDetail?: (deal: ClassifiedDeal) => void;
showDocuments?: boolean;
docStatusMap?: DocStatusMap;
}
const CSV_FIELDS: { key: keyof ClassifiedDeal; label: string }[] = [
{ key: "dealname", label: "Address" },
{ key: "landlordPropertyId", label: "Property Ref" },
{ key: "uprn", label: "UPRN" },
{ key: "displayStage", label: "Stage" },
{ key: "projectCode", label: "Project" },
{ key: "coordinator", label: "Coordinator" },
{ key: "designer", label: "Designer" },
{ key: "installer", label: "Installer" },
{ key: "proposedMeasures", label: "Proposed Measures" },
{ key: "approvedPackage", label: "Approved Package" },
{ key: "actualMeasuresInstalled", label: "Installed Measures" },
{ key: "preSapScore", label: "Pre-SAP" },
{ key: "lodgementStatus", label: "Lodgement Status" },
{ key: "designDate", label: "Design Date" },
{ key: "fullLodgementDate", label: "Lodgement Date" },
];
function escapeCell(value: unknown): string {
if (value === null || value === undefined) return "";
const str =
value instanceof Date
? value.toLocaleDateString("en-GB")
: String(value);
return str.includes(",") || str.includes('"') || str.includes("\n")
? `"${str.replace(/"/g, '""')}"`
: str;
}
export default function PropertyTable({ data, onOpenDrawer, onOpenDetail, showDocuments = false, docStatusMap = {} }: PropertyTableProps) {
const [globalFilter, setGlobalFilter] = useState("");
const [stageFilter, setStageFilter] = useState<string>("all");
const [docFilter, setDocFilter] = useState<DocFilter>("all");
const [sorting, setSorting] = useState<SortingState>([]);
const [pagination, setPagination] = useState<PaginationState>({
pageIndex: 0,
pageSize: 25,
});
const [columnVisibility, setColumnVisibility] = useState<VisibilityState>({
designer: false,
installer: false,
proposedMeasures: false,
approvedPackage: false,
actualMeasuresInstalled: false,
preSapScore: false,
lodgementStatus: false,
designDate: false,
fullLodgementDate: false,
});
// Pre-filter by stage and doc status before TanStack gets it
const filteredData = useMemo(() => {
let result = data;
if (stageFilter !== "all") {
result = result.filter((d) => d.displayStage === stageFilter);
}
if (docFilter !== "all") {
result = result.filter((d) => {
const status = d.uprn ? docStatusMap[d.uprn] : undefined;
if (docFilter === "none") return !status || !status.hasDocs;
if (docFilter === "has_docs") return !!status?.hasDocs;
if (docFilter === "incomplete") return !!status?.hasDocs && !status.isComplete;
return true;
});
}
return result;
}, [data, stageFilter, docFilter, docStatusMap]);
const columns = useMemo(
() => createPropertyTableColumns(onOpenDrawer, showDocuments, docStatusMap, onOpenDetail),
[onOpenDrawer, showDocuments, docStatusMap, onOpenDetail]
);
const table = useReactTable({
data: filteredData,
columns,
state: {
globalFilter,
sorting,
pagination,
columnVisibility,
},
onGlobalFilterChange: setGlobalFilter,
onSortingChange: setSorting,
onPaginationChange: setPagination,
onColumnVisibilityChange: setColumnVisibility,
getCoreRowModel: getCoreRowModel(),
getFilteredRowModel: getFilteredRowModel(),
getSortedRowModel: getSortedRowModel(),
getPaginationRowModel: getPaginationRowModel(),
globalFilterFn: "includesString",
});
const downloadCsv = () => {
const rows = table.getFilteredRowModel().rows;
const header = CSV_FIELDS.map((f) => f.label).join(",");
const body = rows
.map((row) =>
CSV_FIELDS.map((f) => escapeCell(row.original[f.key])).join(",")
)
.join("\n");
const blob = new Blob([header + "\n" + body], { type: "text/csv;charset=utf-8;" });
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = "properties.csv";
a.click();
URL.revokeObjectURL(url);
};
const toggleableColumns = table
.getAllColumns()
.filter((col) => col.getCanHide() && COLUMN_LABELS[col.id]);
const pageCount = table.getPageCount();
const currentPage = table.getState().pagination.pageIndex + 1;
const totalFiltered = table.getFilteredRowModel().rows.length;
return (
<div className="space-y-3">
{/* Toolbar */}
<div className="flex flex-col sm:flex-row gap-3 items-start sm:items-center">
{/* Search */}
<div className="relative flex-1 min-w-0">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-gray-400 pointer-events-none" />
<Input
value={globalFilter}
onChange={(e) => {
setGlobalFilter(e.target.value);
setPagination((p) => ({ ...p, pageIndex: 0 }));
}}
placeholder="Search address, UPRN, coordinator…"
className="pl-9 h-9 text-sm border-gray-200 focus:border-brandblue/40 focus:ring-brandblue/20"
/>
</div>
{/* Stage filter */}
<Select
value={stageFilter}
onValueChange={(v) => {
setStageFilter(v);
setPagination((p) => ({ ...p, pageIndex: 0 }));
}}
>
<SelectTrigger className="h-9 w-[180px] text-sm border-gray-200 shrink-0">
{stageFilter === "all"
? "All stages"
: stageFilter}
</SelectTrigger>
<SelectContent>
<SelectItem value="all">All stages</SelectItem>
{STAGE_ORDER.map((stage) => (
<SelectItem key={stage} value={stage}>
{stage}
</SelectItem>
))}
<SelectItem value="Queries">Queries</SelectItem>
</SelectContent>
</Select>
{/* Docs filter */}
{showDocuments && (
<Select
value={docFilter}
onValueChange={(v) => {
setDocFilter(v as DocFilter);
setPagination((p) => ({ ...p, pageIndex: 0 }));
}}
>
<SelectTrigger className="h-9 w-[160px] text-sm border-gray-200 shrink-0">
{docFilter === "all"
? "All docs"
: docFilter === "has_docs"
? "Has docs"
: docFilter === "incomplete"
? "Incomplete docs"
: "No docs"}
</SelectTrigger>
<SelectContent>
<SelectItem value="all">All docs</SelectItem>
<SelectItem value="has_docs">Has docs</SelectItem>
<SelectItem value="incomplete">Incomplete docs</SelectItem>
<SelectItem value="none">No docs</SelectItem>
</SelectContent>
</Select>
)}
{/* Download CSV */}
<button
onClick={downloadCsv}
className="inline-flex items-center gap-2 h-9 px-3 rounded-lg border border-gray-200 bg-white text-sm font-medium text-gray-600 hover:border-brandblue/30 hover:text-brandblue transition-colors shrink-0"
>
<Download className="h-3.5 w-3.5" />
CSV
</button>
{/* Column visibility */}
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button className="inline-flex items-center gap-2 h-9 px-3 rounded-lg border border-gray-200 bg-white text-sm font-medium text-gray-600 hover:border-brandblue/30 hover:text-brandblue transition-colors shrink-0">
<SlidersHorizontal className="h-3.5 w-3.5" />
Columns
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-48">
<DropdownMenuLabel className="text-xs text-gray-500">
Toggle columns
</DropdownMenuLabel>
<DropdownMenuSeparator />
{toggleableColumns.map((col) => (
<DropdownMenuCheckboxItem
key={col.id}
checked={col.getIsVisible()}
onCheckedChange={(val) => col.toggleVisibility(val)}
className="text-sm"
>
{COLUMN_LABELS[col.id] ?? col.id}
</DropdownMenuCheckboxItem>
))}
</DropdownMenuContent>
</DropdownMenu>
</div>
{/* Result count */}
<p className="text-xs text-gray-400">
Showing{" "}
<span className="font-semibold text-gray-600">
{Math.min(
table.getState().pagination.pageSize,
totalFiltered - table.getState().pagination.pageIndex * table.getState().pagination.pageSize
)}
</span>{" "}
of{" "}
<span className="font-semibold text-gray-600">{totalFiltered}</span>{" "}
{stageFilter !== "all" ? `"${stageFilter}" ` : ""}
{docFilter !== "all" ? `(${docFilter === "has_docs" ? "has docs" : docFilter === "incomplete" ? "incomplete docs" : "no docs"}) ` : ""}
propert{totalFiltered === 1 ? "y" : "ies"}
</p>
{/* Table */}
<div className="rounded-xl border border-gray-200 overflow-hidden shadow-sm">
<div className="overflow-x-auto">
<Table>
<TableHeader>
{table.getHeaderGroups().map((headerGroup) => (
<TableRow
key={headerGroup.id}
className="bg-gray-50/80 hover:bg-gray-50/80 border-b border-gray-200"
>
{headerGroup.headers.map((header) => (
<TableHead
key={header.id}
className="h-10 px-4 first:pl-5 last:pr-5"
>
{header.isPlaceholder
? null
: flexRender(
header.column.columnDef.header,
header.getContext()
)}
</TableHead>
))}
</TableRow>
))}
</TableHeader>
<TableBody>
{table.getRowModel().rows.length ? (
table.getRowModel().rows.map((row, i) => (
<TableRow
key={row.id}
className={`border-b border-gray-100 transition-colors hover:bg-brandlightblue/10 ${
i % 2 === 0 ? "bg-white" : "bg-gray-50/30"
}`}
>
{row.getVisibleCells().map((cell) => (
<TableCell
key={cell.id}
className="py-3 px-4 first:pl-5 last:pr-5"
>
{flexRender(
cell.column.columnDef.cell,
cell.getContext()
)}
</TableCell>
))}
</TableRow>
))
) : (
<TableRow>
<TableCell
colSpan={columns.length}
className="h-32 text-center text-sm text-gray-400"
>
No properties match the current filters.
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
</div>
</div>
{/* Pagination */}
{pageCount > 1 && (
<div className="flex items-center justify-between pt-1">
<div className="flex items-center gap-2">
<span className="text-xs text-gray-500">Rows per page:</span>
<Select
value={String(table.getState().pagination.pageSize)}
onValueChange={(v) =>
table.setPageSize(Number(v))
}
>
<SelectTrigger className="h-7 w-16 text-xs border-gray-200">
{table.getState().pagination.pageSize}
</SelectTrigger>
<SelectContent>
{[10, 25, 50, 100].map((n) => (
<SelectItem key={n} value={String(n)} className="text-xs">
{n}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="flex items-center gap-3">
<span className="text-xs text-gray-500">
Page {currentPage} of {pageCount}
</span>
<div className="flex gap-1">
<button
onClick={() => table.previousPage()}
disabled={!table.getCanPreviousPage()}
className="h-7 w-7 flex items-center justify-center rounded-lg border border-gray-200 text-gray-500 hover:border-brandblue/30 hover:text-brandblue disabled:opacity-30 disabled:cursor-not-allowed transition-colors"
>
<ChevronLeft className="h-3.5 w-3.5" />
</button>
<button
onClick={() => table.nextPage()}
disabled={!table.getCanNextPage()}
className="h-7 w-7 flex items-center justify-center rounded-lg border border-gray-200 text-gray-500 hover:border-brandblue/30 hover:text-brandblue disabled:opacity-30 disabled:cursor-not-allowed transition-colors"
>
<ChevronRight className="h-3.5 w-3.5" />
</button>
</div>
</div>
</div>
)}
</div>
);
}

View file

@ -0,0 +1,324 @@
"use client";
import { ColumnDef } from "@tanstack/react-table";
import { ArrowUpDown, CheckCircle2, AlertCircle, FileX } from "lucide-react";
import { STAGE_COLORS } from "./types";
import type { ClassifiedDeal, DisplayStage, DocStatusMap } from "./types";
// -----------------------------------------------------------------------
// Stage badge — consistent pill rendering
// -----------------------------------------------------------------------
function StageBadge({ stage }: { stage: DisplayStage }) {
const c = STAGE_COLORS[stage] ?? STAGE_COLORS["Unknown Stage"];
return (
<span
className={`inline-flex items-center gap-1.5 px-2.5 py-1 rounded-full text-xs font-medium border whitespace-nowrap ${c.bg} ${c.text} ${c.border}`}
>
<span className={`w-1.5 h-1.5 rounded-full shrink-0 ${c.dot}`} />
{stage}
</span>
);
}
// Sortable column header helper
function SortableHeader({
label,
column,
}: {
label: string;
column: { toggleSorting: (desc: boolean) => void; getIsSorted: () => false | "asc" | "desc" };
}) {
return (
<button
className="flex items-center gap-1 text-xs font-semibold uppercase tracking-wide text-gray-500 hover:text-brandblue transition-colors group"
onClick={() => column.toggleSorting(column.getIsSorted() === "asc")}
>
{label}
<ArrowUpDown className="h-3 w-3 opacity-40 group-hover:opacity-70 transition-opacity" />
</button>
);
}
// -----------------------------------------------------------------------
// Column factory — takes onOpenDrawer so the Documents button can trigger it
// showDocuments controls whether the Docs action column is included
// docStatusMap provides per-UPRN document status for status indicators
// -----------------------------------------------------------------------
export function createPropertyTableColumns(
onOpenDrawer: (uprn: string | null, landlordPropertyId: string | null, dealname: string | null) => void,
showDocuments: boolean = false,
docStatusMap: DocStatusMap = {},
onOpenDetail?: (deal: ClassifiedDeal) => void,
): ColumnDef<ClassifiedDeal>[] {
const columns: ColumnDef<ClassifiedDeal>[] = [
// ── Address ──────────────────────────────────────────────────────────
{
accessorKey: "dealname",
id: "dealname",
header: ({ column }) => <SortableHeader label="Address" column={column as any} />,
cell: ({ row }) => (
<div className="max-w-[220px]">
{onOpenDetail ? (
<button
onClick={() => onOpenDetail(row.original)}
className="text-sm font-medium text-brandblue hover:text-brandmidblue hover:underline underline-offset-2 leading-tight text-left truncate w-full transition-colors"
>
{row.original.dealname ?? "—"}
</button>
) : (
<p className="text-sm font-medium text-gray-900 leading-tight truncate">
{row.original.dealname ?? "—"}
</p>
)}
</div>
),
enableHiding: false,
},
// ── Property ref ─────────────────────────────────────────────────────
{
accessorKey: "landlordPropertyId",
id: "landlordPropertyId",
header: ({ column }) => <SortableHeader label="Ref" column={column as any} />,
cell: ({ row }) => (
<span className="text-xs font-mono text-gray-500">
{row.original.landlordPropertyId ?? "—"}
</span>
),
},
// ── UPRN ─────────────────────────────────────────────────────────────
{
accessorKey: "uprn",
id: "uprn",
header: () => (
<span className="text-xs font-semibold uppercase tracking-wide text-gray-500">UPRN</span>
),
cell: ({ row }) => (
<span className="text-xs font-mono text-gray-400">
{row.original.uprn ?? "—"}
</span>
),
},
// ── Stage badge ──────────────────────────────────────────────────────
{
accessorKey: "displayStage",
id: "displayStage",
header: ({ column }) => <SortableHeader label="Stage" column={column as any} />,
cell: ({ row }) => <StageBadge stage={row.original.displayStage} />,
filterFn: (row, _id, filterValue: string) =>
row.original.displayStage === filterValue,
enableHiding: false,
},
// ── Project code ─────────────────────────────────────────────────────
{
accessorKey: "projectCode",
id: "projectCode",
header: ({ column }) => <SortableHeader label="Project" column={column as any} />,
cell: ({ row }) => (
<span className="text-xs font-medium text-gray-600 bg-gray-100 px-2 py-0.5 rounded">
{row.original.projectCode ?? "—"}
</span>
),
},
// ── Coordinator ──────────────────────────────────────────────────────
{
accessorKey: "coordinator",
id: "coordinator",
header: ({ column }) => <SortableHeader label="Coordinator" column={column as any} />,
cell: ({ row }) => (
<span className="text-sm text-gray-700">
{row.original.coordinator ?? <span className="text-gray-300"></span>}
</span>
),
},
// ── Designer ─────────────────────────────────────────────────────────
{
accessorKey: "designer",
id: "designer",
header: ({ column }) => <SortableHeader label="Designer" column={column as any} />,
cell: ({ row }) => (
<span className="text-sm text-gray-700">
{row.original.designer ?? <span className="text-gray-300"></span>}
</span>
),
},
// ── Installer ────────────────────────────────────────────────────────
{
accessorKey: "installer",
id: "installer",
header: ({ column }) => <SortableHeader label="Installer" column={column as any} />,
cell: ({ row }) => (
<span className="text-sm text-gray-700">
{row.original.installer ?? <span className="text-gray-300"></span>}
</span>
),
},
// ── Proposed measures ────────────────────────────────────────────────
{
accessorKey: "proposedMeasures",
id: "proposedMeasures",
header: () => (
<span className="text-xs font-semibold uppercase tracking-wide text-gray-500">
Proposed Measures
</span>
),
cell: ({ row }) => (
<span className="text-xs text-gray-600 max-w-[180px] line-clamp-2 leading-snug">
{row.original.proposedMeasures ?? <span className="text-gray-300"></span>}
</span>
),
},
// ── Approved package ─────────────────────────────────────────────────
{
accessorKey: "approvedPackage",
id: "approvedPackage",
header: () => (
<span className="text-xs font-semibold uppercase tracking-wide text-gray-500">
Approved Package
</span>
),
cell: ({ row }) => (
<span className="text-xs text-gray-600">
{row.original.approvedPackage ?? <span className="text-gray-300"></span>}
</span>
),
},
// ── Installed measures ───────────────────────────────────────────────
{
accessorKey: "actualMeasuresInstalled",
id: "actualMeasuresInstalled",
header: () => (
<span className="text-xs font-semibold uppercase tracking-wide text-gray-500">
Installed
</span>
),
cell: ({ row }) => (
<span className="text-xs text-gray-600 max-w-[180px] line-clamp-2">
{row.original.actualMeasuresInstalled ?? <span className="text-gray-300"></span>}
</span>
),
},
// ── Pre-SAP score ────────────────────────────────────────────────────
{
accessorKey: "preSapScore",
id: "preSapScore",
header: ({ column }) => <SortableHeader label="Pre-SAP" column={column as any} />,
cell: ({ row }) => {
const score = row.original.preSapScore;
if (!score) return <span className="text-gray-300"></span>;
const n = Number(score);
const colour =
n < 30
? "text-red-600 bg-red-50"
: n < 50
? "text-amber-700 bg-amber-50"
: "text-emerald-700 bg-emerald-50";
return (
<span className={`text-xs font-semibold px-2 py-0.5 rounded ${colour}`}>
{score}
</span>
);
},
},
// ── Lodgement status ─────────────────────────────────────────────────
{
accessorKey: "lodgementStatus",
id: "lodgementStatus",
header: ({ column }) => <SortableHeader label="Lodgement" column={column as any} />,
cell: ({ row }) => (
<span className="text-xs text-gray-600">
{row.original.lodgementStatus ?? <span className="text-gray-300"></span>}
</span>
),
},
// ── Design date ──────────────────────────────────────────────────────
{
accessorKey: "designDate",
id: "designDate",
header: ({ column }) => <SortableHeader label="Design Date" column={column as any} />,
cell: ({ row }) => {
const d = row.original.designDate;
return (
<span className="text-xs text-gray-500">
{d ? new Date(d).toLocaleDateString("en-GB", { day: "numeric", month: "short", year: "2-digit" }) : <span className="text-gray-300"></span>}
</span>
);
},
},
// ── Full lodgement date ──────────────────────────────────────────────
{
accessorKey: "fullLodgementDate",
id: "fullLodgementDate",
header: ({ column }) => <SortableHeader label="Lodgement Date" column={column as any} />,
cell: ({ row }) => {
const d = row.original.fullLodgementDate;
return (
<span className="text-xs text-gray-500">
{d ? new Date(d).toLocaleDateString("en-GB", { day: "numeric", month: "short", year: "2-digit" }) : <span className="text-gray-300"></span>}
</span>
);
},
},
];
if (showDocuments) {
columns.push({
id: "documents",
header: () => (
<span className="text-xs font-semibold uppercase tracking-wide text-gray-500">Docs</span>
),
cell: ({ row }) => {
const uprn = row.original.uprn ?? "";
const status = uprn ? docStatusMap[uprn] : undefined;
const isComplete = status?.isComplete;
const hasDocs = status?.hasDocs;
let icon: React.ReactNode;
let className: string;
if (isComplete) {
icon = <CheckCircle2 className="h-3.5 w-3.5" />;
className =
"inline-flex items-center gap-1.5 px-2.5 py-1.5 rounded-lg text-xs font-medium border border-emerald-200 text-emerald-700 bg-emerald-50 hover:bg-emerald-100 hover:border-emerald-300 transition-all duration-150 whitespace-nowrap";
} else if (hasDocs) {
icon = <AlertCircle className="h-3.5 w-3.5" />;
className =
"inline-flex items-center gap-1.5 px-2.5 py-1.5 rounded-lg text-xs font-medium border border-amber-200 text-amber-700 bg-amber-50 hover:bg-amber-100 hover:border-amber-300 transition-all duration-150 whitespace-nowrap";
} else {
icon = <FileX className="h-3.5 w-3.5" />;
className =
"inline-flex items-center gap-1.5 px-2.5 py-1.5 rounded-lg text-xs font-medium border border-gray-200 text-gray-400 bg-gray-50 hover:bg-gray-100 hover:border-gray-300 transition-all duration-150 whitespace-nowrap";
}
return (
<button
onClick={() => onOpenDrawer(row.original.uprn, row.original.landlordPropertyId, row.original.dealname)}
className={className}
>
{icon}
Docs
</button>
);
},
enableSorting: false,
enableHiding: false,
});
}
return columns;
}

View file

@ -0,0 +1,107 @@
"use client";
import { motion } from "framer-motion";
import { AlertCircle } from "lucide-react";
import { Card, CardContent } from "@/app/shadcn_components/ui/card";
import type { ClassifiedDeal } from "./types";
const SUCCESSFUL_OUTCOMES = new Set(["Surveyed", "Surveyed - Pending Upload"]);
const COLUMNS: (keyof ClassifiedDeal)[] = [
"dealname",
"landlordPropertyId",
"outcome",
"outcomeNotes",
];
const COLUMN_LABELS: Partial<Record<keyof ClassifiedDeal, string>> = {
dealname: "Address",
landlordPropertyId: "Ref",
outcome: "Outcome",
outcomeNotes: "Notes",
};
interface SurveyIssuesPanelProps {
deals: ClassifiedDeal[];
onOpenTable: (
stage: string,
deals: ClassifiedDeal[],
columns?: (keyof ClassifiedDeal)[],
columnLabels?: Partial<Record<keyof ClassifiedDeal, string>>,
breakdown?: Record<string, ClassifiedDeal[]>,
) => void;
}
export default function SurveyIssuesPanel({
deals,
onOpenTable,
}: SurveyIssuesPanelProps) {
// Filter to deals with a populated outcome that is not a success
const issueDeals = deals.filter(
(d) => d.outcome && !SUCCESSFUL_OUTCOMES.has(d.outcome),
);
if (issueDeals.length === 0) return null;
// Group by outcome, sorted by count descending
const groups = new Map<string, ClassifiedDeal[]>();
for (const deal of issueDeals) {
const key = deal.outcome!;
if (!groups.has(key)) groups.set(key, []);
groups.get(key)!.push(deal);
}
const sortedGroups = Array.from(groups.entries()).sort(
(a, b) => b[1].length - a[1].length,
);
return (
<Card className="border border-amber-200 shadow-sm">
<CardContent className="p-6">
<div className="flex items-center justify-between mb-5">
<div>
<div className="flex items-center gap-2">
<AlertCircle className="h-4 w-4 text-amber-500" />
<h3 className="text-base font-semibold text-amber-800">
Survey Issues
</h3>
<span className="ml-2 px-2 py-0.5 rounded-full bg-amber-100 text-amber-700 text-xs font-semibold">
{issueDeals.length} affected
</span>
</div>
<p className="text-sm text-gray-500 mt-0.5">
Properties where the survey did not result in a successful outcome
</p>
</div>
</div>
<div className="grid grid-cols-2 sm:grid-cols-3 gap-3">
{sortedGroups.map(([outcomeLabel, groupDeals]) => (
<motion.button
key={outcomeLabel}
whileHover={{ scale: 1.03 }}
onClick={() =>
onOpenTable(
`Survey Issues — ${outcomeLabel}`,
groupDeals,
COLUMNS,
COLUMN_LABELS,
)
}
className="group text-left rounded-xl border border-amber-200 bg-gradient-to-br from-amber-50 to-white p-4 hover:border-amber-300 hover:shadow-md transition-all duration-200"
>
<p className="text-xs font-semibold text-amber-700 uppercase tracking-wide mb-2 leading-tight">
{outcomeLabel}
</p>
<p className="text-2xl font-bold text-amber-800">
{groupDeals.length}
</p>
<p className="text-xs text-amber-600/70 mt-0.5">
{((groupDeals.length / issueDeals.length) * 100).toFixed(0)}% of
issues
</p>
</motion.button>
))}
</div>
</CardContent>
</Card>
);
}

View file

@ -1,214 +0,0 @@
"use client";
import { useState } from "react";
import { Download } from "lucide-react";
import type { ClassifiedDeal, HubspotDeal } from "./types";
interface TableViewerProps {
data: ClassifiedDeal[];
columns?: (keyof HubspotDeal)[];
columnLabels?: Partial<Record<keyof HubspotDeal, string>>;
breakdown?: Record<string, ClassifiedDeal[]>;
}
export default function TableViewer({
data,
columns,
columnLabels,
breakdown,
}: TableViewerProps) {
const [searchTerms, setSearchTerms] = useState<Record<string, string>>({});
const visibleColumns = columns?.length
? columns
: (Object.keys(data?.[0] || {}) as (keyof HubspotDeal)[]);
// Helper: Get category for a row based on breakdown
const getCategoryForRow = (
row: ClassifiedDeal,
brk: Record<string, ClassifiedDeal[]> | undefined
): string | undefined => {
if (!brk) return undefined;
for (const [category, items] of Object.entries(brk)) {
if (items.includes(row)) return category;
}
return undefined;
};
const getRowStatus = (row: ClassifiedDeal) => {
if (!breakdown) return "untouched";
const category = getCategoryForRow(row, breakdown);
if (category?.includes("Completed")) {
return "completed";
} else if (category?.includes("Progress")) {
return "progress";
}
return "untouched";
};
const getRowBackgroundColor = (status: string) => {
switch (status) {
case "completed":
return "bg-white";
case "progress":
return "bg-white";
case "untouched":
return "bg-white";
default:
return "bg-white";
}
};
const getSortPriority = (status: string) => {
switch (status) {
case "completed":
return 0;
case "progress":
return 1;
case "untouched":
return 2;
default:
return 3;
}
};
// Inline filter derivation (no useMemo)
const filteredData = data.filter((row) =>
visibleColumns.every((col) => {
const term = searchTerms[col]?.toLowerCase() || "";
if (!term) return true;
const value = String(row[col as keyof ClassifiedDeal] ?? "").toLowerCase();
return value.includes(term);
})
);
// Inline sort derivation (no useMemo)
const sortedFilteredData = [...filteredData].sort((a, b) => {
const statusA = getRowStatus(a);
const statusB = getRowStatus(b);
return getSortPriority(statusA) - getSortPriority(statusB);
});
const renderCellContent = (col: keyof HubspotDeal, value: any) => {
if (col === "majorConditionIssuePhotosS3" && value) {
let urls: string[] = [];
if (typeof value === "string") {
try {
const parsed = JSON.parse(value);
urls = Array.isArray(parsed) ? parsed : [value];
} catch {
urls = value.split(/[\s,]+/).filter((u) => u.startsWith("http"));
}
} else if (Array.isArray(value)) {
urls = value;
}
if (urls.length === 0)
return <span className="text-gray-400">No photos</span>;
const handleDownload = async (rawUrl: string) => {
try {
// Extract the object key (after the bucket domain)
const key = rawUrl.split(".amazonaws.com/")[1];
if (!key) return alert("Invalid S3 key");
const res = await fetch("/api/sign-s3-url", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ key }),
});
const data = await res.json();
if (data.url) {
window.open(data.url, "_blank");
} else {
alert("Failed to get signed URL");
}
} catch (err) {
console.error(err);
alert("Error downloading file");
}
};
return (
<div className="flex flex-wrap gap-2">
{urls.map((url, idx) => (
<button
key={idx}
onClick={() => handleDownload(url)}
className="flex items-center gap-2 px-3 py-1.5 bg-gradient-to-r from-brandblue/10 to-brandmidblue/10 text-brandblue text-xs font-medium rounded-lg hover:from-brandblue/20 hover:to-brandmidblue/20 border border-brandblue/20 hover:border-brandblue/40 transition-all duration-300 active:scale-95"
>
<Download className="w-3.5 h-3.5" />
<span>Download</span>
</button>
))}
</div>
);
}
return String(value ?? "");
};
return (
<div className="overflow-x-auto border border-brandblue/10 rounded-xl shadow-lg bg-white">
<table className="min-w-full text-sm border-collapse">
<thead className="bg-gradient-to-r from-brandblue/5 to-brandmidblue/5 sticky top-0 border-b border-brandblue/10">
<tr>
{visibleColumns.map((col) => (
<th key={col as string} className="p-4 text-left font-bold text-brandblue">
<div className="flex flex-col gap-2">
<span className="text-xs uppercase tracking-wide">
{columnLabels?.[col] || (col as string)}
</span>
<input
type="text"
placeholder="Search..."
className="p-2 border border-brandblue/20 rounded-lg text-xs focus:ring-2 focus:ring-brandblue focus:border-brandblue outline-none bg-white hover:border-brandblue/40 transition-all"
onChange={(e) =>
setSearchTerms((prev) => ({
...prev,
[col]: e.target.value,
}))
}
/>
</div>
</th>
))}
</tr>
</thead>
<tbody>
{sortedFilteredData.length === 0 ? (
<tr>
<td
colSpan={visibleColumns.length}
className="text-center py-8 text-gray-500 font-medium"
>
No results found
</td>
</tr>
) : (
sortedFilteredData.map((row, i) => {
const status = getRowStatus(row);
return (
<tr
key={i}
className={`${getRowBackgroundColor(status)} hover:opacity-75 transition-all border-b border-brandblue/5 last:border-b-0`}
>
{visibleColumns.map((col) => (
<td key={col as string} className="p-4 text-gray-800">
{renderCellContent(
col,
row[col as keyof ClassifiedDeal]
)}
</td>
))}
</tr>
);
})
)}
</tbody>
</table>
</div>
);
}

View file

@ -1,13 +1,64 @@
import { getServerSession } from "next-auth";
import { AuthOptions } from "@/app/api/auth/[...nextauth]/authOptions";
import { redirect } from "next/navigation";
import { surveyDB } from "../../../../../db/surveyDB/connection";
import { hubspotDealData } from "../../../../../db/schema/crm/hubspot_deal_table";
import { hubspotCompanyData } from "@/app/db/schema/crm/hubspot_company_table";
import { eq } from "drizzle-orm";
import { eq, inArray } from "drizzle-orm";
import LiveTracker from "./LiveTracker";
import { computeLiveTrackerData } from "./transforms";
import type { HubspotDeal } from "./types";
import { db } from "@/app/db/db";
import { hubspotDealData } from "@/app/db/schema/crm/hubspot_deal_table";
import { uploadedFiles } from "@/app/db/schema/uploaded_files";
import { portfolioOrganisation } from "@/app/db/schema/portfolio_organisation";
import { organisation } from "@/app/db/schema/organisation";
import type { HubspotDeal, DocStatusMap, DocStatus } from "./types";
import { EXPECTED_SURVEY_DOC_TYPES } from "./types";
import type { InferSelectModel } from "drizzle-orm";
import { Card, CardContent } from "@/app/shadcn_components/ui/card";
import { Building2 } from "lucide-react";
type DbDeal = InferSelectModel<typeof hubspotDealData>;
function mapDbRowToHubspotDeal(row: DbDeal): HubspotDeal {
return {
id: row.id,
dealId: row.dealId,
dealname: row.dealname,
dealstage: row.dealstage,
companyId: row.companyId,
projectCode: row.projectCode,
landlordPropertyId: row.landlordPropertyId,
uprn: row.uprn,
outcome: row.outcome,
outcomeNotes: row.outcomeNotes,
majorConditionIssueDescription: row.majorConditionIssueDescription,
majorConditionIssuePhotos: row.majorConditionIssuePhotos,
majorConditionIssuePhotosS3: row.majorConditionIssuePhotosS3,
coordinationStatus: row.coordinationStatus,
designStatus: row.designStatus,
pashubLink: row.pashubLink,
sharepointLink: row.sharepointLink,
dampMouldFlag: row.dampmouldGrowth,
preSapScore: row.preSap,
coordinator: row.coordinator,
ioeV1Date: row.mtpCompletionDate,
ioeV2Date: row.mtpReModelCompletionDate,
ioeV3Date: row.ioeV3CompletionDate,
proposedMeasures: row.proposedMeasures,
approvedPackage: row.approvedPackage,
designer: row.designer,
designDate: row.designCompletionDate,
actualMeasuresInstalled: row.actualMeasuresInstalled,
installer: row.installer,
installerHandover: row.installerHandover,
lodgementStatus: row.lodgementStatus,
measuresLodgementDate: row.measuresLodgementDate,
fullLodgementDate: row.lodgementDate,
confirmedSurveyDate: row.confirmedSurveyDate,
surveyedDate: row.SurveyedDate,
designType: row.dealType,
createdAt: row.createdAt,
updatedAt: row.updatedAt,
};
}
export default async function LiveReportingPage(props: {
params: Promise<{ slug: string }>;
@ -16,61 +67,98 @@ export default async function LiveReportingPage(props: {
const user = await getServerSession(AuthOptions);
if (!user?.user) {
console.error("User not found");
redirect("/");
}
// 🏢 Fetch the company
// Look up the linked organisation for this portfolio
const link = await db
.select({ hubspotCompanyId: organisation.hubspotCompanyId })
.from(portfolioOrganisation)
.innerJoin(organisation, eq(portfolioOrganisation.organisationId, organisation.id))
.where(eq(portfolioOrganisation.portfolioId, BigInt(portfolioId)))
.limit(1);
const [company] = await surveyDB
.select()
.from(hubspotCompanyData)
.where(eq(hubspotCompanyData.groupId, portfolioId));
const pageHeader = (
<div className="mb-6">
<header className="text-3xl font-semibold text-brandblue">Live Projects</header>
<p className="text-sm text-gray-500">
{`Check in on your projects' progress with real-time data updates.`}
</p>
<div className="h-px bg-gray-200 mt-2" />
</div>
);
if (!company) {
if (!link.length || !link[0].hubspotCompanyId) {
return (
<main className="min-h-screen flex items-center justify-center bg-gradient-to-br from-[#14163d] via-[#2d348f] to-[#3943b7] text-white">
<div className="text-center bg-white/10 backdrop-blur-md text-gray-200 p-8 rounded-2xl shadow-2xl border border-white/10">
No information to show.
</div>
</main>
<div className="max-w-7xl mx-auto px-6 pb-10 space-y-4">
{pageHeader}
<Card className="border border-brandblue/10 shadow-sm">
<CardContent className="flex flex-col items-center justify-center py-16 text-center gap-4">
<div className="p-4 rounded-full bg-brandlightblue/40 border border-brandblue/10">
<Building2 className="h-8 w-8 text-brandblue/50" />
</div>
<div>
<p className="text-base font-semibold text-gray-700">No organisation linked</p>
<p className="text-sm text-gray-400 mt-1 max-w-sm">
A Domna administrator needs to connect this portfolio to an organisation in{" "}
<strong>Portfolio Settings</strong> before live tracking data can be displayed.
</p>
</div>
</CardContent>
</Card>
</div>
);
}
// 💼 Fetch deals for that company
const deals = await surveyDB
const companyId = link[0].hubspotCompanyId;
const rawDeals = await db
.select()
.from(hubspotDealData)
.where(eq(hubspotDealData.companyId, company.companyId));
.where(eq(hubspotDealData.companyId, companyId));
if (!deals || deals.length === 0) {
return (
<main className="min-h-screen flex items-center justify-center bg-gradient-to-br from-[#14163d] via-[#2d348f] to-[#3943b7] text-white">
<div className="text-center bg-white/10 backdrop-blur-md text-gray-200 p-8 rounded-2xl shadow-2xl border border-white/10">
No information to show.
</div>
</main>
);
const deals = rawDeals.map(mapDbRowToHubspotDeal);
const trackerData = computeLiveTrackerData(deals);
// Fetch survey document status for all properties
const uprnList = deals
.map((d) => d.uprn)
.filter((u): u is string => !!u)
.map((u) => {
try { return BigInt(u); } catch { return null; }
})
.filter((u): u is bigint => u !== null);
let docStatusMap: DocStatusMap = {};
if (uprnList.length > 0) {
const docRows = await db
.select()
.from(uploadedFiles)
.where(inArray(uploadedFiles.uprn, uprnList));
const grouped: Record<string, Set<string>> = {};
for (const row of docRows) {
if (row.uprn === null || row.fileType === null) continue;
const key = String(row.uprn);
(grouped[key] ??= new Set()).add(row.fileType);
}
for (const [uprn, types] of Object.entries(grouped)) {
const presentTypes = Array.from(types);
const status: DocStatus = {
presentTypes,
hasDocs: presentTypes.length > 0,
isComplete: EXPECTED_SURVEY_DOC_TYPES.every((t) => types.has(t)),
};
docStatusMap[uprn] = status;
}
}
// 🔄 Transform raw deals to typed and computed data
const trackerData = computeLiveTrackerData(deals as HubspotDeal[]);
return (
<div className="max-w-7xl mx-auto px-6 pb-10 space-y-4">
<div className="mb-6">
<header className="text-3xl font-semibold text-brandblue">
Live Projects
</header>
<p className="text-sm text-gray-500">
{`Check in on your projects' progress with real-time data updates.`}
</p>
<div className="h-px bg-gray-200 mt-2" />
</div>
<LiveTracker {...trackerData} />
{pageHeader}
<LiveTracker {...trackerData} docStatusMap={docStatusMap} />
</div>
);
}

View file

@ -12,6 +12,8 @@ import type {
OutcomeSlice,
LiveTrackerProps,
WorkPhaseStats,
DampMouldRiskData,
FunnelStage,
} from "./types";
import {
@ -25,7 +27,7 @@ import {
// -----------------------------------------------------------------------
const STAGE_ID_MAP: Record<string, string> = {
"1617223910": "Scope & Planning", //[Ops] Backlog
"3583836399": "Scope & Planning", //[Ops] Route Planning
"3583836399": "Scope & Planning", //[Ops] Route Planning
"3589581001": "Booking in Progress", // [Bookings] Ready for Bookings Team
"3569878239": "Booking in Progress", //[Bookings] Send initial booking SMS
"1617223911": "Booking in Progress", // [Bookings] Send Email
@ -39,13 +41,13 @@ const STAGE_ID_MAP: Record<string, string> = {
"1617223913": "Assessment in Progress", //[Ops] Survey in Progress
"2558220518": "Assessment in Progress", // [Ops] Not attempted - needs reallocation
"3474594026": "Booking in Progress", //[Ops/Bookings] Rebooked - Needs updating
"3206388924": "Assessment in Progress", //[Ops] Surveyed - Pending Upload from Surveyor (Up to Khalim + Kev as debatable)
"3206388924": "Assessment in Progress", //[Ops] Surveyed - Pending Upload from Surveyor
"1617223915": "Queries", //[Ops] No Access - Need Sign Off
"1617223917": "Queries", //[Ops] No Access - No Revisit
"1887735998": "Queries", //[Ops] Not Viable
"3061261536": "Queries", //[Sales/Tech] Major condition issue
"3948185842": "AFTER_ASSESSMENT", //[Admin] Admin to check all paperwork for external comms
"1617223914": "AFTER_ASSESSMENT",// [Ops]Surveyed in Pashub, Transit Job to Co-ordination
"1617223914": "AFTER_ASSESSMENT", // [Ops] Surveyed in Pashub, Transit Job to Co-ordination
"1617223916": "Queries", // [Ops] Properties to Review Manually
"2628341989": "Assessment in Progress", //[Ops] Assessment needs correction
"3441170637": "AFTER_ASSESSMENT", //[Ops] Awaiting PV Design
@ -54,9 +56,6 @@ const STAGE_ID_MAP: Record<string, string> = {
"1960060104": "Queries", //[Ops] HA Informed
"1960060105": "Queries", //[Ops] HA Works Scheduled
"1960060106": "AFTER_ASSESSMENT", //[Ops] HA Works Complete
// "1668803772": "", //[Ops] ERF Delivered to HA
// "1668803773": "", //[Ops] ERF Signed
// "2769407183": "", //[Ops] PV - Needs Heating Upgrade (Pre EPR D)
"2769407184": "Queries", //[Ops] Talk to client, Needs Heating Upgrade (Pre EPR C)
"2702650617": "AFTER_ASSESSMENT", //[Design] Ready for Design
"2473886962": "AFTER_ASSESSMENT", //[Design] Design in progress
@ -65,12 +64,12 @@ const STAGE_ID_MAP: Record<string, string> = {
// -----------------------------------------------------------------------
// After-assessment sub-classification
// Resolves AFTER_ASSESSMENT deals based on coordinationStatus and designStatus
// Resolves AFTER_ASSESSMENT deals based on coordinationStatus + designStatus
// -----------------------------------------------------------------------
function resolveAfterAssessmentStage(
coordinationStatus: string | null,
designStatus: string | null
): DisplayStage {
): "Coordination in Progress" | "Design in Progress" | "POST_DESIGN" | "Queries" {
const coord = coordinationStatus?.toUpperCase() ?? "";
const design = designStatus?.toUpperCase() ?? "";
@ -83,25 +82,43 @@ function resolveAfterAssessmentStage(
coord.includes("(V2) IOE/MTP COMPLETE") ||
coord.includes("(V3) IOE/MTP COMPLETE")
) {
return design === "UPLOADED" ? "Completed" : "Design in Progress";
return design === "UPLOADED" ? "POST_DESIGN" : "Design in Progress";
}
// Default for AFTER_ASSESSMENT
return "Coordination in Progress";
}
// -----------------------------------------------------------------------
// Post-design sub-classification
// Called when design is UPLOADED — resolves install / lodgement / completed
// -----------------------------------------------------------------------
function resolvePostDesignStage(deal: HubspotDeal): DisplayStage {
if (deal.fullLodgementDate) return "Project Complete";
if (deal.measuresLodgementDate) return "At Post Survey";
if (deal.lodgementStatus) return "At Lodgement";
if (deal.actualMeasuresInstalled || deal.installerHandover) return "Installation Complete";
return "Installation in Progress";
}
// -----------------------------------------------------------------------
// Resolve display stage for a single deal
// Maps dealstage ID + coordinationStatus + designStatus -> DisplayStage
// Maps dealstage ID + coordination/design/install status -> DisplayStage
// -----------------------------------------------------------------------
export function resolveDisplayStage(deal: HubspotDeal): DisplayStage {
const raw = STAGE_ID_MAP[deal.dealstage ?? ""] ?? "AFTER_ASSESSMENT";
if (raw === "AFTER_ASSESSMENT") {
return resolveAfterAssessmentStage(
const afterAssessment = resolveAfterAssessmentStage(
deal.coordinationStatus,
deal.designStatus
);
if (afterAssessment === "POST_DESIGN") {
return resolvePostDesignStage(deal);
}
return afterAssessment;
}
// RA ISSUE override can apply to other stages too
@ -125,6 +142,52 @@ export function classifyDeals(deals: HubspotDeal[]): ClassifiedDeal[] {
}));
}
// -----------------------------------------------------------------------
// Compute damp & mould risk — survey vs coordination stage comparison
// -----------------------------------------------------------------------
export function computeDampMouldRisk(deals: ClassifiedDeal[]): DampMouldRiskData {
const surveyFlagDeals = deals.filter((d) => !!d.majorConditionIssuePhotosS3);
const coordinatorFlagDeals = deals.filter((d) => !!d.dampMouldFlag);
const bothFlaggedCount = surveyFlagDeals.filter((d) => !!d.dampMouldFlag).length;
return {
surveyFlagCount: surveyFlagDeals.length,
coordinatorFlagCount: coordinatorFlagDeals.length,
bothFlaggedCount,
totalDeals: deals.length,
surveyFlagDeals,
coordinatorFlagDeals,
};
}
// -----------------------------------------------------------------------
// Compute pipeline funnel — dual counts (current snapshot + cumulative)
// -----------------------------------------------------------------------
export function computeFunnelStages(deals: ClassifiedDeal[]): FunnelStage[] {
const nonQueryDeals = deals.filter((d) => d.displayStage !== "Queries");
const total = nonQueryDeals.length;
return STAGE_ORDER.map((stage) => {
const stageIndex = STAGE_ORDER.indexOf(stage);
const currentCount = nonQueryDeals.filter(
(d) => d.displayStage === stage
).length;
const cumulativeCount = nonQueryDeals.filter(
(d) => STAGE_ORDER.indexOf(d.displayStage) >= stageIndex
).length;
return {
stage,
currentCount,
currentPct: total > 0 ? (currentCount / total) * 100 : 0,
cumulativeCount,
cumulativePct: total > 0 ? (cumulativeCount / total) * 100 : 0,
};
});
}
// -----------------------------------------------------------------------
// Compute all ProjectProgressData for a set of already-classified deals
// -----------------------------------------------------------------------
@ -154,7 +217,7 @@ export function computeProjectProgress(
}
);
const completedDeals = stageBuckets["Completed"] ?? [];
const completedDeals = stageBuckets["Project Complete"] ?? [];
const completedCount = completedDeals.length;
const completedPercentage =
nonQueryTotal > 0 ? (completedCount / nonQueryTotal) * 100 : 0;
@ -162,12 +225,17 @@ export function computeProjectProgress(
const totalDeals = deals.length;
// Coordination phase:
// completed = Design in Progress + Completed (i.e. coordination is done)
// completed = Design in Progress + Installation in Progress + Installation Complete + At Lodgement + At Post Survey + Project Complete
// in progress = Coordination in Progress
const coordCompletedDeals = deals.filter(
(d) =>
d.displayStage === "Design in Progress" ||
d.displayStage === "Completed"
const coordCompletedDeals = deals.filter((d) =>
[
"Design in Progress",
"Installation in Progress",
"Installation Complete",
"At Lodgement",
"At Post Survey",
"Project Complete",
].includes(d.displayStage)
);
const coordInProgressDeals = deals.filter(
(d) => d.displayStage === "Coordination in Progress"
@ -179,33 +247,79 @@ export function computeProjectProgress(
completedCount: coordCompletedDeals.length,
inProgressCount: coordInProgressDeals.length,
completedPercentage:
totalDeals > 0
? (coordCompletedDeals.length / totalDeals) * 100
: 0,
totalDeals > 0 ? (coordCompletedDeals.length / totalDeals) * 100 : 0,
inProgressPercentage:
totalDeals > 0
? (coordInProgressDeals.length / totalDeals) * 100
: 0,
totalDeals > 0 ? (coordInProgressDeals.length / totalDeals) * 100 : 0,
total: totalDeals,
};
// Design phase:
// completed = Completed stage
// completed = Installation in Progress + Installation Complete + At Lodgement + At Post Survey + Project Complete
// in progress = Design in Progress
const designCompletedDeals = deals.filter((d) =>
[
"Installation in Progress",
"Installation Complete",
"At Lodgement",
"At Post Survey",
"Project Complete",
].includes(d.displayStage)
);
const designInProgressDeals = deals.filter(
(d) => d.displayStage === "Design in Progress"
);
const design: WorkPhaseStats = {
completedDeals,
completedDeals: designCompletedDeals,
inProgressDeals: designInProgressDeals,
completedCount,
completedCount: designCompletedDeals.length,
inProgressCount: designInProgressDeals.length,
completedPercentage:
totalDeals > 0 ? (designCompletedDeals.length / totalDeals) * 100 : 0,
inProgressPercentage:
totalDeals > 0 ? (designInProgressDeals.length / totalDeals) * 100 : 0,
total: totalDeals,
};
// Install phase:
// completed = At Lodgement + At Post Survey + Project Complete
// in progress = Installation Complete
const installCompletedDeals = deals.filter((d) =>
["At Lodgement", "At Post Survey", "Project Complete"].includes(d.displayStage)
);
const installInProgressDeals = deals.filter(
(d) => d.displayStage === "Installation Complete"
);
const install: WorkPhaseStats = {
completedDeals: installCompletedDeals,
inProgressDeals: installInProgressDeals,
completedCount: installCompletedDeals.length,
inProgressCount: installInProgressDeals.length,
completedPercentage:
totalDeals > 0 ? (installCompletedDeals.length / totalDeals) * 100 : 0,
inProgressPercentage:
totalDeals > 0 ? (installInProgressDeals.length / totalDeals) * 100 : 0,
total: totalDeals,
};
// Lodgement phase:
// completed = At Post Survey + Project Complete
// in progress = At Lodgement
const lodgementInProgressDeals = deals.filter(
(d) => d.displayStage === "At Lodgement"
);
const lodgement: WorkPhaseStats = {
completedDeals,
inProgressDeals: lodgementInProgressDeals,
completedCount,
inProgressCount: lodgementInProgressDeals.length,
completedPercentage:
totalDeals > 0 ? (completedCount / totalDeals) * 100 : 0,
inProgressPercentage:
totalDeals > 0
? (designInProgressDeals.length / totalDeals) * 100
? (lodgementInProgressDeals.length / totalDeals) * 100
: 0,
total: totalDeals,
};
@ -220,6 +334,10 @@ export function computeProjectProgress(
totalDeals,
coordination,
design,
install,
lodgement,
dampMouldRisk: computeDampMouldRisk(deals),
funnelStages: computeFunnelStages(deals),
};
}
@ -257,7 +375,7 @@ export function computeOutcomeSlices(deals: ClassifiedDeal[]): OutcomeSlice[] {
// -----------------------------------------------------------------------
export function computeLiveTrackerData(
rawDeals: HubspotDeal[]
): LiveTrackerProps {
): Omit<LiveTrackerProps, "docStatusMap"> {
// Classify all deals (add displayStage field)
const classified = classifyDeals(rawDeals);
@ -283,6 +401,16 @@ export function computeLiveTrackerData(
})
);
// When there are multiple project codes, prepend a synthetic "All Projects" entry
if (projects.length > 1) {
projects.unshift({
projectCode: "__ALL__",
progress: computeProjectProgress(classified),
outcomePieSlices: computeOutcomeSlices(classified),
allDeals: classified,
});
}
return {
projects,
totalDeals: classified.length,

View file

@ -5,6 +5,7 @@
// -----------------------------------------------------------------------
// Raw DB row from hubspotDealData table
// New CRM-synced fields are nullable — populated by HubSpot sync
// -----------------------------------------------------------------------
export type HubspotDeal = {
id: string;
@ -22,12 +23,38 @@ export type HubspotDeal = {
majorConditionIssuePhotosS3: string | null;
coordinationStatus: string | null;
designStatus: string | null;
// ── CRM-synced additions ──────────────────────────────────────────────
pashubLink: string | null;
sharepointLink: string | null;
dampMouldFlag: string | null; // coordinator-stage damp/mould flag
preSapScore: string | null; // kept as text (HubSpot returns strings)
coordinator: string | null;
ioeV1Date: Date | null;
ioeV2Date: Date | null;
ioeV3Date: Date | null;
proposedMeasures: string | null;
approvedPackage: string | null;
designer: string | null;
designDate: Date | null;
actualMeasuresInstalled: string | null;
installer: string | null;
installerHandover: string | null;
lodgementStatus: string | null;
measuresLodgementDate: Date | null;
fullLodgementDate: Date | null;
confirmedSurveyDate: Date | null;
surveyedDate: Date | null;
designType: string | null;
createdAt: Date;
updatedAt: Date;
};
// -----------------------------------------------------------------------
// Stage classification result - human-readable display labels
// Stage classification result — human-readable display labels
// Full end-to-end pipeline: assessment → coordination → design →
// install → lodgement → completed (funded)
// -----------------------------------------------------------------------
export type DisplayStage =
| "Scope & Planning"
@ -35,12 +62,16 @@ export type DisplayStage =
| "Assessment in Progress"
| "Coordination in Progress"
| "Design in Progress"
| "Completed"
| "Installation in Progress"
| "Installation Complete"
| "At Lodgement"
| "At Post Survey"
| "Project Complete"
| "Queries"
| "Unknown Stage";
// -----------------------------------------------------------------------
// A classified deal - original row plus its resolved display stage
// A classified deal original row plus its resolved display stage
// -----------------------------------------------------------------------
export type ClassifiedDeal = HubspotDeal & {
displayStage: DisplayStage;
@ -57,7 +88,7 @@ export type StageProgressItem = {
};
// -----------------------------------------------------------------------
// Coordination/Design summary card data
// Coordination/Design/Install/Lodgement summary card data
// -----------------------------------------------------------------------
export type WorkPhaseStats = {
completedDeals: ClassifiedDeal[];
@ -69,6 +100,29 @@ export type WorkPhaseStats = {
total: number;
};
// -----------------------------------------------------------------------
// Damp & mould risk comparison (survey-stage vs coordination-stage flags)
// -----------------------------------------------------------------------
export type DampMouldRiskData = {
surveyFlagCount: number; // majorConditionIssuePhotosS3 not null
coordinatorFlagCount: number; // dampMouldFlag not null/non-empty
bothFlaggedCount: number; // flagged at both stages (highest risk)
totalDeals: number;
surveyFlagDeals: ClassifiedDeal[];
coordinatorFlagDeals: ClassifiedDeal[];
};
// -----------------------------------------------------------------------
// Pipeline funnel data — dual counts per stage
// -----------------------------------------------------------------------
export type FunnelStage = {
stage: DisplayStage;
currentCount: number; // deals at exactly this stage right now
currentPct: number; // as % of non-query total
cumulativeCount: number; // deals that have reached this stage or beyond
cumulativePct: number;
};
// -----------------------------------------------------------------------
// All computed data for the ProgressOverview component
// -----------------------------------------------------------------------
@ -82,6 +136,10 @@ export type ProjectProgressData = {
totalDeals: number;
coordination: WorkPhaseStats;
design: WorkPhaseStats;
install: WorkPhaseStats;
lodgement: WorkPhaseStats;
dampMouldRisk: DampMouldRiskData;
funnelStages: FunnelStage[];
};
// -----------------------------------------------------------------------
@ -110,19 +168,62 @@ export type LiveTrackerProps = {
projects: ProjectData[];
totalDeals: number;
majorConditionDeals: ClassifiedDeal[]; // for Awaab's Law card
docStatusMap: DocStatusMap;
};
// -----------------------------------------------------------------------
// Table drill-down shape (stays in LiveTracker state)
// columns can include computed ClassifiedDeal fields (e.g. displayStage)
// -----------------------------------------------------------------------
export type TableModal = {
stage: string;
data: ClassifiedDeal[];
columns: (keyof HubspotDeal)[];
columnLabels: Partial<Record<keyof HubspotDeal, string>>;
columns: (keyof ClassifiedDeal)[];
columnLabels: Partial<Record<keyof ClassifiedDeal, string>>;
breakdown?: Record<string, ClassifiedDeal[]>;
};
// -----------------------------------------------------------------------
// Document drawer types
// -----------------------------------------------------------------------
export type PropertyDocument = {
id: string;
s3FileKey: string; // S3 object key — used directly for presigned URL
s3FileBucket: string; // S3 bucket name
docType: string; // fileType enum value
s3UploadTimestamp: string; // ISO string
uprn: string | null;
landlordPropertyId: string | null;
};
// All survey document types expected for a complete survey
export const EXPECTED_SURVEY_DOC_TYPES = [
"photo_pack",
"site_note",
"rd_sap_site_note",
"pas_2023_ventilation",
"pas_2023_condition",
"pas_significance",
"par_photo_pack",
"pas_2023_property",
"pas_2023_occupancy",
] as const;
export type DocStatus = {
presentTypes: string[];
hasDocs: boolean;
isComplete: boolean; // all EXPECTED_SURVEY_DOC_TYPES present
};
export type DocStatusMap = Record<string, DocStatus>; // keyed by UPRN string
export type DocumentDrawerState = {
open: boolean;
uprn: string | null;
landlordPropertyId: string | null;
dealname: string | null;
};
// -----------------------------------------------------------------------
// Surveyor outcome constants (single source of truth)
// -----------------------------------------------------------------------
@ -149,5 +250,90 @@ export const STAGE_ORDER: DisplayStage[] = [
"Assessment in Progress",
"Coordination in Progress",
"Design in Progress",
"Completed",
"Installation in Progress",
"Installation Complete",
"At Lodgement",
"At Post Survey",
"Project Complete",
];
// -----------------------------------------------------------------------
// Stage colour mapping — used for badges (PropertyTable) and funnel bars (AnalyticsView)
// -----------------------------------------------------------------------
export const STAGE_COLORS: Record<
DisplayStage,
{ bg: string; text: string; border: string; dot: string }
> = {
"Scope & Planning": {
bg: "bg-slate-100",
text: "text-slate-700",
border: "border-slate-200",
dot: "bg-slate-400",
},
"Booking in Progress": {
bg: "bg-sky-50",
text: "text-sky-700",
border: "border-sky-200",
dot: "bg-sky-400",
},
"Assessment in Progress": {
bg: "bg-blue-100",
text: "text-blue-900",
border: "border-blue-400",
dot: "bg-blue-700",
},
"Coordination in Progress": {
bg: "bg-indigo-50",
text: "text-indigo-700",
border: "border-indigo-200",
dot: "bg-indigo-400",
},
"Design in Progress": {
bg: "bg-blue-50",
text: "text-blue-700",
border: "border-blue-200",
dot: "bg-blue-400",
},
"Installation in Progress": {
bg: "bg-indigo-50",
text: "text-indigo-600",
border: "border-indigo-200",
dot: "bg-indigo-300",
},
"Installation Complete": {
bg: "bg-teal-50",
text: "text-teal-700",
border: "border-teal-200",
dot: "bg-teal-400",
},
"At Lodgement": {
bg: "bg-cyan-50",
text: "text-cyan-700",
border: "border-cyan-200",
dot: "bg-cyan-400",
},
"At Post Survey": {
bg: "bg-violet-50",
text: "text-violet-700",
border: "border-violet-200",
dot: "bg-violet-400",
},
"Project Complete": {
bg: "bg-emerald-50",
text: "text-emerald-700",
border: "border-emerald-200",
dot: "bg-emerald-500",
},
Queries: {
bg: "bg-red-50",
text: "text-red-600",
border: "border-red-200",
dot: "bg-red-400",
},
"Unknown Stage": {
bg: "bg-gray-50",
text: "text-gray-500",
border: "border-gray-100",
dot: "bg-gray-300",
},
};

View file

@ -371,6 +371,32 @@ module.exports = {
"ui-selected:bg-[#eff6fc]",
"ui-selected:border-[#eff6fc]",
"ui-selected:text-[#eff6fc]",
// brandbrown for Tremor charts
"bg-[#c4a47c]",
"border-[#c4a47c]",
"hover:bg-[#c4a47c]",
"hover:border-[#c4a47c]",
"hover:text-[#c4a47c]",
"fill-[#c4a47c]",
"ring-[#c4a47c]",
"stroke-[#c4a47c]",
"text-[#c4a47c]",
"ui-selected:bg-[#c4a47c]",
"ui-selected:border-[#c4a47c]",
"ui-selected:text-[#c4a47c]",
// lighter blue for Tremor charts
"bg-[#8b96e9]",
"border-[#8b96e9]",
"hover:bg-[#8b96e9]",
"hover:border-[#8b96e9]",
"hover:text-[#8b96e9]",
"fill-[#8b96e9]",
"ring-[#8b96e9]",
"stroke-[#8b96e9]",
"text-[#8b96e9]",
"ui-selected:bg-[#8b96e9]",
"ui-selected:border-[#8b96e9]",
"ui-selected:text-[#8b96e9]",
// brand blues for Tremor charts
"bg-[#14163d]",
"border-[#14163d]",
@ -392,6 +418,12 @@ module.exports = {
"fill-[#5d6be0]",
"stroke-[#5d6be0]",
"text-[#5d6be0]",
// pale blue (4th chart series)
"bg-[#b8bef4]",
"border-[#b8bef4]",
"fill-[#b8bef4]",
"stroke-[#b8bef4]",
"text-[#b8bef4]",
"bg-[#1f3abdff]",
"border-[#1f3abdff]",
"fill-[#1f3abdff]",