mirror of
https://github.com/Hestia-Homes/assessment-model.git
synced 2026-06-08 11:37:25 +00:00
added missing files
This commit is contained in:
parent
ac30e3c13a
commit
f8e6d7afa2
10 changed files with 7489 additions and 0 deletions
24
src/app/api/organisations/route.ts
Normal file
24
src/app/api/organisations/route.ts
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { getServerSession } from "next-auth";
|
||||
import { AuthOptions } from "@/app/api/auth/[...nextauth]/authOptions";
|
||||
import { db } from "@/app/db/db";
|
||||
import { organisation } from "@/app/db/schema/organisation";
|
||||
import { asc } from "drizzle-orm";
|
||||
|
||||
export async function GET(_req: NextRequest) {
|
||||
const session = await getServerSession(AuthOptions);
|
||||
if (!session?.user?.email?.endsWith("@domna.homes")) {
|
||||
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
|
||||
}
|
||||
|
||||
const rows = await db
|
||||
.select({
|
||||
id: organisation.id,
|
||||
name: organisation.name,
|
||||
hubspotCompanyId: organisation.hubspotCompanyId,
|
||||
})
|
||||
.from(organisation)
|
||||
.orderBy(asc(organisation.name));
|
||||
|
||||
return NextResponse.json(rows);
|
||||
}
|
||||
94
src/app/api/portfolio/[portfolioId]/organisation/route.ts
Normal file
94
src/app/api/portfolio/[portfolioId]/organisation/route.ts
Normal file
|
|
@ -0,0 +1,94 @@
|
|||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { getServerSession } from "next-auth";
|
||||
import { AuthOptions } from "@/app/api/auth/[...nextauth]/authOptions";
|
||||
import { eq } from "drizzle-orm";
|
||||
import { db } from "@/app/db/db";
|
||||
import { portfolioOrganisation } from "@/app/db/schema/portfolio_organisation";
|
||||
import { organisation } from "@/app/db/schema/organisation";
|
||||
|
||||
function isDomnaUser(email: string | null | undefined): boolean {
|
||||
return !!email?.endsWith("@domna.homes");
|
||||
}
|
||||
|
||||
// GET — fetch the current linked organisation for this portfolio
|
||||
export async function GET(
|
||||
_req: NextRequest,
|
||||
{ params }: { params: Promise<{ portfolioId: string }> },
|
||||
) {
|
||||
const { portfolioId } = await params;
|
||||
|
||||
const rows = await db
|
||||
.select({
|
||||
id: organisation.id,
|
||||
name: organisation.name,
|
||||
hubspotCompanyId: organisation.hubspotCompanyId,
|
||||
})
|
||||
.from(portfolioOrganisation)
|
||||
.innerJoin(organisation, eq(portfolioOrganisation.organisationId, organisation.id))
|
||||
.where(eq(portfolioOrganisation.portfolioId, BigInt(portfolioId)))
|
||||
.limit(1);
|
||||
|
||||
return NextResponse.json(rows[0] ?? null);
|
||||
}
|
||||
|
||||
// POST — connect an organisation to this portfolio (Domna only)
|
||||
export async function POST(
|
||||
req: NextRequest,
|
||||
{ params }: { params: Promise<{ portfolioId: string }> },
|
||||
) {
|
||||
const session = await getServerSession(AuthOptions);
|
||||
if (!isDomnaUser(session?.user?.email)) {
|
||||
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
|
||||
}
|
||||
|
||||
const { portfolioId } = await params;
|
||||
const body = await req.json();
|
||||
const { organisationId } = body as { organisationId: string };
|
||||
|
||||
if (!organisationId) {
|
||||
return NextResponse.json({ error: "organisationId required" }, { status: 400 });
|
||||
}
|
||||
|
||||
// Upsert: delete any existing link then insert fresh
|
||||
await db
|
||||
.delete(portfolioOrganisation)
|
||||
.where(eq(portfolioOrganisation.portfolioId, BigInt(portfolioId)));
|
||||
|
||||
await db.insert(portfolioOrganisation).values({
|
||||
portfolioId: BigInt(portfolioId),
|
||||
organisationId,
|
||||
});
|
||||
|
||||
// Return the newly linked org
|
||||
const rows = await db
|
||||
.select({
|
||||
id: organisation.id,
|
||||
name: organisation.name,
|
||||
hubspotCompanyId: organisation.hubspotCompanyId,
|
||||
})
|
||||
.from(portfolioOrganisation)
|
||||
.innerJoin(organisation, eq(portfolioOrganisation.organisationId, organisation.id))
|
||||
.where(eq(portfolioOrganisation.portfolioId, BigInt(portfolioId)))
|
||||
.limit(1);
|
||||
|
||||
return NextResponse.json(rows[0] ?? null);
|
||||
}
|
||||
|
||||
// DELETE — disconnect the organisation from this portfolio (Domna only)
|
||||
export async function DELETE(
|
||||
_req: NextRequest,
|
||||
{ params }: { params: Promise<{ portfolioId: string }> },
|
||||
) {
|
||||
const session = await getServerSession(AuthOptions);
|
||||
if (!isDomnaUser(session?.user?.email)) {
|
||||
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
|
||||
}
|
||||
|
||||
const { portfolioId } = await params;
|
||||
|
||||
await db
|
||||
.delete(portfolioOrganisation)
|
||||
.where(eq(portfolioOrganisation.portfolioId, BigInt(portfolioId)));
|
||||
|
||||
return NextResponse.json({ success: true });
|
||||
}
|
||||
38
src/app/api/sign-document-url/route.ts
Normal file
38
src/app/api/sign-document-url/route.ts
Normal file
|
|
@ -0,0 +1,38 @@
|
|||
import { NextResponse } from "next/server";
|
||||
import { S3Client, GetObjectCommand } from "@aws-sdk/client-s3";
|
||||
import { getSignedUrl } from "@aws-sdk/s3-request-presigner";
|
||||
|
||||
const energyAssessmentsS3 = new S3Client({
|
||||
region: process.env.PRESIGN_AWS_REGION,
|
||||
credentials: {
|
||||
accessKeyId: process.env.RETROFIT_ENERGY_ASSESSMENTS_AWS_ACCESS_KEY!,
|
||||
secretAccessKey: process.env.ENERGY_ASSESSMENTS_AWS_SECRET!,
|
||||
},
|
||||
});
|
||||
|
||||
const retrofitDataS3 = new S3Client({
|
||||
region: process.env.RETROFIT_DATA_DEV_REGION,
|
||||
credentials: {
|
||||
accessKeyId: process.env.RETROFIT_DATA_DEV_ACCESS_KEY!,
|
||||
secretAccessKey: process.env.RETROFIT_DATA_DEV_SECRET_KEY!,
|
||||
},
|
||||
});
|
||||
|
||||
export async function POST(req: Request) {
|
||||
try {
|
||||
const { key, bucket } = await req.json();
|
||||
if (!key || !bucket)
|
||||
return NextResponse.json({ error: "Missing key or bucket" }, { status: 400 });
|
||||
|
||||
const isEnergyAssessments = bucket === process.env.RETROFIT_ENERGY_ASSESSMENTS_BUCKET;
|
||||
const s3Client = isEnergyAssessments ? energyAssessmentsS3 : retrofitDataS3;
|
||||
|
||||
const command = new GetObjectCommand({ Bucket: bucket, Key: key });
|
||||
const signedUrl = await getSignedUrl(s3Client, command, { expiresIn: 1800 });
|
||||
|
||||
return NextResponse.json({ url: signedUrl });
|
||||
} catch (error) {
|
||||
console.error("Error generating signed URL:", error);
|
||||
return NextResponse.json({ error: "Failed to sign URL" }, { status: 500 });
|
||||
}
|
||||
}
|
||||
11
src/app/db/migrations/0163_fat_mentallo.sql
Normal file
11
src/app/db/migrations/0163_fat_mentallo.sql
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
CREATE TABLE "portfolio_organisation" (
|
||||
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||
"portfolio_id" bigint NOT NULL,
|
||||
"organisation_id" uuid NOT NULL,
|
||||
"created_at" timestamp (6) with time zone DEFAULT now() NOT NULL,
|
||||
"updated_at" timestamp (6) with time zone DEFAULT now() NOT NULL,
|
||||
CONSTRAINT "portfolio_organisation_portfolio_id_unique" UNIQUE("portfolio_id")
|
||||
);
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE "portfolio_organisation" ADD CONSTRAINT "portfolio_organisation_portfolio_id_portfolio_id_fk" FOREIGN KEY ("portfolio_id") REFERENCES "public"."portfolio"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "portfolio_organisation" ADD CONSTRAINT "portfolio_organisation_organisation_id_organisation_id_fk" FOREIGN KEY ("organisation_id") REFERENCES "public"."organisation"("id") ON DELETE cascade ON UPDATE no action;
|
||||
6311
src/app/db/migrations/meta/0163_snapshot.json
Normal file
6311
src/app/db/migrations/meta/0163_snapshot.json
Normal file
File diff suppressed because it is too large
Load diff
24
src/app/db/schema/portfolio_organisation.ts
Normal file
24
src/app/db/schema/portfolio_organisation.ts
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
import { pgTable, bigint, uuid, timestamp } from "drizzle-orm/pg-core";
|
||||
import { portfolio } from "./portfolio";
|
||||
import { organisation } from "./organisation";
|
||||
import { InferModel } from "drizzle-orm";
|
||||
|
||||
export const portfolioOrganisation = pgTable("portfolio_organisation", {
|
||||
id: uuid("id").defaultRandom().primaryKey(),
|
||||
portfolioId: bigint("portfolio_id", { mode: "bigint" })
|
||||
.notNull()
|
||||
.references(() => portfolio.id, { onDelete: "cascade" })
|
||||
.unique(), // one organisation per portfolio
|
||||
organisationId: uuid("organisation_id")
|
||||
.notNull()
|
||||
.references(() => organisation.id, { onDelete: "cascade" }),
|
||||
createdAt: timestamp("created_at", { precision: 6, withTimezone: true })
|
||||
.defaultNow()
|
||||
.notNull(),
|
||||
updatedAt: timestamp("updated_at", { precision: 6, withTimezone: true })
|
||||
.defaultNow()
|
||||
.notNull(),
|
||||
});
|
||||
|
||||
export type PortfolioOrganisation = InferModel<typeof portfolioOrganisation, "select">;
|
||||
export type NewPortfolioOrganisation = InferModel<typeof portfolioOrganisation, "insert">;
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,289 @@
|
|||
"use client";
|
||||
|
||||
import { useMemo, useState } from "react";
|
||||
import {
|
||||
useReactTable,
|
||||
getCoreRowModel,
|
||||
getFilteredRowModel,
|
||||
getSortedRowModel,
|
||||
getPaginationRowModel,
|
||||
flexRender,
|
||||
type SortingState,
|
||||
type PaginationState,
|
||||
} from "@tanstack/react-table";
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "@/app/shadcn_components/ui/table";
|
||||
import { Input } from "@/app/shadcn_components/ui/input";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
} from "@/app/shadcn_components/ui/select";
|
||||
import { Search, ChevronLeft, ChevronRight, Download } from "lucide-react";
|
||||
import { createDocumentTableColumns } from "./DocumentTableColumns";
|
||||
import type { ClassifiedDeal, DocStatusMap } from "./types";
|
||||
|
||||
type SurveyStatusFilter = "all" | "none" | "partial" | "complete";
|
||||
|
||||
interface DocumentTableProps {
|
||||
data: ClassifiedDeal[];
|
||||
onOpenDrawer: (uprn: string | null, landlordPropertyId: string | null, dealname: string | null) => void;
|
||||
docStatusMap: DocStatusMap;
|
||||
}
|
||||
|
||||
function escapeCell(value: unknown): string {
|
||||
if (value === null || value === undefined) return "";
|
||||
const str =
|
||||
value instanceof Date
|
||||
? value.toLocaleDateString("en-GB")
|
||||
: String(value);
|
||||
return str.includes(",") || str.includes('"') || str.includes("\n")
|
||||
? `"${str.replace(/"/g, '""')}"`
|
||||
: str;
|
||||
}
|
||||
|
||||
export default function DocumentTable({ data, onOpenDrawer, docStatusMap }: DocumentTableProps) {
|
||||
const [globalFilter, setGlobalFilter] = useState("");
|
||||
const [surveyStatusFilter, setSurveyStatusFilter] = useState<SurveyStatusFilter>("all");
|
||||
const [sorting, setSorting] = useState<SortingState>([]);
|
||||
const [pagination, setPagination] = useState<PaginationState>({
|
||||
pageIndex: 0,
|
||||
pageSize: 25,
|
||||
});
|
||||
|
||||
const filteredData = useMemo(() => {
|
||||
if (surveyStatusFilter === "all") return data;
|
||||
return data.filter((d) => {
|
||||
const status = d.uprn ? docStatusMap[d.uprn] : undefined;
|
||||
if (surveyStatusFilter === "none") return !status || !status.hasDocs;
|
||||
if (surveyStatusFilter === "partial") return !!status?.hasDocs && !status.isComplete;
|
||||
if (surveyStatusFilter === "complete") return !!status?.isComplete;
|
||||
return true;
|
||||
});
|
||||
}, [data, surveyStatusFilter, docStatusMap]);
|
||||
|
||||
const columns = useMemo(
|
||||
() => createDocumentTableColumns(onOpenDrawer, docStatusMap),
|
||||
[onOpenDrawer, docStatusMap],
|
||||
);
|
||||
|
||||
const table = useReactTable({
|
||||
data: filteredData,
|
||||
columns,
|
||||
state: { globalFilter, sorting, pagination },
|
||||
onGlobalFilterChange: setGlobalFilter,
|
||||
onSortingChange: setSorting,
|
||||
onPaginationChange: setPagination,
|
||||
getCoreRowModel: getCoreRowModel(),
|
||||
getFilteredRowModel: getFilteredRowModel(),
|
||||
getSortedRowModel: getSortedRowModel(),
|
||||
getPaginationRowModel: getPaginationRowModel(),
|
||||
globalFilterFn: "includesString",
|
||||
});
|
||||
|
||||
const downloadCsv = () => {
|
||||
const rows = table.getFilteredRowModel().rows;
|
||||
const header = "Address,Landlord ID,Survey Status";
|
||||
const body = rows
|
||||
.map((row) => {
|
||||
const status = row.original.uprn ? docStatusMap[row.original.uprn] : undefined;
|
||||
const surveyStatus = status?.isComplete
|
||||
? "Complete"
|
||||
: status?.hasDocs
|
||||
? "Partial"
|
||||
: "No Docs";
|
||||
return [
|
||||
escapeCell(row.original.dealname),
|
||||
escapeCell(row.original.landlordPropertyId),
|
||||
surveyStatus,
|
||||
].join(",");
|
||||
})
|
||||
.join("\n");
|
||||
const blob = new Blob([header + "\n" + body], { type: "text/csv;charset=utf-8;" });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement("a");
|
||||
a.href = url;
|
||||
a.download = "document-management.csv";
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
};
|
||||
|
||||
const pageCount = table.getPageCount();
|
||||
const currentPage = table.getState().pagination.pageIndex + 1;
|
||||
const totalFiltered = table.getFilteredRowModel().rows.length;
|
||||
|
||||
const surveyStatusLabel: Record<SurveyStatusFilter, string> = {
|
||||
all: "All statuses",
|
||||
none: "No Survey Docs",
|
||||
partial: "Partial Survey Docs",
|
||||
complete: "Complete Survey Docs",
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
{/* Toolbar */}
|
||||
<div className="flex flex-col sm:flex-row gap-3 items-start sm:items-center">
|
||||
{/* Search */}
|
||||
<div className="relative flex-1 min-w-0">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-gray-400 pointer-events-none" />
|
||||
<Input
|
||||
value={globalFilter}
|
||||
onChange={(e) => {
|
||||
setGlobalFilter(e.target.value);
|
||||
setPagination((p) => ({ ...p, pageIndex: 0 }));
|
||||
}}
|
||||
placeholder="Search address, landlord ID…"
|
||||
className="pl-9 h-9 text-sm border-gray-200 focus:border-brandblue/40 focus:ring-brandblue/20"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Survey status filter */}
|
||||
<Select
|
||||
value={surveyStatusFilter}
|
||||
onValueChange={(v) => {
|
||||
setSurveyStatusFilter(v as SurveyStatusFilter);
|
||||
setPagination((p) => ({ ...p, pageIndex: 0 }));
|
||||
}}
|
||||
>
|
||||
<SelectTrigger className="h-9 w-[200px] text-sm border-gray-200 shrink-0">
|
||||
{surveyStatusLabel[surveyStatusFilter]}
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">All statuses</SelectItem>
|
||||
<SelectItem value="none">No Survey Docs</SelectItem>
|
||||
<SelectItem value="partial">Partial Survey Docs</SelectItem>
|
||||
<SelectItem value="complete">Complete Survey Docs</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
{/* Download CSV */}
|
||||
<button
|
||||
onClick={downloadCsv}
|
||||
className="inline-flex items-center gap-2 h-9 px-3 rounded-lg border border-gray-200 bg-white text-sm font-medium text-gray-600 hover:border-brandblue/30 hover:text-brandblue transition-colors shrink-0"
|
||||
>
|
||||
<Download className="h-3.5 w-3.5" />
|
||||
CSV
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Result count */}
|
||||
<p className="text-xs text-gray-400">
|
||||
Showing{" "}
|
||||
<span className="font-semibold text-gray-600">
|
||||
{Math.min(
|
||||
table.getState().pagination.pageSize,
|
||||
totalFiltered - table.getState().pagination.pageIndex * table.getState().pagination.pageSize,
|
||||
)}
|
||||
</span>{" "}
|
||||
of{" "}
|
||||
<span className="font-semibold text-gray-600">{totalFiltered}</span>{" "}
|
||||
{surveyStatusFilter !== "all" ? `(${surveyStatusLabel[surveyStatusFilter].toLowerCase()}) ` : ""}
|
||||
propert{totalFiltered === 1 ? "y" : "ies"}
|
||||
</p>
|
||||
|
||||
{/* Table */}
|
||||
<div className="rounded-xl border border-gray-200 overflow-hidden shadow-sm">
|
||||
<div className="overflow-x-auto">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
{table.getHeaderGroups().map((headerGroup) => (
|
||||
<TableRow
|
||||
key={headerGroup.id}
|
||||
className="bg-gray-50/80 hover:bg-gray-50/80 border-b border-gray-200"
|
||||
>
|
||||
{headerGroup.headers.map((header) => (
|
||||
<TableHead key={header.id} className="h-10 px-4 first:pl-5 last:pr-5">
|
||||
{header.isPlaceholder
|
||||
? null
|
||||
: flexRender(header.column.columnDef.header, header.getContext())}
|
||||
</TableHead>
|
||||
))}
|
||||
</TableRow>
|
||||
))}
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{table.getRowModel().rows.length ? (
|
||||
table.getRowModel().rows.map((row, i) => (
|
||||
<TableRow
|
||||
key={row.id}
|
||||
className={`border-b border-gray-100 transition-colors hover:bg-brandlightblue/10 ${
|
||||
i % 2 === 0 ? "bg-white" : "bg-gray-50/30"
|
||||
}`}
|
||||
>
|
||||
{row.getVisibleCells().map((cell) => (
|
||||
<TableCell key={cell.id} className="py-3 px-4 first:pl-5 last:pr-5">
|
||||
{flexRender(cell.column.columnDef.cell, cell.getContext())}
|
||||
</TableCell>
|
||||
))}
|
||||
</TableRow>
|
||||
))
|
||||
) : (
|
||||
<TableRow>
|
||||
<TableCell
|
||||
colSpan={columns.length}
|
||||
className="h-32 text-center text-sm text-gray-400"
|
||||
>
|
||||
No properties match the current filters.
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Pagination */}
|
||||
{pageCount > 1 && (
|
||||
<div className="flex items-center justify-between pt-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-xs text-gray-500">Rows per page:</span>
|
||||
<Select
|
||||
value={String(table.getState().pagination.pageSize)}
|
||||
onValueChange={(v) => table.setPageSize(Number(v))}
|
||||
>
|
||||
<SelectTrigger className="h-7 w-16 text-xs border-gray-200">
|
||||
{table.getState().pagination.pageSize}
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{[10, 25, 50, 100].map((n) => (
|
||||
<SelectItem key={n} value={String(n)} className="text-xs">
|
||||
{n}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="text-xs text-gray-500">
|
||||
Page {currentPage} of {pageCount}
|
||||
</span>
|
||||
<div className="flex gap-1">
|
||||
<button
|
||||
onClick={() => table.previousPage()}
|
||||
disabled={!table.getCanPreviousPage()}
|
||||
className="h-7 w-7 flex items-center justify-center rounded-lg border border-gray-200 text-gray-500 hover:border-brandblue/30 hover:text-brandblue disabled:opacity-30 disabled:cursor-not-allowed transition-colors"
|
||||
>
|
||||
<ChevronLeft className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => table.nextPage()}
|
||||
disabled={!table.getCanNextPage()}
|
||||
className="h-7 w-7 flex items-center justify-center rounded-lg border border-gray-200 text-gray-500 hover:border-brandblue/30 hover:text-brandblue disabled:opacity-30 disabled:cursor-not-allowed transition-colors"
|
||||
>
|
||||
<ChevronRight className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,147 @@
|
|||
"use client";
|
||||
|
||||
import { ColumnDef } from "@tanstack/react-table";
|
||||
import { ArrowUpDown, CheckCircle2, AlertCircle, FileX } from "lucide-react";
|
||||
import type { ClassifiedDeal, DocStatusMap, DocStatus } from "./types";
|
||||
|
||||
function SortableHeader({
|
||||
label,
|
||||
column,
|
||||
}: {
|
||||
label: string;
|
||||
column: { toggleSorting: (desc: boolean) => void; getIsSorted: () => false | "asc" | "desc" };
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
className="flex items-center gap-1 text-xs font-semibold uppercase tracking-wide text-gray-500 hover:text-brandblue transition-colors group"
|
||||
onClick={() => column.toggleSorting(column.getIsSorted() === "asc")}
|
||||
>
|
||||
{label}
|
||||
<ArrowUpDown className="h-3 w-3 opacity-40 group-hover:opacity-70 transition-opacity" />
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
function SurveyStatusBadge({ status }: { status: DocStatus | undefined }) {
|
||||
if (status?.isComplete) {
|
||||
return (
|
||||
<span className="inline-flex items-center gap-1.5 px-2.5 py-1 rounded-full text-xs font-medium border whitespace-nowrap bg-emerald-50 text-emerald-700 border-emerald-200">
|
||||
<CheckCircle2 className="h-3.5 w-3.5" />
|
||||
Complete
|
||||
</span>
|
||||
);
|
||||
}
|
||||
if (status?.hasDocs) {
|
||||
return (
|
||||
<span className="inline-flex items-center gap-1.5 px-2.5 py-1 rounded-full text-xs font-medium border whitespace-nowrap bg-amber-50 text-amber-700 border-amber-200">
|
||||
<AlertCircle className="h-3.5 w-3.5" />
|
||||
Partial
|
||||
</span>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<span className="inline-flex items-center gap-1.5 px-2.5 py-1 rounded-full text-xs font-medium border whitespace-nowrap bg-gray-50 text-gray-400 border-gray-200">
|
||||
<FileX className="h-3.5 w-3.5" />
|
||||
No Docs
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
export function createDocumentTableColumns(
|
||||
onOpenDrawer: (uprn: string | null, landlordPropertyId: string | null, dealname: string | null) => void,
|
||||
docStatusMap: DocStatusMap = {},
|
||||
): ColumnDef<ClassifiedDeal>[] {
|
||||
return [
|
||||
// ── Address ──────────────────────────────────────────────────────────
|
||||
{
|
||||
accessorKey: "dealname",
|
||||
id: "dealname",
|
||||
header: ({ column }) => <SortableHeader label="Address" column={column as any} />,
|
||||
cell: ({ row }) => (
|
||||
<div className="max-w-[260px]">
|
||||
<p className="text-sm font-medium text-gray-900 leading-tight truncate">
|
||||
{row.original.dealname ?? "—"}
|
||||
</p>
|
||||
</div>
|
||||
),
|
||||
enableHiding: false,
|
||||
},
|
||||
|
||||
// ── Landlord ID ──────────────────────────────────────────────────────
|
||||
{
|
||||
accessorKey: "landlordPropertyId",
|
||||
id: "landlordPropertyId",
|
||||
header: ({ column }) => <SortableHeader label="Landlord ID" column={column as any} />,
|
||||
cell: ({ row }) => (
|
||||
<span className="text-xs font-mono text-gray-500">
|
||||
{row.original.landlordPropertyId ?? "—"}
|
||||
</span>
|
||||
),
|
||||
enableHiding: false,
|
||||
},
|
||||
|
||||
// ── Survey Status ─────────────────────────────────────────────────────
|
||||
{
|
||||
id: "surveyStatus",
|
||||
accessorFn: (row) => {
|
||||
const status = row.uprn ? docStatusMap[row.uprn] : undefined;
|
||||
if (status?.isComplete) return 2;
|
||||
if (status?.hasDocs) return 1;
|
||||
return 0;
|
||||
},
|
||||
header: ({ column }) => <SortableHeader label="Survey Status" column={column as any} />,
|
||||
cell: ({ row }) => {
|
||||
const status = row.original.uprn ? docStatusMap[row.original.uprn] : undefined;
|
||||
return <SurveyStatusBadge status={status} />;
|
||||
},
|
||||
enableHiding: false,
|
||||
},
|
||||
|
||||
// ── Documents button ─────────────────────────────────────────────────
|
||||
{
|
||||
id: "documents",
|
||||
header: () => (
|
||||
<span className="text-xs font-semibold uppercase tracking-wide text-gray-500">Docs</span>
|
||||
),
|
||||
cell: ({ row }) => {
|
||||
const uprn = row.original.uprn ?? "";
|
||||
const status = uprn ? docStatusMap[uprn] : undefined;
|
||||
|
||||
let icon: React.ReactNode;
|
||||
let className: string;
|
||||
|
||||
if (status?.isComplete) {
|
||||
icon = <CheckCircle2 className="h-3.5 w-3.5" />;
|
||||
className =
|
||||
"inline-flex items-center gap-1.5 px-2.5 py-1.5 rounded-lg text-xs font-medium border border-emerald-200 text-emerald-700 bg-emerald-50 hover:bg-emerald-100 hover:border-emerald-300 transition-all duration-150 whitespace-nowrap";
|
||||
} else if (status?.hasDocs) {
|
||||
icon = <AlertCircle className="h-3.5 w-3.5" />;
|
||||
className =
|
||||
"inline-flex items-center gap-1.5 px-2.5 py-1.5 rounded-lg text-xs font-medium border border-amber-200 text-amber-700 bg-amber-50 hover:bg-amber-100 hover:border-amber-300 transition-all duration-150 whitespace-nowrap";
|
||||
} else {
|
||||
icon = <FileX className="h-3.5 w-3.5" />;
|
||||
className =
|
||||
"inline-flex items-center gap-1.5 px-2.5 py-1.5 rounded-lg text-xs font-medium border border-gray-200 text-gray-400 bg-gray-50 hover:bg-gray-100 hover:border-gray-300 transition-all duration-150 whitespace-nowrap";
|
||||
}
|
||||
|
||||
return (
|
||||
<button
|
||||
onClick={() =>
|
||||
onOpenDrawer(
|
||||
row.original.uprn,
|
||||
row.original.landlordPropertyId,
|
||||
row.original.dealname,
|
||||
)
|
||||
}
|
||||
className={className}
|
||||
>
|
||||
{icon}
|
||||
Docs
|
||||
</button>
|
||||
);
|
||||
},
|
||||
enableSorting: false,
|
||||
enableHiding: false,
|
||||
},
|
||||
];
|
||||
}
|
||||
|
|
@ -0,0 +1,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>
|
||||
);
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue