diff --git a/server/__tests__/mail-batch.test.ts b/server/__tests__/mail-batch.test.ts new file mode 100644 index 0000000..4f5a563 --- /dev/null +++ b/server/__tests__/mail-batch.test.ts @@ -0,0 +1,137 @@ +process.env.NODE_ENV = 'test'; +process.env.JWT_SECRET = 'test-secret-key'; + +import { describe, it, expect, beforeAll } from 'vitest'; +import request from 'supertest'; +import type { Express } from 'express'; +import { createTestApp, login } from './helpers.js'; + +/** + * Exercises the batched invoice-send route (`POST /api/mail/send-invoices`). + * SMTP is not configured in tests, so `sendInvoiceEmail` reports failure — + * which is exactly what lets us assert the server LOOPS over every id and + * reports a per-invoice outcome without needing a live mail server. The two + * deterministic reasons ("no client email" for a client without an email, + * "invoice not found" for an unknown id) are asserted directly. + */ +let app: Express; +let token: string; +let clientWithEmailId: string; +let clientNoEmailId: string; +let invoiceWithEmailId: string; +let invoiceNoEmailId: string; + +const INVOICE_WITH_EMAIL = 'MAIL-TEST-0001'; +const INVOICE_NO_EMAIL = 'MAIL-TEST-0002'; + +function auth(req: request.Test) { + return req.set('Authorization', `Bearer ${token}`); +} + +beforeAll(async () => { + app = await createTestApp(); + token = await login(app); + + const clientWithEmail = await auth(request(app).post('/api/clients')).send({ + name: 'Client With Email', + contact_email: 'billing@withemail.example', + }); + expect(clientWithEmail.status).toBe(201); + clientWithEmailId = clientWithEmail.body.id; + + const clientNoEmail = await auth(request(app).post('/api/clients')).send({ + name: 'Client No Email', + }); + expect(clientNoEmail.status).toBe(201); + clientNoEmailId = clientNoEmail.body.id; + + const invoiceWithEmail = await auth(request(app).post('/api/invoices')).send({ + invoice_number: INVOICE_WITH_EMAIL, + client_id: clientWithEmailId, + issue_date: '2026-07-01', + due_date: '2026-08-01', + subtotal: 100, + tax_total: 10, + total: 110, + }); + expect(invoiceWithEmail.status).toBe(201); + invoiceWithEmailId = invoiceWithEmail.body.id; + + const invoiceNoEmail = await auth(request(app).post('/api/invoices')).send({ + invoice_number: INVOICE_NO_EMAIL, + client_id: clientNoEmailId, + issue_date: '2026-07-02', + due_date: '2026-08-02', + subtotal: 50, + tax_total: 5, + total: 55, + }); + expect(invoiceNoEmail.status).toBe(201); + invoiceNoEmailId = invoiceNoEmail.body.id; +}); + +describe('POST /api/mail/send-invoices', () => { + it('rejects unauthenticated requests with 401', async () => { + const res = await request(app) + .post('/api/mail/send-invoices') + .send({ invoiceIds: [invoiceWithEmailId] }); + expect(res.status).toBe(401); + }); + + it('returns 400 when invoiceIds is not an array', async () => { + const res = await auth(request(app).post('/api/mail/send-invoices')).send({ + invoiceIds: 'not-an-array', + }); + expect(res.status).toBe(400); + }); + + it('returns 400 for an empty array', async () => { + const res = await auth(request(app).post('/api/mail/send-invoices')).send({ + invoiceIds: [], + }); + expect(res.status).toBe(400); + }); + + it('reports "no client email" for an invoice whose client has no email', async () => { + const res = await auth(request(app).post('/api/mail/send-invoices')).send({ + invoiceIds: [invoiceNoEmailId], + }); + expect(res.status).toBe(200); + expect(res.body.sent).toBe(0); + expect(res.body.total).toBe(1); + expect(res.body.failures).toEqual([ + { invoiceNumber: INVOICE_NO_EMAIL, reason: 'no client email' }, + ]); + }); + + it('reports "invoice not found" for an unknown id', async () => { + const res = await auth(request(app).post('/api/mail/send-invoices')).send({ + invoiceIds: ['does-not-exist'], + }); + expect(res.status).toBe(200); + expect(res.body.failures).toEqual([ + { invoiceNumber: 'does-not-exist', reason: 'invoice not found' }, + ]); + }); + + it('loops over every id, reporting one outcome per invoice', async () => { + // With SMTP unconfigured, the invoice that HAS an email still fails to + // send ("SMTP not configured"), but that proves the send was attempted; + // the no-email invoice fails for its own distinct reason. Both are + // reported, confirming the server iterates the whole list. + const res = await auth(request(app).post('/api/mail/send-invoices')).send({ + invoiceIds: [invoiceWithEmailId, invoiceNoEmailId], + }); + expect(res.status).toBe(200); + expect(res.body.total).toBe(2); + expect(res.body.sent).toBe(0); + expect(res.body.failed).toBe(2); + + const byNumber = Object.fromEntries( + res.body.failures.map((f: { invoiceNumber: string; reason: string }) => [f.invoiceNumber, f.reason]), + ); + expect(byNumber[INVOICE_NO_EMAIL]).toBe('no client email'); + expect(byNumber[INVOICE_WITH_EMAIL]).toBeDefined(); + expect(byNumber[INVOICE_WITH_EMAIL]).not.toBe('no client email'); + }); +}); diff --git a/server/routes/mail.ts b/server/routes/mail.ts index 142ab2c..38f8377 100644 --- a/server/routes/mail.ts +++ b/server/routes/mail.ts @@ -7,34 +7,42 @@ import { formatDisplayDate } from '../utils/dates.js'; const router = Router(); -// Send invoice via email -router.post('/send-invoice', authMiddleware, async (req: AuthRequest, res: Response) => { - const requireRead = requirePermission('invoices', 'read'); - await requireRead(req, res, async () => { - const { invoiceId, recipientEmail } = req.body; +/** Outcome of attempting to email a single invoice. */ +interface InvoiceSendOutcome { + ok: boolean; + /** Invoice number when known, else the requested id (for the failure list). */ + invoiceNumber: string; + recipientEmail?: string; + reason?: string; +} - if (!invoiceId) { - res.status(400).json({ error: 'Invoice ID is required' }); - return; - } +/** + * Build and send the email for one invoice. The recipient defaults to the + * invoice's client email (contact_email, falling back to email); pass + * `recipientEmailOverride` to send elsewhere. Never throws — every failure + * path returns an outcome with a human-readable `reason`, so callers + * (single send and bulk send) can report uniformly. + */ +async function sendInvoiceById( + invoiceId: string, + recipientEmailOverride?: string, +): Promise { + const invoiceResult = queryOne('SELECT * FROM invoices WHERE id = ?', [invoiceId]); + if (invoiceResult.error || !invoiceResult.data) { + return { ok: false, invoiceNumber: invoiceId, reason: 'invoice not found' }; + } - if (!recipientEmail) { - res.status(400).json({ error: 'Recipient email is required' }); - return; - } + const invoice = invoiceResult.data; + const client = invoice.client_id + ? queryOne('SELECT * FROM clients WHERE id = ?', [invoice.client_id]).data + : null; - // Get invoice - const invoiceResult = queryOne('SELECT * FROM invoices WHERE id = ?', [invoiceId]); - if (invoiceResult.error || !invoiceResult.data) { - res.status(404).json({ error: 'Invoice not found' }); - return; - } + const recipientEmail = recipientEmailOverride || client?.contact_email || client?.email; + if (!recipientEmail) { + return { ok: false, invoiceNumber: invoice.invoice_number, reason: 'no client email' }; + } - const invoice = invoiceResult.data; - const client = invoice.client_id - ? queryOne('SELECT * FROM clients WHERE id = ?', [invoice.client_id]).data - : null; - const job = invoice.job_id + const job = invoice.job_id ? queryOne('SELECT * FROM jobs WHERE id = ?', [invoice.job_id]).data : null; const tradingName = job?.trading_name_id @@ -176,20 +184,79 @@ router.post('/send-invoice', authMiddleware, async (req: AuthRequest, res: Respo `; - // Send email - const result = await sendInvoiceEmail( - recipientEmail, - invoice.invoice_number, - invoiceHtml, - companyDisplayName - ); + // Send email + const result = await sendInvoiceEmail( + recipientEmail, + invoice.invoice_number, + invoiceHtml, + companyDisplayName + ); - if (!result.success) { - res.status(500).json({ error: result.error || 'Failed to send email' }); + if (!result.success) { + return { ok: false, invoiceNumber: invoice.invoice_number, reason: result.error || 'failed to send' }; + } + + return { ok: true, invoiceNumber: invoice.invoice_number, recipientEmail }; +} + +// Send a single invoice via email (recipient supplied by the caller). +router.post('/send-invoice', authMiddleware, async (req: AuthRequest, res: Response) => { + const requireRead = requirePermission('invoices', 'read'); + await requireRead(req, res, async () => { + const { invoiceId, recipientEmail } = req.body; + + if (!invoiceId) { + res.status(400).json({ error: 'Invoice ID is required' }); return; } - res.json({ success: true, message: `Invoice sent to ${recipientEmail}` }); + if (!recipientEmail) { + res.status(400).json({ error: 'Recipient email is required' }); + return; + } + + const outcome = await sendInvoiceById(invoiceId, recipientEmail); + if (outcome.ok) { + res.json({ success: true, message: `Invoice sent to ${outcome.recipientEmail}` }); + return; + } + if (outcome.reason === 'invoice not found') { + res.status(404).json({ error: 'Invoice not found' }); + return; + } + res.status(500).json({ error: outcome.reason || 'Failed to send email' }); + }); +}); + +// Send many invoices in one request; the server loops so the client makes a +// single round-trip and SMTP pacing stays server-side. Recipients are +// resolved per invoice from its client. Always 200 with a per-invoice summary. +router.post('/send-invoices', authMiddleware, async (req: AuthRequest, res: Response) => { + const requireRead = requirePermission('invoices', 'read'); + await requireRead(req, res, async () => { + const { invoiceIds } = req.body; + if ( + !Array.isArray(invoiceIds) || + invoiceIds.length === 0 || + invoiceIds.length > 100 || + !invoiceIds.every((id) => typeof id === 'string') + ) { + res.status(400).json({ error: 'invoiceIds must be a non-empty array of at most 100 invoice ids' }); + return; + } + + let sent = 0; + const failures: { invoiceNumber: string; reason: string }[] = []; + for (const invoiceId of invoiceIds) { + const outcome = await sendInvoiceById(invoiceId); + if (outcome.ok) { + sent += 1; + } else { + failures.push({ invoiceNumber: outcome.invoiceNumber, reason: outcome.reason || 'failed to send' }); + } + } + + res.json({ sent, failed: failures.length, total: invoiceIds.length, failures }); }); }); 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..29556c4 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() { @@ -186,50 +199,41 @@ export default function Invoices() { toast({ title: 'Sending emails...', description: `Sending ${selectedNonDrafts.length} invoice(s)` }); - let successCount = 0; - const failures: { invoiceNumber: string; reason: string }[] = []; - - for (const inv of selectedNonDrafts) { - try { - // Get client contact email - const clientEmail = inv.clients?.contact_email || inv.clients?.email; - if (!clientEmail) { - failures.push({ invoiceNumber: inv.invoice_number, reason: 'no client email' }); - continue; - } - - const response = await fetch(`${import.meta.env.VITE_API_URL}/mail/send-invoice`, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - 'Authorization': `Bearer ${localStorage.getItem('auth_token')}` - }, - body: JSON.stringify({ - invoiceId: inv.id, - recipientEmail: clientEmail - }) - }); - - if (!response.ok) { - const error = await response.json(); - throw new Error(error.error || 'Failed to send'); - } - - successCount++; - } catch (error: any) { - failures.push({ invoiceNumber: inv.invoice_number, reason: error.message }); + // One request: the server loops over the ids, resolves each client's email, + // sends, and returns a per-invoice summary. + let sent = 0; + let failures: { invoiceNumber: string; reason: string }[] = []; + try { + const response = await fetch(`${import.meta.env.VITE_API_URL}/mail/send-invoices`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'Authorization': `Bearer ${localStorage.getItem('auth_token')}` + }, + body: JSON.stringify({ invoiceIds: selectedNonDrafts.map(inv => inv.id) }) + }); + + const result = await response.json(); + if (!response.ok) { + throw new Error(result.error || 'Failed to send invoices'); } + + sent = result.sent ?? 0; + failures = result.failures ?? []; + } catch (error: any) { + toast({ title: 'Error', description: error.message, variant: 'destructive' }); + return; } if (failures.length === 0) { - toast({ title: 'Success', description: `Sent ${successCount} invoice(s)` }); + toast({ title: 'Success', description: `Sent ${sent} invoice(s)` }); } else { const shown = failures.slice(0, 3).map(f => `${f.invoiceNumber}: ${f.reason}`); if (failures.length > 3) { shown.push(`…and ${failures.length - 3} more`); } toast({ - title: `Sent ${successCount} of ${selectedNonDrafts.length} invoices`, + title: `Sent ${sent} of ${selectedNonDrafts.length} invoices`, description: shown.join('\n'), variant: 'destructive', }); @@ -374,7 +378,7 @@ export default function Invoices() { 0} + checked={allOnPageSelected} onCheckedChange={toggleSelectAll} /> @@ -400,7 +404,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) => (