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>
421 lines
16 KiB
JavaScript
421 lines
16 KiB
JavaScript
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 [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(1, '', 'all') }, []) // eslint-disable-line
|
|
|
|
const fetchDogs = async (p, q, s) => {
|
|
setLoading(true)
|
|
try {
|
|
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 handleSearchChange = (value) => {
|
|
setSearch(value)
|
|
if (searchTimerRef.current) clearTimeout(searchTimerRef.current)
|
|
searchTimerRef.current = setTimeout(() => fetchDogs(1, value, sexFilter), 300)
|
|
}
|
|
|
|
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}`)
|
|
setDeleteTarget(null)
|
|
fetchDogs(page, search, sexFilter)
|
|
} catch (err) {
|
|
console.error('Delete failed:', err)
|
|
alert('Failed to delete dog. Please try again.')
|
|
} finally {
|
|
setDeleting(false)
|
|
}
|
|
}
|
|
|
|
const totalPages = Math.ceil(total / LIMIT)
|
|
|
|
const calculateAge = (birthDate) => {
|
|
if (!birthDate) return null
|
|
const today = new Date()
|
|
const birth = new Date(birthDate)
|
|
let years = today.getFullYear() - birth.getFullYear()
|
|
let months = today.getMonth() - birth.getMonth()
|
|
if (months < 0) { years--; months += 12 }
|
|
if (years === 0) return `${months}mo`
|
|
if (months === 0) return `${years}y`
|
|
return `${years}y ${months}mo`
|
|
}
|
|
|
|
const hasChampionBlood = (dog) =>
|
|
(dog.sire && dog.sire.is_champion) || (dog.dam && dog.dam.is_champion)
|
|
|
|
if (loading) {
|
|
return <div className="container loading">Loading dogs...</div>
|
|
}
|
|
|
|
return (
|
|
<div className="container" style={{ paddingTop: '2rem', paddingBottom: '3rem' }}>
|
|
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '2rem' }}>
|
|
<div>
|
|
<h1 style={{ marginBottom: '0.25rem' }}>Dogs</h1>
|
|
<p style={{ color: 'var(--text-secondary)', fontSize: '0.875rem' }}>
|
|
{total} {total === 1 ? 'dog' : 'dogs'}
|
|
{search || sexFilter !== 'all' ? ' matching filters' : ' total'}
|
|
</p>
|
|
</div>
|
|
<button className="btn btn-primary" onClick={() => setShowAddModal(true)}>
|
|
<Plus size={18} />
|
|
Add Dog
|
|
</button>
|
|
</div>
|
|
|
|
{/* Search and Filter Bar */}
|
|
<div className="card" style={{ marginBottom: '1.5rem', padding: '1rem' }}>
|
|
<div style={{ display: 'grid', gridTemplateColumns: '1fr auto auto', gap: '1rem', alignItems: 'center' }}>
|
|
<div style={{ position: 'relative' }}>
|
|
<Search size={18} style={{ position: 'absolute', left: '0.875rem', top: '50%', transform: 'translateY(-50%)', color: 'var(--text-muted)' }} />
|
|
<input
|
|
type="text"
|
|
className="input"
|
|
placeholder="Search by name or registration..."
|
|
value={search}
|
|
onChange={(e) => handleSearchChange(e.target.value)}
|
|
style={{ paddingLeft: '2.75rem' }}
|
|
/>
|
|
</div>
|
|
<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>
|
|
</select>
|
|
{(search || sexFilter !== 'all') && (
|
|
<button
|
|
className="btn btn-ghost"
|
|
onClick={handleClearFilters}
|
|
style={{ padding: '0.625rem 1rem', fontSize: '0.875rem' }}
|
|
>
|
|
Clear
|
|
</button>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
{/* Dogs List */}
|
|
{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' }}>
|
|
{search || sexFilter !== 'all' ? 'No dogs found' : 'No dogs yet'}
|
|
</h3>
|
|
<p style={{ color: 'var(--text-secondary)', marginBottom: '2rem' }}>
|
|
{search || sexFilter !== 'all'
|
|
? 'Try adjusting your search or filters'
|
|
: 'Add your first dog to get started'}
|
|
</p>
|
|
{!search && sexFilter === 'all' && (
|
|
<button className="btn btn-primary" onClick={() => setShowAddModal(true)}>
|
|
<Plus size={18} />
|
|
Add Your First Dog
|
|
</button>
|
|
)}
|
|
</div>
|
|
) : (
|
|
<div style={{ display: 'grid', gap: '1rem' }}>
|
|
{dogs.map(dog => (
|
|
<div
|
|
key={dog.id}
|
|
className="card"
|
|
style={{
|
|
padding: '1rem',
|
|
display: 'flex',
|
|
gap: '1rem',
|
|
alignItems: 'center',
|
|
transition: 'var(--transition)',
|
|
}}
|
|
onMouseEnter={(e) => {
|
|
e.currentTarget.style.borderColor = 'var(--primary)'
|
|
e.currentTarget.style.transform = 'translateY(-2px)'
|
|
e.currentTarget.style.boxShadow = '0 8px 16px rgba(0, 0, 0, 0.3)'
|
|
}}
|
|
onMouseLeave={(e) => {
|
|
e.currentTarget.style.borderColor = 'var(--border)'
|
|
e.currentTarget.style.transform = 'translateY(0)'
|
|
e.currentTarget.style.boxShadow = 'var(--shadow-sm)'
|
|
}}
|
|
>
|
|
{/* Avatar */}
|
|
<Link
|
|
to={`/dogs/${dog.id}`}
|
|
style={{ flexShrink: 0, textDecoration: 'none' }}
|
|
>
|
|
<div style={{
|
|
width: '80px', height: '80px',
|
|
borderRadius: 'var(--radius)',
|
|
background: 'var(--bg-primary)',
|
|
border: dog.is_champion
|
|
? '2px solid var(--champion-gold)'
|
|
: hasChampionBlood(dog)
|
|
? '2px solid var(--bloodline-amber)'
|
|
: '2px solid var(--border)',
|
|
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
|
overflow: 'hidden',
|
|
boxShadow: dog.is_champion
|
|
? '0 0 8px var(--champion-glow)'
|
|
: hasChampionBlood(dog)
|
|
? '0 0 8px var(--bloodline-glow)'
|
|
: 'none'
|
|
}}>
|
|
{dog.photo_urls && dog.photo_urls.length > 0 ? (
|
|
<img
|
|
src={dog.photo_urls[0]}
|
|
alt={dog.name}
|
|
style={{ width: '100%', height: '100%', objectFit: 'cover' }}
|
|
/>
|
|
) : (
|
|
<Dog size={32} style={{ color: 'var(--text-muted)', opacity: 0.5 }} />
|
|
)}
|
|
</div>
|
|
</Link>
|
|
|
|
{/* Info — clicking navigates to detail */}
|
|
<Link
|
|
to={`/dogs/${dog.id}`}
|
|
style={{ flex: 1, minWidth: 0, textDecoration: 'none', color: 'inherit' }}
|
|
>
|
|
<h3 style={{
|
|
fontSize: '1.125rem',
|
|
marginBottom: '0.25rem',
|
|
display: 'flex', alignItems: 'center', gap: '0.5rem',
|
|
flexWrap: 'wrap'
|
|
}}>
|
|
<span style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
|
|
{dog.name}
|
|
</span>
|
|
<span style={{ color: dog.sex === 'male' ? 'var(--primary)' : '#ec4899', fontSize: '1rem' }}>
|
|
{dog.sex === 'male' ? '♂' : '♀'}
|
|
</span>
|
|
{dog.is_champion ? <ChampionBadge /> : hasChampionBlood(dog) ? <ChampionBloodlineBadge /> : null}
|
|
</h3>
|
|
|
|
<div style={{
|
|
display: 'flex', flexWrap: 'wrap', gap: '0.75rem',
|
|
fontSize: '0.8125rem', color: 'var(--text-secondary)', marginBottom: '0.5rem'
|
|
}}>
|
|
<span>{dog.breed}</span>
|
|
{dog.birth_date && (
|
|
<>
|
|
<span>·</span>
|
|
<span style={{ display: 'flex', alignItems: 'center', gap: '0.25rem' }}>
|
|
<Calendar size={12} />
|
|
{calculateAge(dog.birth_date)}
|
|
</span>
|
|
</>
|
|
)}
|
|
{dog.color && (
|
|
<>
|
|
<span>·</span>
|
|
<span>{dog.color}</span>
|
|
</>
|
|
)}
|
|
</div>
|
|
|
|
{dog.registration_number && (
|
|
<div style={{
|
|
display: 'inline-flex', alignItems: 'center', gap: '0.25rem',
|
|
padding: '0.25rem 0.5rem',
|
|
background: 'var(--bg-primary)',
|
|
border: '1px solid var(--border)',
|
|
borderRadius: 'var(--radius-sm)',
|
|
fontSize: '0.75rem', fontFamily: 'monospace',
|
|
color: 'var(--text-muted)'
|
|
}}>
|
|
<Hash size={10} />
|
|
{dog.registration_number}
|
|
</div>
|
|
)}
|
|
{dog.chic_number && (
|
|
<div style={{
|
|
display: 'inline-flex', alignItems: 'center', gap: '0.25rem',
|
|
padding: '0.25rem 0.5rem',
|
|
background: 'rgba(99,102,241,0.1)',
|
|
border: '1px solid rgba(99,102,241,0.3)',
|
|
borderRadius: 'var(--radius-sm)',
|
|
fontSize: '0.75rem', fontWeight: 600,
|
|
color: '#818cf8', marginLeft: '0.5rem'
|
|
}}>
|
|
CHIC #{dog.chic_number}
|
|
</div>
|
|
)}
|
|
</Link>
|
|
|
|
{/* Actions */}
|
|
<div style={{ display: 'flex', gap: '0.5rem', flexShrink: 0, alignItems: 'center' }}>
|
|
<Link
|
|
to={`/dogs/${dog.id}`}
|
|
style={{ opacity: 0.5, transition: 'var(--transition)', color: 'inherit' }}
|
|
>
|
|
<ArrowRight size={20} color="var(--text-muted)" />
|
|
</Link>
|
|
<button
|
|
className="btn btn-ghost"
|
|
title={`Delete ${dog.name}`}
|
|
onClick={(e) => { e.stopPropagation(); setDeleteTarget({ id: dog.id, name: dog.name }) }}
|
|
style={{
|
|
padding: '0.4rem',
|
|
color: 'var(--text-muted)',
|
|
border: '1px solid transparent',
|
|
borderRadius: 'var(--radius-sm)',
|
|
display: 'flex', alignItems: 'center',
|
|
transition: 'var(--transition)'
|
|
}}
|
|
onMouseEnter={(e) => {
|
|
e.currentTarget.style.color = '#ef4444'
|
|
e.currentTarget.style.borderColor = '#ef4444'
|
|
e.currentTarget.style.background = 'rgba(239,68,68,0.08)'
|
|
}}
|
|
onMouseLeave={(e) => {
|
|
e.currentTarget.style.color = 'var(--text-muted)'
|
|
e.currentTarget.style.borderColor = 'transparent'
|
|
e.currentTarget.style.background = 'transparent'
|
|
}}
|
|
>
|
|
<Trash2 size={16} />
|
|
</button>
|
|
</div>
|
|
</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 */}
|
|
{showAddModal && (
|
|
<DogForm
|
|
onClose={() => setShowAddModal(false)}
|
|
onSave={handleSave}
|
|
/>
|
|
)}
|
|
|
|
{/* Delete Confirmation Modal */}
|
|
{deleteTarget && (
|
|
<div style={{
|
|
position: 'fixed', inset: 0,
|
|
background: 'rgba(0,0,0,0.65)',
|
|
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
|
zIndex: 1000,
|
|
backdropFilter: 'blur(4px)'
|
|
}}>
|
|
<div className="card" style={{ maxWidth: 420, width: '90%', padding: '2rem', textAlign: 'center' }}>
|
|
<div style={{
|
|
width: 56, height: 56,
|
|
borderRadius: '50%',
|
|
background: 'rgba(239,68,68,0.12)',
|
|
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
|
margin: '0 auto 1rem'
|
|
}}>
|
|
<Trash2 size={26} style={{ color: '#ef4444' }} />
|
|
</div>
|
|
<h3 style={{ margin: '0 0 0.5rem', fontSize: '1.25rem' }}>Delete Dog?</h3>
|
|
<p style={{ color: 'var(--text-secondary)', marginBottom: '1.75rem', lineHeight: 1.6 }}>
|
|
<strong style={{ color: 'var(--text-primary)' }}>{deleteTarget.name}</strong> will be
|
|
permanently removed along with all parent relationships, health records,
|
|
and heat cycles. This cannot be undone.
|
|
</p>
|
|
<div style={{ display: 'flex', gap: '0.75rem', justifyContent: 'center' }}>
|
|
<button
|
|
className="btn btn-ghost"
|
|
onClick={() => setDeleteTarget(null)}
|
|
disabled={deleting}
|
|
style={{ minWidth: 100 }}
|
|
>
|
|
Cancel
|
|
</button>
|
|
<button
|
|
className="btn"
|
|
onClick={handleDelete}
|
|
disabled={deleting}
|
|
style={{
|
|
minWidth: 140,
|
|
background: '#ef4444',
|
|
color: '#fff',
|
|
border: '1px solid #ef4444',
|
|
display: 'flex', alignItems: 'center', justifyContent: 'center', gap: '0.5rem'
|
|
}}
|
|
>
|
|
<Trash2 size={15} />
|
|
{deleting ? 'Deleting…' : 'Yes, Delete'}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
)
|
|
}
|
|
|
|
export default DogList
|