mirror of
https://github.com/Hestia-Homes/assessment-model.git
synced 2026-06-08 11:37:25 +00:00
Merge pull request #295 from Hestia-Homes/main
Some checks failed
Test Suite / unit-tests (push) Has been cancelled
Some checks failed
Test Suite / unit-tests (push) Has been cancelled
Dev deployment for PM views, showing non booked deals
This commit is contained in:
commit
db62311cef
28 changed files with 21071 additions and 283 deletions
56
package-lock.json
generated
56
package-lock.json
generated
|
|
@ -25,6 +25,7 @@
|
|||
"@radix-ui/react-hover-card": "^1.0.6",
|
||||
"@radix-ui/react-label": "^2.1.0",
|
||||
"@radix-ui/react-navigation-menu": "^1.1.3",
|
||||
"@radix-ui/react-popover": "^1.1.15",
|
||||
"@radix-ui/react-scroll-area": "^1.2.10",
|
||||
"@radix-ui/react-select": "^2.2.6",
|
||||
"@radix-ui/react-separator": "^1.0.3",
|
||||
|
|
@ -3812,6 +3813,61 @@
|
|||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-popover": {
|
||||
"version": "1.1.15",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-popover/-/react-popover-1.1.15.tgz",
|
||||
"integrity": "sha512-kr0X2+6Yy/vJzLYJUPCZEc8SfQcf+1COFoAqauJm74umQhta9M7lNJHP7QQS3vkvcGLQUbWpMzwrXYwrYztHKA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@radix-ui/primitive": "1.1.3",
|
||||
"@radix-ui/react-compose-refs": "1.1.2",
|
||||
"@radix-ui/react-context": "1.1.2",
|
||||
"@radix-ui/react-dismissable-layer": "1.1.11",
|
||||
"@radix-ui/react-focus-guards": "1.1.3",
|
||||
"@radix-ui/react-focus-scope": "1.1.7",
|
||||
"@radix-ui/react-id": "1.1.1",
|
||||
"@radix-ui/react-popper": "1.2.8",
|
||||
"@radix-ui/react-portal": "1.1.9",
|
||||
"@radix-ui/react-presence": "1.1.5",
|
||||
"@radix-ui/react-primitive": "2.1.3",
|
||||
"@radix-ui/react-slot": "1.2.3",
|
||||
"@radix-ui/react-use-controllable-state": "1.2.2",
|
||||
"aria-hidden": "^1.2.4",
|
||||
"react-remove-scroll": "^2.6.3"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/react": "*",
|
||||
"@types/react-dom": "*",
|
||||
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
|
||||
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
},
|
||||
"@types/react-dom": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-popover/node_modules/@radix-ui/react-slot": {
|
||||
"version": "1.2.3",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz",
|
||||
"integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@radix-ui/react-compose-refs": "1.1.2"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/react": "*",
|
||||
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-popper": {
|
||||
"version": "1.2.8",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-popper/-/react-popper-1.2.8.tgz",
|
||||
|
|
|
|||
|
|
@ -33,6 +33,7 @@
|
|||
"@radix-ui/react-hover-card": "^1.0.6",
|
||||
"@radix-ui/react-label": "^2.1.0",
|
||||
"@radix-ui/react-navigation-menu": "^1.1.3",
|
||||
"@radix-ui/react-popover": "^1.1.15",
|
||||
"@radix-ui/react-scroll-area": "^1.2.10",
|
||||
"@radix-ui/react-select": "^2.2.6",
|
||||
"@radix-ui/react-separator": "^1.0.3",
|
||||
|
|
|
|||
22
src/app/db/migrations/0215_invert_column_mapping.sql
Normal file
22
src/app/db/migrations/0215_invert_column_mapping.sql
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
-- One-shot inversion of bulk_address_uploads.column_mapping.
|
||||
--
|
||||
-- Old shape: { "<source header>": "<internal field>" } (header -> field), with
|
||||
-- unmapped columns stored as "<header>": "skip".
|
||||
-- New shape: { "<internal field>": "<source header>" } (field -> header), with
|
||||
-- unmapped fields simply absent. See ADR-0003 and the WIP plan (Q2.2/Q5).
|
||||
--
|
||||
-- 'skip' entries are dropped. On a legacy duplicate (two headers -> one field),
|
||||
-- jsonb_object_agg keeps the last header — the new address-uniqueness rule
|
||||
-- forbids that going forward anyway. No-op on NULL/empty mappings, so this is
|
||||
-- safe regardless of data volume. One-shot: assumes rows are still old-shape.
|
||||
UPDATE "bulk_address_uploads"
|
||||
SET "column_mapping" = COALESCE(
|
||||
(
|
||||
SELECT jsonb_object_agg(elem.value, elem.key)
|
||||
FROM jsonb_each_text("column_mapping") AS elem
|
||||
WHERE elem.value <> 'skip'
|
||||
),
|
||||
'{}'::jsonb
|
||||
)
|
||||
WHERE "column_mapping" IS NOT NULL
|
||||
AND "column_mapping" <> '{}'::jsonb;
|
||||
1
src/app/db/migrations/0216_add_subtask_service.sql
Normal file
1
src/app/db/migrations/0216_add_subtask_service.sql
Normal file
|
|
@ -0,0 +1 @@
|
|||
ALTER TABLE "sub_task" ADD COLUMN "service" text;
|
||||
10125
src/app/db/migrations/meta/0215_snapshot.json
Normal file
10125
src/app/db/migrations/meta/0215_snapshot.json
Normal file
File diff suppressed because it is too large
Load diff
10131
src/app/db/migrations/meta/0216_snapshot.json
Normal file
10131
src/app/db/migrations/meta/0216_snapshot.json
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -1506,6 +1506,20 @@
|
|||
"when": 1779969672088,
|
||||
"tag": "0214_superb_maelstrom",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 215,
|
||||
"version": "7",
|
||||
"when": 1779991310301,
|
||||
"tag": "0215_invert_column_mapping",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 216,
|
||||
"version": "7",
|
||||
"when": 1779992128370,
|
||||
"tag": "0216_add_subtask_service",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
@ -47,7 +47,7 @@ export const hubspotDealData = pgTable("hubspot_deal_data", {
|
|||
expectedCommencementDate: timestamp("expected_commencement_date", { precision: 6, withTimezone: true }),
|
||||
coordination_comments: text("coordination_comments"),
|
||||
surveyor: text("surveyor"),
|
||||
damnpMouldAndRepairComments: text("damp_mould_and_repairs_comments"),
|
||||
dampMouldAndRepairComments: text("damp_mould_and_repairs_comments"),
|
||||
batch: text("batch"),
|
||||
batchDescription: text("batch_description"),
|
||||
blockReference: text("block_reference"),
|
||||
|
|
|
|||
|
|
@ -14,6 +14,10 @@ export const subTasks = pgTable("sub_task", {
|
|||
|
||||
status: text("status").notNull().default("In Progress"),
|
||||
|
||||
// Which pipeline this subtask belongs to, e.g. "address2uprn" or
|
||||
// "landlord_description_overrides". NULL = legacy / address. See ADR-0003.
|
||||
service: text("service"),
|
||||
|
||||
inputs: text("inputs"), // could later change to JSONB if desired
|
||||
outputs: text("outputs"),
|
||||
cloudLogsURL: text("cloud_logs_url"),
|
||||
|
|
|
|||
|
|
@ -4,10 +4,10 @@ import { useState } from "react";
|
|||
import { motion } from "framer-motion";
|
||||
import { Home, AlertTriangle, ToggleLeft, ToggleRight } from "lucide-react";
|
||||
import { Card, CardContent } from "@/app/shadcn_components/ui/card";
|
||||
import SurveyedResultsPieChart from "./SurveyedResultsPieChart";
|
||||
import DampMouldRiskPanel from "./DampMouldRiskPanel";
|
||||
import CompletionTrendsChart from "./CompletionTrendsChart";
|
||||
import SurveyIssuesPanel from "./SurveyIssuesPanel";
|
||||
import ExcludedFromPipelinePanel from "./ExcludedFromPipelinePanel";
|
||||
import GroupFilter, { type GroupNode } from "./GroupFilter";
|
||||
import { STAGE_COLORS, STAGE_ORDER } from "./types";
|
||||
import type {
|
||||
|
|
@ -429,6 +429,12 @@ export default function AnalyticsView({
|
|||
deals={currentProject.allDeals}
|
||||
onOpenTable={onOpenTable}
|
||||
/>
|
||||
|
||||
{/* Row 7: Excluded from Pipeline (Removed from Bookings / Removed from Program) */}
|
||||
<ExcludedFromPipelinePanel
|
||||
deals={currentProject.allDeals}
|
||||
onOpenTable={onOpenTable}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -106,6 +106,7 @@ export default function DampMouldRiskPanel({
|
|||
const surveyColumns: (keyof ClassifiedDeal)[] = [
|
||||
"dealname",
|
||||
"landlordPropertyId",
|
||||
"dampMouldFlag",
|
||||
"majorConditionIssueDescription",
|
||||
"majorConditionIssuePhotosS3",
|
||||
];
|
||||
|
|
@ -113,6 +114,7 @@ export default function DampMouldRiskPanel({
|
|||
const surveyLabels: Partial<Record<keyof ClassifiedDeal, string>> = {
|
||||
dealname: "Address",
|
||||
landlordPropertyId: "Property Ref",
|
||||
dampMouldFlag: "Damp & Mould",
|
||||
majorConditionIssueDescription: "Surveyor Notes",
|
||||
majorConditionIssuePhotosS3: "Photo Evidence",
|
||||
};
|
||||
|
|
@ -128,7 +130,7 @@ export default function DampMouldRiskPanel({
|
|||
const coordLabels: Partial<Record<keyof ClassifiedDeal, string>> = {
|
||||
dealname: "Address",
|
||||
landlordPropertyId: "Property Ref",
|
||||
dampMouldFlag: "Coordinator Flag",
|
||||
dampMouldFlag: "Damp & Mould",
|
||||
dampMouldAndRepairComments: "Comments",
|
||||
coordinator: "Coordinator",
|
||||
};
|
||||
|
|
@ -147,7 +149,7 @@ export default function DampMouldRiskPanel({
|
|||
</div>
|
||||
<div>
|
||||
<h3 className="text-base font-semibold text-gray-800">
|
||||
Awaab's Law — Damp & Mould Risk
|
||||
Awaab's Law — Damp, Mould & Other Condition Issues
|
||||
</h3>
|
||||
<p className="text-sm text-gray-500 mt-0.5">
|
||||
Comparison of flags raised at survey vs coordination stage
|
||||
|
|
@ -161,7 +163,7 @@ export default function DampMouldRiskPanel({
|
|||
<ShieldAlert className="h-4 w-4 text-emerald-600" />
|
||||
</div>
|
||||
<p className="text-sm font-medium text-emerald-700">
|
||||
No damp or mould flags recorded for this project.
|
||||
No condition issues recorded for this project.
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
|
|
@ -176,7 +178,7 @@ export default function DampMouldRiskPanel({
|
|||
color="red"
|
||||
onClick={() =>
|
||||
onOpenTable(
|
||||
"Damp & Mould — Survey Stage Flags",
|
||||
"Condition Issues — Survey Stage",
|
||||
risk.surveyFlagDeals,
|
||||
surveyColumns,
|
||||
surveyLabels
|
||||
|
|
@ -192,7 +194,7 @@ export default function DampMouldRiskPanel({
|
|||
color="red"
|
||||
onClick={() =>
|
||||
onOpenTable(
|
||||
"Damp & Mould — Coordination Stage Flags",
|
||||
"Condition Issues — Coordination Stage",
|
||||
risk.coordinatorFlagDeals,
|
||||
coordColumns,
|
||||
coordLabels
|
||||
|
|
@ -210,7 +212,7 @@ export default function DampMouldRiskPanel({
|
|||
{risk.coordinatorFlagCount - risk.surveyFlagCount} additional{" "}
|
||||
{risk.coordinatorFlagCount - risk.surveyFlagCount === 1 ? "property was" : "properties were"}{" "}
|
||||
</span>
|
||||
flagged for damp & mould at the coordination stage that{" "}
|
||||
flagged with condition issues at the coordination stage that{" "}
|
||||
{risk.coordinatorFlagCount - risk.surveyFlagCount === 1 ? "was" : "were"} not
|
||||
identified during the initial survey.
|
||||
</p>
|
||||
|
|
|
|||
|
|
@ -22,9 +22,98 @@ import {
|
|||
TableRow,
|
||||
} from "@/app/shadcn_components/ui/table";
|
||||
import { Input } from "@/app/shadcn_components/ui/input";
|
||||
import { Search, Download, ChevronLeft, ChevronRight } from "lucide-react";
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from "@/app/shadcn_components/ui/popover";
|
||||
import { Search, Download, ChevronLeft, ChevronRight, ChevronDown } from "lucide-react";
|
||||
import type { ClassifiedDeal, HubspotDeal } from "./types";
|
||||
|
||||
const NO_COMMENT_PLACEHOLDER =
|
||||
"Damp & mould discovered — no note from coordinator";
|
||||
const COMMENT_PREVIEW_LIMIT = 60;
|
||||
|
||||
function DampMouldBadgeCell({ value }: { value: unknown }) {
|
||||
const isYes =
|
||||
typeof value === "string" && value.trim().toLowerCase() === "yes";
|
||||
|
||||
if (!isYes) return null;
|
||||
|
||||
return (
|
||||
<span className="inline-flex items-center gap-1 rounded-full bg-red-100 px-2 py-0.5 text-xs font-semibold text-red-700 border border-red-200">
|
||||
Damp & Mould
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function RemovalFlagChip({ label, tooltip }: { label: string; tooltip: string }) {
|
||||
return (
|
||||
<span
|
||||
title={tooltip}
|
||||
className="inline-flex items-center rounded-full bg-slate-100 px-2 py-0.5 text-[10px] font-semibold text-slate-600 border border-slate-200 cursor-help"
|
||||
>
|
||||
{label}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function DealNameCell({ deal }: { deal: ClassifiedDeal }) {
|
||||
const name = deal.dealname;
|
||||
const isRemovedFromProgram = deal.batch === "Removed from Program";
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<span className="text-sm text-gray-800">
|
||||
{name ?? <span className="text-gray-300">—</span>}
|
||||
</span>
|
||||
{isRemovedFromProgram && (
|
||||
<RemovalFlagChip
|
||||
label="Removed from Program"
|
||||
tooltip="This property has been removed from the project. If an outcome is set above, it is real history; no further action expected."
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function DampMouldCommentCell({ value }: { value: unknown }) {
|
||||
const comment = typeof value === "string" ? value.trim() : "";
|
||||
|
||||
if (!comment) {
|
||||
return (
|
||||
<span className="text-sm italic text-gray-500">
|
||||
{NO_COMMENT_PLACEHOLDER}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
const preview =
|
||||
comment.length > COMMENT_PREVIEW_LIMIT
|
||||
? comment.slice(0, COMMENT_PREVIEW_LIMIT).trimEnd() + "…"
|
||||
: comment;
|
||||
|
||||
return (
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
className="group inline-flex items-center gap-1 text-left text-sm text-gray-800 hover:text-brandblue underline-offset-2 hover:underline focus:outline-none focus:underline"
|
||||
>
|
||||
<span>{preview}</span>
|
||||
<ChevronDown className="h-3.5 w-3.5 shrink-0 text-gray-400 group-hover:text-brandblue" />
|
||||
</button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent
|
||||
align="start"
|
||||
className="w-80 max-w-[90vw] whitespace-pre-wrap break-words text-sm text-gray-800"
|
||||
>
|
||||
{comment}
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
);
|
||||
}
|
||||
|
||||
interface DrillDownTableProps {
|
||||
data: ClassifiedDeal[];
|
||||
columns?: (keyof HubspotDeal)[];
|
||||
|
|
@ -124,9 +213,18 @@ export default function DrillDownTable({
|
|||
),
|
||||
cell: ({ row }) => {
|
||||
const value = row.original[key as keyof ClassifiedDeal];
|
||||
if (key === "dealname") {
|
||||
return <DealNameCell deal={row.original} />;
|
||||
}
|
||||
if (key === "majorConditionIssuePhotosS3") {
|
||||
return <PhotoDownloadCell value={value} />;
|
||||
}
|
||||
if (key === "dampMouldAndRepairComments") {
|
||||
return <DampMouldCommentCell value={value} />;
|
||||
}
|
||||
if (key === "dampMouldFlag") {
|
||||
return <DampMouldBadgeCell value={value} />;
|
||||
}
|
||||
return (
|
||||
<span className="text-sm text-gray-800">
|
||||
{value != null ? String(value) : (
|
||||
|
|
|
|||
|
|
@ -0,0 +1,130 @@
|
|||
"use client";
|
||||
|
||||
import { motion } from "framer-motion";
|
||||
import { CircleOff } from "lucide-react";
|
||||
import { Card, CardContent } from "@/app/shadcn_components/ui/card";
|
||||
import type { ClassifiedDeal } from "./types";
|
||||
|
||||
const REMOVED_FROM_BOOKINGS_COLUMNS: (keyof ClassifiedDeal)[] = [
|
||||
"dealname",
|
||||
"landlordPropertyId",
|
||||
"outcomeNotes",
|
||||
"propertyHaltedDate",
|
||||
"propertyHaltedReason",
|
||||
];
|
||||
const REMOVED_FROM_BOOKINGS_LABELS: Partial<Record<keyof ClassifiedDeal, string>> = {
|
||||
dealname: "Address",
|
||||
landlordPropertyId: "Ref",
|
||||
outcomeNotes: "Outcome Notes",
|
||||
propertyHaltedDate: "Halted Date",
|
||||
propertyHaltedReason: "Halted Reason",
|
||||
};
|
||||
|
||||
const REMOVED_FROM_PROGRAM_COLUMNS: (keyof ClassifiedDeal)[] = [
|
||||
"dealname",
|
||||
"landlordPropertyId",
|
||||
"batchDescription",
|
||||
"coordinator",
|
||||
"propertyHaltedDate",
|
||||
"propertyHaltedReason",
|
||||
];
|
||||
const REMOVED_FROM_PROGRAM_LABELS: Partial<Record<keyof ClassifiedDeal, string>> = {
|
||||
dealname: "Address",
|
||||
landlordPropertyId: "Ref",
|
||||
batchDescription: "Group",
|
||||
coordinator: "Coordinator",
|
||||
propertyHaltedDate: "Halted Date",
|
||||
propertyHaltedReason: "Halted Reason",
|
||||
};
|
||||
|
||||
interface ExcludedFromPipelinePanelProps {
|
||||
deals: ClassifiedDeal[];
|
||||
onOpenTable: (
|
||||
stage: string,
|
||||
deals: ClassifiedDeal[],
|
||||
columns?: (keyof ClassifiedDeal)[],
|
||||
columnLabels?: Partial<Record<keyof ClassifiedDeal, string>>,
|
||||
breakdown?: Record<string, ClassifiedDeal[]>,
|
||||
) => void;
|
||||
}
|
||||
|
||||
export default function ExcludedFromPipelinePanel({
|
||||
deals,
|
||||
onOpenTable,
|
||||
}: ExcludedFromPipelinePanelProps) {
|
||||
const removedFromBookings = deals.filter(
|
||||
(d) => d.displayStage === "Removed from Bookings",
|
||||
);
|
||||
const removedFromProgram = deals.filter(
|
||||
(d) => d.displayStage === "Removed from Program",
|
||||
);
|
||||
|
||||
if (removedFromBookings.length === 0 && removedFromProgram.length === 0) return null;
|
||||
|
||||
return (
|
||||
<Card className="border border-slate-200 shadow-sm">
|
||||
<CardContent className="p-6">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<CircleOff className="h-4 w-4 text-slate-500" />
|
||||
<h3 className="text-base font-semibold text-slate-700">
|
||||
Halted or Removed
|
||||
</h3>
|
||||
</div>
|
||||
<p className="text-sm text-gray-500 mb-5">
|
||||
Properties no longer being progressed — either halted before a survey, or removed from the project entirely.
|
||||
</p>
|
||||
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
||||
{removedFromBookings.length > 0 && (
|
||||
<motion.button
|
||||
whileHover={{ scale: 1.02 }}
|
||||
onClick={() =>
|
||||
onOpenTable(
|
||||
"Removed from Bookings",
|
||||
removedFromBookings,
|
||||
REMOVED_FROM_BOOKINGS_COLUMNS,
|
||||
REMOVED_FROM_BOOKINGS_LABELS,
|
||||
)
|
||||
}
|
||||
className="group text-left rounded-xl border border-slate-200 bg-gradient-to-br from-slate-50 to-white p-4 hover:border-slate-300 hover:shadow-md transition-all duration-200"
|
||||
>
|
||||
<p className="text-xs font-semibold text-slate-600 uppercase tracking-wide mb-2 leading-tight">
|
||||
Removed from Bookings
|
||||
</p>
|
||||
<p className="text-2xl font-bold text-slate-800">
|
||||
{removedFromBookings.length}
|
||||
</p>
|
||||
<p className="text-xs text-slate-500 mt-0.5">
|
||||
Removed from the bookings list before a survey took place
|
||||
</p>
|
||||
</motion.button>
|
||||
)}
|
||||
{removedFromProgram.length > 0 && (
|
||||
<motion.button
|
||||
whileHover={{ scale: 1.02 }}
|
||||
onClick={() =>
|
||||
onOpenTable(
|
||||
"Removed from Program",
|
||||
removedFromProgram,
|
||||
REMOVED_FROM_PROGRAM_COLUMNS,
|
||||
REMOVED_FROM_PROGRAM_LABELS,
|
||||
)
|
||||
}
|
||||
className="group text-left rounded-xl border border-slate-200 bg-gradient-to-br from-slate-50 to-white p-4 hover:border-slate-300 hover:shadow-md transition-all duration-200"
|
||||
>
|
||||
<p className="text-xs font-semibold text-slate-600 uppercase tracking-wide mb-2 leading-tight">
|
||||
Removed from Program
|
||||
</p>
|
||||
<p className="text-2xl font-bold text-slate-800">
|
||||
{removedFromProgram.length}
|
||||
</p>
|
||||
<p className="text-xs text-slate-500 mt-0.5">
|
||||
Removed from the project entirely
|
||||
</p>
|
||||
</motion.button>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
Binary file not shown.
|
|
@ -37,6 +37,7 @@ import {
|
|||
} from "@/app/shadcn_components/ui/select";
|
||||
import { Search, SlidersHorizontal, ChevronLeft, ChevronRight, Download } from "lucide-react";
|
||||
import { createPropertyTableColumns } from "./PropertyTableColumns";
|
||||
import { formatPropertyCsv } from "./propertyCsv";
|
||||
import { STAGE_ORDER } from "./types";
|
||||
import type { ClassifiedDeal, DocStatusMap, RemovalStatusByDeal, EffectiveRemovalState } from "./types";
|
||||
|
||||
|
|
@ -57,8 +58,10 @@ const COLUMN_LABELS: Record<string, string> = {
|
|||
epcSapScore: "EPC SAP Score",
|
||||
epcSapScorePotential: "EPC SAP (Potential)",
|
||||
lodgementStatus: "Lodgement Status",
|
||||
surveyedDate: "Surveyed Date",
|
||||
designDate: "Design Date",
|
||||
fullLodgementDate: "Lodgement Date",
|
||||
epcPrn: "EPC Certificate Number",
|
||||
batch: "Group",
|
||||
batchDescription: "Group Description",
|
||||
};
|
||||
|
|
@ -75,41 +78,6 @@ interface PropertyTableProps {
|
|||
removalStatusByDeal?: RemovalStatusByDeal;
|
||||
}
|
||||
|
||||
const CSV_FIELDS: { key: keyof ClassifiedDeal; label: string }[] = [
|
||||
{ key: "dealname", label: "Address" },
|
||||
{ key: "landlordPropertyId", label: "Property Ref" },
|
||||
{ key: "uprn", label: "UPRN" },
|
||||
{ key: "displayStage", label: "Stage" },
|
||||
{ key: "projectCode", label: "Project" },
|
||||
{ key: "coordinator", label: "Coordinator" },
|
||||
{ key: "designer", label: "Designer" },
|
||||
{ key: "installer", label: "Installer" },
|
||||
{ key: "proposedMeasures", label: "Proposed Measures" },
|
||||
{ key: "approvedPackage", label: "Approved Package" },
|
||||
{ key: "actualMeasuresInstalled", label: "Installed Measures" },
|
||||
{ key: "preSapScore", label: "Pre-SAP" },
|
||||
{ key: "eiScore", label: "EI Score" },
|
||||
{ key: "eiScorePotential", label: "EI Score (Potential)" },
|
||||
{ key: "epcSapScore", label: "EPC SAP Score" },
|
||||
{ key: "epcSapScorePotential", label: "EPC SAP (Potential)" },
|
||||
{ key: "lodgementStatus", label: "Lodgement Status" },
|
||||
{ key: "designDate", label: "Design Date" },
|
||||
{ key: "fullLodgementDate", label: "Lodgement Date" },
|
||||
{ key: "batch", label: "Group" },
|
||||
{ key: "batchDescription", label: "Group Description" },
|
||||
];
|
||||
|
||||
function escapeCell(value: unknown): string {
|
||||
if (value === null || value === undefined) return "";
|
||||
const str =
|
||||
value instanceof Date
|
||||
? value.toLocaleDateString("en-GB")
|
||||
: String(value);
|
||||
return str.includes(",") || str.includes('"') || str.includes("\n")
|
||||
? `"${str.replace(/"/g, '""')}"`
|
||||
: str;
|
||||
}
|
||||
|
||||
export default function PropertyTable({ data, onOpenDrawer, portfolioId = "", showDocuments = false, docStatusMap = {}, removalStatusByDeal = {} }: PropertyTableProps) {
|
||||
const [globalFilter, setGlobalFilter] = useState("");
|
||||
const [stageFilter, setStageFilter] = useState<string>("all");
|
||||
|
|
@ -132,8 +100,10 @@ export default function PropertyTable({ data, onOpenDrawer, portfolioId = "", sh
|
|||
epcSapScore: false,
|
||||
epcSapScorePotential: false,
|
||||
lodgementStatus: false,
|
||||
surveyedDate: false,
|
||||
designDate: false,
|
||||
fullLodgementDate: false,
|
||||
epcPrn: false,
|
||||
batch: false,
|
||||
batchDescription: false,
|
||||
});
|
||||
|
|
@ -188,14 +158,8 @@ export default function PropertyTable({ data, onOpenDrawer, portfolioId = "", sh
|
|||
});
|
||||
|
||||
const downloadCsv = () => {
|
||||
const rows = table.getFilteredRowModel().rows;
|
||||
const header = CSV_FIELDS.map((f) => f.label).join(",");
|
||||
const body = rows
|
||||
.map((row) =>
|
||||
CSV_FIELDS.map((f) => escapeCell(row.original[f.key])).join(",")
|
||||
)
|
||||
.join("\n");
|
||||
const blob = new Blob([header + "\n" + body], { type: "text/csv;charset=utf-8;" });
|
||||
const rows = table.getFilteredRowModel().rows.map((r) => r.original);
|
||||
const blob = new Blob([formatPropertyCsv(rows)], { type: "text/csv;charset=utf-8;" });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement("a");
|
||||
a.href = url;
|
||||
|
|
@ -251,6 +215,8 @@ export default function PropertyTable({ data, onOpenDrawer, portfolioId = "", sh
|
|||
</SelectItem>
|
||||
))}
|
||||
<SelectItem value="Queries">Queries</SelectItem>
|
||||
<SelectItem value="Removed from Bookings">Removed from Bookings</SelectItem>
|
||||
<SelectItem value="Removed from Program">Removed from Program</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
|
|
|
|||
|
|
@ -290,6 +290,21 @@ export function createPropertyTableColumns(
|
|||
),
|
||||
},
|
||||
|
||||
// ── Surveyed date ────────────────────────────────────────────────────
|
||||
{
|
||||
accessorKey: "surveyedDate",
|
||||
id: "surveyedDate",
|
||||
header: ({ column }) => <SortableHeader label="Surveyed Date" column={column as any} />,
|
||||
cell: ({ row }) => {
|
||||
const d = row.original.surveyedDate;
|
||||
return (
|
||||
<span className="text-xs text-gray-500">
|
||||
{d ? new Date(d).toLocaleDateString("en-GB", { day: "numeric", month: "short", year: "2-digit" }) : <span className="text-gray-300">—</span>}
|
||||
</span>
|
||||
);
|
||||
},
|
||||
},
|
||||
|
||||
// ── Design date ──────────────────────────────────────────────────────
|
||||
{
|
||||
accessorKey: "designDate",
|
||||
|
|
@ -320,6 +335,18 @@ export function createPropertyTableColumns(
|
|||
},
|
||||
},
|
||||
|
||||
// ── EPC certificate number ───────────────────────────────────────────
|
||||
{
|
||||
accessorKey: "epcPrn",
|
||||
id: "epcPrn",
|
||||
header: ({ column }) => <SortableHeader label="EPC Certificate Number" column={column as any} />,
|
||||
cell: ({ row }) => (
|
||||
<span className="text-xs font-mono text-gray-600">
|
||||
{row.original.epcPrn ?? <span className="text-gray-300">—</span>}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
|
||||
// ── Group ────────────────────────────────────────────────────────────
|
||||
{
|
||||
accessorKey: "batch",
|
||||
|
|
|
|||
|
|
@ -4,8 +4,7 @@ import { motion } from "framer-motion";
|
|||
import { AlertCircle } from "lucide-react";
|
||||
import { Card, CardContent } from "@/app/shadcn_components/ui/card";
|
||||
import type { ClassifiedDeal } from "./types";
|
||||
|
||||
const SUCCESSFUL_OUTCOMES = new Set(["Surveyed", "Surveyed - Pending Upload", "EPC Completed"]);
|
||||
import { SUCCESSFUL_SURVEY_OUTCOMES } from "./types";
|
||||
|
||||
const COLUMNS: (keyof ClassifiedDeal)[] = [
|
||||
"dealname",
|
||||
|
|
@ -35,9 +34,14 @@ export default function SurveyIssuesPanel({
|
|||
deals,
|
||||
onOpenTable,
|
||||
}: SurveyIssuesPanelProps) {
|
||||
// Filter to deals with a populated outcome that is not a success
|
||||
// Deals with a non-successful outcome that are still in the active pipeline.
|
||||
// DNB-overridden rows are classified as "Removed from Bookings" and surfaced
|
||||
// in the Halted or Removed panel instead — exclude them here to avoid double-counting.
|
||||
const issueDeals = deals.filter(
|
||||
(d) => d.outcome && !SUCCESSFUL_OUTCOMES.has(d.outcome),
|
||||
(d) =>
|
||||
d.outcome &&
|
||||
!SUCCESSFUL_SURVEY_OUTCOMES.has(d.outcome) &&
|
||||
d.displayStage !== "Removed from Bookings",
|
||||
);
|
||||
|
||||
if (issueDeals.length === 0) return null;
|
||||
|
|
|
|||
|
|
@ -1,145 +0,0 @@
|
|||
"use client";
|
||||
|
||||
import { DonutChart, Card, Title } from "@tremor/react";
|
||||
import { useState } from "react";
|
||||
import type { OutcomeSlice, ClassifiedDeal } from "./types";
|
||||
|
||||
interface SurveyedPieChartProps {
|
||||
slices: OutcomeSlice[];
|
||||
dealsByOutcome: Record<string, ClassifiedDeal[]>;
|
||||
onOpenTable?: (outcome: string, filteredDeals: ClassifiedDeal[]) => void;
|
||||
}
|
||||
|
||||
export default function SurveyedResultsPieChart({
|
||||
slices,
|
||||
dealsByOutcome,
|
||||
onOpenTable,
|
||||
}: SurveyedPieChartProps) {
|
||||
const colors = [
|
||||
"indigo-600",
|
||||
"indigo-400",
|
||||
"blue-300",
|
||||
"amber-400",
|
||||
"amber-200",
|
||||
"slate-400",
|
||||
"gray-300",
|
||||
"gray-100",
|
||||
"gray-200",
|
||||
];
|
||||
|
||||
const [hovered, setHovered] = useState<string | null>(null);
|
||||
|
||||
const handleClick = (slice: OutcomeSlice) => {
|
||||
if (!slice) return;
|
||||
const filteredDeals = dealsByOutcome[slice.name] ?? [];
|
||||
onOpenTable?.(slice.name, filteredDeals);
|
||||
};
|
||||
|
||||
// Don't show the chart if there's no data
|
||||
if (slices.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Convert OutcomeSlice to chart data format
|
||||
const chartData = slices.map((slice) => ({
|
||||
name: slice.name,
|
||||
amount: slice.amount,
|
||||
percentage: slice.percentage,
|
||||
}));
|
||||
|
||||
return (
|
||||
<Card className="flex flex-col items-center p-8 bg-gradient-to-br from-white to-brandlightblue/5 border border-brandblue/10">
|
||||
{/* Header */}
|
||||
<div className="text-center mb-8 pb-6 border-b border-brandblue/10 w-full">
|
||||
<Title className="text-brandblue text-[16px] font-bold tracking-tight mb-2">
|
||||
Survey Performance
|
||||
</Title>
|
||||
<p className="text-xs text-gray-500">
|
||||
Click a segment or label to view filtered properties
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Donut Chart (Centered) */}
|
||||
<div className="relative flex justify-center items-center mt-6">
|
||||
<DonutChart
|
||||
data={chartData}
|
||||
category="amount"
|
||||
index="name"
|
||||
valueFormatter={(n) => `${n.toLocaleString()}`}
|
||||
colors={colors}
|
||||
onValueChange={handleClick}
|
||||
showLabel={false}
|
||||
className="w-64 h-64 cursor-pointer"
|
||||
customTooltip={({ payload }) => {
|
||||
const item = payload?.[0]?.payload;
|
||||
if (!item) return null;
|
||||
const { name, amount } = item;
|
||||
return (
|
||||
<div
|
||||
className="bg-white/90 backdrop-blur-md px-4 py-3 rounded-lg shadow-lg
|
||||
border border-brandblue/20 text-gray-800 text-sm font-medium"
|
||||
>
|
||||
<div className="flex flex-col items-center">
|
||||
<span className="text-[0.95rem] font-bold text-brandblue">
|
||||
{name}
|
||||
</span>
|
||||
<span className="text-gray-600 text-xs mt-1">
|
||||
{amount.toLocaleString()}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
{slices.length > 0 && (
|
||||
<div className="absolute text-center">
|
||||
<span className="text-4xl font-bold text-brandblue">
|
||||
{slices.reduce((a, b) => a + b.amount, 0)}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Legend (Clean Grid Layout) */}
|
||||
<div className="mt-10 flex flex-wrap justify-center gap-x-6 gap-y-3 max-w-[95%] border-t border-brandblue/10 pt-8">
|
||||
{slices.map((slice, idx) => (
|
||||
<button
|
||||
key={slice.name}
|
||||
onClick={() => handleClick(slice)}
|
||||
onMouseEnter={() => setHovered(slice.name)}
|
||||
onMouseLeave={() => setHovered(null)}
|
||||
className="relative flex items-center space-x-2 text-sm text-gray-700 hover:text-brandblue cursor-pointer transition-colors px-3 py-2 rounded-lg hover:bg-brandlightblue/20"
|
||||
>
|
||||
<span
|
||||
className={`inline-block w-3 h-3 rounded-full bg-${colors[idx]} border-2 border-${colors[idx]}/40 flex-shrink-0 transition-all`}
|
||||
/>
|
||||
<span className="font-medium truncate max-w-[100px]">
|
||||
{slice.name}
|
||||
</span>
|
||||
<span className="text-xs text-gray-500 ml-1 whitespace-nowrap font-semibold">
|
||||
{slice.percentage}%
|
||||
</span>
|
||||
|
||||
{/* Tooltip on hover */}
|
||||
{hovered === slice.name && (
|
||||
<div
|
||||
className="absolute -top-12 left-1/2 -translate-x-1/2 bg-white/95 backdrop-blur-md
|
||||
px-4 py-3 rounded-lg shadow-lg border border-brandblue/20 text-gray-800
|
||||
text-sm font-medium whitespace-nowrap z-20"
|
||||
>
|
||||
<div className="flex flex-col items-center">
|
||||
<span className="text-[0.95rem] font-bold text-brandblue">
|
||||
{slice.name}
|
||||
</span>
|
||||
<span className="text-gray-600 text-xs mt-1">
|
||||
{slice.amount.toLocaleString()}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
|
@ -162,10 +162,11 @@ const mockDealRow = {
|
|||
majorConditionIssuePhotosS3: null,
|
||||
coordinationStatus: null,
|
||||
designStatus: null,
|
||||
bookingStatus: null,
|
||||
pashubLink: null,
|
||||
sharepointLink: null,
|
||||
dampmouldGrowth: null,
|
||||
damnpMouldAndRepairComments: null,
|
||||
dampMouldAndRepairComments: null,
|
||||
preSap: null,
|
||||
mtpCompletionDate: null,
|
||||
mtpReModelCompletionDate: null,
|
||||
|
|
@ -187,6 +188,7 @@ const mockDealRow = {
|
|||
eiScorePotential: null,
|
||||
epcSapScore: null,
|
||||
epcSapScorePotential: null,
|
||||
epcPrn: null,
|
||||
surveyType: null,
|
||||
measuresForPibiOrdered: null,
|
||||
pibiOrderDate: null,
|
||||
|
|
|
|||
|
|
@ -31,10 +31,11 @@ export function mapDbRowToHubspotDeal(row: DealRow): HubspotDeal {
|
|||
majorConditionIssuePhotosS3: d.majorConditionIssuePhotosS3,
|
||||
coordinationStatus: d.coordinationStatus,
|
||||
designStatus: d.designStatus,
|
||||
bookingStatus: d.bookingStatus,
|
||||
pashubLink: d.pashubLink,
|
||||
sharepointLink: d.sharepointLink,
|
||||
dampMouldFlag: d.dampmouldGrowth,
|
||||
dampMouldAndRepairComments: d.damnpMouldAndRepairComments,
|
||||
dampMouldAndRepairComments: d.dampMouldAndRepairComments,
|
||||
preSapScore: d.preSap,
|
||||
coordinator: row.coordinator,
|
||||
ioeV1Date: d.mtpCompletionDate,
|
||||
|
|
@ -58,6 +59,7 @@ export function mapDbRowToHubspotDeal(row: DealRow): HubspotDeal {
|
|||
eiScorePotential: d.eiScorePotential,
|
||||
epcSapScore: d.epcSapScore,
|
||||
epcSapScorePotential: d.epcSapScorePotential,
|
||||
epcPrn: d.epcPrn,
|
||||
surveyType: d.surveyType,
|
||||
measuresForPibiOrdered: d.measuresForPibiOrdered,
|
||||
pibiOrderDate: d.pibiOrderDate,
|
||||
|
|
|
|||
|
|
@ -20,6 +20,7 @@ function makeDeal(overrides: Partial<HubspotDeal> = {}): HubspotDeal {
|
|||
majorConditionIssuePhotosS3: null,
|
||||
coordinationStatus: null,
|
||||
designStatus: null,
|
||||
bookingStatus: null,
|
||||
pashubLink: null,
|
||||
sharepointLink: null,
|
||||
dampMouldFlag: null,
|
||||
|
|
@ -47,6 +48,7 @@ function makeDeal(overrides: Partial<HubspotDeal> = {}): HubspotDeal {
|
|||
eiScorePotential: null,
|
||||
epcSapScore: null,
|
||||
epcSapScorePotential: null,
|
||||
epcPrn: null,
|
||||
surveyType: null,
|
||||
measuresForPibiOrdered: null,
|
||||
pibiOrderDate: null,
|
||||
|
|
|
|||
|
|
@ -19,6 +19,7 @@ function makeDeal(overrides: Partial<ClassifiedDeal> = {}): ClassifiedDeal {
|
|||
majorConditionIssuePhotosS3: null,
|
||||
coordinationStatus: null,
|
||||
designStatus: null,
|
||||
bookingStatus: null,
|
||||
pashubLink: null,
|
||||
sharepointLink: null,
|
||||
dampMouldFlag: null,
|
||||
|
|
@ -46,6 +47,7 @@ function makeDeal(overrides: Partial<ClassifiedDeal> = {}): ClassifiedDeal {
|
|||
eiScorePotential: null,
|
||||
epcSapScore: null,
|
||||
epcSapScorePotential: null,
|
||||
epcPrn: null,
|
||||
surveyType: null,
|
||||
measuresForPibiOrdered: null,
|
||||
pibiOrderDate: null,
|
||||
|
|
|
|||
|
|
@ -0,0 +1,116 @@
|
|||
import { describe, it, expect } from "vitest";
|
||||
import { formatPropertyCsv, PROPERTY_CSV_FIELDS } from "./propertyCsv";
|
||||
import type { ClassifiedDeal, HubspotDeal } from "./types";
|
||||
|
||||
function makeDeal(overrides: Partial<HubspotDeal> = {}): HubspotDeal {
|
||||
return {
|
||||
id: "1",
|
||||
dealId: "deal-1",
|
||||
dealname: "Test Property",
|
||||
dealstage: null,
|
||||
companyId: null,
|
||||
projectCode: null,
|
||||
landlordPropertyId: null,
|
||||
uprn: null,
|
||||
outcome: null,
|
||||
outcomeNotes: null,
|
||||
majorConditionIssueDescription: null,
|
||||
majorConditionIssuePhotos: null,
|
||||
majorConditionIssuePhotosS3: null,
|
||||
coordinationStatus: null,
|
||||
designStatus: null,
|
||||
bookingStatus: null,
|
||||
pashubLink: null,
|
||||
sharepointLink: null,
|
||||
dampMouldFlag: null,
|
||||
dampMouldAndRepairComments: null,
|
||||
preSapScore: null,
|
||||
coordinator: null,
|
||||
ioeV1Date: null,
|
||||
ioeV2Date: null,
|
||||
ioeV3Date: null,
|
||||
proposedMeasures: null,
|
||||
approvedPackage: null,
|
||||
designer: null,
|
||||
designDate: null,
|
||||
actualMeasuresInstalled: null,
|
||||
installer: null,
|
||||
installerHandover: null,
|
||||
lodgementStatus: null,
|
||||
measuresLodgementDate: null,
|
||||
fullLodgementDate: null,
|
||||
confirmedSurveyDate: null,
|
||||
confirmedSurveyTime: null,
|
||||
surveyedDate: null,
|
||||
designType: null,
|
||||
eiScore: null,
|
||||
eiScorePotential: null,
|
||||
epcSapScore: null,
|
||||
epcSapScorePotential: null,
|
||||
epcPrn: null,
|
||||
surveyType: null,
|
||||
measuresForPibiOrdered: null,
|
||||
pibiOrderDate: null,
|
||||
pibiCompletedDate: null,
|
||||
propertyHaltedDate: null,
|
||||
propertyHaltedReason: null,
|
||||
technicalApprovedMeasuresForInstall: null,
|
||||
domnaSurveyType: null,
|
||||
domnaSurveyDate: null,
|
||||
batch: null,
|
||||
batchDescription: null,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function makeClassified(overrides: Partial<ClassifiedDeal> = {}): ClassifiedDeal {
|
||||
return { ...makeDeal(overrides), displayStage: "Scope & Planning", ...overrides };
|
||||
}
|
||||
|
||||
function headerCells(csv: string): string[] {
|
||||
return csv.split("\n")[0].split(",");
|
||||
}
|
||||
|
||||
function rowCells(csv: string, rowIndex: number): string[] {
|
||||
return csv.split("\n")[rowIndex + 1].split(",");
|
||||
}
|
||||
|
||||
describe("formatPropertyCsv", () => {
|
||||
it("includes Surveyed Date and EPC Certificate Number headers, with values for a populated deal", () => {
|
||||
const deal = makeClassified({
|
||||
surveyedDate: new Date("2026-03-15T00:00:00Z"),
|
||||
epcPrn: "1234-5678-9012-3456-7890",
|
||||
});
|
||||
|
||||
const csv = formatPropertyCsv([deal]);
|
||||
const headers = headerCells(csv);
|
||||
const cells = rowCells(csv, 0);
|
||||
|
||||
const surveyedIdx = headers.indexOf("Surveyed Date");
|
||||
const prnIdx = headers.indexOf("EPC Certificate Number");
|
||||
|
||||
expect(surveyedIdx).toBeGreaterThan(-1);
|
||||
expect(prnIdx).toBeGreaterThan(-1);
|
||||
expect(cells[surveyedIdx]).toMatch(/^\d{2}\/\d{2}\/\d{4}$/);
|
||||
expect(cells[prnIdx]).toBe("1234-5678-9012-3456-7890");
|
||||
});
|
||||
|
||||
it("renders null fields as empty cells", () => {
|
||||
const deal = makeClassified({ surveyedDate: null, epcPrn: null });
|
||||
|
||||
const csv = formatPropertyCsv([deal]);
|
||||
const headers = headerCells(csv);
|
||||
const cells = rowCells(csv, 0);
|
||||
|
||||
expect(cells[headers.indexOf("Surveyed Date")]).toBe("");
|
||||
expect(cells[headers.indexOf("EPC Certificate Number")]).toBe("");
|
||||
});
|
||||
|
||||
it("emits header row alone when given no rows", () => {
|
||||
const csv = formatPropertyCsv([]);
|
||||
expect(csv.split("\n")).toHaveLength(1);
|
||||
expect(headerCells(csv)).toEqual(PROPERTY_CSV_FIELDS.map((f) => f.label));
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,52 @@
|
|||
import type { ClassifiedDeal } from "./types";
|
||||
|
||||
export type PropertyCsvField = { key: keyof ClassifiedDeal; label: string };
|
||||
|
||||
export const PROPERTY_CSV_FIELDS: PropertyCsvField[] = [
|
||||
{ key: "dealname", label: "Address" },
|
||||
{ key: "landlordPropertyId", label: "Property Ref" },
|
||||
{ key: "uprn", label: "UPRN" },
|
||||
{ key: "displayStage", label: "Stage" },
|
||||
{ key: "projectCode", label: "Project" },
|
||||
{ key: "coordinator", label: "Coordinator" },
|
||||
{ key: "designer", label: "Designer" },
|
||||
{ key: "installer", label: "Installer" },
|
||||
{ key: "proposedMeasures", label: "Proposed Measures" },
|
||||
{ key: "approvedPackage", label: "Approved Package" },
|
||||
{ key: "actualMeasuresInstalled", label: "Installed Measures" },
|
||||
{ key: "preSapScore", label: "Pre-SAP" },
|
||||
{ key: "eiScore", label: "EI Score" },
|
||||
{ key: "eiScorePotential", label: "EI Score (Potential)" },
|
||||
{ key: "epcSapScore", label: "EPC SAP Score" },
|
||||
{ key: "epcSapScorePotential", label: "EPC SAP (Potential)" },
|
||||
{ key: "lodgementStatus", label: "Lodgement Status" },
|
||||
{ key: "surveyedDate", label: "Surveyed Date" },
|
||||
{ key: "designDate", label: "Design Date" },
|
||||
{ key: "fullLodgementDate", label: "Lodgement Date" },
|
||||
{ key: "epcPrn", label: "EPC Certificate Number" },
|
||||
{ key: "batch", label: "Group" },
|
||||
{ key: "batchDescription", label: "Group Description" },
|
||||
];
|
||||
|
||||
export function escapeCsvCell(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 function formatPropertyCsv(
|
||||
rows: ClassifiedDeal[],
|
||||
fields: PropertyCsvField[] = PROPERTY_CSV_FIELDS,
|
||||
): string {
|
||||
const header = fields.map((f) => f.label).join(",");
|
||||
if (rows.length === 0) return header;
|
||||
const body = rows
|
||||
.map((row) => fields.map((f) => escapeCsvCell(row[f.key])).join(","))
|
||||
.join("\n");
|
||||
return header + "\n" + body;
|
||||
}
|
||||
|
|
@ -5,7 +5,6 @@ import {
|
|||
computeDampMouldRisk,
|
||||
computeFunnelStages,
|
||||
computeProjectProgress,
|
||||
computeOutcomeSlices,
|
||||
computeLiveTrackerData,
|
||||
} from "./transforms";
|
||||
import type { HubspotDeal, ClassifiedDeal } from "./types";
|
||||
|
|
@ -27,6 +26,7 @@ function makeDeal(overrides: Partial<HubspotDeal> = {}): HubspotDeal {
|
|||
majorConditionIssuePhotosS3: null,
|
||||
coordinationStatus: null,
|
||||
designStatus: null,
|
||||
bookingStatus: null,
|
||||
pashubLink: null,
|
||||
sharepointLink: null,
|
||||
dampMouldFlag: null,
|
||||
|
|
@ -54,6 +54,7 @@ function makeDeal(overrides: Partial<HubspotDeal> = {}): HubspotDeal {
|
|||
eiScorePotential: null,
|
||||
epcSapScore: null,
|
||||
epcSapScorePotential: null,
|
||||
epcPrn: null,
|
||||
surveyType: null,
|
||||
measuresForPibiOrdered: null,
|
||||
pibiOrderDate: null,
|
||||
|
|
@ -132,6 +133,117 @@ describe("resolveDisplayStage — RA ISSUE override on non-AFTER_ASSESSMENT stag
|
|||
});
|
||||
});
|
||||
|
||||
describe("resolveDisplayStage — Removed from Bookings", () => {
|
||||
it("classifies as Removed from Bookings when bookingStatus is Do Not Book and no outcome", () => {
|
||||
expect(
|
||||
resolveDisplayStage(makeDeal({
|
||||
dealstage: "1617223910",
|
||||
bookingStatus: "Do Not Book",
|
||||
outcome: null,
|
||||
}))
|
||||
).toBe("Removed from Bookings");
|
||||
});
|
||||
|
||||
it("classifies as Removed from Bookings when bookingStatus is Do Not Book and outcome is a non-successful survey outcome", () => {
|
||||
// Tenant Refusal is a non-successful outcome; with DNB the property is removed from bookings,
|
||||
// not lingering in the normal pipeline.
|
||||
expect(
|
||||
resolveDisplayStage(makeDeal({
|
||||
dealstage: "1617223910",
|
||||
bookingStatus: "Do Not Book",
|
||||
outcome: "Tenant Refusal",
|
||||
}))
|
||||
).toBe("Removed from Bookings");
|
||||
});
|
||||
|
||||
it("falls back to normal stage resolution when bookingStatus is Do Not Book but outcome is Surveyed", () => {
|
||||
// Real survey history overrides DNB — the property stays in the normal pipeline.
|
||||
expect(
|
||||
resolveDisplayStage(makeDeal({
|
||||
dealstage: "1617223910",
|
||||
bookingStatus: "Do Not Book",
|
||||
outcome: "Surveyed",
|
||||
}))
|
||||
).toBe("Scope & Planning");
|
||||
});
|
||||
|
||||
it("falls back to normal stage resolution when bookingStatus is Do Not Book but outcome is Surveyed - Pending Upload", () => {
|
||||
expect(
|
||||
resolveDisplayStage(makeDeal({
|
||||
dealstage: "1617223910",
|
||||
bookingStatus: "Do Not Book",
|
||||
outcome: "Surveyed - Pending Upload",
|
||||
}))
|
||||
).toBe("Scope & Planning");
|
||||
});
|
||||
|
||||
it("falls back to normal stage resolution when bookingStatus is Do Not Book but outcome is EPC Completed", () => {
|
||||
expect(
|
||||
resolveDisplayStage(makeDeal({
|
||||
dealstage: "1617223910",
|
||||
bookingStatus: "Do Not Book",
|
||||
outcome: "EPC Completed",
|
||||
}))
|
||||
).toBe("Scope & Planning");
|
||||
});
|
||||
|
||||
it("classifies as Removed from Bookings for Not Viable + Do Not Book even when the dealstage maps to Queries", () => {
|
||||
// "1887735998" is the Not Viable dealstage, which normally maps to "Queries".
|
||||
// With DNB + a non-successful outcome, Removed from Bookings takes precedence over the Queries mapping.
|
||||
expect(
|
||||
resolveDisplayStage(makeDeal({
|
||||
dealstage: "1887735998",
|
||||
bookingStatus: "Do Not Book",
|
||||
outcome: "Not Viable",
|
||||
}))
|
||||
).toBe("Removed from Bookings");
|
||||
});
|
||||
|
||||
it("does not classify as Removed from Bookings when an outcome is set but bookingStatus is null", () => {
|
||||
expect(
|
||||
resolveDisplayStage(makeDeal({
|
||||
dealstage: "1617223910",
|
||||
bookingStatus: null,
|
||||
outcome: "Surveyed",
|
||||
}))
|
||||
).toBe("Scope & Planning");
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolveDisplayStage — Removed from Program", () => {
|
||||
it("classifies as Removed from Program when batch is 'Removed from Program' regardless of dealstage", () => {
|
||||
expect(
|
||||
resolveDisplayStage(makeDeal({
|
||||
dealstage: "1617223913",
|
||||
batch: "Removed from Program",
|
||||
}))
|
||||
).toBe("Removed from Program");
|
||||
});
|
||||
|
||||
it("Removed from Program takes precedence over Removed from Bookings", () => {
|
||||
expect(
|
||||
resolveDisplayStage(makeDeal({
|
||||
batch: "Removed from Program",
|
||||
bookingStatus: "Do Not Book",
|
||||
outcome: null,
|
||||
}))
|
||||
).toBe("Removed from Program");
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolveDisplayStage — precedence between new stages and existing classification", () => {
|
||||
it("Removed from Bookings takes precedence over a Queries dealstage", () => {
|
||||
// "2663668937" is a Queries-mapped dealstage; without Do Not Book it'd resolve to "Queries"
|
||||
expect(
|
||||
resolveDisplayStage(makeDeal({
|
||||
dealstage: "2663668937",
|
||||
bookingStatus: "Do Not Book",
|
||||
outcome: null,
|
||||
}))
|
||||
).toBe("Removed from Bookings");
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolveDisplayStage — AFTER_ASSESSMENT sub-classification", () => {
|
||||
const AFTER_ASSESSMENT_STAGE = "3948185842";
|
||||
|
||||
|
|
@ -305,6 +417,38 @@ describe("computeDampMouldRisk", () => {
|
|||
expect(result.coordinatorFlagCount).toBe(2);
|
||||
});
|
||||
|
||||
it("ignores a growth flag of 'No' when there is no comment", () => {
|
||||
const deals = [
|
||||
makeClassified({ dampMouldFlag: "No", dampMouldAndRepairComments: null }),
|
||||
makeClassified({ dampMouldFlag: "no", dampMouldAndRepairComments: " " }),
|
||||
];
|
||||
const result = computeDampMouldRisk(deals);
|
||||
expect(result.coordinatorFlagCount).toBe(0);
|
||||
});
|
||||
|
||||
it("treats a 'yes' growth flag case-insensitively, ignoring whitespace", () => {
|
||||
const deals = [
|
||||
makeClassified({ dampMouldFlag: "yes" }),
|
||||
makeClassified({ dampMouldFlag: " Yes " }),
|
||||
makeClassified({ dampMouldFlag: "YES" }),
|
||||
];
|
||||
const result = computeDampMouldRisk(deals);
|
||||
expect(result.coordinatorFlagCount).toBe(3);
|
||||
});
|
||||
|
||||
it("counts a deal with a comment but no growth flag as coordinator-flagged", () => {
|
||||
const deals = [
|
||||
makeClassified({
|
||||
dampMouldFlag: null,
|
||||
dampMouldAndRepairComments: "Mould in NE bedroom corner",
|
||||
}),
|
||||
makeClassified({ dampMouldFlag: null, dampMouldAndRepairComments: null }),
|
||||
];
|
||||
const result = computeDampMouldRisk(deals);
|
||||
expect(result.coordinatorFlagCount).toBe(1);
|
||||
expect(result.coordinatorFlagDeals).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("counts deals flagged at both stages independently", () => {
|
||||
const deals = [
|
||||
makeClassified({ majorConditionIssuePhotosS3: "s3://x", dampMouldFlag: "Yes" }),
|
||||
|
|
@ -367,6 +511,17 @@ describe("computeFunnelStages", () => {
|
|||
const funnel = computeFunnelStages([]);
|
||||
expect(funnel.length).toBe(10); // STAGE_ORDER has 10 entries
|
||||
});
|
||||
|
||||
it("excludes Removed from Bookings and Removed from Program from counts and percentages", () => {
|
||||
const deals = [
|
||||
makeClassified({ displayStage: "Scope & Planning" }),
|
||||
makeClassified({ displayStage: "Removed from Bookings" }),
|
||||
makeClassified({ displayStage: "Removed from Program" }),
|
||||
];
|
||||
const funnel = computeFunnelStages(deals);
|
||||
const scopeEntry = funnel.find((f) => f.stage === "Scope & Planning")!;
|
||||
expect(scopeEntry.currentPct).toBe(100); // 1 out of 1 in-pipeline deal
|
||||
});
|
||||
});
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
|
|
@ -422,40 +577,16 @@ describe("computeProjectProgress", () => {
|
|||
const result = computeProjectProgress([makeClassified({ displayStage: "Queries" })]);
|
||||
expect(result.stageProgress.find((s) => s.stage === "Queries")).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// computeOutcomeSlices
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
describe("computeOutcomeSlices", () => {
|
||||
it("counts only known SURVEYOR_OUTCOMES values", () => {
|
||||
it("excludes Removed from Bookings and Removed from Program from nonQueryTotal", () => {
|
||||
const deals = [
|
||||
makeClassified({ outcome: "Surveyed" }),
|
||||
makeClassified({ outcome: "Surveyed" }),
|
||||
makeClassified({ outcome: "UNKNOWN_VALUE" }),
|
||||
makeClassified({ outcome: null }),
|
||||
makeClassified({ displayStage: "Scope & Planning" }),
|
||||
makeClassified({ displayStage: "Removed from Bookings" }),
|
||||
makeClassified({ displayStage: "Removed from Program" }),
|
||||
];
|
||||
const slices = computeOutcomeSlices(deals);
|
||||
expect(slices).toHaveLength(1);
|
||||
expect(slices[0].name).toBe("Surveyed");
|
||||
expect(slices[0].amount).toBe(2);
|
||||
});
|
||||
|
||||
it("computes percentage as a formatted string with one decimal place", () => {
|
||||
const deals = [
|
||||
makeClassified({ outcome: "Surveyed" }),
|
||||
makeClassified({ outcome: "Surveyed" }),
|
||||
makeClassified({ outcome: "Other" }),
|
||||
makeClassified({ outcome: "Other" }),
|
||||
];
|
||||
const slices = computeOutcomeSlices(deals);
|
||||
const surveyed = slices.find((s) => s.name === "Surveyed")!;
|
||||
expect(surveyed.percentage).toBe("50.0");
|
||||
});
|
||||
|
||||
it("returns empty array when no deals have matching outcomes", () => {
|
||||
expect(computeOutcomeSlices([makeClassified({ outcome: null })])).toEqual([]);
|
||||
const result = computeProjectProgress(deals);
|
||||
expect(result.totalDeals).toBe(3);
|
||||
expect(result.nonQueryTotal).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -9,7 +9,6 @@ import type {
|
|||
DisplayStage,
|
||||
ProjectProgressData,
|
||||
ProjectData,
|
||||
OutcomeSlice,
|
||||
LiveTrackerProps,
|
||||
|
||||
DampMouldRiskData,
|
||||
|
|
@ -18,10 +17,18 @@ import type {
|
|||
|
||||
import {
|
||||
STAGE_ORDER,
|
||||
SURVEYOR_OUTCOMES,
|
||||
MAJOR_CONDITION_STAGE_ID,
|
||||
SUCCESSFUL_SURVEY_OUTCOMES,
|
||||
} from "./types";
|
||||
|
||||
// Terminal stages that exit the pipeline by design — excluded from funnel
|
||||
// counts and stage-progress denominators.
|
||||
const STAGES_EXCLUDED_FROM_PIPELINE: ReadonlySet<DisplayStage> = new Set<DisplayStage>([
|
||||
"Queries",
|
||||
"Removed from Bookings",
|
||||
"Removed from Program",
|
||||
]);
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Stage ID -> raw label mapping
|
||||
// -----------------------------------------------------------------------
|
||||
|
|
@ -106,6 +113,17 @@ function resolvePostDesignStage(deal: HubspotDeal): DisplayStage {
|
|||
// Maps dealstage ID + coordination/design/install status -> DisplayStage
|
||||
// -----------------------------------------------------------------------
|
||||
export function resolveDisplayStage(deal: HubspotDeal): DisplayStage {
|
||||
if (deal.batch === "Removed from Program") {
|
||||
return "Removed from Program";
|
||||
}
|
||||
|
||||
if (
|
||||
deal.bookingStatus === "Do Not Book" &&
|
||||
!(deal.outcome && SUCCESSFUL_SURVEY_OUTCOMES.has(deal.outcome))
|
||||
) {
|
||||
return "Removed from Bookings";
|
||||
}
|
||||
|
||||
const raw = STAGE_ID_MAP[deal.dealstage ?? ""] ?? "AFTER_ASSESSMENT";
|
||||
|
||||
if (raw === "AFTER_ASSESSMENT") {
|
||||
|
|
@ -145,10 +163,16 @@ export function classifyDeals(deals: HubspotDeal[]): ClassifiedDeal[] {
|
|||
// -----------------------------------------------------------------------
|
||||
// Compute damp & mould risk — survey vs coordination stage comparison
|
||||
// -----------------------------------------------------------------------
|
||||
function isCoordinatorFlagged(d: ClassifiedDeal): boolean {
|
||||
const growthIsYes = d.dampMouldFlag?.trim().toLowerCase() === "yes";
|
||||
const hasComment = !!d.dampMouldAndRepairComments?.trim();
|
||||
return growthIsYes || hasComment;
|
||||
}
|
||||
|
||||
export function computeDampMouldRisk(deals: ClassifiedDeal[]): DampMouldRiskData {
|
||||
const surveyFlagDeals = deals.filter((d) => !!d.majorConditionIssuePhotosS3);
|
||||
const coordinatorFlagDeals = deals.filter((d) => !!d.dampMouldFlag);
|
||||
const bothFlaggedCount = surveyFlagDeals.filter((d) => !!d.dampMouldFlag).length;
|
||||
const coordinatorFlagDeals = deals.filter(isCoordinatorFlagged);
|
||||
const bothFlaggedCount = surveyFlagDeals.filter(isCoordinatorFlagged).length;
|
||||
|
||||
return {
|
||||
surveyFlagCount: surveyFlagDeals.length,
|
||||
|
|
@ -164,7 +188,7 @@ export function computeDampMouldRisk(deals: ClassifiedDeal[]): DampMouldRiskData
|
|||
// Compute pipeline funnel — dual counts (current snapshot + cumulative)
|
||||
// -----------------------------------------------------------------------
|
||||
export function computeFunnelStages(deals: ClassifiedDeal[]): FunnelStage[] {
|
||||
const nonQueryDeals = deals.filter((d) => d.displayStage !== "Queries");
|
||||
const nonQueryDeals = deals.filter((d) => !STAGES_EXCLUDED_FROM_PIPELINE.has(d.displayStage));
|
||||
const total = nonQueryDeals.length;
|
||||
|
||||
return STAGE_ORDER.map((stage) => {
|
||||
|
|
@ -195,7 +219,7 @@ export function computeProjectProgress(
|
|||
deals: ClassifiedDeal[]
|
||||
): ProjectProgressData {
|
||||
const queriesDeals = deals.filter((d) => d.displayStage === "Queries");
|
||||
const nonQueryDeals = deals.filter((d) => d.displayStage !== "Queries");
|
||||
const nonQueryDeals = deals.filter((d) => !STAGES_EXCLUDED_FROM_PIPELINE.has(d.displayStage));
|
||||
const nonQueryTotal = nonQueryDeals.length;
|
||||
|
||||
// Stage counts/percentages (queries excluded from percentage calculation)
|
||||
|
|
@ -204,7 +228,7 @@ export function computeProjectProgress(
|
|||
(stageBuckets[deal.displayStage] ??= []).push(deal);
|
||||
}
|
||||
|
||||
const stageProgress = STAGE_ORDER.filter((s) => s !== "Queries").map(
|
||||
const stageProgress = STAGE_ORDER.filter((s) => !STAGES_EXCLUDED_FROM_PIPELINE.has(s)).map(
|
||||
(stage) => {
|
||||
const stageDeals = stageBuckets[stage] ?? [];
|
||||
return {
|
||||
|
|
@ -237,34 +261,6 @@ export function computeProjectProgress(
|
|||
};
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Compute outcome pie slices for the surveyed pie chart
|
||||
// -----------------------------------------------------------------------
|
||||
export function computeOutcomeSlices(deals: ClassifiedDeal[]): OutcomeSlice[] {
|
||||
const counts: Partial<Record<string, number>> = {};
|
||||
|
||||
for (const deal of deals) {
|
||||
if (
|
||||
deal.outcome &&
|
||||
(SURVEYOR_OUTCOMES as readonly string[]).includes(deal.outcome)
|
||||
) {
|
||||
counts[deal.outcome] = (counts[deal.outcome] ?? 0) + 1;
|
||||
}
|
||||
}
|
||||
|
||||
const total = Object.values(counts).reduce<number>(
|
||||
(a, b) => a + (b ?? 0),
|
||||
0
|
||||
);
|
||||
|
||||
return Object.entries(counts).map(([name, amount]) => ({
|
||||
name,
|
||||
amount: amount ?? 0,
|
||||
percentage:
|
||||
total > 0 ? (((amount ?? 0) / total) * 100).toFixed(1) : "0.0",
|
||||
}));
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Top-level function called by page.tsx
|
||||
// Orchestrates all transformations: classify, group by project, compute stats
|
||||
|
|
@ -287,12 +283,11 @@ export function computeLiveTrackerData(
|
|||
(grouped[key] ??= []).push(deal);
|
||||
}
|
||||
|
||||
// For each project group, compute progress data and outcome slices
|
||||
// For each project group, compute progress data
|
||||
const projects: ProjectData[] = Object.entries(grouped).map(
|
||||
([projectCode, deals]) => ({
|
||||
projectCode,
|
||||
progress: computeProjectProgress(deals),
|
||||
outcomePieSlices: computeOutcomeSlices(deals),
|
||||
allDeals: deals,
|
||||
})
|
||||
);
|
||||
|
|
@ -302,7 +297,6 @@ export function computeLiveTrackerData(
|
|||
projects.unshift({
|
||||
projectCode: "__ALL__",
|
||||
progress: computeProjectProgress(classified),
|
||||
outcomePieSlices: computeOutcomeSlices(classified),
|
||||
allDeals: classified,
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -23,6 +23,7 @@ export type HubspotDeal = {
|
|||
majorConditionIssuePhotosS3: string | null;
|
||||
coordinationStatus: string | null;
|
||||
designStatus: string | null;
|
||||
bookingStatus: string | null;
|
||||
|
||||
// ── CRM-synced additions ──────────────────────────────────────────────
|
||||
pashubLink: string | null;
|
||||
|
|
@ -52,6 +53,7 @@ export type HubspotDeal = {
|
|||
eiScorePotential: string | null;
|
||||
epcSapScore: string | null;
|
||||
epcSapScorePotential: string | null;
|
||||
epcPrn: string | null;
|
||||
|
||||
// ── New per-deal workflow fields (issue #249 slice) ────────────────────
|
||||
surveyType: string | null;
|
||||
|
|
@ -88,6 +90,8 @@ export type DisplayStage =
|
|||
| "At Post Survey"
|
||||
| "Project Complete"
|
||||
| "Queries"
|
||||
| "Removed from Bookings"
|
||||
| "Removed from Program"
|
||||
| "Unknown Stage";
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
|
|
@ -145,22 +149,12 @@ export type ProjectProgressData = {
|
|||
funnelStages: FunnelStage[];
|
||||
};
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Surveyed outcome entry (for pie chart)
|
||||
// -----------------------------------------------------------------------
|
||||
export type OutcomeSlice = {
|
||||
name: string; // outcome label
|
||||
amount: number;
|
||||
percentage: string; // pre-formatted "12.3"
|
||||
};
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// What LiveTracker receives from page.tsx for one project
|
||||
// -----------------------------------------------------------------------
|
||||
export type ProjectData = {
|
||||
projectCode: string;
|
||||
progress: ProjectProgressData;
|
||||
outcomePieSlices: OutcomeSlice[]; // empty array = hide pie chart
|
||||
allDeals: ClassifiedDeal[]; // for table drill-downs within project
|
||||
};
|
||||
|
||||
|
|
@ -322,6 +316,14 @@ export const SURVEYOR_OUTCOMES = [
|
|||
|
||||
export type SurveyorOutcome = (typeof SURVEYOR_OUTCOMES)[number];
|
||||
|
||||
// Outcomes that represent real completed survey history. Take precedence over
|
||||
// a "Do Not Book" booking status — the property stays in the normal pipeline.
|
||||
export const SUCCESSFUL_SURVEY_OUTCOMES: ReadonlySet<string> = new Set([
|
||||
"Surveyed",
|
||||
"Surveyed - Pending Upload",
|
||||
"EPC Completed",
|
||||
]);
|
||||
|
||||
export const MAJOR_CONDITION_STAGE_ID = "3061261536" as const;
|
||||
|
||||
// Order of stages for grouping/display (queries excluded from this list)
|
||||
|
|
@ -411,6 +413,18 @@ export const STAGE_COLORS: Record<
|
|||
border: "border-red-200",
|
||||
dot: "bg-red-400",
|
||||
},
|
||||
"Removed from Bookings": {
|
||||
bg: "bg-slate-50",
|
||||
text: "text-slate-600",
|
||||
border: "border-slate-200",
|
||||
dot: "bg-slate-400",
|
||||
},
|
||||
"Removed from Program": {
|
||||
bg: "bg-slate-100",
|
||||
text: "text-slate-700",
|
||||
border: "border-slate-300",
|
||||
dot: "bg-slate-500",
|
||||
},
|
||||
"Unknown Stage": {
|
||||
bg: "bg-gray-50",
|
||||
text: "text-gray-500",
|
||||
|
|
|
|||
31
src/app/shadcn_components/ui/popover.tsx
Normal file
31
src/app/shadcn_components/ui/popover.tsx
Normal file
|
|
@ -0,0 +1,31 @@
|
|||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import * as PopoverPrimitive from "@radix-ui/react-popover"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const Popover = PopoverPrimitive.Root
|
||||
|
||||
const PopoverTrigger = PopoverPrimitive.Trigger
|
||||
|
||||
const PopoverContent = React.forwardRef<
|
||||
React.ElementRef<typeof PopoverPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof PopoverPrimitive.Content>
|
||||
>(({ className, align = "center", sideOffset = 4, ...props }, ref) => (
|
||||
<PopoverPrimitive.Portal>
|
||||
<PopoverPrimitive.Content
|
||||
ref={ref}
|
||||
align={align}
|
||||
sideOffset={sideOffset}
|
||||
className={cn(
|
||||
"z-50 w-72 rounded-md border bg-popover p-4 text-popover-foreground shadow-md outline-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</PopoverPrimitive.Portal>
|
||||
))
|
||||
PopoverContent.displayName = PopoverPrimitive.Content.displayName
|
||||
|
||||
export { Popover, PopoverTrigger, PopoverContent }
|
||||
Loading…
Add table
Reference in a new issue