mirror of
https://github.com/Hestia-Homes/assessment-model.git
synced 2026-06-08 11:37:25 +00:00
polishing project reporting ui
Some checks are pending
Next.js Build Check / build (push) Waiting to run
Some checks are pending
Next.js Build Check / build (push) Waiting to run
This commit is contained in:
parent
8ca1bf2799
commit
988bd2ce8f
8 changed files with 283 additions and 131 deletions
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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 */}
|
||||
<CompletionTrendsChart
|
||||
deals={currentProject.allDeals}
|
||||
isDomnaUser={true} // TODO: Replace with real user check
|
||||
isDomnaUser={true}
|
||||
projectCode={currentProjectCode}
|
||||
onOpenTable={onOpenTable}
|
||||
/>
|
||||
|
||||
{/* Row 2: section header */}
|
||||
|
|
|
|||
|
|
@ -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<Record<keyof ClassifiedDeal, string>>,
|
||||
) => 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<string, string | number>[];
|
||||
let categories: string[];
|
||||
|
|
@ -417,6 +436,36 @@ export default function CompletionTrendsChart({
|
|||
stack={isStacked}
|
||||
customTooltip={ChartTooltip}
|
||||
/>
|
||||
{isAssessments && undatedAssessments.length > 0 && (
|
||||
<div className="mt-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>{" "}
|
||||
assessment{undatedAssessments.length !== 1 ? "s" : ""} from external surveyors 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>
|
||||
)}
|
||||
{isStacked && (
|
||||
<Legend
|
||||
categories={categories}
|
||||
|
|
|
|||
|
|
@ -131,8 +131,6 @@ export default function DampMouldRiskPanel({
|
|||
coordinator: "Coordinator",
|
||||
};
|
||||
|
||||
const bothFlaggedDeals = risk.surveyFlagDeals.filter((d) => !!d.dampMouldFlag);
|
||||
|
||||
const noRisk =
|
||||
risk.surveyFlagCount === 0 &&
|
||||
risk.coordinatorFlagCount === 0;
|
||||
|
|
@ -166,14 +164,14 @@ export default function DampMouldRiskPanel({
|
|||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-3 gap-3 mb-4">
|
||||
<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="amber"
|
||||
color="red"
|
||||
onClick={() =>
|
||||
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({
|
|||
)
|
||||
}
|
||||
/>
|
||||
<RiskStatCard
|
||||
label="Flagged at Both Stages"
|
||||
subtitle="Highest risk — action required"
|
||||
count={risk.bothFlaggedCount}
|
||||
total={totalDeals}
|
||||
icon={ShieldAlert}
|
||||
color="red"
|
||||
onClick={() =>
|
||||
onOpenTable(
|
||||
"Damp & Mould — Flagged at Both Stages",
|
||||
bothFlaggedDeals,
|
||||
coordColumns,
|
||||
coordLabels
|
||||
)
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Missed risk callout */}
|
||||
|
|
|
|||
|
|
@ -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({
|
|||
<div className="space-y-4 w-full">
|
||||
<Tabs
|
||||
value={activeTab}
|
||||
onValueChange={(v) => setActiveTab(v as "analytics" | "properties")}
|
||||
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">
|
||||
|
|
@ -107,6 +108,13 @@ export default function LiveTracker({
|
|||
<Table2 className="h-3.5 w-3.5" />
|
||||
Properties
|
||||
</TabsTrigger>
|
||||
<TabsTrigger
|
||||
value="documents"
|
||||
className="flex items-center gap-2 rounded-lg text-sm font-medium px-4 data-[state=active]:bg-white data-[state=active]:text-brandblue data-[state=active]:shadow-sm transition-all"
|
||||
>
|
||||
<FolderOpen className="h-3.5 w-3.5" />
|
||||
Document Management
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
{/* Analytics tab */}
|
||||
|
|
@ -158,7 +166,38 @@ export default function LiveTracker({
|
|||
<PropertyTable
|
||||
data={currentProject?.allDeals ?? []}
|
||||
onOpenDrawer={handleOpenDrawer}
|
||||
showDocuments={true}
|
||||
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>
|
||||
|
|
@ -194,7 +233,7 @@ export default function LiveTracker({
|
|||
<div className="grid grid-cols-2 gap-3">
|
||||
{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";
|
||||
|
|
|
|||
|
|
@ -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<string, string> = {
|
||||
|
|
@ -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 */}
|
||||
<div className="flex items-center gap-3 min-w-0">
|
||||
{/* Doc type badge */}
|
||||
<span className="inline-flex items-center gap-1 px-2 py-0.5 rounded-md border text-xs font-medium shrink-0 bg-sky-50 text-sky-700 border-sky-200">
|
||||
<FileText className="h-3.5 w-3.5" />
|
||||
{label}
|
||||
</span>
|
||||
<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>
|
||||
|
||||
<div className="flex items-center gap-2 shrink-0">
|
||||
<span className="text-xs text-gray-400 hidden sm:block">
|
||||
{formatDate(doc.s3UploadTimestamp)}
|
||||
</span>
|
||||
<button
|
||||
onClick={handleDownload}
|
||||
disabled={signing}
|
||||
className="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>
|
||||
</div>
|
||||
{/* Right: download button */}
|
||||
<button
|
||||
onClick={handleDownload}
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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<PropertyDocument[]>;
|
||||
},
|
||||
|
|
@ -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<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 as PropertyDocument[]).reduce<
|
||||
const grouped = documents.reduce<
|
||||
Record<string, PropertyDocument[]>
|
||||
>((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 (
|
||||
<Drawer open={open} onOpenChange={(v) => !v && onClose()} direction="right">
|
||||
<DrawerContent className="fixed right-0 top-0 bottom-0 h-full w-full max-w-[440px] 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">
|
||||
<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="px-6 pt-6 pb-4 border-b border-gray-100">
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex-1 min-w-0 pr-4">
|
||||
<DrawerTitle className="text-lg font-semibold text-brandblue leading-tight truncate">
|
||||
<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">
|
||||
<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">
|
||||
<DrawerDescription className="text-xs text-gray-500 mt-0.5 font-mono truncate">
|
||||
Ref: {landlordPropertyId}
|
||||
</DrawerDescription>
|
||||
) : null}
|
||||
|
|
@ -196,7 +216,7 @@ export default function PropertyDrawer({
|
|||
</DrawerClose>
|
||||
</div>
|
||||
|
||||
{hasDocuments && !isLoading && (
|
||||
{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">
|
||||
|
|
@ -209,7 +229,7 @@ export default function PropertyDrawer({
|
|||
{/* Body */}
|
||||
<div className="flex-1 overflow-y-auto px-6 py-4 space-y-5">
|
||||
{/* Loading state */}
|
||||
{isLoading && (
|
||||
{isFetching && (
|
||||
<div className="space-y-3 pt-2">
|
||||
{[1, 2, 3].map((i) => (
|
||||
<div
|
||||
|
|
@ -221,7 +241,7 @@ export default function PropertyDrawer({
|
|||
)}
|
||||
|
||||
{/* Error state */}
|
||||
{isError && !isLoading && (
|
||||
{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" />
|
||||
|
|
@ -235,25 +255,43 @@ export default function PropertyDrawer({
|
|||
</div>
|
||||
)}
|
||||
|
||||
{/* Empty state */}
|
||||
{!isLoading && !isError && !hasDocuments && (
|
||||
<div className="flex flex-col items-center justify-center py-12 text-center">
|
||||
<div className="w-12 h-12 rounded-full bg-gray-50 border border-gray-200 flex items-center justify-center mb-3">
|
||||
<FolderOpen className="h-6 w-6 text-gray-400" />
|
||||
{/* 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>
|
||||
<p className="text-sm font-medium text-gray-700">
|
||||
No documents uploaded
|
||||
</p>
|
||||
<p className="text-xs text-gray-400 mt-1 max-w-[220px]">
|
||||
Survey documents will appear here once uploaded for this
|
||||
property.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Document groups */}
|
||||
<AnimatePresence>
|
||||
{!isLoading &&
|
||||
{!isFetching &&
|
||||
!isError &&
|
||||
hasDocuments &&
|
||||
Object.entries(grouped).map(([category, docs]) => (
|
||||
|
|
@ -274,6 +312,35 @@ export default function PropertyDrawer({
|
|||
</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 */}
|
||||
|
|
|
|||
|
|
@ -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 = {
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue