mirror of
https://github.com/Hestia-Homes/assessment-model.git
synced 2026-06-08 11:37:25 +00:00
group filter to allow batches
This commit is contained in:
parent
54ad998032
commit
ed14ac79eb
15 changed files with 467 additions and 106 deletions
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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");
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
})}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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} />
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
};
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue