fix: add pagination to unbounded GET endpoints
All list endpoints now accept ?page and ?limit (default 50, max 200) and
return { data, total, page, limit } instead of a bare array, preventing
memory and performance failures at scale.
- GET /api/dogs: adds pagination, server-side search (?search) and sex
filter (?sex), and a stats aggregate (total/males/females) for the
Dashboard to avoid counting from the array
- GET /api/litters: adds pagination; also fixes N+1 query by fetching
all puppies for the current page in a single query instead of one per
litter
- DogList: moves search/sex filtering server-side with 300ms debounce;
adds Prev/Next pagination controls
- LitterList: uses paginated response; adds Prev/Next pagination controls
- Dashboard: reads counts from stats/total fields instead of array length
- LitterDetail, LitterForm: switch dogs fetch to /api/dogs/all (complete
list, no pagination, for sire/dam dropdowns)
- DogForm: updates litters fetch to use paginated response shape
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -1,57 +1,69 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useEffect, useState, useRef } from 'react'
|
||||
import { Link, useNavigate } from 'react-router-dom'
|
||||
import { Dog, Plus, Search, Calendar, Hash, ArrowRight, Trash2 } from 'lucide-react'
|
||||
import axios from 'axios'
|
||||
import DogForm from '../components/DogForm'
|
||||
import { ChampionBadge, ChampionBloodlineBadge } from '../components/ChampionBadge'
|
||||
|
||||
const LIMIT = 50
|
||||
|
||||
function DogList() {
|
||||
const [dogs, setDogs] = useState([])
|
||||
const [filteredDogs, setFilteredDogs] = useState([])
|
||||
const [total, setTotal] = useState(0)
|
||||
const [page, setPage] = useState(1)
|
||||
const [search, setSearch] = useState('')
|
||||
const [sexFilter, setSexFilter] = useState('all')
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [showAddModal, setShowAddModal] = useState(false)
|
||||
const [deleteTarget, setDeleteTarget] = useState(null) // { id, name }
|
||||
const [deleting, setDeleting] = useState(false)
|
||||
const searchTimerRef = useRef(null)
|
||||
|
||||
useEffect(() => { fetchDogs() }, [])
|
||||
useEffect(() => { filterDogs() }, [dogs, search, sexFilter])
|
||||
useEffect(() => { fetchDogs(1, '', 'all') }, []) // eslint-disable-line
|
||||
|
||||
const fetchDogs = async () => {
|
||||
const fetchDogs = async (p, q, s) => {
|
||||
setLoading(true)
|
||||
try {
|
||||
const res = await axios.get('/api/dogs')
|
||||
setDogs(res.data)
|
||||
setLoading(false)
|
||||
const params = { page: p, limit: LIMIT }
|
||||
if (q) params.search = q
|
||||
if (s !== 'all') params.sex = s
|
||||
const res = await axios.get('/api/dogs', { params })
|
||||
setDogs(res.data.data)
|
||||
setTotal(res.data.total)
|
||||
setPage(p)
|
||||
} catch (error) {
|
||||
console.error('Error fetching dogs:', error)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const filterDogs = () => {
|
||||
let filtered = dogs
|
||||
if (search) {
|
||||
filtered = filtered.filter(dog =>
|
||||
dog.name.toLowerCase().includes(search.toLowerCase()) ||
|
||||
(dog.registration_number && dog.registration_number.toLowerCase().includes(search.toLowerCase()))
|
||||
)
|
||||
}
|
||||
if (sexFilter !== 'all') {
|
||||
filtered = filtered.filter(dog => dog.sex === sexFilter)
|
||||
}
|
||||
setFilteredDogs(filtered)
|
||||
const handleSearchChange = (value) => {
|
||||
setSearch(value)
|
||||
if (searchTimerRef.current) clearTimeout(searchTimerRef.current)
|
||||
searchTimerRef.current = setTimeout(() => fetchDogs(1, value, sexFilter), 300)
|
||||
}
|
||||
|
||||
const handleSave = () => { fetchDogs() }
|
||||
const handleSexChange = (value) => {
|
||||
setSexFilter(value)
|
||||
fetchDogs(1, search, value)
|
||||
}
|
||||
|
||||
const handleClearFilters = () => {
|
||||
setSearch('')
|
||||
setSexFilter('all')
|
||||
fetchDogs(1, '', 'all')
|
||||
}
|
||||
|
||||
const handleSave = () => { fetchDogs(page, search, sexFilter) }
|
||||
|
||||
const handleDelete = async () => {
|
||||
if (!deleteTarget) return
|
||||
setDeleting(true)
|
||||
try {
|
||||
await axios.delete(`/api/dogs/${deleteTarget.id}`)
|
||||
setDogs(prev => prev.filter(d => d.id !== deleteTarget.id))
|
||||
setDeleteTarget(null)
|
||||
fetchDogs(page, search, sexFilter)
|
||||
} catch (err) {
|
||||
console.error('Delete failed:', err)
|
||||
alert('Failed to delete dog. Please try again.')
|
||||
@@ -60,6 +72,8 @@ function DogList() {
|
||||
}
|
||||
}
|
||||
|
||||
const totalPages = Math.ceil(total / LIMIT)
|
||||
|
||||
const calculateAge = (birthDate) => {
|
||||
if (!birthDate) return null
|
||||
const today = new Date()
|
||||
@@ -85,7 +99,7 @@ function DogList() {
|
||||
<div>
|
||||
<h1 style={{ marginBottom: '0.25rem' }}>Dogs</h1>
|
||||
<p style={{ color: 'var(--text-secondary)', fontSize: '0.875rem' }}>
|
||||
{filteredDogs.length} {filteredDogs.length === 1 ? 'dog' : 'dogs'}
|
||||
{total} {total === 1 ? 'dog' : 'dogs'}
|
||||
{search || sexFilter !== 'all' ? ' matching filters' : ' total'}
|
||||
</p>
|
||||
</div>
|
||||
@@ -105,11 +119,11 @@ function DogList() {
|
||||
className="input"
|
||||
placeholder="Search by name or registration..."
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
onChange={(e) => handleSearchChange(e.target.value)}
|
||||
style={{ paddingLeft: '2.75rem' }}
|
||||
/>
|
||||
</div>
|
||||
<select className="input" value={sexFilter} onChange={(e) => setSexFilter(e.target.value)} style={{ width: '140px' }}>
|
||||
<select className="input" value={sexFilter} onChange={(e) => handleSexChange(e.target.value)} style={{ width: '140px' }}>
|
||||
<option value="all">All Dogs</option>
|
||||
<option value="male">Males ♂</option>
|
||||
<option value="female">Females ♀</option>
|
||||
@@ -117,7 +131,7 @@ function DogList() {
|
||||
{(search || sexFilter !== 'all') && (
|
||||
<button
|
||||
className="btn btn-ghost"
|
||||
onClick={() => { setSearch(''); setSexFilter('all') }}
|
||||
onClick={handleClearFilters}
|
||||
style={{ padding: '0.625rem 1rem', fontSize: '0.875rem' }}
|
||||
>
|
||||
Clear
|
||||
@@ -127,7 +141,7 @@ function DogList() {
|
||||
</div>
|
||||
|
||||
{/* Dogs List */}
|
||||
{filteredDogs.length === 0 ? (
|
||||
{dogs.length === 0 ? (
|
||||
<div className="card" style={{ textAlign: 'center', padding: '4rem 2rem' }}>
|
||||
<Dog size={64} style={{ color: 'var(--text-muted)', margin: '0 auto 1rem', opacity: 0.5 }} />
|
||||
<h3 style={{ marginBottom: '0.5rem' }}>
|
||||
@@ -147,7 +161,7 @@ function DogList() {
|
||||
</div>
|
||||
) : (
|
||||
<div style={{ display: 'grid', gap: '1rem' }}>
|
||||
{filteredDogs.map(dog => (
|
||||
{dogs.map(dog => (
|
||||
<div
|
||||
key={dog.id}
|
||||
className="card"
|
||||
@@ -313,6 +327,31 @@ function DogList() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Pagination */}
|
||||
{totalPages > 1 && (
|
||||
<div style={{ display: 'flex', justifyContent: 'center', alignItems: 'center', gap: '1rem', marginTop: '1.5rem' }}>
|
||||
<button
|
||||
className="btn btn-ghost"
|
||||
onClick={() => fetchDogs(page - 1, search, sexFilter)}
|
||||
disabled={page <= 1 || loading}
|
||||
style={{ padding: '0.5rem 1rem' }}
|
||||
>
|
||||
Previous
|
||||
</button>
|
||||
<span style={{ color: 'var(--text-secondary)', fontSize: '0.875rem' }}>
|
||||
Page {page} of {totalPages}
|
||||
</span>
|
||||
<button
|
||||
className="btn btn-ghost"
|
||||
onClick={() => fetchDogs(page + 1, search, sexFilter)}
|
||||
disabled={page >= totalPages || loading}
|
||||
style={{ padding: '0.5rem 1rem' }}
|
||||
>
|
||||
Next
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Add Dog Modal */}
|
||||
{showAddModal && (
|
||||
<DogForm
|
||||
|
||||
Reference in New Issue
Block a user