group filter to allow batches

This commit is contained in:
Jun-te Kim 2026-05-27 13:57:10 +00:00
parent 54ad998032
commit ed14ac79eb
15 changed files with 467 additions and 106 deletions

View file

@ -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 (
<div className="space-y-6">
{/* Row 1: project selector + stat card (Properties in project) */}
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
{/* Row 1: project selector + (optional) batch filter + properties count */}
<div
className={`grid grid-cols-1 gap-4 ${
showBatchFilter ? "sm:grid-cols-3" : "sm:grid-cols-2"
}`}
>
{/* Project selector */}
<Card className="flex flex-col justify-center items-center border border-brandblue/10 bg-gradient-to-br from-brandlightblue/20 to-white shadow-sm hover:shadow-md transition-shadow p-5">
<div className="w-full flex flex-col">
@ -355,10 +369,19 @@ export default function AnalyticsView({
</div>
</Card>
{/* Properties in project */}
{/* Batch filter — only when current project has batched deals */}
{showBatchFilter && (
<BatchFilter
options={availableBatches}
selected={batchFilter}
onChange={onBatchFilterChange}
/>
)}
{/* Properties in project (label swaps when batch filter is active) */}
<StatCard
icon={Home}
title="Properties in Project"
title={batchFilterActive ? "Properties in Group" : "Properties in Project"}
value={currentProject.allDeals.length}
onClick={() =>
onOpenTable(

View file

@ -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 (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button type="button" className={triggerClassName}>
<span className="flex items-center gap-2 min-w-0">
<Layers className="h-3.5 w-3.5 shrink-0 text-brandblue" />
<span className="truncate">{label}</span>
</span>
<ChevronDown className="h-3.5 w-3.5 shrink-0 text-gray-400" />
</button>
</DropdownMenuTrigger>
<DropdownMenuContent
align={align}
className="min-w-[220px] max-w-[360px] w-[--radix-dropdown-menu-trigger-width]"
>
<DropdownMenuLabel className="text-xs text-gray-500 flex items-center justify-between">
<span>Groups</span>
{selected.length > 0 && (
<button
type="button"
onClick={() => onChange([])}
className="text-[11px] text-brandblue hover:underline font-medium"
>
Clear
</button>
)}
</DropdownMenuLabel>
<DropdownMenuSeparator />
<div className="max-h-72 overflow-y-auto">
{options.map((opt) => (
<DropdownMenuCheckboxItem
key={opt}
checked={selected.includes(opt)}
onCheckedChange={(val) => toggle(opt, !!val)}
onSelect={(e) => e.preventDefault()}
className="text-sm"
>
{opt === UNBATCHED_KEY ? (
<span className="italic text-gray-500">(Ungrouped)</span>
) : (
opt
)}
</DropdownMenuCheckboxItem>
))}
</div>
</DropdownMenuContent>
</DropdownMenu>
);
}
export default function BatchFilter(props: BatchFilterProps) {
const { variant = "card" } = props;
if (variant === "inline") {
return (
<BatchDropdown
{...props}
triggerClassName="h-9 px-3 pr-2 border border-brandblue/20 rounded-lg bg-white text-gray-800 font-medium text-sm focus:ring-2 focus:ring-brandblue focus:border-brandblue focus:outline-none transition-all flex items-center gap-2 min-w-[160px] max-w-[280px]"
/>
);
}
return (
<Card className="flex flex-col justify-center items-center border border-brandblue/10 bg-gradient-to-br from-brandlightblue/20 to-white shadow-sm hover:shadow-md transition-shadow p-5">
<div className="w-full flex flex-col">
<p className="text-xs uppercase tracking-wide text-gray-600 mb-3 font-semibold">
Filter by Group
</p>
<BatchDropdown
{...props}
triggerClassName="w-full px-4 py-2.5 pr-3 border border-brandblue/20 rounded-lg bg-white text-gray-800 font-medium text-sm focus:ring-2 focus:ring-brandblue focus:border-brandblue focus:outline-none transition-all flex items-center justify-between gap-2"
/>
</div>
</Card>
);
}

View file

@ -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");

View file

@ -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<ClassifiedDeal>[] {
@ -204,6 +204,8 @@ export function createDocumentTableColumns(
row.original.uprn,
row.original.landlordPropertyId,
row.original.dealname,
row.original.batch,
row.original.batchDescription,
)
}
className={className}

View file

@ -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<string[]>([]);
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<string>();
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<Record<keyof ClassifiedDeal, string>>,
breakdown?: Record<string, ClassifiedDeal[]>,
) => {
// 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<Record<keyof ClassifiedDeal, string>>);
const withBatch: (keyof ClassifiedDeal)[] = [
...baseColumns,
"batch",
"batchDescription",
];
const withBatchLabels: Partial<Record<keyof ClassifiedDeal, string>> = {
...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<Record<keyof ClassifiedDeal, string>>,
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 */}
<TabsContent value="analytics" className="mt-0">
{currentProject && (
{filteredProject && (
<AnalyticsView
projects={projects}
currentProject={currentProject}
currentProject={filteredProject}
currentProjectCode={currentProjectCode}
onProjectChange={setCurrentProjectCode}
onProjectChange={handleProjectChange}
onOpenTable={handleOpenTable}
majorConditionDeals={majorConditionDeals}
totalDeals={totalDeals}
availableBatches={availableBatches}
batchFilter={batchFilter}
onBatchFilterChange={setBatchFilter}
batchFilterActive={batchFilterActive}
/>
)}
</TabsContent>
@ -188,32 +265,15 @@ export default function LiveTracker({
<TabsContent value="properties" className="mt-0">
<div className="space-y-4">
{/* Project selector — mirrors analytics tab */}
{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>
)}
<TabToolbar
showProjectSelector={projects.length > 1}
currentProjectCode={currentProjectCode}
projectCodes={projectCodes}
onProjectChange={handleProjectChange}
availableBatches={availableBatches}
batchFilter={batchFilter}
onBatchFilterChange={setBatchFilter}
/>
<div
className={`flex items-center gap-2.5 px-4 py-3 rounded-xl border border-amber-200 bg-amber-50 text-amber-800 text-sm ${pendingRemovalCount === 0 ? "hidden" : ""}`}
@ -226,7 +286,7 @@ export default function LiveTracker({
</span>
</div>
<PropertyTable
data={currentProject?.allDeals ?? []}
data={filteredProject?.allDeals ?? []}
onOpenDrawer={handleOpenDrawer}
portfolioId={portfolioId}
docStatusMap={docStatusMap}
@ -237,34 +297,17 @@ export default function LiveTracker({
{/* 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>
)}
<TabToolbar
showProjectSelector={projects.length > 1}
currentProjectCode={currentProjectCode}
projectCodes={projectCodes}
onProjectChange={handleProjectChange}
availableBatches={availableBatches}
batchFilter={batchFilter}
onBatchFilterChange={setBatchFilter}
/>
<DocumentTable
data={currentProject?.allDeals ?? []}
data={filteredProject?.allDeals ?? []}
onOpenDrawer={handleOpenDrawer}
docStatusMap={docStatusMap}
portfolioId={portfolioId}
@ -277,34 +320,17 @@ export default function LiveTracker({
{/* Measures tab */}
<TabsContent value="measures" 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>
)}
<TabToolbar
showProjectSelector={projects.length > 1}
currentProjectCode={currentProjectCode}
projectCodes={projectCodes}
onProjectChange={handleProjectChange}
availableBatches={availableBatches}
batchFilter={batchFilter}
onBatchFilterChange={setBatchFilter}
/>
<MeasuresTable
data={currentProject?.allDeals ?? []}
data={filteredProject?.allDeals ?? []}
approvalsByDeal={approvalsByDeal}
instructedMeasuresByDeal={instructedMeasuresByDeal}
portfolioId={portfolioId}
@ -409,6 +435,8 @@ export default function LiveTracker({
uprn={drawerState.uprn}
landlordPropertyId={drawerState.landlordPropertyId}
dealname={drawerState.dealname}
batch={drawerState.batch}
batchDescription={drawerState.batchDescription}
docStatus={
drawerState.dealId ? docStatusMap[drawerState.dealId] : undefined
}
@ -419,6 +447,8 @@ export default function LiveTracker({
uprn: null,
landlordPropertyId: null,
dealname: null,
batch: null,
batchDescription: null,
})
}
/>
@ -426,3 +456,70 @@ export default function LiveTracker({
</div>
);
}
// 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 (
<div className="flex items-center gap-3 flex-wrap">
{showProjectSelector && (
<>
<span className="text-sm text-gray-500 shrink-0">Project:</span>
<select
value={currentProjectCode}
onChange={(e) => onProjectChange(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>
</>
)}
{showBatch && (
<>
<span
className={`text-sm text-gray-500 shrink-0 ${
showProjectSelector ? "ml-2" : ""
}`}
>
Group:
</span>
<BatchFilter
variant="inline"
options={availableBatches}
selected={batchFilter}
onChange={onBatchFilterChange}
/>
</>
)}
</div>
);
}

View file

@ -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 (
<div className="space-y-4">
@ -425,6 +426,19 @@ export default function MeasuresTable({
{filtered.length} of {dealsWithMeasures.length} properties
</span>
<button
onClick={() => setShowBatchColumns((v) => !v)}
className={`inline-flex items-center gap-1.5 h-8 px-2.5 rounded-md border text-xs font-medium transition-colors ${
showBatchColumns
? "border-brandblue/40 bg-brandblue/5 text-brandblue"
: "border-gray-200 bg-white text-gray-600 hover:border-brandblue/30 hover:text-brandblue"
}`}
aria-pressed={showBatchColumns}
>
<Layers className="h-3.5 w-3.5" />
Group
</button>
{isApprover && (
<>
<Button
@ -531,6 +545,16 @@ export default function MeasuresTable({
<TableHead className="text-xs font-semibold text-gray-500 uppercase tracking-wide">
Status
</TableHead>
{showBatchColumns && (
<>
<TableHead className="text-xs font-semibold text-gray-500 uppercase tracking-wide">
Group
</TableHead>
<TableHead className="text-xs font-semibold text-gray-500 uppercase tracking-wide">
Group Description
</TableHead>
</>
)}
</TableRow>
</TableHeader>
<TableBody>
@ -665,6 +689,21 @@ export default function MeasuresTable({
<TableCell className="py-3">
<ApprovalStatus proposed={proposed} approved={approvedForDeal} />
</TableCell>
{showBatchColumns && (
<>
<TableCell className="py-3">
<span className="text-xs font-mono text-gray-600">
{deal.batch ?? <span className="text-gray-300"></span>}
</span>
</TableCell>
<TableCell className="py-3">
<span className="text-xs text-gray-600 max-w-[220px] line-clamp-2 leading-snug">
{deal.batchDescription ?? <span className="text-gray-300"></span>}
</span>
</TableCell>
</>
)}
</TableRow>
);
})}

View file

@ -23,6 +23,8 @@ interface PropertyDrawerProps {
uprn: string | null;
landlordPropertyId: string | null;
dealname: string | null;
batch?: string | null;
batchDescription?: string | null;
docStatus?: DocStatus;
onClose: () => void;
}
@ -33,6 +35,8 @@ export default function PropertyDrawer({
uprn,
landlordPropertyId,
dealname,
batch,
batchDescription,
docStatus,
onClose,
}: PropertyDrawerProps) {
@ -85,6 +89,15 @@ export default function PropertyDrawer({
Ref: {landlordPropertyId}
</DrawerDescription>
) : null}
{batch && (
<p className="text-xs text-gray-500 mt-1 truncate">
Group:{" "}
<span className="font-mono text-gray-700">{batch}</span>
{batchDescription ? (
<span className="text-gray-500"> {batchDescription}</span>
) : null}
</p>
)}
</div>
<DrawerClose asChild>
<button

View file

@ -59,6 +59,8 @@ const COLUMN_LABELS: Record<string, string> = {
lodgementStatus: "Lodgement Status",
designDate: "Design Date",
fullLodgementDate: "Lodgement Date",
batch: "Group",
batchDescription: "Group Description",
};
type DocFilter = "all" | "has_docs" | "incomplete" | "none";
@ -66,7 +68,7 @@ type RemovalFilter = "all" | "pending_removal" | "removed" | "pending_re_additio
interface PropertyTableProps {
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;
portfolioId?: string;
showDocuments?: boolean;
docStatusMap?: DocStatusMap;
@ -93,6 +95,8 @@ const CSV_FIELDS: { key: keyof ClassifiedDeal; label: string }[] = [
{ 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 {
@ -130,6 +134,8 @@ export default function PropertyTable({ data, onOpenDrawer, portfolioId = "", sh
lodgementStatus: false,
designDate: false,
fullLodgementDate: false,
batch: false,
batchDescription: false,
});
// Pre-filter by stage, doc status, and removal status before TanStack gets it

View file

@ -31,7 +31,7 @@ function SortableHeader({
// docStatusMap provides per-deal document status for status indicators
// -----------------------------------------------------------------------
export function createPropertyTableColumns(
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,
showDocuments: boolean = false,
docStatusMap: DocStatusMap = {},
portfolioId: string = "",
@ -320,6 +320,30 @@ export function createPropertyTableColumns(
},
},
// ── Group ────────────────────────────────────────────────────────────
{
accessorKey: "batch",
id: "batch",
header: ({ column }) => <SortableHeader label="Group" column={column as any} />,
cell: ({ row }) => (
<span className="text-xs font-mono text-gray-600">
{row.original.batch ?? <span className="text-gray-300"></span>}
</span>
),
},
// ── Group description ────────────────────────────────────────────────
{
accessorKey: "batchDescription",
id: "batchDescription",
header: ({ column }) => <SortableHeader label="Group Description" column={column as any} />,
cell: ({ row }) => (
<span className="text-xs text-gray-600 max-w-[220px] line-clamp-2 leading-snug">
{row.original.batchDescription ?? <span className="text-gray-300"></span>}
</span>
),
},
];
if (showDocuments) {
@ -352,7 +376,16 @@ export function createPropertyTableColumns(
return (
<button
onClick={() => onOpenDrawer(row.original.dealId, row.original.uprn, row.original.landlordPropertyId, row.original.dealname)}
onClick={() =>
onOpenDrawer(
row.original.dealId,
row.original.uprn,
row.original.landlordPropertyId,
row.original.dealname,
row.original.batch,
row.original.batchDescription,
)
}
className={className}
>
{icon}

View file

@ -163,6 +163,16 @@ export default function DealPage({
{/* Key details */}
<div className="space-y-0.5 divide-y divide-gray-50">
<InfoRow label="Project" value={deal.projectCode} />
<InfoRow
label="Group"
value={
deal.batch
? deal.batchDescription
? `${deal.batch}${deal.batchDescription}`
: deal.batch
: null
}
/>
<InfoRow label="Coordinator" value={deal.coordinator} />
<InfoRow label="Designer" value={deal.designer} />
<InfoRow label="Installer" value={deal.installer} />

View file

@ -67,6 +67,8 @@ export function mapDbRowToHubspotDeal(row: DealRow): HubspotDeal {
technicalApprovedMeasuresForInstall: d.technicalApprovedMeasuresForInstall,
domnaSurveyType: d.domnaSurveyType,
domnaSurveyDate: d.domnaSurveyDate,
batch: d.batch,
batchDescription: d.batchDescription,
createdAt: d.createdAt,
updatedAt: d.updatedAt,
};

View file

@ -56,6 +56,8 @@ function makeDeal(overrides: Partial<HubspotDeal> = {}): HubspotDeal {
technicalApprovedMeasuresForInstall: null,
domnaSurveyType: null,
domnaSurveyDate: null,
batch: null,
batchDescription: null,
createdAt: new Date(),
updatedAt: new Date(),
...overrides,

View file

@ -55,6 +55,8 @@ function makeDeal(overrides: Partial<ClassifiedDeal> = {}): ClassifiedDeal {
technicalApprovedMeasuresForInstall: "Solar PV",
domnaSurveyType: null,
domnaSurveyDate: null,
batch: null,
batchDescription: null,
createdAt: new Date(),
updatedAt: new Date(),
displayStage: "Coordination in Progress",

View file

@ -63,6 +63,8 @@ function makeDeal(overrides: Partial<HubspotDeal> = {}): HubspotDeal {
technicalApprovedMeasuresForInstall: null,
domnaSurveyType: null,
domnaSurveyDate: null,
batch: null,
batchDescription: null,
createdAt: new Date(),
updatedAt: new Date(),
...overrides,

View file

@ -64,6 +64,9 @@ export type HubspotDeal = {
domnaSurveyType: string | null;
domnaSurveyDate: Date | null;
batch: string | null;
batchDescription: string | null;
createdAt: Date;
updatedAt: Date;
};
@ -297,6 +300,8 @@ export type DocumentDrawerState = {
uprn: string | null;
landlordPropertyId: string | null;
dealname: string | null;
batch: string | null;
batchDescription: string | null;
};
// -----------------------------------------------------------------------