Merge pull request #237 from Hestia-Homes/feature/installer-interaction

Feature/installer interaction Calico filtering feature
This commit is contained in:
KhalimCK 2026-04-22 11:29:27 +01:00 committed by GitHub
commit c8380f513c
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 121 additions and 23 deletions

View file

@ -69,6 +69,8 @@ export async function GET(
const sid = BigInt(scenarioId);
const hideNonCompliant =
request.nextUrl.searchParams.get("hideNonCompliant") === "true";
const useOriginalBaseline =
request.nextUrl.searchParams.get("useOriginalBaseline") === "true";
/* ----------------------------------------------------------
Query 0 scenario definition
@ -129,7 +131,18 @@ export async function GET(
END
)::float AS total_sap_uplift
FROM latest_plans lp
JOIN property p ON p.id = lp.property_id;
JOIN property p ON p.id = lp.property_id
-- Conditional filter: only restrict by original_sap_points when the toggle is on
-- AND the scenario has an EPC target. Written as an OR chain so Postgres evaluates
-- it as a single WHERE clause avoiding the need to dynamically build the query
-- string in application code (which would require string concatenation and risks
-- SQL injection). The OR short-circuits left-to-right: if the first or second
-- condition is true, the third is never evaluated, so all rows pass through.
WHERE (
${useOriginalBaseline} = false -- toggle off include everything
OR ${minSap}::float IS NULL -- no EPC target nothing to filter on
OR p.original_sap_points < ${minSap}::float -- actual filter
);
`);
const scenarioAgg = scenarioMetricsResult.rows[0] as ScenarioAggregates;
@ -162,8 +175,14 @@ export async function GET(
COALESCE(fp.total_uplift, 0)
)::float AS total_funding
FROM latest_plans lp
JOIN property p ON p.id = lp.property_id
LEFT JOIN funding_package fp ON fp.plan_id = lp.id
WHERE lp.cost_of_works > 0;
WHERE lp.cost_of_works > 0
AND (
${useOriginalBaseline} = false
OR ${minSap}::float IS NULL
OR p.original_sap_points < ${minSap}::float
);
`);
const upgraded = upgradedResult.rows[0] as UpgradedAggregates;
@ -223,6 +242,11 @@ export async function GET(
AND plan.post_sap_points >= ${minSap}::float
)
)
AND (
${useOriginalBaseline} = false
OR ${minSap}::float IS NULL
OR p.original_sap_points < ${minSap}::float
)
ORDER BY created_at DESC
LIMIT 1
) lp ON true
@ -256,6 +280,11 @@ export async function GET(
AND plan.post_sap_points >= ${minSap}::float
)
)
AND (
${useOriginalBaseline} = false
OR ${minSap}::float IS NULL
OR p.original_sap_points < ${minSap}::float
)
ORDER BY created_at DESC
LIMIT 1
) lp ON true

View file

@ -39,13 +39,16 @@ async function fetchScenarioReport({
portfolioId,
scenarioId,
hideNonCompliant,
useOriginalBaseline,
}: {
portfolioId: number;
scenarioId: number | "default";
hideNonCompliant: boolean;
useOriginalBaseline: boolean;
}) {
const params = new URLSearchParams({
hideNonCompliant: String(hideNonCompliant),
useOriginalBaseline: String(useOriginalBaseline),
});
const path = `/api/portfolio/${portfolioId}/scenario/${scenarioId}/metrics`;
@ -89,6 +92,8 @@ export function ReportingClientArea({
const [measuresOpen, setMeasuresOpen] = useState<boolean>(false);
const [appliedHideNonCompliant, setAppliedHideNonCompliant] =
useState<boolean>(false);
const [appliedUseOriginalBaseline, setAppliedUseOriginalBaseline] =
useState<boolean>(false);
const [showToast, setShowToast] = useState(false);
const drawerOpen = Boolean(selectedScenarioId);
@ -107,12 +112,14 @@ export function ReportingClientArea({
portfolioId,
selectedScenarioId,
appliedHideNonCompliant,
appliedUseOriginalBaseline,
],
queryFn: () =>
fetchScenarioReport({
portfolioId,
scenarioId: selectedScenarioId!,
hideNonCompliant: appliedHideNonCompliant,
useOriginalBaseline: appliedUseOriginalBaseline,
}),
enabled: selectedScenarioId !== null, // only run when scenario selected or default selected
keepPreviousData: true, // keep showing old data while loading new scenario or applying filter
@ -238,12 +245,14 @@ export function ReportingClientArea({
<ReportingFunctionalityButtons
hideNonCompliant={appliedHideNonCompliant}
useOriginalBaseline={appliedUseOriginalBaseline}
disabled={scenarioBusy}
canFilterNonCompliant={
selectedScenarioId !== null && selectedScenarioId !== "default"
}
onApply={async (value) => {
setAppliedHideNonCompliant(value);
onApply={async ({ hideNonCompliant, useOriginalBaseline }) => {
setAppliedHideNonCompliant(hideNonCompliant);
setAppliedUseOriginalBaseline(useOriginalBaseline);
}}
/>

View file

@ -13,33 +13,45 @@ export interface ReportingFunctionalityButtonsProps {
/** Currently applied value */
hideNonCompliant: boolean;
/** Currently applied value */
useOriginalBaseline: boolean;
/**
* Explicit user action.
* Parent decides what "apply" means (refetch, mutate, etc).
*/
onApply: (value: boolean) => Promise<void> | void;
onApply: (options: {
hideNonCompliant: boolean;
useOriginalBaseline: boolean;
}) => Promise<void> | void;
disabled?: boolean;
/* Whether hideNonCompliant filter is available */
/* Whether filters are available (only for specific non-default scenarios) */
canFilterNonCompliant?: boolean;
}
export function ReportingFunctionalityButtons({
hideNonCompliant,
useOriginalBaseline,
onApply,
disabled = false,
canFilterNonCompliant = true,
}: ReportingFunctionalityButtonsProps) {
const [draftHideNonCompliant, setDraftHideNonCompliant] =
useState<boolean>(hideNonCompliant);
const [draftUseOriginalBaseline, setDraftUseOriginalBaseline] =
useState<boolean>(useOriginalBaseline);
const [isApplying, setIsApplying] = useState(false);
async function handleApply() {
try {
setIsApplying(true);
await onApply(draftHideNonCompliant);
await onApply({
hideNonCompliant: draftHideNonCompliant,
useOriginalBaseline: draftUseOriginalBaseline,
});
} finally {
setIsApplying(false);
}
@ -50,7 +62,8 @@ export function ReportingFunctionalityButtons({
// reset the filter and trigger the fetch
setIsApplying(true);
setDraftHideNonCompliant(false);
await onApply(false);
setDraftUseOriginalBaseline(false);
await onApply({ hideNonCompliant: false, useOriginalBaseline: false });
} finally {
setIsApplying(false);
}
@ -61,6 +74,7 @@ export function ReportingFunctionalityButtons({
onOpenChange={(open) => {
if (open) {
setDraftHideNonCompliant(hideNonCompliant);
setDraftUseOriginalBaseline(useOriginalBaseline);
}
}}
>
@ -72,7 +86,7 @@ export function ReportingFunctionalityButtons({
className={`
relative flex items-center gap-2
${
hideNonCompliant
hideNonCompliant || useOriginalBaseline
? "border-brandmidblue/40 bg-brandlightblue/40"
: ""
}
@ -81,7 +95,7 @@ export function ReportingFunctionalityButtons({
{/* Filter icon */}
<svg
className={`h-4 w-4 ${
hideNonCompliant ? "text-brandmidblue" : "text-gray-500"
hideNonCompliant || useOriginalBaseline ? "text-brandmidblue" : "text-gray-500"
}`}
viewBox="0 0 20 20"
fill="currentColor"
@ -89,7 +103,7 @@ export function ReportingFunctionalityButtons({
<path d="M3 4a1 1 0 011-1h12a1 1 0 01.8 1.6l-4.8 6.4V16a1 1 0 01-1.447.894l-2-1A1 1 0 018 14v-2.999L3.2 5.6A1 1 0 013 4z" />
</svg>
Filter options
{hideNonCompliant && (
{(hideNonCompliant || useOriginalBaseline) && (
<span className="absolute -top-1 -right-1 h-2 w-2 rounded-full bg-brandmidblue" />
)}
</Button>
@ -140,6 +154,47 @@ export function ReportingFunctionalityButtons({
</label>
</div>
{/* Use original SAP points */}
<div
className={`flex items-start gap-4 ${
!canFilterNonCompliant ? "opacity-50 pointer-events-none" : ""
}`}
>
<Checkbox
id="use-original-baseline"
checked={draftUseOriginalBaseline}
disabled={!canFilterNonCompliant}
onCheckedChange={(checked) =>
setDraftUseOriginalBaseline(Boolean(checked))
}
className="mt-1"
/>
<label
htmlFor="use-original-baseline"
className="cursor-pointer space-y-1"
>
<div className="flex items-center gap-2 text-sm font-medium text-gray-900 leading-snug">
<svg
className="h-4 w-4 text-gray-400"
viewBox="0 0 20 20"
fill="currentColor"
>
<path d="M9 2a1 1 0 000 2h2a1 1 0 100-2H9z" />
<path
fillRule="evenodd"
d="M4 5a2 2 0 012-2 3 3 0 003 3h2a3 3 0 003-3 2 2 0 012 2v11a2 2 0 01-2 2H6a2 2 0 01-2-2V5zm3 4a1 1 0 000 2h.01a1 1 0 100-2H7zm3 0a1 1 0 000 2h3a1 1 0 100-2h-3zm-3 4a1 1 0 100 2h.01a1 1 0 100-2H7zm3 0a1 1 0 100 2h3a1 1 0 100-2h-3z"
clipRule="evenodd"
/>
</svg>
Use lodged SAP points
</div>
<div className="text-xs text-gray-500 leading-relaxed">
Base metrics on properties below the EPC target using their lodged SAP rating, rather than the current modelled rating
</div>
</label>
</div>
{/* Actions */}
<div className="flex justify-end gap-2 pt-4 border-t">
<Button

View file

@ -317,21 +317,26 @@ export default function PropertyTable({ data, onOpenDrawer, onOpenDetail, showDo
Columns
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-48">
<DropdownMenuLabel className="text-xs text-gray-500">
<DropdownMenuContent align="end" className="w-52">
<DropdownMenuLabel className="text-xs text-gray-500 sticky top-0 bg-white z-10">
Toggle columns
</DropdownMenuLabel>
<DropdownMenuSeparator />
{toggleableColumns.map((col) => (
<DropdownMenuCheckboxItem
key={col.id}
checked={col.getIsVisible()}
onCheckedChange={(val) => col.toggleVisibility(val)}
className="text-sm"
>
{COLUMN_LABELS[col.id] ?? col.id}
</DropdownMenuCheckboxItem>
))}
<div className="relative">
<div className="max-h-72 overflow-y-auto">
{toggleableColumns.map((col) => (
<DropdownMenuCheckboxItem
key={col.id}
checked={col.getIsVisible()}
onCheckedChange={(val) => col.toggleVisibility(val)}
className="text-sm"
>
{COLUMN_LABELS[col.id] ?? col.id}
</DropdownMenuCheckboxItem>
))}
</div>
<div className="pointer-events-none absolute bottom-0 left-0 right-0 h-8 bg-gradient-to-t from-white to-transparent" />
</div>
</DropdownMenuContent>
</DropdownMenu>
</div>