From 367c847e1600aa335f5070ab892d28e6045ece16 Mon Sep 17 00:00:00 2001 From: Corianas Date: Sun, 12 Jul 2026 14:43:44 +0800 Subject: [PATCH 1/2] Paginate the eleven list pages (25 per page) usePagination clamps the current page at derive time, so search or filter changes that shrink the result set never strand you on a dead page. ListPagination shows a Showing X of Y summary with Prev/Next and hides itself for single-page lists. Data stays fully loaded, so search, status-pill counts, and filters remain instant over the whole set; only the rendered slice is paginated. Invoices select-all is now page-scoped, leaving selections on other pages intact for bulk actions. Co-Authored-By: Claude Fable 5 --- src/components/ListPagination.tsx | 51 +++++++++++++++++++++++++++++++ src/hooks/usePagination.ts | 49 +++++++++++++++++++++++++++++ src/pages/Assets.tsx | 17 +++++++++-- src/pages/Clients.tsx | 17 +++++++++-- src/pages/Contacts.tsx | 17 +++++++++-- src/pages/Inventory.tsx | 19 ++++++++++-- src/pages/Invoices.tsx | 38 ++++++++++++++++++----- src/pages/Issues.tsx | 17 +++++++++-- src/pages/Jobs.tsx | 17 +++++++++-- src/pages/Locations.tsx | 17 +++++++++-- src/pages/Payments.tsx | 31 ++++++++++++++++--- src/pages/Team.tsx | 17 +++++++++-- src/pages/Vendors.tsx | 17 +++++++++-- 13 files changed, 293 insertions(+), 31 deletions(-) create mode 100644 src/components/ListPagination.tsx create mode 100644 src/hooks/usePagination.ts diff --git a/src/components/ListPagination.tsx b/src/components/ListPagination.tsx new file mode 100644 index 0000000..f5f6224 --- /dev/null +++ b/src/components/ListPagination.tsx @@ -0,0 +1,51 @@ +import { ChevronLeft, ChevronRight } from 'lucide-react'; +import { Button } from '@/components/ui/button'; + +interface ListPaginationProps { + page: number; + totalPages: number; + total: number; + startIndex: number; + endIndex: number; + onPageChange: (page: number) => void; +} + +/** Prev/next pager shown under a paginated list. Renders nothing for a single page. */ +export function ListPagination({ + page, + totalPages, + total, + startIndex, + endIndex, + onPageChange, +}: ListPaginationProps) { + if (totalPages <= 1) return null; + + return ( +
+

+ Showing {startIndex}–{endIndex} of {total} +

+
+ + +
+
+ ); +} diff --git a/src/hooks/usePagination.ts b/src/hooks/usePagination.ts new file mode 100644 index 0000000..8fb3673 --- /dev/null +++ b/src/hooks/usePagination.ts @@ -0,0 +1,49 @@ +import { useState } from 'react'; + +interface UsePaginationResult { + /** Effective 1-based current page (clamped to the valid range). */ + page: number; + setPage: (page: number) => void; + /** The slice of `items` belonging to the current page. */ + pageItems: T[]; + /** Total number of pages (minimum 1, even when `items` is empty). */ + totalPages: number; + /** Total number of items across all pages. */ + total: number; + /** 1-based index of the first item on the current page (0 when `total` is 0). */ + startIndex: number; + /** 1-based index of the last item on the current page (0 when `total` is 0). */ + endIndex: number; +} + +/** + * Client-side pagination over an already-loaded array. Data stays fully + * loaded (so search/filter/counts remain instant over the complete set) — + * only the rendered slice is paginated. + * + * The current page is clamped at derive time (`Math.min(page, totalPages)`), + * so shrinking the input array (e.g. via search/filter) never strands the + * caller on a page that no longer exists — no effect needed to reset it. + */ +export function usePagination(items: T[], pageSize = 25): UsePaginationResult { + const [page, setPage] = useState(1); + + const total = items.length; + const totalPages = Math.max(1, Math.ceil(total / pageSize)); + const effectivePage = Math.min(page, totalPages); + + const startIndex = total === 0 ? 0 : (effectivePage - 1) * pageSize + 1; + const endIndex = total === 0 ? 0 : Math.min(effectivePage * pageSize, total); + + const pageItems = items.slice((effectivePage - 1) * pageSize, effectivePage * pageSize); + + return { + page: effectivePage, + setPage, + pageItems, + totalPages, + total, + startIndex, + endIndex, + }; +} diff --git a/src/pages/Assets.tsx b/src/pages/Assets.tsx index b1ef918..48c261d 100644 --- a/src/pages/Assets.tsx +++ b/src/pages/Assets.tsx @@ -16,6 +16,8 @@ import { Badge } from '@/components/ui/badge'; import { Plus, Search, HardDrive } from 'lucide-react'; import { PermissionGate } from '@/components/PermissionGate'; import { EmptyState } from '@/components/EmptyState'; +import { ListPagination } from '@/components/ListPagination'; +import { usePagination } from '@/hooks/usePagination'; import { useBranding } from '@/contexts/BrandingContext'; import type { Tables } from '@/integrations/supabase/types'; @@ -55,6 +57,8 @@ export default function Assets() { asset.clients?.name?.toLowerCase().includes(search.toLowerCase()) ); + const pagination = usePagination(filteredAssets); + return (
@@ -156,7 +160,7 @@ export default function Assets() { ) : ( - filteredAssets.map((asset) => ( + pagination.pageItems.map((asset) => ( {asset.asset_tag} @@ -208,7 +212,7 @@ export default function Assets() {
) : ( - filteredAssets.map((asset) => ( + pagination.pageItems.map((asset) => ( + + )}
diff --git a/src/pages/Clients.tsx b/src/pages/Clients.tsx index a983d69..aed0204 100644 --- a/src/pages/Clients.tsx +++ b/src/pages/Clients.tsx @@ -16,6 +16,8 @@ import { Badge } from '@/components/ui/badge'; import { Plus, Search, Users } from 'lucide-react'; import { PermissionGate } from '@/components/PermissionGate'; import { EmptyState } from '@/components/EmptyState'; +import { ListPagination } from '@/components/ListPagination'; +import { usePagination } from '@/hooks/usePagination'; import type { Tables } from '@/integrations/supabase/types'; type Client = Tables<'clients'>; @@ -67,6 +69,8 @@ export default function Clients() { client.primary_contact?.name?.toLowerCase().includes(search.toLowerCase()) ); + const pagination = usePagination(filteredClients); + return (
@@ -166,7 +170,7 @@ export default function Clients() { ) : ( - filteredClients.map((client) => ( + pagination.pageItems.map((client) => (
) : ( - filteredClients.map((client) => ( + pagination.pageItems.map((client) => ( + + )}
diff --git a/src/pages/Contacts.tsx b/src/pages/Contacts.tsx index 359c0e4..0ac3e36 100644 --- a/src/pages/Contacts.tsx +++ b/src/pages/Contacts.tsx @@ -16,6 +16,8 @@ import { Badge } from '@/components/ui/badge'; import { Plus, Search, User } from 'lucide-react'; import { PermissionGate } from '@/components/PermissionGate'; import { EmptyState } from '@/components/EmptyState'; +import { ListPagination } from '@/components/ListPagination'; +import { usePagination } from '@/hooks/usePagination'; import type { Tables } from '@/integrations/supabase/types'; type Contact = Tables<'contacts'>; @@ -81,6 +83,8 @@ export default function Contacts() { ); }); + const pagination = usePagination(filteredContacts); + return (
@@ -180,7 +184,7 @@ export default function Contacts() { ) : ( - filteredContacts.map((contact) => { + pagination.pageItems.map((contact) => { const orgLabel = organisationLabel(contact); const isActive = contact.is_active !== false; return ( @@ -221,7 +225,7 @@ export default function Contacts() {
) : ( - filteredContacts.map((contact) => { + pagination.pageItems.map((contact) => { const orgLabel = organisationLabel(contact); const isActive = contact.is_active !== false; return ( @@ -248,6 +252,15 @@ export default function Contacts() { }) )}
+ + )} diff --git a/src/pages/Inventory.tsx b/src/pages/Inventory.tsx index c38c74f..23dbab0 100644 --- a/src/pages/Inventory.tsx +++ b/src/pages/Inventory.tsx @@ -16,6 +16,8 @@ import { Badge } from '@/components/ui/badge'; import { Plus, Search, AlertTriangle, Package } from 'lucide-react'; import { useBranding } from '@/contexts/BrandingContext'; import { EmptyState } from '@/components/EmptyState'; +import { ListPagination } from '@/components/ListPagination'; +import { usePagination } from '@/hooks/usePagination'; import type { Tables } from '@/integrations/supabase/types'; type Item = Tables<'items'>; @@ -47,7 +49,9 @@ export default function Inventory() { item.category?.toLowerCase().includes(search.toLowerCase()) ); - const lowStockItems = items.filter(item => + const pagination = usePagination(filteredItems); + + const lowStockItems = items.filter(item => (item.current_stock || 0) <= (item.reorder_level || 0) && item.is_active ); @@ -164,7 +168,7 @@ export default function Inventory() { ) : ( - filteredItems.map((item) => { + pagination.pageItems.map((item) => { const isLowStock = (item.current_stock || 0) <= (item.reorder_level || 0); return ( @@ -221,7 +225,7 @@ export default function Inventory() { ) : ( - filteredItems.map((item) => { + pagination.pageItems.map((item) => { const isLowStock = (item.current_stock || 0) <= (item.reorder_level || 0); return ( + + )} diff --git a/src/pages/Invoices.tsx b/src/pages/Invoices.tsx index 8909f45..2bda09b 100644 --- a/src/pages/Invoices.tsx +++ b/src/pages/Invoices.tsx @@ -24,6 +24,8 @@ import { useToast } from '@/hooks/use-toast'; import { useBranding } from '@/contexts/BrandingContext'; import { formatDisplayDate, todayLocal } from '@/lib/dates'; import { EmptyState } from '@/components/EmptyState'; +import { ListPagination } from '@/components/ListPagination'; +import { usePagination } from '@/hooks/usePagination'; import type { Tables } from '@/integrations/supabase/types'; type Invoice = Tables<'invoices'> & { clients?: { name: string; contact_email?: string; email?: string } | null }; @@ -85,6 +87,8 @@ export default function Invoices() { inv.clients?.name?.toLowerCase().includes(search.toLowerCase())) ); + const pagination = usePagination(filteredInvoices); + const toggleSelect = (id: string) => { const newSelected = new Set(selectedIds); if (newSelected.has(id)) { @@ -95,12 +99,21 @@ export default function Invoices() { setSelectedIds(newSelected); }; + // Select-all is scoped to the current page: it reflects and toggles only + // the ids visible on this page, leaving selections on other pages intact. + const pageIds = pagination.pageItems.map(inv => inv.id); + const allOnPageSelected = pageIds.length > 0 && pageIds.every(id => selectedIds.has(id)); + const toggleSelectAll = () => { - if (selectedIds.size === filteredInvoices.length) { - setSelectedIds(new Set()); - } else { - setSelectedIds(new Set(filteredInvoices.map(inv => inv.id))); - } + setSelectedIds(prev => { + const next = new Set(prev); + if (allOnPageSelected) { + pageIds.forEach(id => next.delete(id)); + } else { + pageIds.forEach(id => next.add(id)); + } + return next; + }); }; function clearFilters() { @@ -374,7 +387,7 @@ export default function Invoices() { 0} + checked={allOnPageSelected} onCheckedChange={toggleSelectAll} /> @@ -400,7 +413,7 @@ export default function Invoices() { ) : ( - filteredInvoices.map((invoice) => ( + pagination.pageItems.map((invoice) => ( ) : ( - filteredInvoices.map((invoice) => ( + pagination.pageItems.map((invoice) => (
+ + )}
diff --git a/src/pages/Issues.tsx b/src/pages/Issues.tsx index 359a221..ca23fe9 100644 --- a/src/pages/Issues.tsx +++ b/src/pages/Issues.tsx @@ -9,6 +9,8 @@ import { Badge } from '@/components/ui/badge'; import { Plus, Search, AlertCircle } from 'lucide-react'; import { formatDisplayDate } from '@/lib/dates'; import { EmptyState } from '@/components/EmptyState'; +import { ListPagination } from '@/components/ListPagination'; +import { usePagination } from '@/hooks/usePagination'; import type { Tables } from '@/integrations/supabase/types'; type Issue = Tables<'issues'> & { clients?: { name: string } | null }; @@ -30,6 +32,8 @@ export default function Issues() { const filteredIssues = issues.filter(i => i.title.toLowerCase().includes(search.toLowerCase())); + const pagination = usePagination(filteredIssues); + return (
@@ -80,7 +84,7 @@ export default function Issues() { ) : ( - filteredIssues.map(issue => ( + pagination.pageItems.map(issue => ( {issue.title} {issue.clients?.name || '-'} @@ -102,7 +106,7 @@ export default function Issues() {
) : ( - filteredIssues.map(issue => ( + pagination.pageItems.map(issue => (
{issue.title} @@ -119,6 +123,15 @@ export default function Issues() { )) )}
+ + )}
diff --git a/src/pages/Jobs.tsx b/src/pages/Jobs.tsx index be6e76b..273538f 100644 --- a/src/pages/Jobs.tsx +++ b/src/pages/Jobs.tsx @@ -16,6 +16,8 @@ import { Badge } from '@/components/ui/badge'; import { Plus, Search, Briefcase } from 'lucide-react'; import { useBranding } from '@/contexts/BrandingContext'; import { EmptyState } from '@/components/EmptyState'; +import { ListPagination } from '@/components/ListPagination'; +import { usePagination } from '@/hooks/usePagination'; import type { Tables } from '@/integrations/supabase/types'; type Job = Tables<'jobs'> & { clients?: { name: string } | null }; @@ -55,6 +57,8 @@ export default function Jobs() { job.clients?.name?.toLowerCase().includes(search.toLowerCase()) ); + const pagination = usePagination(filteredJobs); + return (
@@ -150,7 +154,7 @@ export default function Jobs() { ) : ( - filteredJobs.map((job) => ( + pagination.pageItems.map((job) => ( {job.job_number} @@ -185,7 +189,7 @@ export default function Jobs() {
) : ( - filteredJobs.map((job) => ( + pagination.pageItems.map((job) => ( + + )}
diff --git a/src/pages/Locations.tsx b/src/pages/Locations.tsx index d990a90..aa7a893 100644 --- a/src/pages/Locations.tsx +++ b/src/pages/Locations.tsx @@ -10,6 +10,8 @@ import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'; import { Plus, Search, MapPin, Building2, Phone, Mail } from 'lucide-react'; import { EmptyState } from '@/components/EmptyState'; +import { ListPagination } from '@/components/ListPagination'; +import { usePagination } from '@/hooks/usePagination'; interface Location { id: string; @@ -88,6 +90,8 @@ export default function Locations() { return matchesSearch && matchesType; }); + const pagination = usePagination(filteredLocations); + function formatAddress(location: Location) { const parts = [location.address_line1, location.city, location.state, location.postcode].filter(Boolean); return parts.join(', ') || 'No address'; @@ -243,7 +247,7 @@ export default function Locations() {
) : ( - filteredLocations.map((location) => ( + pagination.pageItems.map((location) => ( ) : ( - filteredLocations.map((location) => ( + pagination.pageItems.map((location) => ( + + )} diff --git a/src/pages/Payments.tsx b/src/pages/Payments.tsx index 15cae02..7dfb03f 100644 --- a/src/pages/Payments.tsx +++ b/src/pages/Payments.tsx @@ -28,6 +28,8 @@ import { useToast } from '@/hooks/use-toast'; import { useBranding } from '@/contexts/BrandingContext'; import { formatDisplayDate } from '@/lib/dates'; import { EmptyState } from '@/components/EmptyState'; +import { ListPagination } from '@/components/ListPagination'; +import { usePagination } from '@/hooks/usePagination'; import type { Tables } from '@/integrations/supabase/types'; import MakePaymentDialog from '@/components/MakePaymentDialog'; import EditPurchaseDialog from '@/components/EditPurchaseDialog'; @@ -134,6 +136,9 @@ export default function Payments() { p.reference?.toLowerCase().includes(search.toLowerCase()) ); + const paymentsPagination = usePagination(filteredPayments); + const purchasesPagination = usePagination(filteredPurchases); + const totalCollected = payments.reduce((sum, p) => sum + p.amount, 0); const totalSpent = purchases.reduce((sum, p) => sum + p.total, 0); @@ -346,7 +351,7 @@ export default function Payments() { ) : ( - filteredPayments.map((payment) => ( + paymentsPagination.pageItems.map((payment) => ( {formatDisplayDate(payment.date)} @@ -378,7 +383,7 @@ export default function Payments() { ) : ( - filteredPayments.map((payment) => ( + paymentsPagination.pageItems.map((payment) => ( + + )} @@ -474,7 +488,7 @@ export default function Payments() { ) : ( - filteredPurchases.map((purchase) => ( + purchasesPagination.pageItems.map((purchase) => ( {formatDisplayDate(purchase.date)} {purchase.description} @@ -518,7 +532,7 @@ export default function Payments() { ) : ( - filteredPurchases.map((purchase) => ( + purchasesPagination.pageItems.map((purchase) => (