adjusted lazy load banner

This commit is contained in:
Khalim Conn-Kowlessar 2026-04-13 15:47:49 +00:00
parent 41c068c419
commit 052928a5dd
4 changed files with 173 additions and 41 deletions

View file

@ -2,13 +2,15 @@ import { NextRequest, NextResponse } from "next/server";
import { getProperties, getPropertiesCount } from "@/app/portfolio/[slug]/utils";
import { FilterGroups } from "@/app/utils/propertyFilters";
const PAGE_LIMIT = 1000;
const DEFAULT_LIMIT = 1000;
export async function POST(req: NextRequest) {
const body = await req.json();
const portfolioId = body.portfolioId;
const filterGroups: FilterGroups = body.filters ?? [];
const limit: number = body.limit ?? DEFAULT_LIMIT;
const offset: number = body.offset ?? 0;
if (!portfolioId) {
return NextResponse.json(
@ -17,9 +19,10 @@ export async function POST(req: NextRequest) {
);
}
// Only compute the total count on the first page — it's expensive and doesn't change
const [data, total] = await Promise.all([
getProperties(portfolioId, PAGE_LIMIT, 0, filterGroups),
getPropertiesCount(portfolioId, filterGroups),
getProperties(portfolioId, limit, offset, filterGroups),
offset === 0 ? getPropertiesCount(portfolioId, filterGroups) : Promise.resolve(null),
]);
return NextResponse.json({ data, total });

View file

@ -16,12 +16,20 @@ import { HomeIcon } from "@heroicons/react/24/outline";
import { sapToEpc } from "@/app/utils";
import { columns } from "@/app/portfolio/[slug]/components/propertyTableColumns";
import { PropertyWithRelations } from "@/app/db/schema/property";
import { TENURE_OPTIONS, MAINFUEL_OPTIONS, EnumOption } from "@/app/utils/propertyFilters";
import {
TENURE_OPTIONS,
MAINFUEL_OPTIONS,
EnumOption,
} from "@/app/utils/propertyFilters";
import {
OPTIONAL_COLUMN_IDS,
OPTIONAL_COLUMN_LABELS,
} from "@/app/portfolio/[slug]/components/propertyTableColumns";
import { VisibilityState, Updater } from "@tanstack/react-table";
import {
VisibilityState,
Updater,
PaginationState,
} from "@tanstack/react-table";
import {
DropdownMenu,
@ -47,7 +55,10 @@ import { Button } from "@/app/shadcn_components/ui/button";
----------------------------------------- */
const EXPORT_LIMIT = 1000;
function resolveEnumLabel(options: EnumOption[], dbValue: string | null | undefined): string {
function resolveEnumLabel(
options: EnumOption[],
dbValue: string | null | undefined,
): string {
if (dbValue == null) return "";
const opt = options.find((o) => o.dbValues.includes(dbValue));
return opt?.label ?? dbValue;
@ -55,18 +66,31 @@ function resolveEnumLabel(options: EnumOption[], dbValue: string | null | undefi
function exportToCsv(data: PropertyWithRelations[]) {
const headers = [
"Address", "Postcode", "Property Ref",
"Current EPC", "Lodged EPC", "Expected EPC",
"EPC Expiry", "EPC Expired",
"Address",
"Postcode",
"Property Ref",
"Current EPC",
"Lodged EPC",
"Expected EPC",
"EPC Expiry",
"EPC Expired",
"Plan Cost (£)",
"Property Type", "Built Form", "Tenure", "Year Built",
"Floor Area (m²)", "CO₂ Emissions (kg/m²/yr)", "Main Fuel",
"Property Type",
"Built Form",
"Tenure",
"Year Built",
"Floor Area (m²)",
"CO₂ Emissions (kg/m²/yr)",
"Main Fuel",
];
const rows = data.map((p) => {
const lodgedLetter = p.originalSapPoints ? sapToEpc(p.originalSapPoints) ?? "" : "";
const expectedSap = (p.currentSapPoints ?? 0) + (p.totalRecommendationSapPoints ?? 0);
const expectedLetter = expectedSap > 0 ? sapToEpc(expectedSap) ?? "" : "";
const lodgedLetter = p.originalSapPoints
? (sapToEpc(p.originalSapPoints) ?? "")
: "";
const expectedSap =
(p.currentSapPoints ?? 0) + (p.totalRecommendationSapPoints ?? 0);
const expectedLetter = expectedSap > 0 ? (sapToEpc(expectedSap) ?? "") : "";
let expiryStr = "";
if (p.epcLodgementDate) {
@ -97,7 +121,7 @@ function exportToCsv(data: PropertyWithRelations[]) {
const csv = [headers, ...rows]
.map((row) =>
row.map((cell) => `"${String(cell).replace(/"/g, '""')}"`).join(",")
row.map((cell) => `"${String(cell).replace(/"/g, '""')}"`).join(","),
)
.join("\n");
@ -363,7 +387,7 @@ export default function PropertyTable({
filterGroups: allFilterGroups,
});
const data = filteredResponse?.data ?? [];
const queryData = filteredResponse?.data ?? [];
const filteredTotal = filteredResponse?.total ?? 0;
// Second query for total (no filters) — React Query dedupes when filters are empty
@ -374,7 +398,92 @@ export default function PropertyTable({
const totalCount = allResponse?.total ?? 0;
const DISPLAY_LIMIT = 1000;
const isAtDisplayLimit = data.length >= DISPLAY_LIMIT && filteredTotal > DISPLAY_LIMIT;
const LOAD_MORE_SIZE = 100;
// ── Extra rows (lazy-loaded pages beyond the initial 1 000) ──────────────
// Keyed to the current filter set so they auto-clear on filter change.
const filterKey = useMemo(
() => JSON.stringify(allFilterGroups),
[allFilterGroups],
);
const [extraState, setExtraState] = useState<{
filterKey: string;
rows: PropertyWithRelations[];
}>({ filterKey: "", rows: [] });
const extraRows = extraState.filterKey === filterKey ? extraState.rows : [];
// The full data visible to the table — initial batch + any lazy-loaded rows
const tableData = useMemo(
() => (extraRows.length > 0 ? [...queryData, ...extraRows] : queryData),
[queryData, extraRows],
);
// Controlled pagination (lifted from DataTable so we can detect the last page)
const [pagination, setPagination] = useState<PaginationState>({
pageIndex: 0,
pageSize: 7,
});
// Reset to page 1 whenever the filter changes
const prevFilterKeyRef = useRef(filterKey);
if (prevFilterKeyRef.current !== filterKey) {
prevFilterKeyRef.current = filterKey;
if (pagination.pageIndex !== 0)
setPagination((p) => ({ ...p, pageIndex: 0 }));
}
const [isFetchingMore, setIsFetchingMore] = useState(false);
const pageCount = Math.ceil(tableData.length / pagination.pageSize);
const isOnLastPage = pageCount > 0 && pagination.pageIndex >= pageCount - 1;
const hasMore = tableData.length < filteredTotal;
const isAtDisplayLimit =
queryData.length >= DISPLAY_LIMIT && filteredTotal > DISPLAY_LIMIT;
const loadMore = useCallback(async () => {
if (isFetchingMore || !hasMore || isLoading || isFetching) return;
setIsFetchingMore(true);
try {
const res = await fetch("/api/properties", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
portfolioId,
filters: allFilterGroups,
offset: tableData.length,
limit: LOAD_MORE_SIZE,
}),
});
if (!res.ok) return;
const json: { data: PropertyWithRelations[] } = await res.json();
setExtraState((prev) => ({
filterKey,
rows:
prev.filterKey === filterKey
? [...prev.rows, ...json.data]
: json.data,
}));
} catch {
// silently ignore — user can navigate away and back to retry
} finally {
setIsFetchingMore(false);
}
}, [
isFetchingMore,
hasMore,
isLoading,
isFetching,
portfolioId,
allFilterGroups,
tableData.length,
filterKey,
]);
useEffect(() => {
if (isOnLastPage && hasMore) loadMore();
}, [isOnLastPage, hasMore, loadMore]);
/* ----------------------------------------
Delete preview state
@ -481,7 +590,9 @@ export default function PropertyTable({
{/* Export */}
{filteredTotal > EXPORT_LIMIT ? (
<div title={`Export is available for up to ${EXPORT_LIMIT.toLocaleString()} properties. Refine your filters to enable it.`}>
<div
title={`Export is available for up to ${EXPORT_LIMIT.toLocaleString()} properties. Refine your filters to enable it.`}
>
<button
disabled
className="flex items-center gap-1.5 h-8 px-3 rounded-lg border border-slate-200 bg-slate-100 text-xs font-semibold text-slate-400 cursor-not-allowed opacity-60"
@ -492,8 +603,8 @@ export default function PropertyTable({
</div>
) : (
<button
onClick={() => exportToCsv(data)}
disabled={isLoading || data.length === 0}
onClick={() => exportToCsv(tableData)}
disabled={isLoading || tableData.length === 0}
className="flex items-center gap-1.5 h-8 px-3 rounded-lg border border-slate-200 bg-slate-100 text-xs font-semibold text-primary hover:bg-slate-200 transition disabled:opacity-50 disabled:cursor-not-allowed"
>
<ArrowDownTrayIcon className="h-3.5 w-3.5" />
@ -555,14 +666,18 @@ export default function PropertyTable({
</div>
{/* Display-limit notice */}
{isAtDisplayLimit && (
<div className="mb-3 flex items-center gap-2 rounded-lg border border-amber-200 bg-amber-50 px-3 py-2 text-xs text-amber-700">
{isAtDisplayLimit && hasMore && (
<div className="mb-3 flex items-center gap-2 rounded-lg border border-slate-200 bg-slate-50 px-3 py-2 text-xs text-slate-500">
<span>
Your filters match{" "}
<span className="font-semibold">{filteredTotal.toLocaleString()}</span>{" "}
properties only the first{" "}
<span className="font-semibold">{DISPLAY_LIMIT.toLocaleString()}</span>{" "}
are shown. Refine your filters to narrow results.
Showing{" "}
<span className="font-semibold text-primary">
{tableData.length.toLocaleString()}
</span>{" "}
of{" "}
<span className="font-semibold text-primary">
{filteredTotal.toLocaleString()}
</span>{" "}
properties more load automatically as you navigate to the last page.
</span>
</div>
)}
@ -572,7 +687,7 @@ export default function PropertyTable({
{/* Collapsible filter sidebar */}
<div
className={[
"shrink-0 overflow-hidden transition-all duration-300 ease-in-out rounded-xl mr-2",
"shrink-0 overflow-hidden transition-all duration-300 ease-in-out rounded-xl",
sidebarOpen
? "w-72 opacity-100 border border-slate-100 bg-slate-50"
: "w-0 opacity-0",
@ -591,7 +706,7 @@ export default function PropertyTable({
{/* Table area */}
<div className="flex-1 min-w-0 bg-white rounded-xl border border-slate-100 shadow-sm relative">
{isFetching && !isLoading && <LoadingOverlay />}
{((isFetching && !isLoading) || isFetchingMore) && <LoadingOverlay />}
{isLoading ? (
<div className="p-6 text-slate-400 text-sm">
@ -601,7 +716,7 @@ export default function PropertyTable({
<div className="p-6 text-red-500 text-sm">
Failed to load properties.
</div>
) : data.length === 0 && hasActiveFilters ? (
) : queryData.length === 0 && hasActiveFilters ? (
<div className="p-10 text-center text-slate-500">
<p className="text-sm">No properties match your filters.</p>
<button
@ -611,11 +726,11 @@ export default function PropertyTable({
Clear filters
</button>
</div>
) : data.length === 0 ? (
) : queryData.length === 0 ? (
<EmptyPropertyState />
) : (
<DataTable
data={data}
data={tableData}
columns={columns}
onDeleteProperty={(id) => setDeletePropertyId(id)}
columnVisibility={columnVisibility}
@ -624,6 +739,10 @@ export default function PropertyTable({
updater: Updater<VisibilityState>,
) => void
}
pagination={pagination}
onPaginationChange={
setPagination as (updater: Updater<PaginationState>) => void
}
/>
)}
</div>

View file

@ -39,28 +39,36 @@ const fuzzyFilter: FilterFn<any> = (row, columnId, value, addMeta) => {
return itemRank.passed;
};
const DEFAULT_PAGINATION: PaginationState = { pageIndex: 0, pageSize: 7 };
interface DataTableProps<TData> {
columns: ColumnDef<TData, any>[];
data: TData[];
onDeleteProperty?: (propertyId: number) => void;
columnVisibility: VisibilityState;
onColumnVisibilityChange: (updater: Updater<VisibilityState>) => void;
columnVisibility?: VisibilityState;
onColumnVisibilityChange?: (updater: Updater<VisibilityState>) => void;
// Controlled pagination — when omitted the table manages its own pagination state
pagination?: PaginationState;
onPaginationChange?: (updater: Updater<PaginationState>) => void;
}
export default function DataTable<TData extends Record<string, any>>({
data,
columns,
onDeleteProperty,
columnVisibility,
columnVisibility = {},
onColumnVisibilityChange,
pagination: controlledPagination,
onPaginationChange: controlledOnPaginationChange,
}: DataTableProps<TData>) {
const [sorting, setSorting] = useState<SortingState>([]);
const [columnFilters, setColumnFilters] = useState<ColumnFiltersState>([]);
const [globalFilter, setGlobalFilter] = useState("");
const [pagination, setPagination] = useState<PaginationState>({
pageIndex: 0,
pageSize: 7,
});
const [internalPagination, setInternalPagination] = useState<PaginationState>(DEFAULT_PAGINATION);
const isControlled = controlledPagination !== undefined;
const pagination = isControlled ? controlledPagination : internalPagination;
const onPaginationChange = isControlled ? controlledOnPaginationChange! : setInternalPagination;
const table = useReactTable({
data,
@ -71,10 +79,12 @@ export default function DataTable<TData extends Record<string, any>>({
getFilteredRowModel: getFilteredRowModel(),
getPaginationRowModel: getPaginationRowModel(),
autoResetPageIndex: false,
onSortingChange: setSorting,
onColumnFiltersChange: setColumnFilters,
onGlobalFilterChange: setGlobalFilter,
onPaginationChange: setPagination,
onPaginationChange,
onColumnVisibilityChange,
globalFilterFn: fuzzyFilter,

View file

@ -9,7 +9,7 @@ interface Params {
export interface PropertiesResponse {
data: PropertyWithRelations[];
total: number;
total: number | null; // null when fetched with offset > 0 (count not recomputed)
}
export function useProperties({ portfolioId, filterGroups }: Params) {