mirror of
https://github.com/Hestia-Homes/assessment-model.git
synced 2026-06-08 11:37:25 +00:00
Merge branch 'main' into feature/add_rc_comments
This commit is contained in:
commit
b2cd473c9d
49 changed files with 18994 additions and 1767 deletions
56
src/app/api/live-tracking/property-documents/route.ts
Normal file
56
src/app/api/live-tracking/property-documents/route.ts
Normal 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 },
|
||||
);
|
||||
}
|
||||
}
|
||||
24
src/app/api/organisations/route.ts
Normal file
24
src/app/api/organisations/route.ts
Normal 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);
|
||||
}
|
||||
94
src/app/api/portfolio/[portfolioId]/organisation/route.ts
Normal file
94
src/app/api/portfolio/[portfolioId]/organisation/route.ts
Normal 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 });
|
||||
}
|
||||
53
src/app/api/portfolio/[portfolioId]/tasks/route.ts
Normal file
53
src/app/api/portfolio/[portfolioId]/tasks/route.ts
Normal 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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
38
src/app/api/sign-document-url/route.ts
Normal file
38
src/app/api/sign-document-url/route.ts
Normal 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 });
|
||||
}
|
||||
}
|
||||
26
src/app/api/tasks/[taskId]/route.ts
Normal file
26
src/app/api/tasks/[taskId]/route.ts
Normal 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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
37
src/app/api/tasks/route.ts
Normal file
37
src/app/api/tasks/route.ts
Normal 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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -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, {
|
||||
|
|
|
|||
11
src/app/db/migrations/0164_high_sumo.sql
Normal file
11
src/app/db/migrations/0164_high_sumo.sql
Normal 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;
|
||||
3
src/app/db/migrations/0165_small_khan.sql
Normal file
3
src/app/db/migrations/0165_small_khan.sql
Normal 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';
|
||||
|
|
@ -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": {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
6317
src/app/db/migrations/meta/0164_snapshot.json
Normal file
6317
src/app/db/migrations/meta/0164_snapshot.json
Normal file
File diff suppressed because it is too large
Load diff
6320
src/app/db/migrations/meta/0165_snapshot.json
Normal file
6320
src/app/db/migrations/meta/0165_snapshot.json
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -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
|
||||
}
|
||||
]
|
||||
}
|
||||
24
src/app/db/schema/portfolio_organisation.ts
Normal file
24
src/app/db/schema/portfolio_organisation.ts
Normal 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">;
|
||||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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";
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
58
src/app/portfolio/[slug]/(portfolio)/settings/layout.tsx
Normal file
58
src/app/portfolio/[slug]/(portfolio)/settings/layout.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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 · {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>
|
||||
);
|
||||
}
|
||||
22
src/app/portfolio/[slug]/(portfolio)/settings/logs/page.tsx
Normal file
22
src/app/portfolio/[slug]/(portfolio)/settings/logs/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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`);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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'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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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,
|
||||
},
|
||||
];
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
},
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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]",
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue