Merge branch 'main' into feature/uploaded-files-table

This commit is contained in:
Daniel Roth 2026-04-07 16:03:36 +00:00
commit 2f8ac99c4d
56 changed files with 43527 additions and 1425 deletions

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -0,0 +1,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

View file

@ -1114,6 +1114,48 @@
"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

@ -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,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

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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