diff --git a/src/app/portfolio/[slug]/(portfolio)/your-projects/live/AnalyticsView.tsx b/src/app/portfolio/[slug]/(portfolio)/your-projects/live/AnalyticsView.tsx index 73b3dec2..526ed1f0 100644 --- a/src/app/portfolio/[slug]/(portfolio)/your-projects/live/AnalyticsView.tsx +++ b/src/app/portfolio/[slug]/(portfolio)/your-projects/live/AnalyticsView.tsx @@ -8,6 +8,7 @@ import SurveyedResultsPieChart from "./SurveyedResultsPieChart"; import DampMouldRiskPanel from "./DampMouldRiskPanel"; import CompletionTrendsChart from "./CompletionTrendsChart"; import SurveyIssuesPanel from "./SurveyIssuesPanel"; +import BatchFilter from "./BatchFilter"; import { STAGE_COLORS, STAGE_ORDER } from "./types"; import type { ProjectData, @@ -312,6 +313,10 @@ interface AnalyticsViewProps { ) => void; majorConditionDeals: ClassifiedDeal[]; totalDeals: number; + availableBatches: string[]; + batchFilter: string[]; + onBatchFilterChange: (next: string[]) => void; + batchFilterActive: boolean; } export default function AnalyticsView({ @@ -322,11 +327,20 @@ export default function AnalyticsView({ onOpenTable, majorConditionDeals, totalDeals, + availableBatches, + batchFilter, + onBatchFilterChange, + batchFilterActive, }: AnalyticsViewProps) { + const showBatchFilter = availableBatches.length > 0; return (
- {/* Row 1: project selector + stat card (Properties in project) */} -
+ {/* Row 1: project selector + (optional) batch filter + properties count */} +
{/* Project selector */}
@@ -355,10 +369,19 @@ export default function AnalyticsView({
- {/* Properties in project */} + {/* Batch filter — only when current project has batched deals */} + {showBatchFilter && ( + + )} + + {/* Properties in project (label swaps when batch filter is active) */} onOpenTable( diff --git a/src/app/portfolio/[slug]/(portfolio)/your-projects/live/BatchFilter.tsx b/src/app/portfolio/[slug]/(portfolio)/your-projects/live/BatchFilter.tsx new file mode 100644 index 00000000..1fd765a2 --- /dev/null +++ b/src/app/portfolio/[slug]/(portfolio)/your-projects/live/BatchFilter.tsx @@ -0,0 +1,123 @@ +"use client"; + +import { ChevronDown, Layers } from "lucide-react"; +import { + DropdownMenu, + DropdownMenuCheckboxItem, + DropdownMenuContent, + DropdownMenuLabel, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from "@/app/shadcn_components/ui/dropdown-menu"; +import { Card } from "@/app/shadcn_components/ui/card"; + +export const UNBATCHED_KEY = "__UNBATCHED__" as const; + +interface BatchFilterProps { + options: string[]; // batch codes present in the current project; may include UNBATCHED_KEY + selected: string[]; // empty array = no filter applied (show everything) + onChange: (next: string[]) => void; + variant?: "card" | "inline"; +} + +function BatchDropdown({ + options, + selected, + onChange, + triggerClassName, + align = "start", +}: BatchFilterProps & { + triggerClassName: string; + align?: "start" | "end"; +}) { + const toggle = (value: string, checked: boolean) => { + if (checked) { + onChange([...selected, value]); + } else { + onChange(selected.filter((v) => v !== value)); + } + }; + + const label = + selected.length === 0 + ? "All groups" + : selected + .map((s) => (s === UNBATCHED_KEY ? "(Ungrouped)" : s)) + .join(", "); + + return ( + + + + + + + Groups + {selected.length > 0 && ( + + )} + + +
+ {options.map((opt) => ( + toggle(opt, !!val)} + onSelect={(e) => e.preventDefault()} + className="text-sm" + > + {opt === UNBATCHED_KEY ? ( + (Ungrouped) + ) : ( + opt + )} + + ))} +
+
+
+ ); +} + +export default function BatchFilter(props: BatchFilterProps) { + const { variant = "card" } = props; + + if (variant === "inline") { + return ( + + ); + } + + return ( + +
+

+ Filter by Group +

+ +
+
+ ); +} diff --git a/src/app/portfolio/[slug]/(portfolio)/your-projects/live/DocumentTable.tsx b/src/app/portfolio/[slug]/(portfolio)/your-projects/live/DocumentTable.tsx index 8efbcb81..cd09460e 100644 --- a/src/app/portfolio/[slug]/(portfolio)/your-projects/live/DocumentTable.tsx +++ b/src/app/portfolio/[slug]/(portfolio)/your-projects/live/DocumentTable.tsx @@ -36,7 +36,7 @@ type InstallStatusFilter = "all" | "none" | "hasDocs" | "partial" | "complete"; interface DocumentTableProps { data: ClassifiedDeal[]; - onOpenDrawer: (dealId: string, uprn: string | null, landlordPropertyId: string | null, dealname: string | null) => void; + onOpenDrawer: (dealId: string, uprn: string | null, landlordPropertyId: string | null, dealname: string | null, batch: string | null, batchDescription: string | null) => void; docStatusMap: DocStatusMap; portfolioId: string; userCapability: PortfolioCapabilityType; @@ -112,7 +112,7 @@ export default function DocumentTable({ data, onOpenDrawer, docStatusMap, portfo const downloadCsv = () => { const rows = table.getFilteredRowModel().rows; - const header = "Address,Landlord ID,Retrofit Assessment Status,Install Docs Status"; + const header = "Address,Landlord ID,Retrofit Assessment Status,Install Docs Status,Group,Group Description"; const body = rows .map((row) => { const status = docStatusMap[row.original.dealId]; @@ -133,6 +133,8 @@ export default function DocumentTable({ data, onOpenDrawer, docStatusMap, portfo escapeCell(row.original.landlordPropertyId), retroStatus, installStatus, + escapeCell(row.original.batch), + escapeCell(row.original.batchDescription), ].join(","); }) .join("\n"); diff --git a/src/app/portfolio/[slug]/(portfolio)/your-projects/live/DocumentTableColumns.tsx b/src/app/portfolio/[slug]/(portfolio)/your-projects/live/DocumentTableColumns.tsx index f1371e79..1975defc 100644 --- a/src/app/portfolio/[slug]/(portfolio)/your-projects/live/DocumentTableColumns.tsx +++ b/src/app/portfolio/[slug]/(portfolio)/your-projects/live/DocumentTableColumns.tsx @@ -101,7 +101,7 @@ function InstallDocsBadge({ status }: { status: DocStatus | undefined }) { } export function createDocumentTableColumns( - onOpenDrawer: (dealId: string, uprn: string | null, landlordPropertyId: string | null, dealname: string | null) => void, + onOpenDrawer: (dealId: string, uprn: string | null, landlordPropertyId: string | null, dealname: string | null, batch: string | null, batchDescription: string | null) => void, docStatusMap: DocStatusMap = {}, onUpload?: (deal: ClassifiedDeal) => void, ): ColumnDef[] { @@ -204,6 +204,8 @@ export function createDocumentTableColumns( row.original.uprn, row.original.landlordPropertyId, row.original.dealname, + row.original.batch, + row.original.batchDescription, ) } className={className} diff --git a/src/app/portfolio/[slug]/(portfolio)/your-projects/live/LiveTracker.tsx b/src/app/portfolio/[slug]/(portfolio)/your-projects/live/LiveTracker.tsx index 8296d3ba..24b8050c 100644 --- a/src/app/portfolio/[slug]/(portfolio)/your-projects/live/LiveTracker.tsx +++ b/src/app/portfolio/[slug]/(portfolio)/your-projects/live/LiveTracker.tsx @@ -23,6 +23,8 @@ import MeasuresTable from "./MeasuresTable"; import type { HubspotDeal } from "./types"; import PropertyDrawer from "./PropertyDrawer"; import AnalyticsView from "./AnalyticsView"; +import { computeProjectProgress, computeOutcomeSlices } from "./transforms"; +import BatchFilter, { UNBATCHED_KEY } from "./BatchFilter"; import type { LiveTrackerProps, TableModal, @@ -31,6 +33,7 @@ import type { DocStatusMap, RemovalStatusByDeal, InstructedMeasuresByDeal, + ProjectData, } from "./types"; export default function LiveTracker({ @@ -58,8 +61,52 @@ export default function LiveTracker({ (p) => p.projectCode === currentProjectCode, ); + // ── Batch filter state (resets on project change) ───────────────────── + const [batchFilter, setBatchFilter] = useState([]); + const handleProjectChange = (code: string) => { + setCurrentProjectCode(code); + setBatchFilter([]); + }; + + // Available batch options for the current project. Excluded entirely under + // "All Projects", or when the project has no real group codes (a lone + // "(Ungrouped)" option would be meaningless, so we hide the filter). + const isAllProjects = currentProjectCode === "__ALL__"; + const availableBatches: string[] = (() => { + if (isAllProjects || !currentProject) return []; + const codes = new Set(); + let hasUnbatched = false; + for (const d of currentProject.allDeals) { + if (d.batch) codes.add(d.batch); + else hasUnbatched = true; + } + if (codes.size === 0) return []; + const sorted = Array.from(codes).sort(); + if (hasUnbatched) sorted.push(UNBATCHED_KEY); + return sorted; + })(); + + // Filtered view of the current project. Empty batchFilter = no filter applied. + const filteredProject: ProjectData | undefined = (() => { + if (!currentProject) return undefined; + if (batchFilter.length === 0 || isAllProjects) return currentProject; + const wantUnbatched = batchFilter.includes(UNBATCHED_KEY); + const wantCodes = new Set(batchFilter.filter((b) => b !== UNBATCHED_KEY)); + const filteredDeals = currentProject.allDeals.filter((d) => + d.batch ? wantCodes.has(d.batch) : wantUnbatched, + ); + return { + projectCode: currentProject.projectCode, + progress: computeProjectProgress(filteredDeals), + outcomePieSlices: computeOutcomeSlices(filteredDeals), + allDeals: filteredDeals, + }; + })(); + + const batchFilterActive = batchFilter.length > 0 && !isAllProjects; + // ── Pending removal count for current project ──────────────────────── - const pendingRemovalCount = (currentProject?.allDeals ?? []).filter((d) => { + const pendingRemovalCount = (filteredProject?.allDeals ?? []).filter((d) => { const state = d.dealId ? removalStatusByDeal[d.dealId] : undefined; return state === "pending_removal" || state === "pending_re_addition"; }).length; @@ -74,6 +121,8 @@ export default function LiveTracker({ uprn: null, landlordPropertyId: null, dealname: null, + batch: null, + batchDescription: null, }); const handleOpenTable = ( @@ -83,17 +132,31 @@ export default function LiveTracker({ columnLabels?: Partial>, breakdown?: Record, ) => { + // Always append batch columns to the drill-down view per design. + const baseColumns = + columns ?? + (["dealname", "landlordPropertyId"] as (keyof ClassifiedDeal)[]); + const baseLabels = + columnLabels ?? + ({ + dealname: "Address Ref.", + landlordPropertyId: "Property Ref.", + } as Partial>); + const withBatch: (keyof ClassifiedDeal)[] = [ + ...baseColumns, + "batch", + "batchDescription", + ]; + const withBatchLabels: Partial> = { + ...baseLabels, + batch: "Group", + batchDescription: "Group Description", + }; setOpenTable({ stage, data: filteredDeals, - columns: (columns || [ - "dealname", - "landlordPropertyId", - ]) as (keyof ClassifiedDeal)[], - columnLabels: (columnLabels || { - dealname: "Address Ref.", - landlordPropertyId: "Property Ref.", - }) as Partial>, + columns: withBatch, + columnLabels: withBatchLabels, breakdown, }); }; @@ -103,8 +166,18 @@ export default function LiveTracker({ uprn: string | null, landlordPropertyId: string | null, dealname: string | null, + batch: string | null, + batchDescription: string | null, ) => { - setDrawerState({ open: true, dealId, uprn, landlordPropertyId, dealname }); + setDrawerState({ + open: true, + dealId, + uprn, + landlordPropertyId, + dealname, + batch, + batchDescription, + }); }; if (!totalDeals) { @@ -171,15 +244,19 @@ export default function LiveTracker({ {/* Analytics tab */} - {currentProject && ( + {filteredProject && ( )} @@ -188,32 +265,15 @@ export default function LiveTracker({
{/* Project selector — mirrors analytics tab */} - {projects.length > 1 && ( -
- Project: - -
- )} + 1} + currentProjectCode={currentProjectCode} + projectCodes={projectCodes} + onProjectChange={handleProjectChange} + availableBatches={availableBatches} + batchFilter={batchFilter} + onBatchFilterChange={setBatchFilter} + />
- {projects.length > 1 && ( -
- Project: - -
- )} + 1} + currentProjectCode={currentProjectCode} + projectCodes={projectCodes} + onProjectChange={handleProjectChange} + availableBatches={availableBatches} + batchFilter={batchFilter} + onBatchFilterChange={setBatchFilter} + />
- {projects.length > 1 && ( -
- Project: - -
- )} + 1} + currentProjectCode={currentProjectCode} + projectCodes={projectCodes} + onProjectChange={handleProjectChange} + availableBatches={availableBatches} + batchFilter={batchFilter} + onBatchFilterChange={setBatchFilter} + /> @@ -426,3 +456,70 @@ export default function LiveTracker({
); } + +// Toolbar row shown above each non-analytics tab. Holds the project selector +// (when the portfolio has more than one project) and the inline group filter +// (when the current project has any batched deals). +function TabToolbar({ + showProjectSelector, + currentProjectCode, + projectCodes, + onProjectChange, + availableBatches, + batchFilter, + onBatchFilterChange, +}: { + showProjectSelector: boolean; + currentProjectCode: string; + projectCodes: string[]; + onProjectChange: (code: string) => void; + availableBatches: string[]; + batchFilter: string[]; + onBatchFilterChange: (next: string[]) => void; +}) { + const showBatch = availableBatches.length > 0; + if (!showProjectSelector && !showBatch) return null; + return ( +
+ {showProjectSelector && ( + <> + Project: + + + )} + {showBatch && ( + <> + + Group: + + + + )} +
+ ); +} diff --git a/src/app/portfolio/[slug]/(portfolio)/your-projects/live/MeasuresTable.tsx b/src/app/portfolio/[slug]/(portfolio)/your-projects/live/MeasuresTable.tsx index 8c8d4ce8..c1703489 100644 --- a/src/app/portfolio/[slug]/(portfolio)/your-projects/live/MeasuresTable.tsx +++ b/src/app/portfolio/[slug]/(portfolio)/your-projects/live/MeasuresTable.tsx @@ -14,7 +14,7 @@ import { Input } from "@/app/shadcn_components/ui/input"; import { Badge } from "@/app/shadcn_components/ui/badge"; import { Button } from "@/app/shadcn_components/ui/button"; import { Checkbox } from "@/app/shadcn_components/ui/checkbox"; -import { Search, CheckSquare, ListChecks, Loader2, X } from "lucide-react"; +import { Search, CheckSquare, ListChecks, Loader2, X, Layers } from "lucide-react"; import { STAGE_COLORS } from "./types"; import type { ClassifiedDeal, ApprovalsByDeal } from "./types"; import { parseMeasures } from "@/app/lib/parseMeasures"; @@ -319,6 +319,7 @@ export default function MeasuresTable({ const [showApproveModal, setShowApproveModal] = useState(false); const [showInstructModal, setShowInstructModal] = useState(false); + const [showBatchColumns, setShowBatchColumns] = useState(false); const dealsWithMeasures = useMemo( () => data.filter((d) => d.proposedMeasures || (instructedMeasuresByDeal[d.dealId]?.length ?? 0) > 0), @@ -404,7 +405,7 @@ export default function MeasuresTable({ ); } - const colSpan = mode === "instruct" ? 7 : 6; + const colSpan = (mode === "instruct" ? 7 : 6) + (showBatchColumns ? 2 : 0); return (
@@ -425,6 +426,19 @@ export default function MeasuresTable({ {filtered.length} of {dealsWithMeasures.length} properties + + {isApprover && ( <>