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:
@@ -64,8 +64,8 @@ function DogForm({ dog, onClose, onSave, isExternal = false }) {
|
|||||||
|
|
||||||
const fetchLitters = async () => {
|
const fetchLitters = async () => {
|
||||||
try {
|
try {
|
||||||
const res = await axios.get('/api/litters')
|
const res = await axios.get('/api/litters', { params: { limit: 200 } })
|
||||||
const data = res.data || []
|
const data = res.data.data || []
|
||||||
setLitters(data)
|
setLitters(data)
|
||||||
setLittersAvailable(data.length > 0)
|
setLittersAvailable(data.length > 0)
|
||||||
if (data.length === 0) setUseManualParents(true)
|
if (data.length === 0) setUseManualParents(true)
|
||||||
|
|||||||
@@ -39,7 +39,7 @@ function LitterForm({ litter, prefill, onClose, onSave }) {
|
|||||||
|
|
||||||
const fetchDogs = async () => {
|
const fetchDogs = async () => {
|
||||||
try {
|
try {
|
||||||
const res = await axios.get('/api/dogs')
|
const res = await axios.get('/api/dogs/all')
|
||||||
setDogs(res.data)
|
setDogs(res.data)
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error fetching dogs:', error)
|
console.error('Error fetching dogs:', error)
|
||||||
|
|||||||
@@ -21,21 +21,21 @@ function Dashboard() {
|
|||||||
const fetchDashboardData = async () => {
|
const fetchDashboardData = async () => {
|
||||||
try {
|
try {
|
||||||
const [dogsRes, littersRes, heatCyclesRes] = await Promise.all([
|
const [dogsRes, littersRes, heatCyclesRes] = await Promise.all([
|
||||||
axios.get('/api/dogs'),
|
axios.get('/api/dogs', { params: { page: 1, limit: 8 } }),
|
||||||
axios.get('/api/litters'),
|
axios.get('/api/litters', { params: { page: 1, limit: 1 } }),
|
||||||
axios.get('/api/breeding/heat-cycles/active')
|
axios.get('/api/breeding/heat-cycles/active')
|
||||||
])
|
])
|
||||||
|
|
||||||
const dogs = dogsRes.data
|
const { data: recentDogsList, stats: dogStats } = dogsRes.data
|
||||||
setStats({
|
setStats({
|
||||||
totalDogs: dogs.length,
|
totalDogs: dogStats?.total ?? 0,
|
||||||
males: dogs.filter(d => d.sex === 'male').length,
|
males: dogStats?.males ?? 0,
|
||||||
females: dogs.filter(d => d.sex === 'female').length,
|
females: dogStats?.females ?? 0,
|
||||||
totalLitters: littersRes.data.length,
|
totalLitters: littersRes.data.total,
|
||||||
activeHeatCycles: heatCyclesRes.data.length
|
activeHeatCycles: heatCyclesRes.data.length
|
||||||
})
|
})
|
||||||
|
|
||||||
setRecentDogs(dogs.slice(0, 8))
|
setRecentDogs(recentDogsList)
|
||||||
setLoading(false)
|
setLoading(false)
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error fetching dashboard data:', error)
|
console.error('Error fetching dashboard data:', error)
|
||||||
|
|||||||
@@ -1,57 +1,69 @@
|
|||||||
import { useEffect, useState } from 'react'
|
import { useEffect, useState, useRef } from 'react'
|
||||||
import { Link, useNavigate } from 'react-router-dom'
|
import { Link, useNavigate } from 'react-router-dom'
|
||||||
import { Dog, Plus, Search, Calendar, Hash, ArrowRight, Trash2 } from 'lucide-react'
|
import { Dog, Plus, Search, Calendar, Hash, ArrowRight, Trash2 } from 'lucide-react'
|
||||||
import axios from 'axios'
|
import axios from 'axios'
|
||||||
import DogForm from '../components/DogForm'
|
import DogForm from '../components/DogForm'
|
||||||
import { ChampionBadge, ChampionBloodlineBadge } from '../components/ChampionBadge'
|
import { ChampionBadge, ChampionBloodlineBadge } from '../components/ChampionBadge'
|
||||||
|
|
||||||
|
const LIMIT = 50
|
||||||
|
|
||||||
function DogList() {
|
function DogList() {
|
||||||
const [dogs, setDogs] = useState([])
|
const [dogs, setDogs] = useState([])
|
||||||
const [filteredDogs, setFilteredDogs] = useState([])
|
const [total, setTotal] = useState(0)
|
||||||
|
const [page, setPage] = useState(1)
|
||||||
const [search, setSearch] = useState('')
|
const [search, setSearch] = useState('')
|
||||||
const [sexFilter, setSexFilter] = useState('all')
|
const [sexFilter, setSexFilter] = useState('all')
|
||||||
const [loading, setLoading] = useState(true)
|
const [loading, setLoading] = useState(true)
|
||||||
const [showAddModal, setShowAddModal] = useState(false)
|
const [showAddModal, setShowAddModal] = useState(false)
|
||||||
const [deleteTarget, setDeleteTarget] = useState(null) // { id, name }
|
const [deleteTarget, setDeleteTarget] = useState(null) // { id, name }
|
||||||
const [deleting, setDeleting] = useState(false)
|
const [deleting, setDeleting] = useState(false)
|
||||||
|
const searchTimerRef = useRef(null)
|
||||||
|
|
||||||
useEffect(() => { fetchDogs() }, [])
|
useEffect(() => { fetchDogs(1, '', 'all') }, []) // eslint-disable-line
|
||||||
useEffect(() => { filterDogs() }, [dogs, search, sexFilter])
|
|
||||||
|
|
||||||
const fetchDogs = async () => {
|
const fetchDogs = async (p, q, s) => {
|
||||||
|
setLoading(true)
|
||||||
try {
|
try {
|
||||||
const res = await axios.get('/api/dogs')
|
const params = { page: p, limit: LIMIT }
|
||||||
setDogs(res.data)
|
if (q) params.search = q
|
||||||
setLoading(false)
|
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) {
|
} catch (error) {
|
||||||
console.error('Error fetching dogs:', error)
|
console.error('Error fetching dogs:', error)
|
||||||
|
} finally {
|
||||||
setLoading(false)
|
setLoading(false)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const filterDogs = () => {
|
const handleSearchChange = (value) => {
|
||||||
let filtered = dogs
|
setSearch(value)
|
||||||
if (search) {
|
if (searchTimerRef.current) clearTimeout(searchTimerRef.current)
|
||||||
filtered = filtered.filter(dog =>
|
searchTimerRef.current = setTimeout(() => fetchDogs(1, value, sexFilter), 300)
|
||||||
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 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 () => {
|
const handleDelete = async () => {
|
||||||
if (!deleteTarget) return
|
if (!deleteTarget) return
|
||||||
setDeleting(true)
|
setDeleting(true)
|
||||||
try {
|
try {
|
||||||
await axios.delete(`/api/dogs/${deleteTarget.id}`)
|
await axios.delete(`/api/dogs/${deleteTarget.id}`)
|
||||||
setDogs(prev => prev.filter(d => d.id !== deleteTarget.id))
|
|
||||||
setDeleteTarget(null)
|
setDeleteTarget(null)
|
||||||
|
fetchDogs(page, search, sexFilter)
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Delete failed:', err)
|
console.error('Delete failed:', err)
|
||||||
alert('Failed to delete dog. Please try again.')
|
alert('Failed to delete dog. Please try again.')
|
||||||
@@ -60,6 +72,8 @@ function DogList() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const totalPages = Math.ceil(total / LIMIT)
|
||||||
|
|
||||||
const calculateAge = (birthDate) => {
|
const calculateAge = (birthDate) => {
|
||||||
if (!birthDate) return null
|
if (!birthDate) return null
|
||||||
const today = new Date()
|
const today = new Date()
|
||||||
@@ -85,7 +99,7 @@ function DogList() {
|
|||||||
<div>
|
<div>
|
||||||
<h1 style={{ marginBottom: '0.25rem' }}>Dogs</h1>
|
<h1 style={{ marginBottom: '0.25rem' }}>Dogs</h1>
|
||||||
<p style={{ color: 'var(--text-secondary)', fontSize: '0.875rem' }}>
|
<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'}
|
{search || sexFilter !== 'all' ? ' matching filters' : ' total'}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -105,11 +119,11 @@ function DogList() {
|
|||||||
className="input"
|
className="input"
|
||||||
placeholder="Search by name or registration..."
|
placeholder="Search by name or registration..."
|
||||||
value={search}
|
value={search}
|
||||||
onChange={(e) => setSearch(e.target.value)}
|
onChange={(e) => handleSearchChange(e.target.value)}
|
||||||
style={{ paddingLeft: '2.75rem' }}
|
style={{ paddingLeft: '2.75rem' }}
|
||||||
/>
|
/>
|
||||||
</div>
|
</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="all">All Dogs</option>
|
||||||
<option value="male">Males ♂</option>
|
<option value="male">Males ♂</option>
|
||||||
<option value="female">Females ♀</option>
|
<option value="female">Females ♀</option>
|
||||||
@@ -117,7 +131,7 @@ function DogList() {
|
|||||||
{(search || sexFilter !== 'all') && (
|
{(search || sexFilter !== 'all') && (
|
||||||
<button
|
<button
|
||||||
className="btn btn-ghost"
|
className="btn btn-ghost"
|
||||||
onClick={() => { setSearch(''); setSexFilter('all') }}
|
onClick={handleClearFilters}
|
||||||
style={{ padding: '0.625rem 1rem', fontSize: '0.875rem' }}
|
style={{ padding: '0.625rem 1rem', fontSize: '0.875rem' }}
|
||||||
>
|
>
|
||||||
Clear
|
Clear
|
||||||
@@ -127,7 +141,7 @@ function DogList() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Dogs List */}
|
{/* Dogs List */}
|
||||||
{filteredDogs.length === 0 ? (
|
{dogs.length === 0 ? (
|
||||||
<div className="card" style={{ textAlign: 'center', padding: '4rem 2rem' }}>
|
<div className="card" style={{ textAlign: 'center', padding: '4rem 2rem' }}>
|
||||||
<Dog size={64} style={{ color: 'var(--text-muted)', margin: '0 auto 1rem', opacity: 0.5 }} />
|
<Dog size={64} style={{ color: 'var(--text-muted)', margin: '0 auto 1rem', opacity: 0.5 }} />
|
||||||
<h3 style={{ marginBottom: '0.5rem' }}>
|
<h3 style={{ marginBottom: '0.5rem' }}>
|
||||||
@@ -147,7 +161,7 @@ function DogList() {
|
|||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div style={{ display: 'grid', gap: '1rem' }}>
|
<div style={{ display: 'grid', gap: '1rem' }}>
|
||||||
{filteredDogs.map(dog => (
|
{dogs.map(dog => (
|
||||||
<div
|
<div
|
||||||
key={dog.id}
|
key={dog.id}
|
||||||
className="card"
|
className="card"
|
||||||
@@ -313,6 +327,31 @@ function DogList() {
|
|||||||
</div>
|
</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 */}
|
{/* Add Dog Modal */}
|
||||||
{showAddModal && (
|
{showAddModal && (
|
||||||
<DogForm
|
<DogForm
|
||||||
|
|||||||
@@ -278,7 +278,7 @@ function LitterDetail() {
|
|||||||
|
|
||||||
const fetchAllDogs = async () => {
|
const fetchAllDogs = async () => {
|
||||||
try {
|
try {
|
||||||
const res = await axios.get('/api/dogs')
|
const res = await axios.get('/api/dogs/all')
|
||||||
setAllDogs(res.data)
|
setAllDogs(res.data)
|
||||||
} catch (err) { console.error('Error fetching dogs:', err) }
|
} catch (err) { console.error('Error fetching dogs:', err) }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,8 +4,12 @@ import { useNavigate } from 'react-router-dom'
|
|||||||
import axios from 'axios'
|
import axios from 'axios'
|
||||||
import LitterForm from '../components/LitterForm'
|
import LitterForm from '../components/LitterForm'
|
||||||
|
|
||||||
|
const LIMIT = 50
|
||||||
|
|
||||||
function LitterList() {
|
function LitterList() {
|
||||||
const [litters, setLitters] = useState([])
|
const [litters, setLitters] = useState([])
|
||||||
|
const [total, setTotal] = useState(0)
|
||||||
|
const [page, setPage] = useState(1)
|
||||||
const [loading, setLoading] = useState(true)
|
const [loading, setLoading] = useState(true)
|
||||||
const [showForm, setShowForm] = useState(false)
|
const [showForm, setShowForm] = useState(false)
|
||||||
const [editingLitter, setEditingLitter] = useState(null)
|
const [editingLitter, setEditingLitter] = useState(null)
|
||||||
@@ -13,7 +17,7 @@ function LitterList() {
|
|||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
fetchLitters()
|
fetchLitters(1)
|
||||||
// Auto-open form with prefill from BreedingCalendar "Record Litter" CTA
|
// Auto-open form with prefill from BreedingCalendar "Record Litter" CTA
|
||||||
const stored = sessionStorage.getItem('prefillLitter')
|
const stored = sessionStorage.getItem('prefillLitter')
|
||||||
if (stored) {
|
if (stored) {
|
||||||
@@ -27,10 +31,12 @@ function LitterList() {
|
|||||||
}
|
}
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
const fetchLitters = async () => {
|
const fetchLitters = async (p = page) => {
|
||||||
try {
|
try {
|
||||||
const res = await axios.get('/api/litters')
|
const res = await axios.get('/api/litters', { params: { page: p, limit: LIMIT } })
|
||||||
setLitters(res.data)
|
setLitters(res.data.data)
|
||||||
|
setTotal(res.data.total)
|
||||||
|
setPage(p)
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error fetching litters:', error)
|
console.error('Error fetching litters:', error)
|
||||||
} finally {
|
} finally {
|
||||||
@@ -38,6 +44,8 @@ function LitterList() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const totalPages = Math.ceil(total / LIMIT)
|
||||||
|
|
||||||
const handleCreate = () => {
|
const handleCreate = () => {
|
||||||
setEditingLitter(null)
|
setEditingLitter(null)
|
||||||
setPrefill(null)
|
setPrefill(null)
|
||||||
@@ -56,14 +64,14 @@ function LitterList() {
|
|||||||
if (!window.confirm('Delete this litter record? Puppies will be unlinked but not deleted.')) return
|
if (!window.confirm('Delete this litter record? Puppies will be unlinked but not deleted.')) return
|
||||||
try {
|
try {
|
||||||
await axios.delete(`/api/litters/${id}`)
|
await axios.delete(`/api/litters/${id}`)
|
||||||
fetchLitters()
|
fetchLitters(page)
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error deleting litter:', error)
|
console.error('Error deleting litter:', error)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleSave = () => {
|
const handleSave = () => {
|
||||||
fetchLitters()
|
fetchLitters(page)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (loading) {
|
if (loading) {
|
||||||
@@ -80,7 +88,7 @@ function LitterList() {
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{litters.length === 0 ? (
|
{total === 0 ? (
|
||||||
<div className="card" style={{ textAlign: 'center', padding: '4rem' }}>
|
<div className="card" style={{ textAlign: 'center', padding: '4rem' }}>
|
||||||
<Activity size={64} style={{ color: 'var(--text-secondary)', margin: '0 auto 1rem' }} />
|
<Activity size={64} style={{ color: 'var(--text-secondary)', margin: '0 auto 1rem' }} />
|
||||||
<h2>No litters recorded yet</h2>
|
<h2>No litters recorded yet</h2>
|
||||||
@@ -143,6 +151,31 @@ function LitterList() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Pagination */}
|
||||||
|
{totalPages > 1 && (
|
||||||
|
<div style={{ display: 'flex', justifyContent: 'center', alignItems: 'center', gap: '1rem', marginTop: '1.5rem' }}>
|
||||||
|
<button
|
||||||
|
className="btn btn-ghost"
|
||||||
|
onClick={() => fetchLitters(page - 1)}
|
||||||
|
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={() => fetchLitters(page + 1)}
|
||||||
|
disabled={page >= totalPages || loading}
|
||||||
|
style={{ padding: '0.5rem 1rem' }}
|
||||||
|
>
|
||||||
|
Next
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{showForm && (
|
{showForm && (
|
||||||
<LitterForm
|
<LitterForm
|
||||||
litter={editingLitter}
|
litter={editingLitter}
|
||||||
|
|||||||
@@ -55,34 +55,72 @@ function attachParents(db, dogs) {
|
|||||||
return dogs;
|
return dogs;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── GET dogs
|
// ── GET dogs (paginated)
|
||||||
// Default: kennel dogs only (is_external = 0)
|
// Default: kennel dogs only (is_external = 0)
|
||||||
// ?include_external=1 : all active dogs (kennel + external)
|
// ?include_external=1 : all active dogs (kennel + external)
|
||||||
// ?external_only=1 : external dogs only
|
// ?external_only=1 : external dogs only
|
||||||
|
// ?page=1&limit=50 : pagination
|
||||||
|
// ?search=term : filter by name or registration_number
|
||||||
|
// ?sex=male|female : filter by sex
|
||||||
|
// Response: { data, total, page, limit, stats: { total, males, females } }
|
||||||
// ─────────────────────────────────────────────────────────────────────────
|
// ─────────────────────────────────────────────────────────────────────────
|
||||||
router.get('/', (req, res) => {
|
router.get('/', (req, res) => {
|
||||||
try {
|
try {
|
||||||
const db = getDatabase();
|
const db = getDatabase();
|
||||||
const includeExternal = req.query.include_external === '1' || req.query.include_external === 'true';
|
const includeExternal = req.query.include_external === '1' || req.query.include_external === 'true';
|
||||||
const externalOnly = req.query.external_only === '1' || req.query.external_only === 'true';
|
const externalOnly = req.query.external_only === '1' || req.query.external_only === 'true';
|
||||||
|
const search = (req.query.search || '').trim();
|
||||||
|
const sex = req.query.sex === 'male' || req.query.sex === 'female' ? req.query.sex : '';
|
||||||
|
const page = Math.max(1, parseInt(req.query.page, 10) || 1);
|
||||||
|
const limit = Math.min(200, Math.max(1, parseInt(req.query.limit, 10) || 50));
|
||||||
|
const offset = (page - 1) * limit;
|
||||||
|
|
||||||
let whereClause;
|
let baseWhere;
|
||||||
if (externalOnly) {
|
if (externalOnly) {
|
||||||
whereClause = 'WHERE is_active = 1 AND is_external = 1';
|
baseWhere = 'is_active = 1 AND is_external = 1';
|
||||||
} else if (includeExternal) {
|
} else if (includeExternal) {
|
||||||
whereClause = 'WHERE is_active = 1';
|
baseWhere = 'is_active = 1';
|
||||||
} else {
|
} else {
|
||||||
whereClause = 'WHERE is_active = 1 AND is_external = 0';
|
baseWhere = 'is_active = 1 AND is_external = 0';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const filters = [];
|
||||||
|
const params = [];
|
||||||
|
if (search) {
|
||||||
|
filters.push('(name LIKE ? OR registration_number LIKE ?)');
|
||||||
|
params.push(`%${search}%`, `%${search}%`);
|
||||||
|
}
|
||||||
|
if (sex) {
|
||||||
|
filters.push('sex = ?');
|
||||||
|
params.push(sex);
|
||||||
|
}
|
||||||
|
|
||||||
|
const whereClause = 'WHERE ' + [baseWhere, ...filters].join(' AND ');
|
||||||
|
|
||||||
|
const total = db.prepare(`SELECT COUNT(*) as count FROM dogs ${whereClause}`).get(...params).count;
|
||||||
|
|
||||||
|
const statsWhere = externalOnly
|
||||||
|
? 'WHERE is_active = 1 AND is_external = 1'
|
||||||
|
: includeExternal
|
||||||
|
? 'WHERE is_active = 1'
|
||||||
|
: 'WHERE is_active = 1 AND is_external = 0';
|
||||||
|
const stats = db.prepare(`
|
||||||
|
SELECT
|
||||||
|
COUNT(*) as total,
|
||||||
|
SUM(CASE WHEN sex = 'male' THEN 1 ELSE 0 END) as males,
|
||||||
|
SUM(CASE WHEN sex = 'female' THEN 1 ELSE 0 END) as females
|
||||||
|
FROM dogs ${statsWhere}
|
||||||
|
`).get();
|
||||||
|
|
||||||
const dogs = db.prepare(`
|
const dogs = db.prepare(`
|
||||||
SELECT ${DOG_COLS}
|
SELECT ${DOG_COLS}
|
||||||
FROM dogs
|
FROM dogs
|
||||||
${whereClause}
|
${whereClause}
|
||||||
ORDER BY name
|
ORDER BY name
|
||||||
`).all();
|
LIMIT ? OFFSET ?
|
||||||
|
`).all(...params, limit, offset);
|
||||||
|
|
||||||
res.json(attachParents(db, dogs));
|
res.json({ data: attachParents(db, dogs), total, page, limit, stats });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error fetching dogs:', error);
|
console.error('Error fetching dogs:', error);
|
||||||
res.status(500).json({ error: error.message });
|
res.status(500).json({ error: error.message });
|
||||||
|
|||||||
@@ -2,31 +2,50 @@ const express = require('express');
|
|||||||
const router = express.Router();
|
const router = express.Router();
|
||||||
const { getDatabase } = require('../db/init');
|
const { getDatabase } = require('../db/init');
|
||||||
|
|
||||||
// GET all litters
|
// GET all litters (paginated)
|
||||||
|
// ?page=1&limit=50
|
||||||
|
// Response: { data, total, page, limit }
|
||||||
router.get('/', (req, res) => {
|
router.get('/', (req, res) => {
|
||||||
try {
|
try {
|
||||||
const db = getDatabase();
|
const db = getDatabase();
|
||||||
|
const page = Math.max(1, parseInt(req.query.page, 10) || 1);
|
||||||
|
const limit = Math.min(200, Math.max(1, parseInt(req.query.limit, 10) || 50));
|
||||||
|
const offset = (page - 1) * limit;
|
||||||
|
|
||||||
|
const total = db.prepare('SELECT COUNT(*) as count FROM litters').get().count;
|
||||||
|
|
||||||
const litters = db.prepare(`
|
const litters = db.prepare(`
|
||||||
SELECT l.*,
|
SELECT l.*,
|
||||||
s.name as sire_name, s.registration_number as sire_reg,
|
s.name as sire_name, s.registration_number as sire_reg,
|
||||||
d.name as dam_name, d.registration_number as dam_reg
|
d.name as dam_name, d.registration_number as dam_reg
|
||||||
FROM litters l
|
FROM litters l
|
||||||
JOIN dogs s ON l.sire_id = s.id
|
JOIN dogs s ON l.sire_id = s.id
|
||||||
JOIN dogs d ON l.dam_id = d.id
|
JOIN dogs d ON l.dam_id = d.id
|
||||||
ORDER BY l.breeding_date DESC
|
ORDER BY l.breeding_date DESC
|
||||||
`).all();
|
LIMIT ? OFFSET ?
|
||||||
|
`).all(limit, offset);
|
||||||
litters.forEach(litter => {
|
|
||||||
litter.puppies = db.prepare(`
|
if (litters.length > 0) {
|
||||||
SELECT * FROM dogs WHERE litter_id = ? AND is_active = 1
|
const litterIds = litters.map(l => l.id);
|
||||||
`).all(litter.id);
|
const placeholders = litterIds.map(() => '?').join(',');
|
||||||
litter.puppies.forEach(puppy => {
|
const allPuppies = db.prepare(`
|
||||||
puppy.photo_urls = puppy.photo_urls ? JSON.parse(puppy.photo_urls) : [];
|
SELECT * FROM dogs WHERE litter_id IN (${placeholders}) AND is_active = 1
|
||||||
|
`).all(...litterIds);
|
||||||
|
|
||||||
|
const puppiesByLitter = {};
|
||||||
|
allPuppies.forEach(p => {
|
||||||
|
p.photo_urls = p.photo_urls ? JSON.parse(p.photo_urls) : [];
|
||||||
|
if (!puppiesByLitter[p.litter_id]) puppiesByLitter[p.litter_id] = [];
|
||||||
|
puppiesByLitter[p.litter_id].push(p);
|
||||||
});
|
});
|
||||||
litter.actual_puppy_count = litter.puppies.length;
|
|
||||||
});
|
litters.forEach(l => {
|
||||||
|
l.puppies = puppiesByLitter[l.id] || [];
|
||||||
res.json(litters);
|
l.actual_puppy_count = l.puppies.length;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
res.json({ data: litters, total, page, limit });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
res.status(500).json({ error: error.message });
|
res.status(500).json({ error: error.message });
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user