mirror of
https://github.com/Hestia-Homes/assessment-model.git
synced 2026-06-08 11:37:25 +00:00
adjusted lazy load banner
This commit is contained in:
parent
41c068c419
commit
052928a5dd
4 changed files with 173 additions and 41 deletions
|
|
@ -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 });
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue