diff --git a/src/app/api/properties/route.ts b/src/app/api/properties/route.ts index 6a52bb7..bf0239a 100644 --- a/src/app/api/properties/route.ts +++ b/src/app/api/properties/route.ts @@ -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 }); diff --git a/src/app/portfolio/[slug]/components/PropertyTable.tsx b/src/app/portfolio/[slug]/components/PropertyTable.tsx index 2c1bc75..ff740dc 100644 --- a/src/app/portfolio/[slug]/components/PropertyTable.tsx +++ b/src/app/portfolio/[slug]/components/PropertyTable.tsx @@ -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({ + 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 ? ( -
+
{/* Display-limit notice */} - {isAtDisplayLimit && ( -
+ {isAtDisplayLimit && hasMore && ( +
- Your filters match{" "} - {filteredTotal.toLocaleString()}{" "} - properties — only the first{" "} - {DISPLAY_LIMIT.toLocaleString()}{" "} - are shown. Refine your filters to narrow results. + Showing{" "} + + {tableData.length.toLocaleString()} + {" "} + of{" "} + + {filteredTotal.toLocaleString()} + {" "} + properties — more load automatically as you navigate to the last page.
)} @@ -572,7 +687,7 @@ export default function PropertyTable({ {/* Collapsible filter sidebar */}
- {isFetching && !isLoading && } + {((isFetching && !isLoading) || isFetchingMore) && } {isLoading ? (
@@ -601,7 +716,7 @@ export default function PropertyTable({
Failed to load properties.
- ) : data.length === 0 && hasActiveFilters ? ( + ) : queryData.length === 0 && hasActiveFilters ? (

No properties match your filters.

- ) : data.length === 0 ? ( + ) : queryData.length === 0 ? ( ) : ( setDeletePropertyId(id)} columnVisibility={columnVisibility} @@ -624,6 +739,10 @@ export default function PropertyTable({ updater: Updater, ) => void } + pagination={pagination} + onPaginationChange={ + setPagination as (updater: Updater) => void + } /> )}
diff --git a/src/app/portfolio/[slug]/components/dataTable.tsx b/src/app/portfolio/[slug]/components/dataTable.tsx index 255e0fc..18acbb7 100644 --- a/src/app/portfolio/[slug]/components/dataTable.tsx +++ b/src/app/portfolio/[slug]/components/dataTable.tsx @@ -39,28 +39,36 @@ const fuzzyFilter: FilterFn = (row, columnId, value, addMeta) => { return itemRank.passed; }; +const DEFAULT_PAGINATION: PaginationState = { pageIndex: 0, pageSize: 7 }; + interface DataTableProps { columns: ColumnDef[]; data: TData[]; onDeleteProperty?: (propertyId: number) => void; - columnVisibility: VisibilityState; - onColumnVisibilityChange: (updater: Updater) => void; + columnVisibility?: VisibilityState; + onColumnVisibilityChange?: (updater: Updater) => void; + // Controlled pagination — when omitted the table manages its own pagination state + pagination?: PaginationState; + onPaginationChange?: (updater: Updater) => void; } export default function DataTable>({ data, columns, onDeleteProperty, - columnVisibility, + columnVisibility = {}, onColumnVisibilityChange, + pagination: controlledPagination, + onPaginationChange: controlledOnPaginationChange, }: DataTableProps) { const [sorting, setSorting] = useState([]); const [columnFilters, setColumnFilters] = useState([]); const [globalFilter, setGlobalFilter] = useState(""); - const [pagination, setPagination] = useState({ - pageIndex: 0, - pageSize: 7, - }); + const [internalPagination, setInternalPagination] = useState(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>({ getFilteredRowModel: getFilteredRowModel(), getPaginationRowModel: getPaginationRowModel(), + autoResetPageIndex: false, + onSortingChange: setSorting, onColumnFiltersChange: setColumnFilters, onGlobalFilterChange: setGlobalFilter, - onPaginationChange: setPagination, + onPaginationChange, onColumnVisibilityChange, globalFilterFn: fuzzyFilter, diff --git a/src/app/portfolio/[slug]/components/useProperties.ts b/src/app/portfolio/[slug]/components/useProperties.ts index f33bc88..b45455f 100644 --- a/src/app/portfolio/[slug]/components/useProperties.ts +++ b/src/app/portfolio/[slug]/components/useProperties.ts @@ -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) {