mirror of
https://github.com/Hestia-Homes/assessment-model.git
synced 2026-06-08 11:37:25 +00:00
Merge pull request #237 from Hestia-Homes/feature/installer-interaction
Feature/installer interaction Calico filtering feature
This commit is contained in:
commit
c8380f513c
4 changed files with 121 additions and 23 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}}
|
||||
/>
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue