diff --git a/src/app/api/live-tracking/property-documents/route.ts b/src/app/api/live-tracking/property-documents/route.ts index 9ebf596..912dba8 100644 --- a/src/app/api/live-tracking/property-documents/route.ts +++ b/src/app/api/live-tracking/property-documents/route.ts @@ -1,5 +1,5 @@ import { NextResponse } from "next/server"; -import { eq, or } from "drizzle-orm"; +import { eq } from "drizzle-orm"; import { db } from "@/app/db/db"; import { uploadedFiles } from "@/app/db/schema/uploaded_files"; @@ -16,23 +16,24 @@ export async function GET(req: Request) { } try { - const conditions = []; - - if (uprnParam) { - const uprnBigInt = BigInt(uprnParam); - conditions.push(eq(uploadedFiles.uprn, uprnBigInt)); - } - - if (landlordPropertyIdParam) { - conditions.push( - eq(uploadedFiles.landlordPropertyId, landlordPropertyIdParam), - ); - } + // 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() + .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(conditions.length === 1 ? conditions[0] : or(...conditions)); + .where(condition); const documents = rows.map((row) => ({ id: String(row.id), diff --git a/src/app/portfolio/[slug]/(portfolio)/your-projects/live/AnalyticsView.tsx b/src/app/portfolio/[slug]/(portfolio)/your-projects/live/AnalyticsView.tsx index 8235469..5aea535 100644 --- a/src/app/portfolio/[slug]/(portfolio)/your-projects/live/AnalyticsView.tsx +++ b/src/app/portfolio/[slug]/(portfolio)/your-projects/live/AnalyticsView.tsx @@ -14,6 +14,7 @@ import type { ClassifiedDeal, TableModal, FunnelStage, + DisplayStage, } from "./types"; // ----------------------------------------------------------------------- @@ -117,8 +118,9 @@ function PipelineFunnel({ }) { const [mode, setMode] = useState<"current" | "cumulative">("cumulative"); + const ALWAYS_VISIBLE: DisplayStage[] = ["At Lodgement", "Project Complete"]; const visibleStages = funnelStages.filter( - (s) => s.currentCount > 0 || s.cumulativeCount > 0, + (s) => s.currentCount > 0 || s.cumulativeCount > 0 || ALWAYS_VISIBLE.includes(s.stage), ); const maxCount = Math.max( @@ -337,8 +339,9 @@ export default function AnalyticsView({ {/* Row 1.5: Completion trends chart */} {/* Row 2: section header */} diff --git a/src/app/portfolio/[slug]/(portfolio)/your-projects/live/CompletionTrendsChart.tsx b/src/app/portfolio/[slug]/(portfolio)/your-projects/live/CompletionTrendsChart.tsx index a3eb771..9f92ec7 100644 --- a/src/app/portfolio/[slug]/(portfolio)/your-projects/live/CompletionTrendsChart.tsx +++ b/src/app/portfolio/[slug]/(portfolio)/your-projects/live/CompletionTrendsChart.tsx @@ -1,6 +1,7 @@ "use client"; import { useState } from "react"; +import { AlertCircle } from "lucide-react"; import { Card, Title, BarChart, Legend } from "@tremor/react"; import { Button } from "@/app/shadcn_components/ui/button"; import { Input } from "@/app/shadcn_components/ui/input"; @@ -16,6 +17,12 @@ interface CompletionTrendsChartProps { deals: ClassifiedDeal[]; isDomnaUser?: boolean; projectCode?: string; + onOpenTable?: ( + stage: string, + deals: ClassifiedDeal[], + columns?: (keyof ClassifiedDeal)[], + columnLabels?: Partial>, + ) => void; } const METRICS = [ @@ -290,6 +297,7 @@ export default function CompletionTrendsChart({ deals, isDomnaUser, projectCode, + onOpenTable, }: CompletionTrendsChartProps) { const [metric, setMetric] = useState(METRICS[0].key); const [targets, setTargets] = useState<{ [week: string]: number }>({}); @@ -305,6 +313,17 @@ export default function CompletionTrendsChart({ const isDesign = metric === "design"; const isStacked = isCoordination || isAssessments || isLodgement || isDesign; + // Assessments from external surveyors (no surveyedDate recorded) + const undatedAssessments = isAssessments + ? deals.filter((d) => { + const o = d.outcome ?? ""; + return ( + (o === "Surveyed" || o === "Surveyed - Pending Upload") && + !d.surveyedDate + ); + }) + : []; + // Compute chart data, categories, and colours in one place let chartData: Record[]; let categories: string[]; @@ -417,6 +436,36 @@ export default function CompletionTrendsChart({ stack={isStacked} customTooltip={ChartTooltip} /> + {isAssessments && undatedAssessments.length > 0 && ( +
+
+ + + {undatedAssessments.length}{" "} + assessment{undatedAssessments.length !== 1 ? "s" : ""} from external surveyors have no date recorded + +
+ {onOpenTable && ( + + )} +
+ )} {isStacked && ( !!d.dampMouldFlag); - const noRisk = risk.surveyFlagCount === 0 && risk.coordinatorFlagCount === 0; @@ -166,14 +164,14 @@ export default function DampMouldRiskPanel({ ) : ( <> -
+
onOpenTable( "Damp & Mould — Survey Stage Flags", @@ -189,7 +187,7 @@ export default function DampMouldRiskPanel({ count={risk.coordinatorFlagCount} total={totalDeals} icon={Droplets} - color="orange" + color="red" onClick={() => onOpenTable( "Damp & Mould — Coordination Stage Flags", @@ -199,22 +197,6 @@ export default function DampMouldRiskPanel({ ) } /> - - onOpenTable( - "Damp & Mould — Flagged at Both Stages", - bothFlaggedDeals, - coordColumns, - coordLabels - ) - } - />
{/* Missed risk callout */} diff --git a/src/app/portfolio/[slug]/(portfolio)/your-projects/live/LiveTracker.tsx b/src/app/portfolio/[slug]/(portfolio)/your-projects/live/LiveTracker.tsx index 5418d15..04086ea 100644 --- a/src/app/portfolio/[slug]/(portfolio)/your-projects/live/LiveTracker.tsx +++ b/src/app/portfolio/[slug]/(portfolio)/your-projects/live/LiveTracker.tsx @@ -9,9 +9,10 @@ import { TabsTrigger, } from "@/app/shadcn_components/ui/tabs"; import { Card, CardContent } from "@/app/shadcn_components/ui/card"; -import { BarChart2, Table2 } from "lucide-react"; +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 AnalyticsView from "./AnalyticsView"; @@ -30,7 +31,7 @@ export default function LiveTracker({ docStatusMap, }: LiveTrackerProps) { // ── Tab state ──────────────────────────────────────────────────────── - const [activeTab, setActiveTab] = useState<"analytics" | "properties">( + const [activeTab, setActiveTab] = useState<"analytics" | "properties" | "documents">( "analytics", ); @@ -89,7 +90,7 @@ export default function LiveTracker({
setActiveTab(v as "analytics" | "properties")} + onValueChange={(v) => setActiveTab(v as "analytics" | "properties" | "documents")} > {/* Tab bar */} @@ -107,6 +108,13 @@ export default function LiveTracker({ Properties + + + Document Management + {/* Analytics tab */} @@ -158,7 +166,38 @@ export default function LiveTracker({ +
+ + {/* Document Management tab */} + +
+ {projects.length > 1 && ( +
+ Project: + +
+ )} +
@@ -194,7 +233,7 @@ export default function LiveTracker({
{Object.entries(openTable.breakdown).map( ([category, items]) => { - const isCompleted = category.includes("Completed"); + 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"; diff --git a/src/app/portfolio/[slug]/(portfolio)/your-projects/live/PropertyDrawer.tsx b/src/app/portfolio/[slug]/(portfolio)/your-projects/live/PropertyDrawer.tsx index 0199ada..dec0cba 100644 --- a/src/app/portfolio/[slug]/(portfolio)/your-projects/live/PropertyDrawer.tsx +++ b/src/app/portfolio/[slug]/(portfolio)/your-projects/live/PropertyDrawer.tsx @@ -1,11 +1,12 @@ "use client"; import { useQuery } from "@tanstack/react-query"; -import { useState } from "react"; +import { useRef, useState } from "react"; import { motion, AnimatePresence } from "framer-motion"; import { FileDown, FileText, + FileX, Loader2, FolderOpen, X, @@ -20,6 +21,7 @@ import { 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 = { @@ -61,10 +63,10 @@ function DocumentRow({ doc }: { doc: PropertyDocument }) { async function handleDownload() { setSigning(true); try { - const res = await fetch("/api/sign-s3-url", { + const res = await fetch("/api/sign-document-url", { method: "POST", headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ key: doc.s3FileKey }), + 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(); @@ -85,33 +87,34 @@ function DocumentRow({ doc }: { doc: PropertyDocument }) { layout initial={{ opacity: 0, y: 4 }} animate={{ opacity: 1, y: 0 }} - className="flex items-center justify-between gap-3 p-3 rounded-lg border border-gray-100 bg-white hover:border-brandblue/20 hover:shadow-sm transition-all duration-150" + 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 */}
- {/* Doc type badge */} - - - {label} - +
+ +
+
+

{label}

+

+ {formatDate(doc.s3UploadTimestamp)} +

+
-
- - {formatDate(doc.s3UploadTimestamp)} - - -
+ {/* Right: download button */} + ); } @@ -136,16 +139,19 @@ export default function PropertyDrawer({ }: PropertyDrawerProps) { const canQuery = !!(uprn || landlordPropertyId); const { - data: documents = [], - isLoading, + 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}`); + 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; }, @@ -153,8 +159,17 @@ export default function PropertyDrawer({ 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([]); + 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 as PropertyDocument[]).reduce< + const grouped = documents.reduce< Record >((acc, doc) => { const category = getDocCategory(doc.docType); @@ -164,24 +179,29 @@ export default function PropertyDrawer({ 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 ( !v && onClose()} direction="right"> - + {/* Remove the default drag handle */}
- -
-
- + +
+
+ {dealname ?? "Property Documents"} {uprn ? ( - + UPRN: {uprn} ) : landlordPropertyId ? ( - + Ref: {landlordPropertyId} ) : null} @@ -196,7 +216,7 @@ export default function PropertyDrawer({
- {hasDocuments && !isLoading && ( + {hasDocuments && !isFetching && (
@@ -209,7 +229,7 @@ export default function PropertyDrawer({ {/* Body */}
{/* Loading state */} - {isLoading && ( + {isFetching && (
{[1, 2, 3].map((i) => (
@@ -235,25 +255,43 @@ export default function PropertyDrawer({
)} - {/* Empty state */} - {!isLoading && !isError && !hasDocuments && ( -
-
- + {/* Empty state — shows all missing doc types */} + {!isFetching && !isError && !hasDocuments && ( +
+
+
+ +
+

+ No documents available +

+

+ All {EXPECTED_SURVEY_DOC_TYPES.length} survey documents are + outstanding. +

+
+
+

+ Missing Documents ({missingTypes.length}) +

+ {missingTypes.map((t) => ( +
+ + + {DOC_TYPE_LABELS[t] ?? t} + +
+ ))}
-

- No documents uploaded -

-

- Survey documents will appear here once uploaded for this - property. -

)} {/* Document groups */} - {!isLoading && + {!isFetching && !isError && hasDocuments && Object.entries(grouped).map(([category, docs]) => ( @@ -274,6 +312,35 @@ export default function PropertyDrawer({ ))} + + {/* Missing documents section — shown when some but not all docs are present */} + {!isFetching && + !isError && + hasDocuments && + missingTypes.length > 0 && ( + +

+ Missing Documents ({missingTypes.length}) +

+
+ {missingTypes.map((t) => ( +
+ + + {DOC_TYPE_LABELS[t] ?? t} + +
+ ))} +
+
+ )}
{/* Footer */} diff --git a/src/app/portfolio/[slug]/(portfolio)/your-projects/live/transforms.ts b/src/app/portfolio/[slug]/(portfolio)/your-projects/live/transforms.ts index ab5916c..d8d6bf3 100644 --- a/src/app/portfolio/[slug]/(portfolio)/your-projects/live/transforms.ts +++ b/src/app/portfolio/[slug]/(portfolio)/your-projects/live/transforms.ts @@ -94,10 +94,11 @@ function resolveAfterAssessmentStage( // Called when design is UPLOADED — resolves install / lodgement / completed // ----------------------------------------------------------------------- function resolvePostDesignStage(deal: HubspotDeal): DisplayStage { - if (deal.fullLodgementDate) return "Completed"; - if (deal.lodgementStatus) return "Lodgement"; + 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 "Awaiting Install"; + return "Installation in Progress"; } // ----------------------------------------------------------------------- @@ -216,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; @@ -224,15 +225,16 @@ export function computeProjectProgress( const totalDeals = deals.length; // Coordination phase: - // completed = Design in Progress + Awaiting Install + Installation Complete + Lodgement + Completed + // completed = Design in Progress + Installation in Progress + Installation Complete + At Lodgement + At Post Survey + Project Complete // in progress = Coordination in Progress const coordCompletedDeals = deals.filter((d) => [ "Design in Progress", - "Awaiting Install", + "Installation in Progress", "Installation Complete", - "Lodgement", - "Completed", + "At Lodgement", + "At Post Survey", + "Project Complete", ].includes(d.displayStage) ); const coordInProgressDeals = deals.filter( @@ -252,14 +254,15 @@ export function computeProjectProgress( }; // Design phase: - // completed = Awaiting Install + Installation Complete + Lodgement + Completed + // completed = Installation in Progress + Installation Complete + At Lodgement + At Post Survey + Project Complete // in progress = Design in Progress const designCompletedDeals = deals.filter((d) => [ - "Awaiting Install", + "Installation in Progress", "Installation Complete", - "Lodgement", - "Completed", + "At Lodgement", + "At Post Survey", + "Project Complete", ].includes(d.displayStage) ); const designInProgressDeals = deals.filter( @@ -279,10 +282,10 @@ export function computeProjectProgress( }; // Install phase: - // completed = Lodgement + Completed + // completed = At Lodgement + At Post Survey + Project Complete // in progress = Installation Complete const installCompletedDeals = deals.filter((d) => - ["Lodgement", "Completed"].includes(d.displayStage) + ["At Lodgement", "At Post Survey", "Project Complete"].includes(d.displayStage) ); const installInProgressDeals = deals.filter( (d) => d.displayStage === "Installation Complete" @@ -301,10 +304,10 @@ export function computeProjectProgress( }; // Lodgement phase: - // completed = Completed - // in progress = Lodgement + // completed = At Post Survey + Project Complete + // in progress = At Lodgement const lodgementInProgressDeals = deals.filter( - (d) => d.displayStage === "Lodgement" + (d) => d.displayStage === "At Lodgement" ); const lodgement: WorkPhaseStats = { diff --git a/src/app/portfolio/[slug]/(portfolio)/your-projects/live/types.ts b/src/app/portfolio/[slug]/(portfolio)/your-projects/live/types.ts index c51e7d8..931413c 100644 --- a/src/app/portfolio/[slug]/(portfolio)/your-projects/live/types.ts +++ b/src/app/portfolio/[slug]/(portfolio)/your-projects/live/types.ts @@ -62,10 +62,11 @@ export type DisplayStage = | "Assessment in Progress" | "Coordination in Progress" | "Design in Progress" - | "Awaiting Install" + | "Installation in Progress" | "Installation Complete" - | "Lodgement" - | "Completed" + | "At Lodgement" + | "At Post Survey" + | "Project Complete" | "Queries" | "Unknown Stage"; @@ -249,10 +250,11 @@ export const STAGE_ORDER: DisplayStage[] = [ "Assessment in Progress", "Coordination in Progress", "Design in Progress", - "Awaiting Install", + "Installation in Progress", "Installation Complete", - "Lodgement", - "Completed", + "At Lodgement", + "At Post Survey", + "Project Complete", ]; // ----------------------------------------------------------------------- @@ -275,10 +277,10 @@ export const STAGE_COLORS: Record< dot: "bg-sky-400", }, "Assessment in Progress": { - bg: "bg-violet-50", - text: "text-violet-700", - border: "border-violet-200", - dot: "bg-violet-400", + bg: "bg-blue-100", + text: "text-blue-900", + border: "border-blue-400", + dot: "bg-blue-700", }, "Coordination in Progress": { bg: "bg-indigo-50", @@ -292,11 +294,11 @@ export const STAGE_COLORS: Record< border: "border-blue-200", dot: "bg-blue-400", }, - "Awaiting Install": { - bg: "bg-purple-50", - text: "text-purple-700", - border: "border-purple-200", - dot: "bg-purple-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", @@ -304,13 +306,19 @@ export const STAGE_COLORS: Record< border: "border-teal-200", dot: "bg-teal-400", }, - Lodgement: { + "At Lodgement": { bg: "bg-cyan-50", text: "text-cyan-700", border: "border-cyan-200", dot: "bg-cyan-400", }, - Completed: { + "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",