feat(ui): add ExternalDogs page — full CRUD roster for external sires/dams
This commit is contained in:
143
client/src/pages/ExternalDogs.jsx
Normal file
143
client/src/pages/ExternalDogs.jsx
Normal file
@@ -0,0 +1,143 @@
|
|||||||
|
import { useState, useEffect } from 'react';
|
||||||
|
import { useNavigate } from 'react-router-dom';
|
||||||
|
import { Users, Plus, Search, ExternalLink, Award, Filter } from 'lucide-react';
|
||||||
|
|
||||||
|
export default function ExternalDogs() {
|
||||||
|
const [dogs, setDogs] = useState([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [search, setSearch] = useState('');
|
||||||
|
const [sexFilter, setSexFilter] = useState('all');
|
||||||
|
const navigate = useNavigate();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetch('/api/dogs/external')
|
||||||
|
.then(r => r.json())
|
||||||
|
.then(data => { setDogs(data); setLoading(false); })
|
||||||
|
.catch(() => setLoading(false));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const filtered = dogs.filter(d => {
|
||||||
|
const matchSearch = d.name.toLowerCase().includes(search.toLowerCase()) ||
|
||||||
|
(d.breed || '').toLowerCase().includes(search.toLowerCase());
|
||||||
|
const matchSex = sexFilter === 'all' || d.sex === sexFilter;
|
||||||
|
return matchSearch && matchSex;
|
||||||
|
});
|
||||||
|
|
||||||
|
const sires = filtered.filter(d => d.sex === 'male');
|
||||||
|
const dams = filtered.filter(d => d.sex === 'female');
|
||||||
|
|
||||||
|
if (loading) return <div className="loading">Loading external dogs...</div>;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="page-container">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="page-header">
|
||||||
|
<div className="page-header-left">
|
||||||
|
<ExternalLink size={28} className="page-icon" />
|
||||||
|
<div>
|
||||||
|
<h1 className="page-title">External Dogs</h1>
|
||||||
|
<p className="page-subtitle">External sires, dams, and ancestors used in your breeding program</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
className="btn btn-primary"
|
||||||
|
onClick={() => navigate('/dogs/new?external=1')}
|
||||||
|
>
|
||||||
|
<Plus size={16} /> Add External Dog
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Filters */}
|
||||||
|
<div className="filter-bar">
|
||||||
|
<div className="search-wrapper">
|
||||||
|
<Search size={16} className="search-icon" />
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="Search by name or breed..."
|
||||||
|
value={search}
|
||||||
|
onChange={e => setSearch(e.target.value)}
|
||||||
|
className="search-input"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="filter-group">
|
||||||
|
<Filter size={14} />
|
||||||
|
<select value={sexFilter} onChange={e => setSexFilter(e.target.value)} className="filter-select">
|
||||||
|
<option value="all">All</option>
|
||||||
|
<option value="male">Sires (Male)</option>
|
||||||
|
<option value="female">Dams (Female)</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<span className="result-count">{filtered.length} dog{filtered.length !== 1 ? 's' : ''}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{filtered.length === 0 ? (
|
||||||
|
<div className="empty-state">
|
||||||
|
<ExternalLink size={48} className="empty-icon" />
|
||||||
|
<h3>No external dogs yet</h3>
|
||||||
|
<p>Add sires, dams, or ancestors that aren't part of your kennel roster.</p>
|
||||||
|
<button className="btn btn-primary" onClick={() => navigate('/dogs/new?external=1')}>
|
||||||
|
<Plus size={16} /> Add First External Dog
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="external-sections">
|
||||||
|
{(sexFilter === 'all' || sexFilter === 'male') && sires.length > 0 && (
|
||||||
|
<section className="external-section">
|
||||||
|
<h2 className="section-heading">♂ Sires ({sires.length})</h2>
|
||||||
|
<div className="dog-grid">
|
||||||
|
{sires.map(dog => <DogCard key={dog.id} dog={dog} navigate={navigate} />)}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
{(sexFilter === 'all' || sexFilter === 'female') && dams.length > 0 && (
|
||||||
|
<section className="external-section">
|
||||||
|
<h2 className="section-heading">♀ Dams ({dams.length})</h2>
|
||||||
|
<div className="dog-grid">
|
||||||
|
{dams.map(dog => <DogCard key={dog.id} dog={dog} navigate={navigate} />)}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function DogCard({ dog, navigate }) {
|
||||||
|
const photo = dog.photo_urls?.[0];
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="dog-card dog-card--external"
|
||||||
|
onClick={() => navigate(`/dogs/${dog.id}`)}
|
||||||
|
role="button"
|
||||||
|
tabIndex={0}
|
||||||
|
onKeyDown={e => e.key === 'Enter' && navigate(`/dogs/${dog.id}`)}
|
||||||
|
>
|
||||||
|
<div className="dog-card-photo">
|
||||||
|
{photo
|
||||||
|
? <img src={photo} alt={dog.name} />
|
||||||
|
: <div className="dog-card-photo-placeholder"><Users size={32} /></div>
|
||||||
|
}
|
||||||
|
{dog.is_champion === 1 && <span className="champion-badge" title="Champion">🏆</span>}
|
||||||
|
<span className="external-badge"><ExternalLink size={11} /> Ext</span>
|
||||||
|
</div>
|
||||||
|
<div className="dog-card-body">
|
||||||
|
<div className="dog-card-name">
|
||||||
|
{dog.is_champion === 1 && <Award size={13} className="champion-icon" />}
|
||||||
|
{dog.name}
|
||||||
|
</div>
|
||||||
|
<div className="dog-card-meta">{dog.breed}</div>
|
||||||
|
<div className="dog-card-meta dog-card-meta--muted">
|
||||||
|
{dog.sex === 'male' ? '\u2642 Sire' : '\u2640 Dam'}
|
||||||
|
{dog.birth_date && <> · {dog.birth_date}</>}
|
||||||
|
</div>
|
||||||
|
{(dog.sire || dog.dam) && (
|
||||||
|
<div className="dog-card-parents">
|
||||||
|
{dog.sire && <span>S: {dog.sire.name}</span>}
|
||||||
|
{dog.dam && <span>D: {dog.dam.name}</span>}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user