Merge branch 'main' into feature/domna_iq

This commit is contained in:
KhalimCK 2026-04-04 20:36:57 +01:00 committed by GitHub
commit b76861b9b8
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
90 changed files with 95766 additions and 918 deletions

8
.claude/settings.json Normal file
View file

@ -0,0 +1,8 @@
{
"permissions": {
"deny": [
"Bash(npx drizzle-kit generate)",
"Bash(npx drizzle-kit push)"
]
}
}

View file

@ -132,4 +132,4 @@ the permission set to access the bucket, `rerofit-plan-inputs-<stage>`. The name
Quick wins:
- [] Frequently asked questions page
- [] Frequently asked questions page.

56
package-lock.json generated
View file

@ -11,6 +11,9 @@
"@aws-sdk/client-s3": "^3.971.0",
"@aws-sdk/client-sqs": "^3.864.0",
"@aws-sdk/s3-request-presigner": "^3.927.0",
"@dnd-kit/core": "^6.3.1",
"@dnd-kit/sortable": "^10.0.0",
"@dnd-kit/utilities": "^3.2.2",
"@headlessui/react": "^2.2.7",
"@heroicons/react": "^2.2.0",
"@hookform/resolvers": "^3.9.1",
@ -1242,6 +1245,59 @@
"ms": "^2.1.1"
}
},
"node_modules/@dnd-kit/accessibility": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/@dnd-kit/accessibility/-/accessibility-3.1.1.tgz",
"integrity": "sha512-2P+YgaXF+gRsIihwwY1gCsQSYnu9Zyj2py8kY5fFvUM1qm2WA2u639R6YNVfU4GWr+ZM5mqEsfHZZLoRONbemw==",
"license": "MIT",
"dependencies": {
"tslib": "^2.0.0"
},
"peerDependencies": {
"react": ">=16.8.0"
}
},
"node_modules/@dnd-kit/core": {
"version": "6.3.1",
"resolved": "https://registry.npmjs.org/@dnd-kit/core/-/core-6.3.1.tgz",
"integrity": "sha512-xkGBRQQab4RLwgXxoqETICr6S5JlogafbhNsidmrkVv2YRs5MLwpjoF2qpiGjQt8S9AoxtIV603s0GIUpY5eYQ==",
"license": "MIT",
"dependencies": {
"@dnd-kit/accessibility": "^3.1.1",
"@dnd-kit/utilities": "^3.2.2",
"tslib": "^2.0.0"
},
"peerDependencies": {
"react": ">=16.8.0",
"react-dom": ">=16.8.0"
}
},
"node_modules/@dnd-kit/sortable": {
"version": "10.0.0",
"resolved": "https://registry.npmjs.org/@dnd-kit/sortable/-/sortable-10.0.0.tgz",
"integrity": "sha512-+xqhmIIzvAYMGfBYYnbKuNicfSsk4RksY2XdmJhT+HAC01nix6fHCztU68jooFiMUB01Ky3F0FyOvhG/BZrWkg==",
"license": "MIT",
"dependencies": {
"@dnd-kit/utilities": "^3.2.2",
"tslib": "^2.0.0"
},
"peerDependencies": {
"@dnd-kit/core": "^6.3.0",
"react": ">=16.8.0"
}
},
"node_modules/@dnd-kit/utilities": {
"version": "3.2.2",
"resolved": "https://registry.npmjs.org/@dnd-kit/utilities/-/utilities-3.2.2.tgz",
"integrity": "sha512-+MKAJEOfaBe5SmV6t34p80MMKhjvUz0vRrvVJbPT0WElzaOJ/1xs+D+KDv+tD/NE5ujfrChEcshd4fLn0wpiqg==",
"license": "MIT",
"dependencies": {
"tslib": "^2.0.0"
},
"peerDependencies": {
"react": ">=16.8.0"
}
},
"node_modules/@drizzle-team/brocli": {
"version": "0.10.2",
"resolved": "https://registry.npmjs.org/@drizzle-team/brocli/-/brocli-0.10.2.tgz",

View file

@ -17,6 +17,9 @@
"@aws-sdk/client-s3": "^3.971.0",
"@aws-sdk/client-sqs": "^3.864.0",
"@aws-sdk/s3-request-presigner": "^3.927.0",
"@dnd-kit/core": "^6.3.1",
"@dnd-kit/sortable": "^10.0.0",
"@dnd-kit/utilities": "^3.2.2",
"@headlessui/react": "^2.2.7",
"@heroicons/react": "^2.2.0",
"@hookform/resolvers": "^3.9.1",

View file

@ -0,0 +1,5 @@
Contact: mailto:security@domna.homes
Expires: 2027-01-01T00:00:00.000Z
Preferred-Languages: en
Policy: https://domna.homes/security
Canonical: https://ara.domna.homes/.well-known/security.txt

BIN
public/domna-email-logo.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 42 KiB

4
public/robots.txt Normal file
View file

@ -0,0 +1,4 @@
User-agent: *
Allow: /
Sitemap: https://domna.homes/sitemap.xml

View file

@ -146,8 +146,8 @@ export const AuthOptions: NextAuthOptions = {
.where(
and(
eq(accounts.userId, dbUser.id),
eq(accounts.provider, account.provider)
)
eq(accounts.provider, account.provider),
),
);
const emailVerified =
@ -157,7 +157,7 @@ export const AuthOptions: NextAuthOptions = {
// This handles the case where we had not set up accounts but
// signed up users with oauth
console.log(
`Linking ${account.provider} account for user ${normalisedEmail}`
`Linking ${account.provider} account for user ${normalisedEmail}`,
);
await db

View file

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

View file

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

View file

@ -0,0 +1,67 @@
import { NextRequest, NextResponse } from "next/server";
import { z } from "zod";
const CategorisationBodySchema = z.object({
portfolio_id: z.number(),
scenarios_to_consider: z.array(z.number()).optional(),
scenario_priority_order: z.array(z.number()).optional(),
})
export async function POST(request: NextRequest) {
console.log("API hit");
const body = await request.json();
let validatedBody;
try {
validatedBody = CategorisationBodySchema.parse(body);
} catch (error) {
console.error("Invalid input: ", error);
return new NextResponse(JSON.stringify({ msg: "Invalid input" }), {
status: 400,
});
}
try {
// This triggers the work distribution, but doesn't wait for the lambdas to complete categorisation
// Instead we'll check the task ID before allowing user to select the new recommendations
const headers = {
"x-api-key": process.env.FASTAPI_API_KEY || "",
Authorization: `Bearer ${
request.cookies.get("__Secure-next-auth.session-token")?.value ||
request.cookies.get("next-auth.session-token")?.value
}`,
"Content-Type": "application/json",
};
const url = `${process.env.FASTAPI_API_URL}/v1/plan/categorisation`;
console.log("Request:", url, validatedBody);
const response = await fetch(url, {
method: "POST",
headers: headers,
body: JSON.stringify(validatedBody),
});
if (!response.ok) {
console.error("Error triggering plan:", response.statusText);
return new NextResponse(
JSON.stringify({ msg: "Error triggering plan" }),
{
status: 500,
},
);
}
return new NextResponse(JSON.stringify({ msg: "Categorisation job started" }), {
status: 200,
});
} catch (error) {
console.error(error);
return new NextResponse(JSON.stringify({ msg: "Internal server error" }), {
status: 500,
});
}
}

View file

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

View file

@ -0,0 +1,65 @@
import { db } from "@/app/db/db";
import { sql } from "drizzle-orm";
import { NextRequest, NextResponse } from "next/server";
type MeasureAggregateRow = {
measure_type: string | null;
type: string | null;
includes_battery: boolean | null;
homes_count: number;
total_cost: number | null;
average_cost: number | null;
};
export async function GET(
request: NextRequest,
props: { params: Promise<{ portfolioId: string }> },
) {
const { portfolioId } = await props.params;
const pid = BigInt(portfolioId);
const result = await db.execute(sql`
SELECT
r.measure_type,
r.type,
COUNT(DISTINCT r.property_id)::int AS homes_count,
SUM(r.estimated_cost)::float AS total_cost,
AVG(r.estimated_cost)::float AS average_cost
FROM recommendation r
WHERE r.default = true
AND r.already_installed = false
AND EXISTS (
SELECT 1
FROM (
SELECT DISTINCT ON (p.property_id)
p.id
FROM plan p
WHERE p.portfolio_id = ${pid}
AND p.is_default = true
ORDER BY p.property_id, p.created_at DESC
) lp
JOIN plan_recommendations pr
ON pr.plan_id = lp.id
WHERE pr.recommendation_id = r.id
)
GROUP BY
r.measure_type,
r.type
ORDER BY total_cost DESC;
`);
const measures = (result.rows as MeasureAggregateRow[]).map((row) => ({
measureType: row.measure_type ?? "unknown",
type: row.type ?? "unknown",
homesCount: row.homes_count,
totalCost: Number(row.total_cost ?? 0),
averageCost: Number(row.average_cost ?? 0),
// includesBattery: row.includes_battery ?? false,
}));
return NextResponse.json({
portfolioId: Number(portfolioId),
measures,
});
}

View file

@ -0,0 +1,262 @@
import { db } from "@/app/db/db";
import { sql } from "drizzle-orm";
import { NextRequest, NextResponse } from "next/server";
import { sapToEpc } from "@/app/utils";
import type { PortfolioGoalType } from "@/app/db/schema/portfolio";
/* =======================
Types
======================= */
type ScenarioAggregates = {
n_units: number;
avg_sap: number | null;
avg_carbon: number | null;
avg_bills: number | null;
total_carbon: number | null;
total_bills: number | null;
total_sap_uplift: number | null;
};
type UpgradedAggregates = {
n_units_upgraded: number;
total_cost: number | null;
contingency: number | null;
total_funding: number | null;
};
type PortfolioAggregates = {
avg_sap: number | null;
avg_carbon: number | null;
avg_bills: number | null;
total_carbon: number | null;
total_bills: number | null;
};
type EpcRow = {
effective_sap: number | null;
};
/* =======================
Constants
======================= */
const EPC_MIN_SAP: Record<string, number> = {
A: 92,
B: 81,
C: 69,
D: 55,
E: 39,
F: 21,
G: 0,
};
/* =======================
Route
======================= */
export async function GET(
request: NextRequest,
props: { params: Promise<{ portfolioId: string }> },
) {
const { portfolioId } = await props.params;
if (!portfolioId || portfolioId === "null") {
return NextResponse.json({ error: "Invalid portfolioId" }, { status: 400 });
}
const pid = BigInt(portfolioId);
const hideNonCompliant =
request.nextUrl.searchParams.get("hideNonCompliant") === "true";
/* ----------------------------------------------------------
QUERY 1 Scenario metrics (PLANS ONLY)
---------------------------------------------------------- */
const scenarioMetricsResult = await db.execute(sql`
WITH latest_plans AS (
SELECT DISTINCT ON (property_id)
*
FROM plan
WHERE portfolio_id = ${pid}
AND is_default = true
ORDER BY property_id, created_at DESC
)
SELECT
COUNT(*)::int AS n_units,
AVG(post_sap_points)::float AS avg_sap,
AVG(post_co2_emissions)::float AS avg_carbon,
AVG(post_energy_bill)::float AS avg_bills,
SUM(post_co2_emissions)::float AS total_carbon,
SUM(post_energy_bill)::float AS total_bills,
SUM(
CASE
WHEN cost_of_works > 0
AND post_sap_points IS NOT NULL
THEN post_sap_points - p.current_sap_points
ELSE 0
END
)::float AS total_sap_uplift
FROM latest_plans lp
JOIN property p ON p.id = lp.property_id;
`);
const scenarioAgg = scenarioMetricsResult.rows[0] as ScenarioAggregates;
/* ----------------------------------------------------------
QUERY 1b Upgrade costs (PLANS ONLY)
---------------------------------------------------------- */
const upgradedResult = await db.execute(sql`
WITH latest_plans AS (
SELECT DISTINCT ON (property_id)
*
FROM plan
WHERE portfolio_id = ${pid}
AND is_default = true
ORDER BY property_id, created_at DESC
)
SELECT
COUNT(*)::int AS n_units_upgraded,
SUM(cost_of_works)::float AS total_cost,
SUM(contingency_cost)::float AS contingency,
SUM(
COALESCE(fp.project_funding, 0) +
COALESCE(fp.total_uplift, 0)
)::float AS total_funding
FROM latest_plans lp
LEFT JOIN funding_package fp ON fp.plan_id = lp.id
WHERE lp.cost_of_works > 0;
`);
const upgraded = upgradedResult.rows[0] as UpgradedAggregates;
/* ----------------------------------------------------------
QUERY 2 Portfolio AFTER scenario (ALL properties)
---------------------------------------------------------- */
const portfolioMetricsResult = await db.execute(sql`
SELECT
AVG(effective_sap)::float AS avg_sap,
AVG(effective_carbon)::float AS avg_carbon,
AVG(effective_bills)::float AS avg_bills,
SUM(effective_carbon)::float AS total_carbon,
SUM(effective_bills)::float AS total_bills
FROM (
SELECT
/* ---------- SAP ---------- */
CASE
WHEN lp.id IS NOT NULL THEN lp.post_sap_points
ELSE p.current_sap_points
END AS effective_sap,
/* ---------- Carbon ---------- */
CASE
WHEN lp.id IS NOT NULL THEN lp.post_co2_emissions
ELSE e.co2_emissions
END AS effective_carbon,
/* ---------- Bills ---------- */
CASE
WHEN lp.id IS NOT NULL THEN lp.post_energy_bill
ELSE (
e.heating_cost_current +
e.hot_water_cost_current +
e.lighting_cost_current +
e.appliances_cost_current +
e.gas_standing_charge +
e.electricity_standing_charge -
COALESCE(e.installed_measures_total_energy_bill_adjustment, 0)
)
END AS effective_bills
FROM property p
LEFT JOIN property_details_epc e
ON e.property_id = p.id
LEFT JOIN LATERAL (
SELECT *
FROM plan
WHERE plan.property_id = p.id
AND plan.portfolio_id = ${pid}
AND plan.is_default = true
ORDER BY created_at DESC
LIMIT 1
) lp ON true
WHERE p.portfolio_id = ${pid}
) q;
`);
const portfolioAgg = portfolioMetricsResult.rows[0] as PortfolioAggregates;
/* ----------------------------------------------------------
QUERY 3 EPC band distribution (ALL properties)
---------------------------------------------------------- */
const epcRows = await db.execute(sql`
SELECT
CASE
WHEN lp.id IS NOT NULL THEN lp.post_sap_points
ELSE p.current_sap_points
END AS effective_sap
FROM property p
LEFT JOIN LATERAL (
SELECT *
FROM plan
WHERE plan.property_id = p.id
AND plan.portfolio_id = ${pid}
AND plan.is_default = true
ORDER BY created_at DESC
LIMIT 1
) lp ON true
WHERE p.portfolio_id = ${pid};
`);
const scenario_epc_counts: Record<string, number> = {
A: 0,
B: 0,
C: 0,
D: 0,
E: 0,
F: 0,
G: 0,
Unknown: 0,
};
for (const row of epcRows.rows as EpcRow[]) {
const band = sapToEpc(row.effective_sap);
scenario_epc_counts[band] += 1;
}
/* ----------------------------------------------------------
RESPONSE
---------------------------------------------------------- */
const constructionCost = upgraded.total_cost ?? 0;
const nUpgraded = upgraded.n_units_upgraded ?? 0;
const pc_cost = constructionCost * 0.3;
return NextResponse.json({
/* -------- portfolio-after-scenario -------- */
avg_sap:
portfolioAgg.avg_sap !== null
? Number(portfolioAgg.avg_sap).toFixed(1)
: null,
avg_carbon: portfolioAgg.avg_carbon,
avg_bills: portfolioAgg.avg_bills,
total_carbon: portfolioAgg.total_carbon,
total_bills: portfolioAgg.total_bills,
/* -------- scenario-only -------- */
n_units: scenarioAgg.n_units,
n_units_upgraded: nUpgraded,
construction_cost: constructionCost,
contingency: upgraded.contingency ?? 0,
total_funding: upgraded.total_funding ?? 0,
net_cost: constructionCost - (upgraded.total_funding ?? 0),
total_sap_uplift: scenarioAgg.total_sap_uplift ?? 0,
gross_per_unit:
nUpgraded > 0 ? (constructionCost + pc_cost) / nUpgraded : 0,
/* -------- shared -------- */
scenario_epc_counts,
pc_cost,
});
}

View file

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

View file

@ -21,7 +21,7 @@ import { Button } from "@/app/shadcn_components/ui/button";
import { cva } from "class-variance-authority";
import { getUploadedFile } from "@/app/db/surveyDB/schema/surveyDB";
import BookSurveyModal from "@/app/portfolio/[slug]/components/BookSurveyModal";
import BookingSuccessToast from "@/app/portfolio/[slug]/components/BookingSuccessToast";
import SuccessToast from "@/app/portfolio/[slug]/components/SuccessToast";
import { PropertyMeta } from "@/app/db/schema/property";
interface ToolbarProps {
@ -179,8 +179,9 @@ export function Toolbar({
)}
{/* ✅ Toast */}
<BookingSuccessToast
<SuccessToast
show={showToast}
showConfetti={showToast}
onClose={() => setShowToast(false)}
message="Survey Request Recieved!"
subtext="We'll be in contact soon. 🎉"

View file

@ -12,6 +12,9 @@ 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,
@ -35,6 +38,9 @@ const schema = {
...Users,
tasks,
subTasks,
...CrmSchema,
...UploadedFilesSchema,
...PortfolioOrgSchema,
};
export const db = drizzle(pool, {

View file

@ -0,0 +1,6 @@
ALTER TABLE "plan_recommendations" DROP CONSTRAINT "plan_recommendations_plan_id_plan_id_fk";
--> statement-breakpoint
ALTER TABLE "recommendation_materials" DROP CONSTRAINT "recommendation_materials_recommendation_id_recommendation_id_fk";
--> statement-breakpoint
ALTER TABLE "plan_recommendations" ADD CONSTRAINT "plan_recommendations_plan_id_plan_id_fk" FOREIGN KEY ("plan_id") REFERENCES "public"."plan"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "recommendation_materials" ADD CONSTRAINT "recommendation_materials_recommendation_id_recommendation_id_fk" FOREIGN KEY ("recommendation_id") REFERENCES "public"."recommendation"("id") ON DELETE cascade ON UPDATE no action;

View file

@ -0,0 +1,3 @@
ALTER TABLE "plan_recommendations" DROP CONSTRAINT "plan_recommendations_recommendation_id_recommendation_id_fk";
--> statement-breakpoint
ALTER TABLE "plan_recommendations" ADD CONSTRAINT "plan_recommendations_recommendation_id_recommendation_id_fk" FOREIGN KEY ("recommendation_id") REFERENCES "public"."recommendation"("id") ON DELETE cascade ON UPDATE no action;

View file

@ -0,0 +1,3 @@
ALTER TABLE "plan_recommendations" DROP CONSTRAINT "plan_recommendations_plan_id_plan_id_fk";
--> statement-breakpoint
ALTER TABLE "plan_recommendations" ADD CONSTRAINT "plan_recommendations_plan_id_plan_id_fk" FOREIGN KEY ("plan_id") REFERENCES "public"."plan"("id") ON DELETE no action ON UPDATE no action;

View file

@ -0,0 +1,3 @@
ALTER TABLE "plan_recommendations" DROP CONSTRAINT "plan_recommendations_recommendation_id_recommendation_id_fk";
--> statement-breakpoint
ALTER TABLE "plan_recommendations" ADD CONSTRAINT "plan_recommendations_recommendation_id_recommendation_id_fk" FOREIGN KEY ("recommendation_id") REFERENCES "public"."recommendation"("id") ON DELETE no action ON UPDATE no action;

View file

@ -0,0 +1,3 @@
CREATE TYPE "public"."source" AS ENUM('portfolio_id');--> statement-breakpoint
ALTER TABLE "tasks" ADD COLUMN "source" "source";--> statement-breakpoint
ALTER TABLE "tasks" ADD COLUMN "source_id" text;

View file

@ -0,0 +1,4 @@
ALTER TABLE "postcode_search" ADD COLUMN "last_updated_at" timestamp;--> statement-breakpoint
UPDATE "postcode_search" SET "last_updated_at" = "created_at";--> statement-breakpoint
ALTER TABLE "postcode_search" ALTER COLUMN "last_updated_at" SET DEFAULT now();--> statement-breakpoint
ALTER TABLE "postcode_search" ALTER COLUMN "last_updated_at" SET NOT NULL;

View file

@ -0,0 +1,5 @@
ALTER TABLE "property" ADD COLUMN "lodged_sap_points" real;--> statement-breakpoint
ALTER TABLE "property" ADD COLUMN "lodged_epc_rating" "epc";--> statement-breakpoint
ALTER TABLE "property_details_epc" ADD COLUMN "lodged_co2_emissions" real;--> statement-breakpoint
ALTER TABLE "property_details_epc" ADD COLUMN "lodged_heat_demand" real;--> statement-breakpoint
ALTER TABLE "property_details_epc" ADD COLUMN "has_been_remodelled" boolean DEFAULT false;

View file

@ -0,0 +1,38 @@
CREATE TABLE "organisation" (
"id" uuid PRIMARY KEY DEFAULT gen_random_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,
"hubspot_company_id" text,
"name" text
);
--> statement-breakpoint
CREATE TABLE "team" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"name" text NOT NULL,
"org_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
);
--> statement-breakpoint
CREATE TABLE "team_members" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"user_id" bigint NOT NULL,
"team_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
);
--> statement-breakpoint
CREATE TABLE "team_portfolio_permissions" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"team_id" uuid NOT NULL,
"portfolio_id" bigint NOT NULL,
"role" "role" NOT NULL,
"created_at" timestamp (6) with time zone DEFAULT now() NOT NULL,
"updated_at" timestamp (6) with time zone DEFAULT now() NOT NULL
);
--> statement-breakpoint
ALTER TABLE "team" ADD CONSTRAINT "team_org_id_organisation_id_fk" FOREIGN KEY ("org_id") REFERENCES "public"."organisation"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "team_members" ADD CONSTRAINT "team_members_user_id_user_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."user"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "team_members" ADD CONSTRAINT "team_members_team_id_team_id_fk" FOREIGN KEY ("team_id") REFERENCES "public"."team"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "team_portfolio_permissions" ADD CONSTRAINT "team_portfolio_permissions_team_id_team_id_fk" FOREIGN KEY ("team_id") REFERENCES "public"."team"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "team_portfolio_permissions" ADD CONSTRAINT "team_portfolio_permissions_portfolio_id_portfolio_id_fk" FOREIGN KEY ("portfolio_id") REFERENCES "public"."portfolio"("id") ON DELETE no action ON UPDATE no action;

View file

@ -0,0 +1,13 @@
CREATE TYPE "public"."file_source" AS ENUM('pas hub', 'sharepoint', 'hubspot');--> statement-breakpoint
CREATE TYPE "public"."file_type" AS ENUM('photo_pack', 'site_note', 'rd_sap_site_note', 'pas_2023_ventilation', 'pas_2023_condition', 'pas_significance', 'par_photo_pack', 'pas_2023_property', 'pas_2023_occupancy');--> statement-breakpoint
CREATE TABLE "uploaded_files" (
"id" bigserial PRIMARY KEY NOT NULL,
"s3_file_bucket" text NOT NULL,
"s3_file_key" text NOT NULL,
"s3_upload_timestamp" timestamp with time zone NOT NULL,
"landlord_property_id" text,
"uprn" bigint,
"hubspot_listing_id" bigint,
"file_type" "file_type",
"file_source" "file_source"
);

View file

@ -0,0 +1,22 @@
ALTER TABLE "hubspot_deal_data" ADD COLUMN "pashub_link" text;--> statement-breakpoint
ALTER TABLE "hubspot_deal_data" ADD COLUMN "sharepoint_link" text;--> statement-breakpoint
ALTER TABLE "hubspot_deal_data" ADD COLUMN "dampmould_growth" text;--> statement-breakpoint
ALTER TABLE "hubspot_deal_data" ADD COLUMN "pre_sap" text;--> statement-breakpoint
ALTER TABLE "hubspot_deal_data" ADD COLUMN "coordinator" text;--> statement-breakpoint
ALTER TABLE "hubspot_deal_data" ADD COLUMN "mtp_completion_date" timestamp (6) with time zone;--> statement-breakpoint
ALTER TABLE "hubspot_deal_data" ADD COLUMN "mtp_re_model_completion_date" timestamp (6) with time zone;--> statement-breakpoint
ALTER TABLE "hubspot_deal_data" ADD COLUMN "ioe_v3_completion_date" timestamp (6) with time zone;--> statement-breakpoint
ALTER TABLE "hubspot_deal_data" ADD COLUMN "proposed_measures" text;--> statement-breakpoint
ALTER TABLE "hubspot_deal_data" ADD COLUMN "approved_package" text;--> statement-breakpoint
ALTER TABLE "hubspot_deal_data" ADD COLUMN "designer" text;--> statement-breakpoint
ALTER TABLE "hubspot_deal_data" ADD COLUMN "design_completion_date" timestamp (6) with time zone;--> statement-breakpoint
ALTER TABLE "hubspot_deal_data" ADD COLUMN "actual_measures_installed" text;--> statement-breakpoint
ALTER TABLE "hubspot_deal_data" ADD COLUMN "installer" text;--> statement-breakpoint
ALTER TABLE "hubspot_deal_data" ADD COLUMN "installer_handover" text;--> statement-breakpoint
ALTER TABLE "hubspot_deal_data" ADD COLUMN "lodgement_status" text;--> statement-breakpoint
ALTER TABLE "hubspot_deal_data" ADD COLUMN "measures_lodgement_date" timestamp (6) with time zone;--> statement-breakpoint
ALTER TABLE "hubspot_deal_data" ADD COLUMN "lodgement_date" timestamp (6) with time zone;--> statement-breakpoint
ALTER TABLE "hubspot_deal_data" ADD COLUMN "expected_commencement_date" timestamp (6) with time zone;--> statement-breakpoint
ALTER TABLE "hubspot_deal_data" ADD COLUMN "surveyor" text;--> statement-breakpoint
ALTER TABLE "hubspot_deal_data" ADD COLUMN "confirmed_survey_date" timestamp (6) with time zone;--> statement-breakpoint
ALTER TABLE "hubspot_deal_data" ADD COLUMN "confirmed_survey_time" text;

View file

@ -0,0 +1 @@
ALTER TABLE "hubspot_deal_data" ADD COLUMN "listing_id" text;

View file

@ -0,0 +1 @@
ALTER TABLE "hubspot_deal_data" ADD COLUMN "surveyed_date" timestamp (6) with time zone;

View file

@ -0,0 +1 @@
ALTER TABLE "hubspot_deal_data" ADD COLUMN "design_type" text;

View file

@ -0,0 +1 @@
ALTER TABLE "hubspot_deal_data" ADD COLUMN "damp_mould_and_repairs_comments" text;

View file

@ -0,0 +1,11 @@
CREATE TABLE "portfolio_organisation" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"portfolio_id" bigint NOT NULL,
"organisation_id" uuid NOT NULL,
"created_at" timestamp (6) with time zone DEFAULT now() NOT NULL,
"updated_at" timestamp (6) with time zone DEFAULT now() NOT NULL,
CONSTRAINT "portfolio_organisation_portfolio_id_unique" UNIQUE("portfolio_id")
);
--> statement-breakpoint
ALTER TABLE "portfolio_organisation" ADD CONSTRAINT "portfolio_organisation_portfolio_id_portfolio_id_fk" FOREIGN KEY ("portfolio_id") REFERENCES "public"."portfolio"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "portfolio_organisation" ADD CONSTRAINT "portfolio_organisation_organisation_id_organisation_id_fk" FOREIGN KEY ("organisation_id") REFERENCES "public"."organisation"("id") ON DELETE cascade ON UPDATE no action;

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

View file

@ -1051,6 +1051,111 @@
"when": 1769597155526,
"tag": "0149_rich_luminals",
"breakpoints": true
},
{
"idx": 150,
"version": "7",
"when": 1771753702175,
"tag": "0150_green_switch",
"breakpoints": true
},
{
"idx": 151,
"version": "7",
"when": 1771754445853,
"tag": "0151_regular_lila_cheney",
"breakpoints": true
},
{
"idx": 152,
"version": "7",
"when": 1771754572720,
"tag": "0152_sparkling_kat_farrell",
"breakpoints": true
},
{
"idx": 153,
"version": "7",
"when": 1771757665072,
"tag": "0153_large_machine_man",
"breakpoints": true
},
{
"idx": 154,
"version": "7",
"when": 1772194536121,
"tag": "0154_workable_stingray",
"breakpoints": true
},
{
"idx": 155,
"version": "7",
"when": 1772637615885,
"tag": "0155_calm_hydra",
"breakpoints": true
},
{
"idx": 156,
"version": "7",
"when": 1773838366481,
"tag": "0156_long_kitty_pryde",
"breakpoints": true
},
{
"idx": 157,
"version": "7",
"when": 1774268836524,
"tag": "0157_cynical_serpent_society",
"breakpoints": true
},
{
"idx": 158,
"version": "7",
"when": 1774538269794,
"tag": "0158_ancient_colleen_wing",
"breakpoints": true
},
{
"idx": 159,
"version": "7",
"when": 1774965050131,
"tag": "0159_sad_molly_hayes",
"breakpoints": true
},
{
"idx": 160,
"version": "7",
"when": 1774970639805,
"tag": "0160_slimy_polaris",
"breakpoints": true
},
{
"idx": 161,
"version": "7",
"when": 1775041707059,
"tag": "0161_fresh_taskmaster",
"breakpoints": true
},
{
"idx": 162,
"version": "7",
"when": 1775041844023,
"tag": "0162_powerful_paladin",
"breakpoints": true
},
{
"idx": 163,
"version": "7",
"when": 1775123235194,
"tag": "0163_cultured_madripoor",
"breakpoints": true
},
{
"idx": 164,
"version": "7",
"when": 1775310006908,
"tag": "0164_high_sumo",
"breakpoints": true
}
]
}

View file

@ -32,4 +32,5 @@ export const postcodeSearch = pgTable("postcode_search", {
// Timestamp for when the entry was first created
createdAt: timestamp("created_at").defaultNow().notNull(),
lastUpdatedAt: timestamp("last_updated_at").defaultNow().notNull(),
});

View file

@ -11,6 +11,7 @@ export const hubspotDealData = pgTable("hubspot_deal_data", {
projectCode: text("project_code"),
landlordPropertyId: text("landlord_property_id"),
listingId: text("listing_id"),
uprn: text("uprn"),
outcome: text("outcome"),
outcomeNotes: text("outcome_notes"),
@ -22,6 +23,32 @@ export const hubspotDealData = pgTable("hubspot_deal_data", {
coordinationStatus: text("coordination_status"),
designStatus: text("design_status"),
pashubLink: text("pashub_link"),
sharepointLink: text("sharepoint_link"),
dampmouldGrowth: text("dampmould_growth"),
preSap: text("pre_sap"),
coordinator: text("coordinator"),
mtpCompletionDate: timestamp("mtp_completion_date", { precision: 6, withTimezone: true }),
mtpReModelCompletionDate: timestamp("mtp_re_model_completion_date", { precision: 6, withTimezone: true }),
ioeV3CompletionDate: timestamp("ioe_v3_completion_date", { precision: 6, withTimezone: true }),
proposedMeasures: text("proposed_measures"),
approvedPackage: text("approved_package"),
designer: text("designer"),
dealType: text("design_type"),
designCompletionDate: timestamp("design_completion_date", { precision: 6, withTimezone: true }),
actualMeasuresInstalled: text("actual_measures_installed"),
installer: text("installer"),
installerHandover: text("installer_handover"),
lodgementStatus: text("lodgement_status"),
measuresLodgementDate: timestamp("measures_lodgement_date", { precision: 6, withTimezone: true }),
lodgementDate: timestamp("lodgement_date", { precision: 6, withTimezone: true }),
expectedCommencementDate: timestamp("expected_commencement_date", { precision: 6, withTimezone: true }),
surveyor: text("surveyor"),
damnpMouldAndRepairComments: text("damp_mould_and_repairs_comments"),
confirmedSurveyDate: timestamp("confirmed_survey_date", { precision: 6, withTimezone: true }),
confirmedSurveyTime: text("confirmed_survey_time"),
SurveyedDate: timestamp("surveyed_date", { precision: 6, withTimezone: true }),
createdAt: timestamp("created_at", { precision: 6, withTimezone: true })
.defaultNow()
.notNull(),

View file

@ -0,0 +1,17 @@
import { pgTable, text, timestamp, uuid } from "drizzle-orm/pg-core";
import { InferModel } from "drizzle-orm";
export const organisation = pgTable("organisation", {
id: uuid("id").defaultRandom().primaryKey(),
createdAt: timestamp("created_at", { precision: 6, withTimezone: true })
.defaultNow()
.notNull(),
updatedAt: timestamp("updated_at", { precision: 6, withTimezone: true })
.defaultNow()
.notNull(),
hubspotCompanyId: text("hubspot_company_id"),
name: text("name"),
});
export type Organisation = InferModel<typeof organisation, "select">;
export type NewOrganisation = InferModel<typeof organisation, "insert">;

View file

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

View file

@ -88,7 +88,7 @@ export const Epc: [string, ...string[]] = ["A", "B", "C", "D", "E", "F", "G"];
export const propertyCreationStatusEnum = pgEnum(
"creation_status",
PropertyCreationStatus
PropertyCreationStatus,
);
export const epcEnum = pgEnum("epc", Epc);
export const propertyStatusEnum = pgEnum("status", PortfolioStatus);
@ -137,18 +137,22 @@ export const property = pgTable(
// 1) The number of points we've adjusted by
// 2) a flag to indicate whether the SAP points have been adjusted, for easily filtering
installedMeasuresSapPointAdjustment: real(
"installed_measures_sap_point_adjustment"
"installed_measures_sap_point_adjustment",
),
isSapPointsAdjustedForInstalledMeasures: boolean(
"is_sap_points_adjusted_for_installed_measures"
"is_sap_points_adjusted_for_installed_measures",
).default(false),
// To be deprecated
originalSapPoints: real("original_sap_points"),
// lodged data
lodgedSapPoints: real("lodged_sap_points"),
lodgedEpcRating: epcEnum("lodged_epc_rating"),
},
(table) => [
uniqueIndex("uq_property_portfolio_uprn")
.on(table.portfolioId, table.uprn)
.where(sql`${table.uprn} IS NOT NULL`),
]
],
);
export const FeatureRating: [string, ...string[]] = [
@ -212,7 +216,7 @@ export const propertyDetailsEpc = pgTable(
// Bad naming but currentEnergyDemand is the current kwh consumption - needs to be renamed
currentEnergyDemand: real("current_energy_demand"),
currentEnergyDemandHeatingHotwater: real(
"current_energy_demand_heating_hotwater"
"current_energy_demand_heating_hotwater",
),
estimated: boolean("estimated").default(false),
// We indicate if the property has an overwritten SAP 05 EPC. I.e. there is a valid EPC, however it's a SAP 05
@ -236,36 +240,42 @@ export const propertyDetailsEpc = pgTable(
// 3) a flag to indicate whether the values have been adjusted, for easily filtering
// original values - we don't need bills because we don't actually adjust any of the originals we just subtract adjustments from current values
// TODO - deprecate
originalCo2Emissions: real("original_co2_emissions"),
originalPrimaryEnergyConsumption: real(
"original_primary_energy_consumption"
"original_primary_energy_consumption",
),
originalCurrentEnergyDemand: real("original_current_energy_demand"),
originalCurrentEnergyDemandHeatingHotwater: real(
"original_current_energy_demand_heating_hotwater"
"original_current_energy_demand_heating_hotwater",
),
// adjustment quantities
// adjustment quantities - TODO: deprecate
installedMeasuresCo2Adjustment: real("installed_measures_co2_adjustment"),
installedMeasuresEnergyDemandAdjustment: real(
"installed_measures_energy_demand_adjustment"
"installed_measures_energy_demand_adjustment",
),
installedMeasuresTotalEnergyBillAdjustment: real(
"installed_measures_total_energy_bill_adjustment"
"installed_measures_total_energy_bill_adjustment",
),
installedMeasuresHeatDemandAdjustment: real(
"installed_measures_heat_demand_adjustment"
"installed_measures_heat_demand_adjustment",
),
isEpcAdjustedForInstalledMeasures: boolean(
"is_epc_adjusted_for_installed_measures"
"is_epc_adjusted_for_installed_measures",
).default(false),
// Lodged values
lodgedCo2Emissions: real("lodged_co2_emissions"),
lodgedHeatDemand: real("lodged_heat_demand"),
hasBeenRemodelled: boolean("has_been_remodelled").default(false),
},
(table) => [
uniqueIndex("uq_property_details_epc_property_portfolio").on(
table.propertyId,
table.portfolioId
table.portfolioId,
),
]
],
);
export const propertyDetailsSpatial = pgTable(
@ -281,7 +291,7 @@ export const propertyDetailsSpatial = pgTable(
isListedBuilding: boolean("is_listed_building"),
isHeritageBuilding: boolean("is_heritage_building"),
},
(table) => [uniqueIndex("uq_property_details_spatial_uprn").on(table.uprn)]
(table) => [uniqueIndex("uq_property_details_spatial_uprn").on(table.uprn)],
);
export const propertyDetailsMeter = pgTable("property_details_meter", {

View file

@ -95,15 +95,15 @@ export const recommendation = pgTable(
index("idx_recommendation_active_defaults")
.on(table.id)
.where(
sql`${table.default} = true AND ${table.alreadyInstalled} = false`
sql`${table.default} = true AND ${table.alreadyInstalled} = false`,
),
index("idx_recommendation_active_id_property")
.on(table.id, table.propertyId)
.where(
sql`${table.default} = true AND ${table.alreadyInstalled} = false`
sql`${table.default} = true AND ${table.alreadyInstalled} = false`,
),
]
],
);
export const unitQuantity: [string, ...string[]] = ["m2", "part", "kwp"];
@ -117,7 +117,7 @@ export const recommendationMaterials = pgTable(
mode: "bigint",
})
.notNull()
.references(() => recommendation.id),
.references(() => recommendation.id, { onDelete: "cascade" }),
materialId: bigint("material_id", { mode: "bigint" })
.notNull()
.references(() => material.id),
@ -129,9 +129,9 @@ export const recommendationMaterials = pgTable(
},
(table) => [
index("recommendation_materials_recommendation_id_idx").on(
table.recommendationId
table.recommendationId,
),
]
],
);
// We create a plan type, for common plan types that we produce for clients
@ -165,7 +165,7 @@ export const plan = pgTable(
.references(() => property.id),
scenarioId: bigint("scenario_id", { mode: "bigint" }).references(
() => scenario.id
() => scenario.id,
),
createdAt: timestamp("created_at").notNull().defaultNow(),
@ -220,15 +220,15 @@ export const plan = pgTable(
(table) => [
index("idx_plan_portfolio_scenario").on(
table.portfolioId,
table.scenarioId
table.scenarioId,
),
index("idx_plan_latest_per_property").on(
table.portfolioId,
table.scenarioId,
table.propertyId,
table.createdAt.desc()
table.createdAt.desc(),
),
]
],
);
export const planRecommendations = pgTable(
@ -248,9 +248,9 @@ export const planRecommendations = pgTable(
index("idx_plan_recommendations_plan_id").on(table.planId),
index("idx_plan_recommendations_plan_rec").on(
table.planId,
table.recommendationId
table.recommendationId,
),
]
],
);
export const HousingType: [string, ...string[]] = ["Private", "Social"];
@ -273,7 +273,7 @@ export const scenario = pgTable("scenario", {
alreadyInstalledFilePath: text("already_installed_file_path"),
patchesFilePath: text("patches_file_path"),
nonInvasideRecommendationsFilePath: text(
"non_invasive_recommendations_file_path"
"non_invasive_recommendations_file_path",
),
exclusions: text("exclusions"),
multiPlan: boolean("multi_plan"),
@ -298,10 +298,10 @@ export const scenario = pgTable("scenario", {
energyBillPerUnitPreRetrofit: text("energy_bill_per_unit_pre_retrofit"),
energyBillPerUnitPostRetrofit: text("energy_bill_per_unit_post_retrofit"),
energyConsumptionPerUnitPreRetrofit: text(
"energy_consumption_per_unit_pre_retrofit"
"energy_consumption_per_unit_pre_retrofit",
),
energyConsumptionPerUnitPostRetrofit: text(
"energy_consumption_per_unit_post_retrofit"
"energy_consumption_per_unit_post_retrofit",
),
valuationImprovementPerUnit: text("valuation_improvement_per_unit"),
costPerUnit: text("cost_per_unit"),
@ -345,7 +345,7 @@ export const installedMeasure = pgTable(
index("idx_installed_measure_uprn_measure")
.on(table.uprn, table.measureType)
.where(sql`${table.isActive} = true`),
]
],
);
export type Plan = InferModel<typeof plan, "select">;
@ -460,7 +460,7 @@ export const measuresDisplayLabels = {
export type MeasureKey = keyof typeof measuresDisplayLabels;
export const measuresList: MeasureKey[] = Object.keys(
measuresDisplayLabels
measuresDisplayLabels,
) as MeasureKey[];
export const MeasureKeyEnum = z.enum([

View file

@ -1,4 +1,6 @@
import { pgTable, uuid, text, timestamp } from "drizzle-orm/pg-core";
import { pgTable, uuid, text, timestamp, pgEnum } from "drizzle-orm/pg-core";
export const sourceEnum = pgEnum("source", ["portfolio_id"]);
export const tasks = pgTable("tasks", {
id: uuid("id").defaultRandom().primaryKey(),
@ -12,6 +14,10 @@ export const tasks = pgTable("tasks", {
service: text("service"), // e.g. plan, wchg etc
source: sourceEnum("source"), // enum for task source type
sourceId: text("source_id"), // identifier for the source (e.g., portfolio_id value)
updatedAt: timestamp("updated_at", { precision: 6, withTimezone: true })
.defaultNow()
.$onUpdate(() => new Date())

65
src/app/db/schema/team.ts Normal file
View file

@ -0,0 +1,65 @@
import { pgTable, text, timestamp, uuid, bigint } from "drizzle-orm/pg-core";
import { InferModel } from "drizzle-orm";
import { organisation } from "./organisation";
import { user } from "./users";
import { portfolio, roleEnum } from "./portfolio";
export const team = pgTable("team", {
id: uuid("id").defaultRandom().primaryKey(),
name: text("name").notNull(),
orgId: uuid("org_id")
.notNull()
.references(() => organisation.id),
createdAt: timestamp("created_at", { precision: 6, withTimezone: true })
.defaultNow()
.notNull(),
updatedAt: timestamp("updated_at", { precision: 6, withTimezone: true })
.defaultNow()
.notNull(),
});
export const teamMembers = pgTable("team_members", {
id: uuid("id").defaultRandom().primaryKey(),
userId: bigint("user_id", { mode: "bigint" })
.notNull()
.references(() => user.id),
teamId: uuid("team_id")
.notNull()
.references(() => team.id),
createdAt: timestamp("created_at", { precision: 6, withTimezone: true })
.defaultNow()
.notNull(),
updatedAt: timestamp("updated_at", { precision: 6, withTimezone: true })
.defaultNow()
.notNull(),
});
export const teamPortfolioPermissions = pgTable("team_portfolio_permissions", {
id: uuid("id").defaultRandom().primaryKey(),
teamId: uuid("team_id")
.notNull()
.references(() => team.id),
portfolioId: bigint("portfolio_id", { mode: "bigint" })
.notNull()
.references(() => portfolio.id),
role: roleEnum("role").notNull(),
createdAt: timestamp("created_at", { precision: 6, withTimezone: true })
.defaultNow()
.notNull(),
updatedAt: timestamp("updated_at", { precision: 6, withTimezone: true })
.defaultNow()
.notNull(),
});
export type Team = InferModel<typeof team, "select">;
export type NewTeam = InferModel<typeof team, "insert">;
export type TeamMembers = InferModel<typeof teamMembers, "select">;
export type NewTeamMembers = InferModel<typeof teamMembers, "insert">;
export type TeamPortfolioPermissions = InferModel<
typeof teamPortfolioPermissions,
"select"
>;
export type NewTeamPortfolioPermissions = InferModel<
typeof teamPortfolioPermissions,
"insert"
>;

View file

@ -0,0 +1,36 @@
import { bigint, bigserial, pgEnum, pgTable, text, timestamp } from "drizzle-orm/pg-core";
export const fileType = pgEnum("file_type", [
"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"
]);
export const fileSource = pgEnum("file_source", [
"pas hub",
"sharepoint",
"hubspot"
]);
export const uploadedFiles = pgTable(
"uploaded_files",
{
id: bigserial("id", { mode: "bigint" }).primaryKey(),
s3FileBucket: text("s3_file_bucket").notNull(),
s3FileKey: text("s3_file_key").notNull(),
s3UploadTimestamp: timestamp("s3_upload_timestamp", {
withTimezone: true
}).notNull(),
landlordPropertyId: text("landlord_property_id"),
uprn: bigint("uprn", { mode: "bigint" }),
hubspotListingId: bigint("hubspot_listing_id", { mode: "bigint" }),
fileType: fileType("file_type"),
source: fileSource("file_source")
}
);

View file

@ -13,7 +13,21 @@ export async function MagicLinksEmail({
url: string;
provider: { server: any; from: string };
}) {
const { host } = new URL(url);
const parsed = new URL(url);
const host = parsed.host;
const baseUrl = parsed.origin;
const logoUrl = `${baseUrl}/domna-email-logo.png`;
const token = parsed.searchParams.get("token");
const email = parsed.searchParams.get("email");
if (!token || !email) {
throw new Error("Magic link token or email missing");
}
// Create a clean login link instead of the NextAuth callback
const loginUrl = `${parsed.origin}/verify/${token}`;
const transport = createTransport(provider.server);
@ -25,9 +39,20 @@ export async function MagicLinksEmail({
const result = await transport.sendMail({
to: identifier,
from: provider.from,
subject: "Your secure Ara sign-in link",
text: plainText({ url, host }),
html: domnaHtml({ url, host, brandColor, accentColor, brown, background }),
subject: "Sign in to Ara",
text: plainText({ url: loginUrl, host }),
html: domnaHtml({
url: loginUrl,
logoUrl,
host,
brandColor,
accentColor,
brown,
background,
}),
headers: {
"List-Unsubscribe": `<mailto:${provider.from}>`,
},
});
const failed = result.rejected.filter(Boolean);
@ -38,6 +63,7 @@ export async function MagicLinksEmail({
function domnaHtml({
url,
logoUrl,
host,
brandColor,
accentColor,
@ -45,6 +71,7 @@ function domnaHtml({
background,
}: {
url: string;
logoUrl: string;
host: string;
brandColor: string;
accentColor: string;
@ -60,7 +87,7 @@ function domnaHtml({
<tr>
<td align="center" style="background: linear-gradient(90deg, ${brandColor}, ${accentColor}); padding: 12px 8px;">
<img
src="https://145275138.fs1.hubspotusercontent-eu1.net/hubfs/145275138/base_logo_transparent_background.png"
src="${logoUrl}"
alt="Domna Logo"
width="120"
height="auto"

View file

@ -0,0 +1,312 @@
import { useState } from "react";
import { useMutation } from "@tanstack/react-query";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuTrigger,
} from "@/app/shadcn_components/ui/dropdown-menu";
import { Button } from "@/app/shadcn_components/ui/button";
import { Checkbox } from "@/app/shadcn_components/ui/checkbox";
import { Label } from "@/app/shadcn_components/ui/label";
import { GripVertical } from "lucide-react";
import { HelpCircle } from "lucide-react";
import { Loader2 } from "lucide-react";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@/app/shadcn_components/ui/tooltip";
import {
DndContext,
closestCenter,
} from "@dnd-kit/core";
import {
SortableContext,
useSortable,
verticalListSortingStrategy,
arrayMove,
} from "@dnd-kit/sortable";
import { CSS } from "@dnd-kit/utilities";
import { ScenarioSummary } from "./types";
export interface RecommendationsOptionsProps {
disabled?: boolean;
scenarios: ScenarioSummary[]
portfolioId: number
onSuccess: () => void;
}
interface ScenarioWithPriority {
id: number;
priority: number; // 1 = highest, 2 = next, etc.
}
interface CategorisationTriggerRequest {
portfolio_id: number;
scenarios_to_consider?: number[] | null;
scenario_priority_order?: number[] | null;
}
function mapScenariosToPayload(
selectedScenarios: ScenarioWithPriority[],
portfolio_id: number
): CategorisationTriggerRequest {
if (!selectedScenarios || selectedScenarios.length === 0) {
return {
portfolio_id,
scenarios_to_consider: null,
scenario_priority_order: null,
};
}
// Sort by priority just in case
const sorted = [...selectedScenarios].sort((a, b) => a.priority - b.priority);
return {
portfolio_id,
scenarios_to_consider: sorted.map((s) => s.id),
scenario_priority_order: sorted.map((s) => s.id),
};
}
function SortableScenarioItem({
id,
name,
index,
}: {
id: number;
name: string;
index: number;
}) {
const { attributes, listeners, setNodeRef, transform, transition, isDragging } =
useSortable({ id });
const style = {
transform: CSS.Transform.toString(transform),
transition,
};
return (
<div
ref={setNodeRef}
style={style}
className={`flex items-center gap-2 p-2 border rounded bg-muted cursor-grab
${isDragging ? "opacity-70 scale-105 shadow-lg" : ""}
`}
{...attributes}
>
<GripVertical
className="h-4 w-4 text-muted-foreground"
{...listeners}
/>
<span className="flex-1">{name}</span>
<span className="text-xs text-muted-foreground">
Priority {index + 1}
</span>
</div>
);
}
function sendCategorisationRequest(selectedScenarios: ScenarioWithPriority[], portfolioId: number) {
const payload = mapScenariosToPayload(selectedScenarios, portfolioId);
return fetch("/api/plan/categorisation", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
});
}
export function RecommendationsOptions({
disabled = false,
scenarios,
portfolioId,
onSuccess
}: RecommendationsOptionsProps) {
const [isApplying, setIsApplying] = useState(false);
const [open, setOpen] = useState(false);
const [selectedScenarios, setSelectedScenarios] = useState<ScenarioWithPriority[]>([]);
const [warning, setWarning] = useState<string | null>(null);
const toggleScenario = (id: number) => {
setWarning("")
setSelectedScenarios((prev) => {
const exists = prev.find((s) => s.id === id);
if (exists) {
// Remove
return prev.filter((s) => s.id !== id);
} else {
// Add at the end with next priority
return [...prev, { id, priority: prev.length + 1 }];
}
});
};
const handleSelectAll = () => {
setWarning("")
setSelectedScenarios(
scenarios.map((s, index) => ({ id: s.id, priority: index + 1 }))
);
};
const handleDeselectAll = () => {
setWarning("")
setSelectedScenarios([]);
};
const handleDragEnd = (event: any) => {
const { active, over } = event;
if (!over || active.id === over.id) return;
setSelectedScenarios((items) => {
const oldIndex = items.findIndex((s) => s.id === active.id);
const newIndex = items.findIndex((s) => s.id === over.id);
const newOrder = arrayMove(items, oldIndex, newIndex);
// Update priority to match array index
return newOrder.map((s, index) => ({ ...s, priority: index + 1 }));
});
};
const { mutate, isPending } = useMutation({
mutationFn: () => sendCategorisationRequest(selectedScenarios, portfolioId),
// onSuccess: () => {
// },
// onError: () => {
// }
});
const handleSubmit = () => {
if (selectedScenarios.length === 1) {
setWarning("Cannot generate recommendations for a single scenario");
return;
}
setWarning(null);
mutate();
onSuccess();
setOpen(false);
};
const handleCancel = () => {
setWarning("")
setSelectedScenarios([]);
setOpen(false);
};
const selectedScenarioObjects = selectedScenarios.map(
(s) => ({
...scenarios.find((sc) => sc.id === s.id)!,
priority: s.priority,
})
);
return (
<DropdownMenu open={open} onOpenChange={setOpen}>
<DropdownMenuTrigger asChild>
<Button
variant="outline"
size="sm"
disabled={disabled || isApplying}
className={`
rounded-md px-3 py-2 text-sm font-medium transition
${
disabled
? "bg-gray-200 text-gray-400 cursor-not-allowed"
: "bg-brandblue text-white hover:bg-hoverblue"
}
`}
>
Calculate Recommended
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent className="w-80 p-4 space-y-4 max-h-[50vh] overflow-y-auto">
<div className="flex justify-between">
<Button size="sm" variant="ghost" onClick={handleSelectAll}>
Select all
</Button>
<Button size="sm" variant="ghost" onClick={handleDeselectAll}>
Deselect all
</Button>
</div>
<div className="space-y-2">
<h4 className="font-semibold">Select scenarios to consider</h4>
{scenarios.map((scenario) => (
<div key={scenario.id} className="flex items-center gap-2">
<Checkbox
checked={selectedScenarios.some((s) => s.id === scenario.id)}
onCheckedChange={() => toggleScenario(scenario.id)}
/>
<Label>{scenario.name}</Label>
</div>
))}
</div>
{selectedScenarioObjects.length > 0 && (
<div className="space-y-2">
<div className="flex items-center gap-1">
<h4 className="font-semibold">Drag to prioritise</h4>
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<HelpCircle className="h-4 w-4 text-muted-foreground" />
</TooltipTrigger>
<TooltipContent>
<p>
This decides the order of selection if multiple scenarios have equal
outputs.
</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
</div>
<DndContext
collisionDetection={closestCenter}
onDragEnd={handleDragEnd}
>
<SortableContext
items={selectedScenarios}
strategy={verticalListSortingStrategy}
>
{selectedScenarioObjects.map((scenario, index) => (
<SortableScenarioItem
key={scenario.id}
id={scenario.id}
name={scenario.name}
index={index}
/>
))}
</SortableContext>
</DndContext>
</div>
)}
{warning && (
<p className="text-sm text-red-600 font-medium">{warning}</p>
)}
<div className="flex justify-end gap-2 pt-2">
<Button variant="ghost" size="sm" onClick={handleCancel}>
Cancel
</Button>
<Button size="sm" onClick={handleSubmit} disabled={isApplying}>
<span className="flex items-center gap-2">
{isApplying && <Loader2 className="h-4 w-4 animate-spin" />}
Submit
</span>
</Button>
</div>
</DropdownMenuContent>
</DropdownMenu>
);
}

View file

@ -22,6 +22,8 @@ import type {
ScenarioSummary,
} from "./types";
import { ReportingFunctionalityButtons } from "./ReportingFunctionalityButtons";
import { RecommendationsOptions } from "./RecommendationsOptions";
import SuccessToast from "../../components/SuccessToast";
interface ReportingClientAreaProps {
baseline: BaselineMetrics;
@ -39,19 +41,21 @@ async function fetchScenarioReport({
hideNonCompliant,
}: {
portfolioId: number;
scenarioId: number;
hideNonCompliant: boolean; /* this will remove plans that do not meet upgrade targets*/
scenarioId: number | "default";
hideNonCompliant: boolean;
}) {
const params = new URLSearchParams({
hideNonCompliant: String(hideNonCompliant),
});
const res = await fetch(
`/api/portfolio/${portfolioId}/scenario/${scenarioId}/metrics?${params.toString()}`,
);
const path = `/api/portfolio/${portfolioId}/scenario/${scenarioId}/metrics`;
const res = await fetch(`${path}?${params.toString()}`);
if (!res.ok) {
console.error("Failed to fetch scenario report:", await res.text());
throw new Error("Failed to load scenario report");
}
return res.json();
}
@ -60,11 +64,11 @@ async function fetchScenarioMeasures({
scenarioId,
}: {
portfolioId: number;
scenarioId: number;
scenarioId: number | "default";
}) {
const res = await fetch(
`/api/portfolio/${portfolioId}/scenario/${scenarioId}/measures`,
);
const path = `/api/portfolio/${portfolioId}/scenario/${scenarioId}/measures`;
const res = await fetch(path);
if (!res.ok) {
throw new Error("Failed to load measures");
@ -79,12 +83,13 @@ export function ReportingClientArea({
scenarios,
portfolioId,
}: ReportingClientAreaProps) {
const [selectedScenarioId, setSelectedScenarioId] = useState<number | null>(
null,
);
const [selectedScenarioId, setSelectedScenarioId] = useState<
number | "default" | null
>(null);
const [measuresOpen, setMeasuresOpen] = useState<boolean>(false);
const [appliedHideNonCompliant, setAppliedHideNonCompliant] =
useState<boolean>(false);
const [showToast, setShowToast] = useState(false);
const drawerOpen = Boolean(selectedScenarioId);
@ -109,7 +114,7 @@ export function ReportingClientArea({
scenarioId: selectedScenarioId!,
hideNonCompliant: appliedHideNonCompliant,
}),
enabled: !!selectedScenarioId, // only run when scenario selected
enabled: selectedScenarioId !== null, // only run when scenario selected or default selected
keepPreviousData: true, // keep showing old data while loading new scenario or applying filter
refetchOnWindowFocus: false,
});
@ -234,6 +239,9 @@ export function ReportingClientArea({
<ReportingFunctionalityButtons
hideNonCompliant={appliedHideNonCompliant}
disabled={scenarioBusy}
canFilterNonCompliant={
selectedScenarioId !== null && selectedScenarioId !== "default"
}
onApply={async (value) => {
setAppliedHideNonCompliant(value);
}}
@ -261,6 +269,14 @@ export function ReportingClientArea({
</button>
</div>
)}
{ !selectedScenarioId &&
<RecommendationsOptions
disabled={scenarioBusy}
scenarios={scenarios}
portfolioId={portfolioId}
onSuccess={() => setShowToast(true)}
/>
}
</div>
{/* LOADING + ERROR STATES */}
@ -333,6 +349,15 @@ export function ReportingClientArea({
data={measuresData ?? null}
error={measuresError}
/>
<SuccessToast
show={showToast}
showConfetti={false}
onClose={() => setShowToast(false)}
message="Recommendation process triggered"
subtext="Recommendations might take a few minutes to update"
timeoutMs={6000}
/>
</>
);
}

View file

@ -20,12 +20,16 @@ export interface ReportingFunctionalityButtonsProps {
onApply: (value: boolean) => Promise<void> | void;
disabled?: boolean;
/* Whether hideNonCompliant filter is available */
canFilterNonCompliant?: boolean;
}
export function ReportingFunctionalityButtons({
hideNonCompliant,
onApply,
disabled = false,
canFilterNonCompliant = true,
}: ReportingFunctionalityButtonsProps) {
const [draftHideNonCompliant, setDraftHideNonCompliant] =
useState<boolean>(hideNonCompliant);
@ -97,10 +101,15 @@ export function ReportingFunctionalityButtons({
>
<div className="space-y-5">
{/* Filter option */}
<div className="flex items-start gap-4">
<div
className={`flex items-start gap-4 ${
!canFilterNonCompliant ? "opacity-50 pointer-events-none" : ""
}`}
>
<Checkbox
id="hide-non-compliant"
checked={draftHideNonCompliant}
disabled={!canFilterNonCompliant}
onCheckedChange={(checked) =>
setDraftHideNonCompliant(Boolean(checked))
}
@ -136,7 +145,7 @@ export function ReportingFunctionalityButtons({
<Button
variant="ghost"
size="sm"
disabled={isApplying}
disabled={isApplying || !canFilterNonCompliant}
onClick={handleReset}
>
Reset
@ -145,7 +154,7 @@ export function ReportingFunctionalityButtons({
<Button
size="sm"
className="bg-brandmidblue hover:bg-hoverblue"
disabled={isApplying}
disabled={isApplying || !canFilterNonCompliant}
onClick={handleApply}
>
{isApplying ? "Applying…" : "Apply filters"}

View file

@ -16,8 +16,8 @@ export interface ScenarioOption {
interface ScenarioSelectorProps {
scenarios: ScenarioOption[];
selected: number | null;
onChange: (id: number | null) => void;
selected: number | null | "default";
onChange: (id: number | null | "default") => void;
}
export const ScenarioSelector: FC<ScenarioSelectorProps> = ({
@ -30,9 +30,16 @@ export const ScenarioSelector: FC<ScenarioSelectorProps> = ({
<span className="text-sm text-gray-600">Scenario:</span>
<Select
value={selected ? String(selected) : "none"}
value={
selected === null
? "none"
: selected === "default"
? "default"
: String(selected)
}
onValueChange={(val) => {
if (val === "none") onChange(null);
else if (val === "default") onChange("default");
else onChange(Number(val));
}}
>
@ -43,6 +50,10 @@ export const ScenarioSelector: FC<ScenarioSelectorProps> = ({
<SelectContent>
<SelectItem value="none">No scenario (baseline only)</SelectItem>
<SelectItem value="default">
Best option (recommended plans)
</SelectItem>
{scenarios.map((s) => (
<SelectItem key={s.id} value={String(s.id)}>
{s.name}

View file

@ -1,6 +1,6 @@
"use client";
import { useState, useMemo } from "react";
import { useMemo } from "react";
import { ScenarioSelector } from "./scenarioSelector";
export function ScenarioSelectorWrapper({
@ -11,23 +11,37 @@ export function ScenarioSelectorWrapper({
}: {
scenarios: { id: number; name: string }[];
portfolioId: number;
selectedScenarioId: number | null;
setSelectedScenarioId: (id: number | null) => void;
selectedScenarioId: number | null | "default";
setSelectedScenarioId: (id: number | null | "default") => void;
}) {
// The ID we will eventually pass into React Query
// const activeContextId = useMemo(
// () => selectedScenarioId ?? portfolioId,
// [selectedScenarioId, portfolioId]
// );
const [selectedScenarioName, setSelectedScenarioName] = useState<
string | null
>(null);
function handleSelect(id: number | null) {
function handleSelect(id: number | null | "default") {
setSelectedScenarioId(id);
const scenario = scenarios.find((s) => s.id === id);
setSelectedScenarioName(scenario ? scenario.name : null);
}
const selectionMeta = useMemo(() => {
if (selectedScenarioId === null) {
return {
label: "Baseline",
description: "Current portfolio performance",
className: "bg-gray-100 text-gray-600 border border-gray-200",
};
}
if (selectedScenarioId === "default") {
return {
label: "Recommended",
description: "Best upgrade plan per property",
className: "bg-brandmidblue text-white border border-brandblue",
};
}
const scenario = scenarios.find((s) => s.id === selectedScenarioId);
return {
label: scenario?.name ?? "Scenario",
description: "Custom upgrade scenario",
className: "bg-white text-gray-700 border border-gray-300",
};
}, [selectedScenarioId, scenarios]);
return (
<div className="flex items-center gap-4">
@ -37,13 +51,20 @@ export function ScenarioSelectorWrapper({
onChange={handleSelect}
/>
{selectedScenarioId !== null ? (
<div className="text-xs text-gray-500">
Scenario selected: {selectedScenarioName}
<div className="flex items-center gap-3">
<div
className={`
inline-flex items-center rounded-full px-3 py-1 text-xs font-medium
${selectionMeta.className}
`}
>
{selectionMeta.label}
</div>
) : (
<div className="text-xs text-gray-400">Using portfolio baseline</div>
)}
<span className="text-xs text-gray-400">
{selectionMeta.description}
</span>
</div>
</div>
);
}

View file

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

View file

@ -35,6 +35,7 @@ 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";
import OrganisationLinkCard from "./OrganisationLinkCard";
// dropdown selection component for both goal and status
@ -215,9 +216,11 @@ async function deletePortfolio({
export default function PortfolioSettings({
portfolioId,
portfolioSettingsData,
isDomnaUser = false,
}: {
portfolioId: string;
portfolioSettingsData: PortfolioSettingsType;
isDomnaUser?: boolean;
}) {
// This is a client component so we can access the session directly
const session = useSession();
@ -475,6 +478,7 @@ export default function PortfolioSettings({
</Table>
</div>
<UsersPermissionsCard portfolioId={portfolioId} />
{isDomnaUser && <OrganisationLinkCard portfolioId={portfolioId} />}
<div className="rounded-md border border-red-500 mt-2">
<Table>
<TableHeader>

View file

@ -1,3 +1,5 @@
import { getServerSession } from "next-auth";
import { AuthOptions } from "@/app/api/auth/[...nextauth]/authOptions";
import { getPortfolioSettings } from "../../utils";
import PortfolioSettings from "./PortfolioSettings";
@ -8,7 +10,13 @@ export default async function PortfolioSettingsPage(
) {
const params = await props.params;
const portfolioId = params.slug;
const portfolioSettingsData = await getPortfolioSettings(portfolioId);
const [portfolioSettingsData, session] = await Promise.all([
getPortfolioSettings(portfolioId),
getServerSession(AuthOptions),
]);
const isDomnaUser = !!session?.user?.email?.endsWith("@domna.homes");
return (
<>
@ -16,6 +24,7 @@ export default async function PortfolioSettingsPage(
<PortfolioSettings
portfolioId={portfolioId}
portfolioSettingsData={portfolioSettingsData}
isDomnaUser={isDomnaUser}
/>
</div>
</>

View file

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

View file

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

View file

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

View file

@ -1,277 +0,0 @@
"use client";
import { useMemo } from "react";
import { BarList, Card, Title } from "@tremor/react";
const STAGE_ORDER = [
"Initial planning",
"Booking Team to contact tenant",
"In Assessment",
"In Coordination",
"In Design",
"Completed",
"Queries",
];
const stage = (label: string) => STAGE_ORDER.find((s) => s === label)!;
// 🔧 Helper function to determine stage label after assessment based on coordination and design status
const getAfterAssessmentLabel = (
coordinationStatus?: string,
designStatus?: string
): string => {
// Normalize strings to uppercase for case-insensitive comparison
const coordStatusUpper = coordinationStatus?.toUpperCase() ?? "";
const designStatusUpper = designStatus?.toUpperCase() ?? "";
// 1. If coordination status is 'ra issue', return to 'queries'
if (coordStatusUpper === "RA ISSUE") {
return "Queries";
}
// 2. If coordination status contains v1/v2/v3 ioe/mtp completed, show as 'In Design'
if (
coordStatusUpper.includes("V1 IOE/MTP COMPLETE") ||
coordStatusUpper.includes("V2 IOE/MTP COMPLETE") ||
coordStatusUpper.includes("V3 IOE/MTP COMPLETE")
) {
// 3. If design status is 'Uploaded', show as 'Completed'
if (designStatusUpper === "UPLOADED") {
return "Completed";
}
// Otherwise show as 'In Design'
return "In Design";
}
// Default to 'In Coordination'
return "In Coordination";
};
// 🏷️ Deal stage → display stage mapping
const STAGE_LABELS: Record<string, string> = {
"1617223910": stage("Initial planning"), // 0 - [Ops] Backlog
"3583836399": stage("Initial planning"), // 0 - [Ops] Route Planning
"3589581001": stage("Booking team to contact tenant"), // 1 - [Bookings] Ready for Bookings Team
"3569878239": stage("Booking team to contact tenant"), // 1 - [Bookings] Send initial booking SMS
"1617223911": stage("Booking team to contact tenant"), // 1 - [Bookings] Send Email
"1984184569": stage("Booking team to contact tenant"), // 1 - [Bookings] Phone booking
"3569572028": stage("Booking team to contact tenant"), // 1 - [Bookings] Preferences received from Tenant
"3570936026": stage("Booking team to contact tenant"), // 1 - [Bookings] Send Confirmation Comms
"2663668937": stage("Queries"), // 4 - [Bookings/Sales] Booking issues - needs HA support (Check with Aidan)
"1984401629": stage("In Assessment"), // 2 - [Bookings/Ops/Sales] No Contact Details - Ready for Route
"2558220518": stage("Booking team to contact tenant"), // 1 - [Ops] Not attempted - needs reallocation
"3474594026": stage("Booking team to contact tenant"), // 1 - [Ops/Bookings] Rebooked - Needs updating
"1617223912": stage("In Assessment"), // 2 - [Ops] Ready for Assignment to Route
"1617223913": stage("In Assessment"), // 2 - [Ops] Survey in Progress
"3206388924": stage("In Assessment"), // 2 - [Ops] Surveyed - Pending Upload from Surveyor
"1617223915": stage("In Assessment"), // 2 - [Ops] No Access - Need Sign Off
"1617223917": stage("Queries"), // 3 - [Ops] No Access - No Revisit
"2571417798": stage("Booking team to contact tenant"), // 1 - [Ops] Surveyed under 2019 - Needs Re-survey
"1617223916": stage("In Assessment"), // 5 - [Ops] Properties to Review Manually
// 🔧 ===== AFTER ASSESSMENT - Determine exact stage using coordination/design status logic =====
// These are special internal stages that will be processed by getAfterAssessmentLabel
// and mapped to their final display stages ("In Coordination", "In Design", "Completed")
"2628341989": "AFTER_ASSESSMENT", // 5 - [Ops] Assessment needs correction
"3441170637": "AFTER_ASSESSMENT", // 5 - [Ops] Awaiting PV Design
"1617223914": "AFTER_ASSESSMENT", // 5 - [Ops] Surveyed in Pashub, Transit Job to Co-ordination
"2628233422": "AFTER_ASSESSMENT", // 5 - [Coordination] Ready for coordination
"2702650617": "AFTER_ASSESSMENT", // 5 - [Design] Ready for Design
"2473886962": "AFTER_ASSESSMENT", // 5 - [Design] Design in progress
"1668803774": "AFTER_ASSESSMENT", // 6 - [Finance] Ready for Invoicing
"3440363736": "AFTER_ASSESSMENT", // 6 - [Finance] Needs Invoicing - Files Sent
// 🔧 Exception stages (handled separately)
"1887735998": stage("Queries"), // 3 - [Ops] Not Viable
"3061261536": stage("Queries"), // 4 - [Sales/Tech] Major condition issue
"1887735999": stage("Queries"), // 4 - [Ops] Needs HA Works
"3016601828": stage("Queries"), // 4 - [Engagement Team] EPC C Before Works
"2769407183": stage("Queries"), // 4 - [Ops] PV - Needs Heating Upgrade (Pre EPR D)
};
// 🧩 Reasons for exception stages (HA support / Not viable)
const STAGE_REASONS: Record<string, string> = {
// ---- Needs support from HA ----
"2663668937": "Booking issues due to tenant difficulties.",
"3061261536": "Awaab's Law",
"1887735999": "<Please contact the Tech Team for implementation>",
"3016601828": "RA is currently EPR C. Convert to EPC?",
"2769407183": "Needs HA heating upgrade. Domna/HA discussion required.",
// ---- Not viable for funding ----
"1617223917": "<Please contact the Tech Team for implementation>",
"1887735998": "<Please contact the Tech Team for implementation>",
};
// ✅ Define an explicit Deal type for clarity
interface Deal {
dealname: string;
landlordPropertyId: string;
dealstage: string;
coordinationStatus?: string;
designStatus?: string;
reason?: string;
[key: string]: any;
}
interface DealStageChartProps {
deals: Deal[];
onOpenTable?: (
stageName: string,
filteredDeals: Deal[],
columns?: string[],
columnLabels?: { [key: string]: string }
) => void;
}
export function DealStageChart({ deals, onOpenTable }: DealStageChartProps) {
const data = useMemo(() => {
const counts: Record<string, number> = {};
deals.forEach((d) => {
const stageId = d.dealstage || "unknown";
let stageName = STAGE_LABELS[stageId] || "Unknown Stage";
// 🔧 For deals marked as "AFTER_ASSESSMENT", determine exact stage using coordination/design status logic
if (stageName === "AFTER_ASSESSMENT") {
const label = getAfterAssessmentLabel(d.coordinationStatus, d.designStatus);
stageName = label || "In Coordination"; // Default to "In Coordination" if no label returned
}
// 🔧 For "Initial Planning" deals, check if coordination status is 'RA ISSUE'
if (stageName === "Initial planning") {
const coordStatusUpper = d.coordinationStatus?.toUpperCase() ?? "";
if (coordStatusUpper === "RA ISSUE") {
stageName = "Queries";
}
}
counts[stageName] = (counts[stageName] || 0) + 1;
});
return STAGE_ORDER.map((name) => ({
name,
value: counts[name] || 0,
}));
}, [deals]);
const total = deals.length;
const handleBarClick = (value: { name: string; value: number }) => {
const filteredDeals: Deal[] = deals
.filter((d) => {
let stageName = STAGE_LABELS[d.dealstage] || "Unknown Stage";
// 🔧 For deals marked as "AFTER_ASSESSMENT", determine exact stage using coordination/design status logic
if (stageName === "AFTER_ASSESSMENT") {
const label = getAfterAssessmentLabel(d.coordinationStatus, d.designStatus);
stageName = label || "In Coordination"; // Default to "In Coordination" if no label returned
}
// 🔧 For "Initial Planning" deals, check if coordination status is 'RA ISSUE'
if (stageName === "Initial planning") {
const coordStatusUpper = d.coordinationStatus?.toUpperCase() ?? "";
if (coordStatusUpper === "RA ISSUE") {
stageName = "Queries";
}
}
return stageName === value.name;
})
.map((d) => ({
...d,
// ✅ Always provide a string to avoid undefined issues
reason: STAGE_REASONS[d.dealstage] ?? "",
}));
const isException =
value.name === "Needs support from HA" ||
value.name === "Not viable for funding";
// Add "Reason" column if it's an exception stage
const columns = isException
? ["dealname", "landlordPropertyId", "reason"]
: ["dealname", "landlordPropertyId"];
const columnLabels = isException
? {
dealname: "Address Ref.",
landlordPropertyId: "Property Ref.",
reason: "Reason",
}
: {
dealname: "Address Ref.",
landlordPropertyId: "Property Ref.",
};
// ✅ Explicit cast ensures no type mismatch
onOpenTable?.(value.name, filteredDeals, columns, columnLabels as Record<string, string>);
};
// Split into normal + exception stages
const normalStages = data.filter(
(d) =>
!["Queries"].includes(d.name) &&
d.name !== ""
);
const exceptionStages = data.filter((d) =>
["Queries"].includes(d.name)
);
return (
<div className="flex flex-col gap-3">
{/* ✅ Main Progress Chart */}
<Card className="bg-white rounded-xl shadow-sm hover:shadow-md transition-all duration-200 p-6 flex flex-col items-center justify-center">
<div className="text-center mb-3">
<Title className="text-gray-800 text-base font-semibold">
Project Progress by Stage
</Title>
<p className="text-xs text-gray-500 mt-0.5">
Click a bar to view related properties
</p>
<p className="text-xs text-gray-700 font-medium mt-1">
Total: {total.toLocaleString()} properties
</p>
</div>
<div className="w-full max-w-md">
<BarList
data={normalStages}
color="blue"
sortOrder="none"
className="cursor-pointer"
onValueChange={handleBarClick}
/>
</div>
</Card>
{/* 🚨 Exception Chart */}
<Card className="bg-white rounded-xl shadow-sm hover:shadow-md transition-all duration-200 p-6 flex flex-col items-center justify-center">
<div className="text-center mb-3">
<Title className="text-gray-800 text-base font-semibold">
Needs HA Support & Not Viable
</Title>
<p className="text-xs text-gray-500 mt-0.5">
Click to explore exception properties (reasons appear in table)
</p>
</div>
<div className="w-full max-w-md">
<BarList
data={exceptionStages}
color="red"
sortOrder="none"
className="cursor-pointer"
onValueChange={handleBarClick}
/>
</div>
</Card>
</div>
);
}

View file

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

View file

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

View file

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

View file

@ -0,0 +1,120 @@
"use client"
interface ExpandableCountBarProps<T> {
title: string
items: T[]
count?: number
percentage?: number
inProgressPercentage?: number
total?: number
secondaryStats?: Array<{ label: string; count: number }>
onClick?: (items: T[]) => void
className?: string
}
export default function ExpandableCountBar<T>({
title,
items,
count,
percentage,
inProgressPercentage,
total,
secondaryStats,
onClick,
className = "",
}: ExpandableCountBarProps<T>) {
const displayCount = count ?? items.length
const displayTotal = total ?? items.length
const displayPercentage = percentage ?? 0
const displayInProgressPercentage = inProgressPercentage ?? 0
const radius = 40
const circumference = 2 * Math.PI * radius
const completedStrokeDashoffset = circumference - (displayPercentage / 100) * circumference
const inProgressStrokeDashoffset = circumference - ((displayPercentage + displayInProgressPercentage) / 100) * circumference
return (
<button
onClick={() => onClick?.(items)}
className={`w-full cursor-pointer rounded-xl border border-brandblue/20 bg-gradient-to-br from-brandlightblue/20 to-brandlightblue/5 shadow-sm hover:shadow-md hover:border-brandblue/40 transition-all duration-300 p-6 flex flex-col gap-4 group active:scale-95 ${className}`}
>
<div className="flex items-center justify-between gap-4">
<div className="flex-1 text-left">
<p className="text-base font-semibold text-brandblue group-hover:text-brandmidblue transition-colors">
{title}
</p>
<p className="text-xs text-gray-500 mt-1">Click to view breakdown</p>
</div>
{/* Circular Progress */}
<div className="relative w-24 h-24 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-brandblue/10"
/>
{/* In Progress circle (gold) - drawn first so it appears underneath */}
{displayInProgressPercentage > 0 && (
<circle
cx="50"
cy="50"
r={radius}
fill="none"
stroke="#f59e0b"
strokeWidth="3"
strokeDasharray={circumference}
strokeDashoffset={inProgressStrokeDashoffset}
strokeLinecap="round"
className="transition-all duration-700 ease-out"
/>
)}
{/* Completed progress circle (blue) - drawn last so it appears on top */}
{displayPercentage > 0 && (
<circle
cx="50"
cy="50"
r={radius}
fill="none"
stroke="#14163d"
strokeWidth="3"
strokeDasharray={circumference}
strokeDashoffset={completedStrokeDashoffset}
strokeLinecap="round"
className="transition-all duration-700 ease-out"
/>
)}
</svg>
{/* Center text */}
<div className="absolute inset-0 flex flex-col items-center justify-center">
<span className="text-lg font-bold text-brandblue">
{displayPercentage.toFixed(0)}%
</span>
<span className="text-xs text-gray-600 font-semibold">
{displayCount}/{displayTotal}
</span>
</div>
</div>
</div>
{/* Secondary Stats */}
{secondaryStats && secondaryStats.length > 0 && (
<div className="grid grid-cols-2 gap-2 pt-3 border-t border-brandblue/10">
{secondaryStats.map((stat, index) => (
<div key={stat.label} className="text-left">
<p className="text-xs text-gray-600 font-medium mb-1">{stat.label}</p>
<p className={`text-lg font-bold ${index === 0 ? 'text-brandblue' : 'text-amber-500'}`}>{stat.count}</p>
</div>
))}
</div>
)}
<span className="text-brandblue/40 group-hover:text-brandblue/70 text-xl transition-colors transform group-hover:-rotate-180 duration-300 self-end"></span>
</button>
)
}

View file

@ -0,0 +1,318 @@
"use client";
import { useState } from "react";
import { motion } from "framer-motion";
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) {
// ── Tab state ────────────────────────────────────────────────────────
const [activeTab, setActiveTab] = useState<"analytics" | "properties" | "documents">(
"analytics",
);
// ── 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,
);
// ── Drill-down table modal (used by AnalyticsView) ───────────────────
const [openTable, setOpenTable] = useState<TableModal | null>(null);
// ── Document drawer (used by PropertyTable) ──────────────────────────
const [drawerState, setDrawerState] = useState<DocumentDrawerState>({
open: false,
uprn: null,
landlordPropertyId: null,
dealname: null,
});
// ── Property detail drawer ───────────────────────────────────────────
const [detailDeal, setDetailDeal] = useState<ClassifiedDeal | null>(null);
const handleOpenTable = (
stage: string,
filteredDeals: ClassifiedDeal[],
columns?: (keyof ClassifiedDeal)[],
columnLabels?: Partial<Record<keyof ClassifiedDeal, string>>,
breakdown?: Record<string, ClassifiedDeal[]>,
) => {
setOpenTable({
stage,
data: filteredDeals,
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">
<CardContent>
<p className="text-gray-600 text-sm">No deal data available.</p>
</CardContent>
</Card>
);
}
return (
<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"
>
<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>
{/* 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>
)}
<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"
onClick={() => setOpenTable(null)}
>
<motion.div
initial={{ opacity: 0, scale: 0.95 }}
animate={{ opacity: 1, scale: 1 }}
transition={{ duration: 0.2 }}
className="bg-white rounded-2xl shadow-2xl p-8 w-full max-w-6xl h-[90vh] flex flex-col border border-brandblue/10"
onClick={(e) => e.stopPropagation()}
>
<div className="mb-6 border-b border-brandblue/10 pb-6">
<h2 className="text-2xl font-bold text-brandblue mb-3">
{openTable.stage}
</h2>
<p className="text-sm text-gray-600 mb-4">
Showing{" "}
<span className="font-semibold text-brandblue">
{openTable.data.length}
</span>{" "}
properties
</p>
{openTable.breakdown && (
<div className="grid grid-cols-2 gap-3">
{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`}
>
{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 bg-white p-4">
<DrillDownTable
data={openTable.data}
columns={openTable.columns as (keyof HubspotDeal)[]}
columnLabels={openTable.columnLabels}
/>
</div>
<div className="mt-6 flex justify-end gap-3">
<button
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>
</div>
</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>
);
}

View file

@ -0,0 +1,207 @@
"use client";
import { motion } from "framer-motion";
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 ClassifiedDeal)[],
columnLabels?: Partial<Record<keyof ClassifiedDeal, string>>,
breakdown?: Record<string, ClassifiedDeal[]>,
) => void;
}
export default function ProgressOverview({
data,
onOpenTable,
}: ProgressOverviewProps) {
const {
completedPercentage,
completedCount,
nonQueryTotal,
stageProgress,
} = data;
// 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 (
<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
</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>
<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>
</motion.button>
{/* ── 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,
EARLY_COLUMNS,
EARLY_LABELS,
)
}
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>
<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>
)}
</CardContent>
</Card>
);
}

View file

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

View file

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

View file

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

View file

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

View file

@ -1,284 +0,0 @@
"use client";
import { useState } from "react";
import { DealStageChart } from "./DealStageChart";
import SurveyedPieChart from "./SurveyedResultsPieChart";
import TableViewer from "./TableViewer";
import {
Card,
CardHeader,
CardTitle,
CardContent,
} from "@/app/shadcn_components/ui/card";
import { Home, AlertTriangle } from "lucide-react";
import { motion } from "framer-motion";
interface ReportsProps {
deals: Record<string, any>[];
}
const MAJOR_CONDITION_STAGE_ID = "3061261536";
export default function LiveTracker({ deals }: ReportsProps) {
const groupedDeals = deals.reduce(
(acc, deal) => {
const project = deal.projectCode || "Unknown Project";
(acc[project] ||= []).push(deal);
return acc;
},
{} as Record<string, any[]>
);
const [openTable, setOpenTable] = useState<{
stage: string;
data: any[];
columns: string[];
columnLabels: Record<string, string>;
} | null>(null);
const projectCodes = Object.keys(groupedDeals);
const [currentProjectCode, setCurrentProjectCode] = useState(projectCodes[0]);
const currentDeals = groupedDeals[currentProjectCode];
// Check if there's any survey data
const surveyorOutcomes = [
"Surveyed",
"Surveyed - Pending Upload",
"Tenant Refusal",
"Other",
"Not Viable",
"Not Attempted",
"No Answer",
"Cancelled / No Show",
"Rescheduled",
];
const hasSurveyData = currentDeals.some((deal: any) =>
deal.outcome && surveyorOutcomes.includes(deal.outcome)
);
const totalProperties = deals.length;
const majorConditionDeals = deals.filter(
(d) => d.dealstage === MAJOR_CONDITION_STAGE_ID
);
const majorIssues = majorConditionDeals.length;
const majorPercent = ((majorIssues / totalProperties) * 100).toFixed(1);
const handleOpenTable = (
stage: string,
filteredDeals: any[],
columns?: string[],
columnLabels?: Record<string, string>
) => {
setOpenTable({
stage,
data: filteredDeals,
columns:
columns || ["dealname", "landlordPropertyId"],
columnLabels:
columnLabels || {
dealname: "Address Ref.",
landlordPropertyId: "Property Ref.",
},
});
};
if (!deals?.length) {
return (
<Card className="p-8 text-center bg-gradient-to-br from-white to-gray-50 border border-gray-100 shadow-sm">
<CardContent>
<p className="text-gray-500 text-sm">No deal data available.</p>
</CardContent>
</Card>
);
}
return (
<div className="space-y-4 w-full">
{/* 🌍 Global Overview */}
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4">
{/* Total Properties */}
<StatCard
icon={Home}
title="Total Properties"
value={totalProperties}
onClick={() =>
handleOpenTable(
"All Properties",
deals,
["dealname", "landlordPropertyId", "projectCode"],
{
dealname: "Address Ref.",
landlordPropertyId: "Property Ref.",
projectCode: "Project Code",
}
)
}
accent="brandblue"
/>
{/* Major Issues */}
<StatCard
icon={AlertTriangle}
title="Awaab's Law Reporting"
value={`${majorIssues}`}
subtitle={`(${majorPercent}%)`}
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="red"
/>
{/* Project Selector */}
<Card className="flex flex-col justify-center items-center border border-gray-100 bg-gradient-to-br from-white to-gray-50 shadow-sm">
<CardHeader className="pb-2">
<CardTitle className="font-normal">
<p className="text-xs uppercase text-gray-500 mb-1">
Select Project
</p>
</CardTitle>
</CardHeader>
<CardContent>
<div className="relative w-56">
<select
id="projectSelect"
value={currentProjectCode}
onChange={(e) => setCurrentProjectCode(e.target.value)}
className="w-full px-3 py-2 border rounded-lg bg-white text-gray-800 focus:ring-2 focus:ring-brandblue focus:outline-none"
>
{projectCodes.map((code) => (
<option key={code} value={code}>
{code}
</option>
))}
</select>
<div className="absolute right-3 top-2.5 text-gray-400 pointer-events-none">
</div>
</div>
</CardContent>
</Card>
</div>
{/* 📊 Project Insights */}
<Card className="border border-gray-100 bg-gradient-to-br from-white to-gray-50 shadow-md">
<CardHeader>
<CardTitle className="text-center text-lg font-semibold text-brandblue tracking-tight">
Project-Level Insights {currentProjectCode}
</CardTitle>
</CardHeader>
<CardContent className={`grid gap-6 ${hasSurveyData ? "grid-cols-1 md:grid-cols-2" : "grid-cols-1 max-w-2xl mx-auto"}`}>
<motion.div
whileHover={{ scale: 1.01 }}
className="border rounded-xl p-5 bg-white shadow-sm hover:shadow-md transition"
>
<DealStageChart
deals={currentDeals}
onOpenTable={handleOpenTable}
/>
</motion.div>
{hasSurveyData && (
<motion.div
whileHover={{ scale: 1.01 }}
className="border rounded-xl p-5 bg-white shadow-sm hover:shadow-md transition"
>
<SurveyedPieChart
deals={currentDeals}
onOpenTable={handleOpenTable}
/>
</motion.div>
)}
</CardContent>
</Card>
{/* 🔹 Table Modal */}
{openTable && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 backdrop-blur-sm transition-opacity">
<div className="bg-white rounded-2xl shadow-2xl p-6 w-full max-w-6xl h-[90vh] flex flex-col animate-fadeIn">
<h2 className="text-2xl font-semibold mb-4 text-center text-gray-800">
{openTable.stage} {openTable.data.length} Properties
</h2>
<div className="flex-1 overflow-auto">
<TableViewer
data={openTable.data}
columns={openTable.columns}
columnLabels={openTable.columnLabels}
/>
</div>
<div className="mt-4 flex justify-center">
<button
onClick={() => setOpenTable(null)}
className="px-6 py-2 bg-gray-200 hover:bg-gray-300 rounded-lg transition"
>
Close
</button>
</div>
</div>
</div>
)}
</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";
}) {
const accentColor =
accent === "red"
? "from-red-50 to-white text-red-600 hover:border-red-300"
: "from-brandlightblue/20 to-white text-brandblue hover:border-brandblue/40";
return (
<motion.button
onClick={onClick}
whileHover={{ scale: 1.02 }}
className={`group relative text-left border rounded-xl bg-gradient-to-br ${accentColor} transition-all duration-200 shadow-sm hover:shadow-md p-5`}
>
<div className="flex items-center justify-between">
<div>
<p className="text-xs uppercase text-gray-500 mb-1">{title}</p>
<p className="text-3xl font-bold text-gray-800 group-hover:text-inherit">
{value}
{subtitle && (
<span className="text-base font-medium text-gray-500 ml-1">
{subtitle}
</span>
)}
</p>
</div>
<Icon className="h-6 w-6 opacity-50 group-hover:opacity-100 transition" />
</div>
</motion.button>
);
}

View file

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

View file

@ -1,29 +1,20 @@
"use client";
import { DonutChart, Card, Title } from "@tremor/react";
import { useMemo, useState } from "react";
import { useState } from "react";
import type { OutcomeSlice, ClassifiedDeal } from "./types";
interface SurveyedPieChartProps {
deals: Record<string, any>[];
onOpenTable?: (outcome: string, filteredDeals: Record<string, any>[]) => void;
slices: OutcomeSlice[];
dealsByOutcome: Record<string, ClassifiedDeal[]>;
onOpenTable?: (outcome: string, filteredDeals: ClassifiedDeal[]) => void;
}
export default function SurveyedPieChart({
deals,
export default function SurveyedResultsPieChart({
slices,
dealsByOutcome,
onOpenTable,
}: SurveyedPieChartProps) {
const surveyorOutcomes = [
"Surveyed",
"Surveyed - Pending Upload",
"Tenant Refusal",
"Other",
"Not Viable",
"Not Attempted",
"No Answer",
"Cancelled / No Show",
"Rescheduled",
];
const colors = [
"indigo-600",
"indigo-400",
@ -36,43 +27,34 @@ export default function SurveyedPieChart({
"gray-200",
];
const data = useMemo(() => {
const outcomeCounts: Record<string, number> = {};
deals.forEach((deal) => {
const outcome = deal.outcome;
if (outcome && surveyorOutcomes.includes(outcome)) {
outcomeCounts[outcome] = (outcomeCounts[outcome] || 0) + 1;
}
});
const total = Object.values(outcomeCounts).reduce((a, b) => a + b, 0);
return Object.entries(outcomeCounts).map(([name, amount]) => ({
name,
amount,
percentage: total ? ((amount / total) * 100).toFixed(1) : "0.0",
}));
}, [deals]);
const handleClick = (value: { name: string; amount: number }) => {
if (!value) return;
const filteredDeals = deals.filter((d) => d.outcome === value.name);
onOpenTable?.(value.name, filteredDeals);
};
const [hovered, setHovered] = useState<string | null>(null);
const handleClick = (slice: OutcomeSlice) => {
if (!slice) return;
const filteredDeals = dealsByOutcome[slice.name] ?? [];
onOpenTable?.(slice.name, filteredDeals);
};
// Don't show the chart if there's no data
if (data.length === 0) {
if (slices.length === 0) {
return null;
}
// Convert OutcomeSlice to chart data format
const chartData = slices.map((slice) => ({
name: slice.name,
amount: slice.amount,
percentage: slice.percentage,
}));
return (
<Card className="flex flex-col items-center p-6 pt-10 pb-8 bg-white">
<Card className="flex flex-col items-center p-8 bg-gradient-to-br from-white to-brandlightblue/5 border border-brandblue/10">
{/* Header */}
<div className="text-center mb-4">
<Title className="text-gray-800 text-[15px] font-semibold tracking-tight">
<div className="text-center mb-8 pb-6 border-b border-brandblue/10 w-full">
<Title className="text-brandblue text-[16px] font-bold tracking-tight mb-2">
Survey Performance
</Title>
<p className="text-xs text-gray-500 mt-1">
<p className="text-xs text-gray-500">
Click a segment or label to view filtered properties
</p>
</div>
@ -80,7 +62,7 @@ export default function SurveyedPieChart({
{/* Donut Chart (Centered) */}
<div className="relative flex justify-center items-center mt-6">
<DonutChart
data={data}
data={chartData}
category="amount"
index="name"
valueFormatter={(n) => `${n.toLocaleString()}`}
@ -94,64 +76,68 @@ export default function SurveyedPieChart({
const { name, amount } = item;
return (
<div
className="bg-white/80 backdrop-blur-md px-4 py-2.5 rounded-lg shadow-md
border border-gray-200 text-gray-800 text-sm font-medium"
className="bg-white/90 backdrop-blur-md px-4 py-3 rounded-lg shadow-lg
border border-brandblue/20 text-gray-800 text-sm font-medium"
>
<div className="flex flex-col items-center">
<span className="text-[0.95rem] font-semibold text-gray-900">
<span className="text-[0.95rem] font-bold text-brandblue">
{name}
</span>
<span className="opacity-70">{amount.toLocaleString()}</span>
<span className="text-gray-600 text-xs mt-1">
{amount.toLocaleString()}
</span>
</div>
</div>
);
}}
/>
{data.length > 0 && (
{slices.length > 0 && (
<div className="absolute text-center">
<span className="text-3xl font-semibold text-gray-800">
{data.reduce((a, b) => a + b.amount, 0)}
<span className="text-4xl font-bold text-brandblue">
{slices.reduce((a, b) => a + b.amount, 0)}
</span>
</div>
)}
</div>
{/* Legend (Clean Grid Layout) */}
<div className="mt-8 flex flex-wrap justify-center gap-x-6 gap-y-3 max-w-[90%]">
{data.map((item, idx) => (
<div
key={item.name}
onClick={() => handleClick(item)}
onMouseEnter={() => setHovered(item.name)}
<div className="mt-10 flex flex-wrap justify-center gap-x-6 gap-y-3 max-w-[95%] border-t border-brandblue/10 pt-8">
{slices.map((slice, idx) => (
<button
key={slice.name}
onClick={() => handleClick(slice)}
onMouseEnter={() => setHovered(slice.name)}
onMouseLeave={() => setHovered(null)}
className="relative flex items-center space-x-2 text-sm text-gray-700 hover:text-gray-900 cursor-pointer transition-colors"
className="relative flex items-center space-x-2 text-sm text-gray-700 hover:text-brandblue cursor-pointer transition-colors px-3 py-2 rounded-lg hover:bg-brandlightblue/20"
>
<span
className={`inline-block w-3.5 h-3.5 rounded-full bg-${colors[idx]} border border-gray-300 flex-shrink-0`}
className={`inline-block w-3 h-3 rounded-full bg-${colors[idx]} border-2 border-${colors[idx]}/40 flex-shrink-0 transition-all`}
/>
<span className="font-medium truncate max-w-[110px]">
{item.name}
<span className="font-medium truncate max-w-[100px]">
{slice.name}
</span>
<span className="text-xs text-gray-500 ml-1 whitespace-nowrap">
{item.percentage}%
<span className="text-xs text-gray-500 ml-1 whitespace-nowrap font-semibold">
{slice.percentage}%
</span>
{/* Tooltip on hover */}
{hovered === item.name && (
{hovered === slice.name && (
<div
className="absolute -top-11 left-1/2 -translate-x-1/2 bg-white/80 backdrop-blur-md
px-4 py-2.5 rounded-lg shadow-md border border-gray-200 text-gray-800
className="absolute -top-12 left-1/2 -translate-x-1/2 bg-white/95 backdrop-blur-md
px-4 py-3 rounded-lg shadow-lg border border-brandblue/20 text-gray-800
text-sm font-medium whitespace-nowrap z-20"
>
<div className="flex flex-col items-center">
<span className="text-[0.95rem] font-semibold text-gray-900">
{item.name}
<span className="text-[0.95rem] font-bold text-brandblue">
{slice.name}
</span>
<span className="text-gray-600 text-xs mt-1">
{slice.amount.toLocaleString()}
</span>
<span className="opacity-70">{item.amount.toLocaleString()}</span>
</div>
</div>
)}
</div>
</button>
))}
</div>
</Card>

View file

@ -1,150 +0,0 @@
"use client";
import { useState, useMemo } from "react";
import { Download } from "lucide-react";
interface TableViewerProps {
data: Record<string, any>[];
columns?: string[];
columnLabels?: Record<string, string>;
}
export default function TableViewer({
data,
columns,
columnLabels,
}: TableViewerProps) {
const [searchTerms, setSearchTerms] = useState<Record<string, string>>({});
const visibleColumns = columns?.length
? columns
: Object.keys(data?.[0] || {});
const filteredData = useMemo(() => {
return data.filter((row) =>
visibleColumns.every((col) => {
const term = searchTerms[col]?.toLowerCase() || "";
if (!term) return true;
const value = String(row[col] ?? "").toLowerCase();
return value.includes(term);
})
);
}, [data, searchTerms, visibleColumns]);
const renderCellContent = (col: string, 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-1 px-2 py-1 bg-blue-50 text-blue-600 text-xs rounded hover:bg-blue-100 transition"
>
<Download className="w-3 h-3" />
<span>Download Photos</span>
</button>
))}
</div>
);
}
return String(value ?? "");
};
return (
<div className="overflow-x-auto border rounded-xl shadow-lg bg-white">
<table className="min-w-full text-sm border-collapse">
<thead className="bg-gray-100 sticky top-0">
<tr>
{visibleColumns.map((col) => (
<th
key={col}
className="border-b p-3 text-left text-gray-700 font-semibold"
>
<div className="flex flex-col gap-1">
<span>{columnLabels?.[col] || col}</span>
<input
type="text"
placeholder="Search..."
className="p-1 border border-gray-300 rounded text-xs focus:ring-1 focus:ring-blue-400 outline-none"
onChange={(e) =>
setSearchTerms((prev) => ({
...prev,
[col]: e.target.value,
}))
}
/>
</div>
</th>
))}
</tr>
</thead>
<tbody>
{filteredData.length === 0 ? (
<tr>
<td
colSpan={visibleColumns.length}
className="text-center py-6 text-gray-400"
>
No results found
</td>
</tr>
) : (
filteredData.map((row, i) => (
<tr
key={i}
className="odd:bg-white even:bg-gray-50 hover:bg-blue-50 transition"
>
{visibleColumns.map((col) => (
<td key={col} className="border-b p-3 text-gray-700">
{renderCellContent(col, row[col])}
</td>
))}
</tr>
))
)}
</tbody>
</table>
</div>
);
}

View file

@ -1,11 +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 LiveTracker from "./Report";
import { eq, inArray } from "drizzle-orm";
import LiveTracker from "./LiveTracker";
import { computeLiveTrackerData } from "./transforms";
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 }>;
@ -14,58 +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;
}
}
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 deals={deals} />
{pageHeader}
<LiveTracker {...trackerData} docStatusMap={docStatusMap} />
</div>
);
}

View file

@ -0,0 +1,419 @@
/**
* Live Tracking Feature - Pure Data Transformation Functions
* No React, no hooks, no side effects. All business logic lives here.
*/
import type {
HubspotDeal,
ClassifiedDeal,
DisplayStage,
ProjectProgressData,
ProjectData,
OutcomeSlice,
LiveTrackerProps,
WorkPhaseStats,
DampMouldRiskData,
FunnelStage,
} from "./types";
import {
STAGE_ORDER,
SURVEYOR_OUTCOMES,
MAJOR_CONDITION_STAGE_ID,
} from "./types";
// -----------------------------------------------------------------------
// Stage ID -> raw label mapping
// -----------------------------------------------------------------------
const STAGE_ID_MAP: Record<string, string> = {
"1617223910": "Scope & Planning", //[Ops] Backlog
"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
"1984184569": "Booking in Progress", // [Bookings] Phone booking
"3569572028": "Booking in Progress", // [Bookings] Preferences received from Tenant
"3570936026": "Booking in Progress", // [Bookings] Send Confirmation Comms
"3680650446": "Booking in Progress", //[Bookings] Manual Confirmation Comms Required
"2663668937": "Queries", //[Bookings/Sales] Booking issues - needs HA support
"1984401629": "Queries", //[Bookings/Ops/Sales] No Contact Details - Ready for Route
"1617223912": "Assessment in Progress", //[Ops] Ready for Assignment to Route
"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
"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
"1617223916": "Queries", // [Ops] Properties to Review Manually
"2628341989": "Assessment in Progress", //[Ops] Assessment needs correction
"3441170637": "AFTER_ASSESSMENT", //[Ops] Awaiting PV Design
"2628233422": "AFTER_ASSESSMENT", //[Coordination] Ready for coordination
"1887735999": "AFTER_ASSESSMENT", //[Ops] Needs HA Works
"1960060104": "Queries", //[Ops] HA Informed
"1960060105": "Queries", //[Ops] HA Works Scheduled
"1960060106": "AFTER_ASSESSMENT", //[Ops] HA Works Complete
"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
"1668803774": "AFTER_ASSESSMENT", //[Ops] post-ERF / completed coordination stage
};
// -----------------------------------------------------------------------
// After-assessment sub-classification
// Resolves AFTER_ASSESSMENT deals based on coordinationStatus + designStatus
// -----------------------------------------------------------------------
function resolveAfterAssessmentStage(
coordinationStatus: string | null,
designStatus: string | null
): "Coordination in Progress" | "Design in Progress" | "POST_DESIGN" | "Queries" {
const coord = coordinationStatus?.toUpperCase() ?? "";
const design = designStatus?.toUpperCase() ?? "";
// RA ISSUE always -> Queries
if (coord === "RA ISSUE") return "Queries";
// V1/V2/V3 IOE/MTP COMPLETE pattern
if (
coord.includes("(V1) IOE/MTP COMPLETE") ||
coord.includes("(V2) IOE/MTP COMPLETE") ||
coord.includes("(V3) IOE/MTP COMPLETE")
) {
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 + coordination/design/install status -> DisplayStage
// -----------------------------------------------------------------------
export function resolveDisplayStage(deal: HubspotDeal): DisplayStage {
const raw = STAGE_ID_MAP[deal.dealstage ?? ""] ?? "AFTER_ASSESSMENT";
if (raw === "AFTER_ASSESSMENT") {
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
if (raw === "Scope & Planning" || raw === "Assessment in Progress") {
if (deal.coordinationStatus?.toUpperCase() === "RA ISSUE") {
return "Queries";
}
}
return raw as DisplayStage;
}
// -----------------------------------------------------------------------
// Classify all deals in a list
// Adds displayStage to each deal
// -----------------------------------------------------------------------
export function classifyDeals(deals: HubspotDeal[]): ClassifiedDeal[] {
return deals.map((deal) => ({
...deal,
displayStage: resolveDisplayStage(deal),
}));
}
// -----------------------------------------------------------------------
// 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
// -----------------------------------------------------------------------
export function computeProjectProgress(
deals: ClassifiedDeal[]
): ProjectProgressData {
const queriesDeals = deals.filter((d) => d.displayStage === "Queries");
const nonQueryDeals = deals.filter((d) => d.displayStage !== "Queries");
const nonQueryTotal = nonQueryDeals.length;
// Stage counts/percentages (queries excluded from percentage calculation)
const stageBuckets: Record<string, ClassifiedDeal[]> = {};
for (const deal of nonQueryDeals) {
(stageBuckets[deal.displayStage] ??= []).push(deal);
}
const stageProgress = STAGE_ORDER.filter((s) => s !== "Queries").map(
(stage) => {
const stageDeals = stageBuckets[stage] ?? [];
return {
stage,
count: stageDeals.length,
percentage:
nonQueryTotal > 0 ? (stageDeals.length / nonQueryTotal) * 100 : 0,
deals: stageDeals,
};
}
);
const completedDeals = stageBuckets["Project Complete"] ?? [];
const completedCount = completedDeals.length;
const completedPercentage =
nonQueryTotal > 0 ? (completedCount / nonQueryTotal) * 100 : 0;
const totalDeals = deals.length;
// Coordination phase:
// 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) =>
[
"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"
);
const coordination: WorkPhaseStats = {
completedDeals: coordCompletedDeals,
inProgressDeals: coordInProgressDeals,
completedCount: coordCompletedDeals.length,
inProgressCount: coordInProgressDeals.length,
completedPercentage:
totalDeals > 0 ? (coordCompletedDeals.length / totalDeals) * 100 : 0,
inProgressPercentage:
totalDeals > 0 ? (coordInProgressDeals.length / totalDeals) * 100 : 0,
total: totalDeals,
};
// Design phase:
// 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: designCompletedDeals,
inProgressDeals: designInProgressDeals,
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
? (lodgementInProgressDeals.length / totalDeals) * 100
: 0,
total: totalDeals,
};
return {
stageProgress,
queriesDeals,
completedDeals,
completedCount,
completedPercentage,
nonQueryTotal,
totalDeals,
coordination,
design,
install,
lodgement,
dampMouldRisk: computeDampMouldRisk(deals),
funnelStages: computeFunnelStages(deals),
};
}
// -----------------------------------------------------------------------
// Compute outcome pie slices for the surveyed pie chart
// -----------------------------------------------------------------------
export function computeOutcomeSlices(deals: ClassifiedDeal[]): OutcomeSlice[] {
const counts: Partial<Record<string, number>> = {};
for (const deal of deals) {
if (
deal.outcome &&
(SURVEYOR_OUTCOMES as readonly string[]).includes(deal.outcome)
) {
counts[deal.outcome] = (counts[deal.outcome] ?? 0) + 1;
}
}
const total = Object.values(counts).reduce<number>(
(a, b) => a + (b ?? 0),
0
);
return Object.entries(counts).map(([name, amount]) => ({
name,
amount: amount ?? 0,
percentage:
total > 0 ? (((amount ?? 0) / total) * 100).toFixed(1) : "0.0",
}));
}
// -----------------------------------------------------------------------
// Top-level function called by page.tsx
// Orchestrates all transformations: classify, group by project, compute stats
// -----------------------------------------------------------------------
export function computeLiveTrackerData(
rawDeals: HubspotDeal[]
): Omit<LiveTrackerProps, "docStatusMap"> {
// Classify all deals (add displayStage field)
const classified = classifyDeals(rawDeals);
// Filter for major condition deals (Awaab's Law)
const majorConditionDeals = classified.filter(
(d) => d.dealstage === MAJOR_CONDITION_STAGE_ID
);
// Group deals by projectCode
const grouped: Record<string, ClassifiedDeal[]> = {};
for (const deal of classified) {
const key = deal.projectCode ?? "Unknown Project";
(grouped[key] ??= []).push(deal);
}
// For each project group, compute progress data and outcome slices
const projects: ProjectData[] = Object.entries(grouped).map(
([projectCode, deals]) => ({
projectCode,
progress: computeProjectProgress(deals),
outcomePieSlices: computeOutcomeSlices(deals),
allDeals: deals,
})
);
// 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,
majorConditionDeals,
};
}

View file

@ -0,0 +1,339 @@
/**
* Live Tracking Feature - Type Definitions
* Single source of truth for all TypeScript interfaces and constants
*/
// -----------------------------------------------------------------------
// Raw DB row from hubspotDealData table
// New CRM-synced fields are nullable — populated by HubSpot sync
// -----------------------------------------------------------------------
export type HubspotDeal = {
id: string;
dealId: string;
dealname: string | null;
dealstage: string | null;
companyId: string | null;
projectCode: string | null;
landlordPropertyId: string | null;
uprn: string | null;
outcome: string | null;
outcomeNotes: string | null;
majorConditionIssueDescription: string | null;
majorConditionIssuePhotos: string | null;
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
// Full end-to-end pipeline: assessment → coordination → design →
// install → lodgement → completed (funded)
// -----------------------------------------------------------------------
export type DisplayStage =
| "Scope & Planning"
| "Booking in Progress"
| "Assessment in Progress"
| "Coordination in Progress"
| "Design in Progress"
| "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
// -----------------------------------------------------------------------
export type ClassifiedDeal = HubspotDeal & {
displayStage: DisplayStage;
};
// -----------------------------------------------------------------------
// One entry in the stage progress bar list
// -----------------------------------------------------------------------
export type StageProgressItem = {
stage: DisplayStage;
count: number;
percentage: number; // out of non-query total
deals: ClassifiedDeal[];
};
// -----------------------------------------------------------------------
// Coordination/Design/Install/Lodgement summary card data
// -----------------------------------------------------------------------
export type WorkPhaseStats = {
completedDeals: ClassifiedDeal[];
inProgressDeals: ClassifiedDeal[];
completedCount: number;
inProgressCount: number;
completedPercentage: number; // out of ALL deals in project
inProgressPercentage: number;
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
// -----------------------------------------------------------------------
export type ProjectProgressData = {
stageProgress: StageProgressItem[];
queriesDeals: ClassifiedDeal[];
completedDeals: ClassifiedDeal[];
completedCount: number;
completedPercentage: number; // out of non-query total
nonQueryTotal: number;
totalDeals: number;
coordination: WorkPhaseStats;
design: WorkPhaseStats;
install: WorkPhaseStats;
lodgement: WorkPhaseStats;
dampMouldRisk: DampMouldRiskData;
funnelStages: FunnelStage[];
};
// -----------------------------------------------------------------------
// Surveyed outcome entry (for pie chart)
// -----------------------------------------------------------------------
export type OutcomeSlice = {
name: string; // outcome label
amount: number;
percentage: string; // pre-formatted "12.3"
};
// -----------------------------------------------------------------------
// What LiveTracker receives from page.tsx for one project
// -----------------------------------------------------------------------
export type ProjectData = {
projectCode: string;
progress: ProjectProgressData;
outcomePieSlices: OutcomeSlice[]; // empty array = hide pie chart
allDeals: ClassifiedDeal[]; // for table drill-downs within project
};
// -----------------------------------------------------------------------
// Top-level props for LiveTracker (client root)
// -----------------------------------------------------------------------
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 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)
// -----------------------------------------------------------------------
export const SURVEYOR_OUTCOMES = [
"Surveyed",
"Surveyed - Pending Upload",
"Tenant Refusal",
"Other",
"Not Viable",
"Not Attempted",
"No Answer",
"Cancelled / No Show",
"Rescheduled",
] as const;
export type SurveyorOutcome = (typeof SURVEYOR_OUTCOMES)[number];
export const MAJOR_CONDITION_STAGE_ID = "3061261536" as const;
// Order of stages for grouping/display (queries excluded from this list)
export const STAGE_ORDER: DisplayStage[] = [
"Scope & Planning",
"Booking in Progress",
"Assessment in Progress",
"Coordination in Progress",
"Design in Progress",
"Installation in Progress",
"Installation Complete",
"At Lodgement",
"At Post Survey",
"Project Complete",
];
// -----------------------------------------------------------------------
// Stage colour mapping — used for badges (PropertyTable) and funnel bars (AnalyticsView)
// -----------------------------------------------------------------------
export const STAGE_COLORS: Record<
DisplayStage,
{ bg: string; text: string; border: string; dot: string }
> = {
"Scope & Planning": {
bg: "bg-slate-100",
text: "text-slate-700",
border: "border-slate-200",
dot: "bg-slate-400",
},
"Booking in Progress": {
bg: "bg-sky-50",
text: "text-sky-700",
border: "border-sky-200",
dot: "bg-sky-400",
},
"Assessment in Progress": {
bg: "bg-blue-100",
text: "text-blue-900",
border: "border-blue-400",
dot: "bg-blue-700",
},
"Coordination in Progress": {
bg: "bg-indigo-50",
text: "text-indigo-700",
border: "border-indigo-200",
dot: "bg-indigo-400",
},
"Design in Progress": {
bg: "bg-blue-50",
text: "text-blue-700",
border: "border-blue-200",
dot: "bg-blue-400",
},
"Installation in Progress": {
bg: "bg-indigo-50",
text: "text-indigo-600",
border: "border-indigo-200",
dot: "bg-indigo-300",
},
"Installation Complete": {
bg: "bg-teal-50",
text: "text-teal-700",
border: "border-teal-200",
dot: "bg-teal-400",
},
"At Lodgement": {
bg: "bg-cyan-50",
text: "text-cyan-700",
border: "border-cyan-200",
dot: "bg-cyan-400",
},
"At Post Survey": {
bg: "bg-violet-50",
text: "text-violet-700",
border: "border-violet-200",
dot: "bg-violet-400",
},
"Project Complete": {
bg: "bg-emerald-50",
text: "text-emerald-700",
border: "border-emerald-200",
dot: "bg-emerald-500",
},
Queries: {
bg: "bg-red-50",
text: "text-red-600",
border: "border-red-200",
dot: "bg-red-400",
},
"Unknown Stage": {
bg: "bg-gray-50",
text: "text-gray-500",
border: "border-gray-100",
dot: "bg-gray-300",
},
};

View file

@ -5,28 +5,32 @@ import { motion, AnimatePresence } from "framer-motion";
import Confetti from "react-confetti";
import { CheckCircle } from "lucide-react";
interface BookingSuccessToastProps {
interface SuccessToastProps {
show: boolean;
showConfetti: boolean;
onClose: () => void;
message?: string;
subtext?: string;
message: string;
subtext: string;
timeoutMs?: number;
}
export default function BookingSuccessToast({
export default function SuccessToast({
show,
showConfetti,
onClose,
message = "Booking Confirmed!",
subtext = "Youre all set. 🎉",
}: BookingSuccessToastProps) {
message,
subtext,
timeoutMs = 4000
}: SuccessToastProps) {
const [confetti, setConfetti] = useState(false);
useEffect(() => {
if (show) {
setConfetti(true);
setConfetti(showConfetti);
const timer = setTimeout(() => {
setConfetti(false);
onClose();
}, 4000);
}, timeoutMs);
return () => clearTimeout(timer);
}
}, [show, onClose]);

View file

@ -0,0 +1,129 @@
import { db } from "@/app/db/db";
import { verificationTokens } from "@/app/db/schema/users";
import { eq } from "drizzle-orm";
import crypto from "crypto";
import { Button } from "@/app/shadcn_components/ui/button";
import { Card } from "@/app/shadcn_components/ui/card";
import { ShieldCheck } from "lucide-react";
async function getEmailByToken(token: string) {
const secret = process.env.NEXTAUTH_SECRET!;
const hashedToken = crypto
.createHash("sha256")
.update(token + secret)
.digest("hex");
const record = await db
.select()
.from(verificationTokens)
.where(eq(verificationTokens.token, hashedToken))
.limit(1);
if (!record.length) return null;
return record[0].identifier;
}
export default async function VerifyPage({
params,
}: {
params: Promise<{ token: string }>;
}) {
const { token } = await params;
const email = await getEmailByToken(token);
return (
<div className="relative min-h-screen flex flex-col bg-gradient-to-b from-gray-50 to-white overflow-hidden">
{/* Soft background brand glow */}
<div className="absolute inset-0 pointer-events-none overflow-hidden">
<div className="absolute -top-24 -left-24 w-[28rem] h-[28rem] bg-brandblue/10 rounded-full blur-3xl" />
<div className="absolute bottom-0 right-0 w-[30rem] h-[30rem] bg-midblue/10 rounded-full blur-3xl" />
</div>
{/* Hero */}
<div className="relative bg-gradient-to-r from-brandblue to-midblue text-white py-16 px-8">
<div className="max-w-5xl mx-auto text-center">
<h1 className="text-4xl font-bold mb-4">Sign in to Ara</h1>
<p className="text-white/90 text-lg max-w-xl mx-auto">
Continue securely to access your retrofit planning tools and
property insights.
</p>
</div>
</div>
{/* Center content */}
<div className="relative flex-1 flex items-center justify-center px-6">
<div className="w-full max-w-md">
<Card className="p-10 shadow-xl border border-gray-100 backdrop-blur-sm text-center space-y-6">
{/* Security icon */}
<div className="flex justify-center">
<div className="bg-brandblue/10 p-3 rounded-full">
<ShieldCheck className="w-7 h-7 text-brandblue" />
</div>
</div>
{email ? (
<>
<h2 className="text-xl font-semibold text-brandblue">
Confirm sign-in
</h2>
<p className="text-sm text-gray-600 leading-relaxed">
Click below to securely sign in to your Ara account.
</p>
<form
action="/api/auth/callback/email"
method="GET"
className="pt-2"
>
<input type="hidden" name="token" value={token} />
<input type="hidden" name="email" value={email} />
<Button
type="submit"
className="bg-brandbrown hover:bg-hoverblue w-full text-base py-3"
>
Continue to Ara
</Button>
</form>
<p className="text-xs text-gray-400">
This link expires after one hour.
</p>
</>
) : (
<>
<h2 className="text-xl font-semibold text-red-500">
Link expired
</h2>
<p className="text-sm text-gray-600">
This login link has already been used or has expired.
</p>
<Button
asChild
className="bg-brandbrown hover:bg-hoverblue w-full text-base py-3"
>
<a href="/">Request new login link</a>
</Button>
</>
)}
</Card>
</div>
</div>
{/* Footer */}
<div className="pb-10 text-center text-xs text-gray-400 space-y-1">
<p>Secure authentication powered by Ara</p>
<p>© {new Date().getFullYear()} Domna Homes</p>
</div>
</div>
);
}

View file

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