feature/external-dogs #50
@@ -1,5 +1,5 @@
|
|||||||
import { BrowserRouter as Router, Routes, Route, Link, useLocation } from 'react-router-dom'
|
import { BrowserRouter as Router, Routes, Route, Link, useLocation } from 'react-router-dom'
|
||||||
import { Home, PawPrint, Activity, Heart, FlaskConical, Settings } from 'lucide-react'
|
import { Home, PawPrint, Activity, Heart, FlaskConical, Settings, ExternalLink } from 'lucide-react'
|
||||||
import Dashboard from './pages/Dashboard'
|
import Dashboard from './pages/Dashboard'
|
||||||
import DogList from './pages/DogList'
|
import DogList from './pages/DogList'
|
||||||
import DogDetail from './pages/DogDetail'
|
import DogDetail from './pages/DogDetail'
|
||||||
@@ -9,6 +9,7 @@ import LitterDetail from './pages/LitterDetail'
|
|||||||
import BreedingCalendar from './pages/BreedingCalendar'
|
import BreedingCalendar from './pages/BreedingCalendar'
|
||||||
import PairingSimulator from './pages/PairingSimulator'
|
import PairingSimulator from './pages/PairingSimulator'
|
||||||
import SettingsPage from './pages/SettingsPage'
|
import SettingsPage from './pages/SettingsPage'
|
||||||
|
import ExternalDogs from './pages/ExternalDogs'
|
||||||
import { useSettings } from './hooks/useSettings'
|
import { useSettings } from './hooks/useSettings'
|
||||||
import './App.css'
|
import './App.css'
|
||||||
|
|
||||||
@@ -42,6 +43,7 @@ function AppInner() {
|
|||||||
<div className="nav-links">
|
<div className="nav-links">
|
||||||
<NavLink to="/" icon={Home} label="Dashboard" />
|
<NavLink to="/" icon={Home} label="Dashboard" />
|
||||||
<NavLink to="/dogs" icon={PawPrint} label="Dogs" />
|
<NavLink to="/dogs" icon={PawPrint} label="Dogs" />
|
||||||
|
<NavLink to="/external" icon={ExternalLink} label="External" />
|
||||||
<NavLink to="/litters" icon={Activity} label="Litters" />
|
<NavLink to="/litters" icon={Activity} label="Litters" />
|
||||||
<NavLink to="/breeding" icon={Heart} label="Breeding" />
|
<NavLink to="/breeding" icon={Heart} label="Breeding" />
|
||||||
<NavLink to="/pairing" icon={FlaskConical} label="Pairing" />
|
<NavLink to="/pairing" icon={FlaskConical} label="Pairing" />
|
||||||
@@ -55,6 +57,7 @@ function AppInner() {
|
|||||||
<Route path="/" element={<Dashboard />} />
|
<Route path="/" element={<Dashboard />} />
|
||||||
<Route path="/dogs" element={<DogList />} />
|
<Route path="/dogs" element={<DogList />} />
|
||||||
<Route path="/dogs/:id" element={<DogDetail />} />
|
<Route path="/dogs/:id" element={<DogDetail />} />
|
||||||
|
<Route path="/external" element={<ExternalDogs />} />
|
||||||
<Route path="/pedigree/:id" element={<PedigreeView />} />
|
<Route path="/pedigree/:id" element={<PedigreeView />} />
|
||||||
<Route path="/litters" element={<LitterList />} />
|
<Route path="/litters" element={<LitterList />} />
|
||||||
<Route path="/litters/:id" element={<LitterDetail />} />
|
<Route path="/litters/:id" element={<LitterDetail />} />
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
import { useState, useEffect } from 'react'
|
import { useState, useEffect } from 'react'
|
||||||
import { X, Award } from 'lucide-react'
|
import { X, Award, ExternalLink } from 'lucide-react'
|
||||||
import axios from 'axios'
|
import axios from 'axios'
|
||||||
|
|
||||||
function DogForm({ dog, onClose, onSave }) {
|
function DogForm({ dog, onClose, onSave, isExternal = false }) {
|
||||||
const [formData, setFormData] = useState({
|
const [formData, setFormData] = useState({
|
||||||
name: '',
|
name: '',
|
||||||
registration_number: '',
|
registration_number: '',
|
||||||
@@ -16,6 +16,7 @@ function DogForm({ dog, onClose, onSave }) {
|
|||||||
dam_id: null,
|
dam_id: null,
|
||||||
litter_id: null,
|
litter_id: null,
|
||||||
is_champion: false,
|
is_champion: false,
|
||||||
|
is_external: isExternal ? 1 : 0,
|
||||||
})
|
})
|
||||||
const [dogs, setDogs] = useState([])
|
const [dogs, setDogs] = useState([])
|
||||||
const [litters, setLitters] = useState([])
|
const [litters, setLitters] = useState([])
|
||||||
@@ -24,9 +25,14 @@ function DogForm({ dog, onClose, onSave }) {
|
|||||||
const [useManualParents, setUseManualParents] = useState(true)
|
const [useManualParents, setUseManualParents] = useState(true)
|
||||||
const [littersAvailable, setLittersAvailable] = useState(false)
|
const [littersAvailable, setLittersAvailable] = useState(false)
|
||||||
|
|
||||||
|
// Derive effective external state (editing an existing external dog or explicitly flagged)
|
||||||
|
const effectiveExternal = isExternal || (dog && dog.is_external)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
fetchDogs()
|
if (!effectiveExternal) {
|
||||||
fetchLitters()
|
fetchDogs()
|
||||||
|
fetchLitters()
|
||||||
|
}
|
||||||
if (dog) {
|
if (dog) {
|
||||||
setFormData({
|
setFormData({
|
||||||
name: dog.name || '',
|
name: dog.name || '',
|
||||||
@@ -41,6 +47,7 @@ function DogForm({ dog, onClose, onSave }) {
|
|||||||
dam_id: dog.dam?.id || null,
|
dam_id: dog.dam?.id || null,
|
||||||
litter_id: dog.litter_id || null,
|
litter_id: dog.litter_id || null,
|
||||||
is_champion: !!dog.is_champion,
|
is_champion: !!dog.is_champion,
|
||||||
|
is_external: dog.is_external ?? (isExternal ? 1 : 0),
|
||||||
})
|
})
|
||||||
setUseManualParents(!dog.litter_id)
|
setUseManualParents(!dog.litter_id)
|
||||||
}
|
}
|
||||||
@@ -104,9 +111,10 @@ function DogForm({ dog, onClose, onSave }) {
|
|||||||
const submitData = {
|
const submitData = {
|
||||||
...formData,
|
...formData,
|
||||||
is_champion: formData.is_champion ? 1 : 0,
|
is_champion: formData.is_champion ? 1 : 0,
|
||||||
sire_id: formData.sire_id || null,
|
is_external: effectiveExternal ? 1 : 0,
|
||||||
dam_id: formData.dam_id || null,
|
sire_id: effectiveExternal ? null : (formData.sire_id || null),
|
||||||
litter_id: useManualParents ? null : (formData.litter_id || null),
|
dam_id: effectiveExternal ? null : (formData.dam_id || null),
|
||||||
|
litter_id: (effectiveExternal || useManualParents) ? null : (formData.litter_id || null),
|
||||||
registration_number: formData.registration_number || null,
|
registration_number: formData.registration_number || null,
|
||||||
birth_date: formData.birth_date || null,
|
birth_date: formData.birth_date || null,
|
||||||
color: formData.color || null,
|
color: formData.color || null,
|
||||||
@@ -133,10 +141,31 @@ function DogForm({ dog, onClose, onSave }) {
|
|||||||
<div className="modal-overlay" onClick={onClose}>
|
<div className="modal-overlay" onClick={onClose}>
|
||||||
<div className="modal-content" onClick={(e) => e.stopPropagation()}>
|
<div className="modal-content" onClick={(e) => e.stopPropagation()}>
|
||||||
<div className="modal-header">
|
<div className="modal-header">
|
||||||
<h2>{dog ? 'Edit Dog' : 'Add New Dog'}</h2>
|
<h2>
|
||||||
|
{effectiveExternal && <ExternalLink size={18} style={{ marginRight: '0.4rem', verticalAlign: 'middle', color: 'var(--text-muted)' }} />}
|
||||||
|
{dog ? 'Edit Dog' : effectiveExternal ? 'Add External Dog' : 'Add New Dog'}
|
||||||
|
</h2>
|
||||||
<button className="btn-icon" onClick={onClose}><X size={24} /></button>
|
<button className="btn-icon" onClick={onClose}><X size={24} /></button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{effectiveExternal && (
|
||||||
|
<div style={{
|
||||||
|
margin: '0 0 1rem',
|
||||||
|
padding: '0.6rem 1rem',
|
||||||
|
background: 'rgba(99,102,241,0.08)',
|
||||||
|
border: '1px solid rgba(99,102,241,0.25)',
|
||||||
|
borderRadius: 'var(--radius)',
|
||||||
|
fontSize: '0.875rem',
|
||||||
|
color: 'var(--text-secondary)',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: '0.5rem',
|
||||||
|
}}>
|
||||||
|
<ExternalLink size={14} />
|
||||||
|
External dog — not part of your kennel roster. Litter and parent fields are not applicable.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
<form onSubmit={handleSubmit} className="modal-body">
|
<form onSubmit={handleSubmit} className="modal-body">
|
||||||
{error && <div className="error">{error}</div>}
|
{error && <div className="error">{error}</div>}
|
||||||
|
|
||||||
@@ -221,69 +250,71 @@ function DogForm({ dog, onClose, onSave }) {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Parent Section */}
|
{/* Parent Section — hidden for external dogs */}
|
||||||
<div style={{
|
{!effectiveExternal && (
|
||||||
marginTop: '1.5rem', padding: '1rem',
|
<div style={{
|
||||||
background: 'rgba(194, 134, 42, 0.04)',
|
marginTop: '1.5rem', padding: '1rem',
|
||||||
borderRadius: '8px',
|
background: 'rgba(194, 134, 42, 0.04)',
|
||||||
border: '1px solid rgba(194, 134, 42, 0.15)'
|
borderRadius: '8px',
|
||||||
}}>
|
border: '1px solid rgba(194, 134, 42, 0.15)'
|
||||||
<label className="label" style={{ marginBottom: '0.75rem', display: 'block', fontWeight: '600' }}>Parent Information</label>
|
}}>
|
||||||
|
<label className="label" style={{ marginBottom: '0.75rem', display: 'block', fontWeight: '600' }}>Parent Information</label>
|
||||||
|
|
||||||
{littersAvailable && (
|
{littersAvailable && (
|
||||||
<div style={{ display: 'flex', gap: '1.5rem', marginBottom: '1rem', flexWrap: 'wrap' }}>
|
<div style={{ display: 'flex', gap: '1.5rem', marginBottom: '1rem', flexWrap: 'wrap' }}>
|
||||||
<label style={{ display: 'flex', alignItems: 'center', gap: '0.5rem', cursor: 'pointer', fontSize: '0.95rem' }}>
|
<label style={{ display: 'flex', alignItems: 'center', gap: '0.5rem', cursor: 'pointer', fontSize: '0.95rem' }}>
|
||||||
<input type="radio" name="parentMode" checked={!useManualParents}
|
<input type="radio" name="parentMode" checked={!useManualParents}
|
||||||
onChange={() => setUseManualParents(false)} style={{ width: '16px', height: '16px' }} />
|
onChange={() => setUseManualParents(false)} style={{ width: '16px', height: '16px' }} />
|
||||||
<span>Link to Litter</span>
|
<span>Link to Litter</span>
|
||||||
</label>
|
</label>
|
||||||
<label style={{ display: 'flex', alignItems: 'center', gap: '0.5rem', cursor: 'pointer', fontSize: '0.95rem' }}>
|
<label style={{ display: 'flex', alignItems: 'center', gap: '0.5rem', cursor: 'pointer', fontSize: '0.95rem' }}>
|
||||||
<input type="radio" name="parentMode" checked={useManualParents}
|
<input type="radio" name="parentMode" checked={useManualParents}
|
||||||
onChange={() => setUseManualParents(true)} style={{ width: '16px', height: '16px' }} />
|
onChange={() => setUseManualParents(true)} style={{ width: '16px', height: '16px' }} />
|
||||||
<span>Manual Parent Selection</span>
|
<span>Manual Parent Selection</span>
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{!useManualParents && littersAvailable ? (
|
{!useManualParents && littersAvailable ? (
|
||||||
<div className="form-group" style={{ marginTop: '0.5rem' }}>
|
<div className="form-group" style={{ marginTop: '0.5rem' }}>
|
||||||
<label className="label">Select Litter</label>
|
<label className="label">Select Litter</label>
|
||||||
<select name="litter_id" className="input"
|
<select name="litter_id" className="input"
|
||||||
value={formData.litter_id || ''} onChange={handleChange}>
|
value={formData.litter_id || ''} onChange={handleChange}>
|
||||||
<option value="">No Litter</option>
|
<option value="">No Litter</option>
|
||||||
{litters.map(l => (
|
{litters.map(l => (
|
||||||
<option key={l.id} value={l.id}>
|
<option key={l.id} value={l.id}>
|
||||||
{l.sire_name} x {l.dam_name} - {new Date(l.breeding_date).toLocaleDateString()}
|
{l.sire_name} x {l.dam_name} - {new Date(l.breeding_date).toLocaleDateString()}
|
||||||
</option>
|
</option>
|
||||||
))}
|
))}
|
||||||
</select>
|
</select>
|
||||||
{formData.litter_id && (
|
{formData.litter_id && (
|
||||||
<div style={{ marginTop: '0.5rem', fontSize: '0.875rem', color: 'var(--primary)', fontStyle: 'italic' }}>
|
<div style={{ marginTop: '0.5rem', fontSize: '0.875rem', color: 'var(--primary)', fontStyle: 'italic' }}>
|
||||||
✓ Parents will be automatically set from the selected litter
|
✓ Parents will be automatically set from the selected litter
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="form-grid" style={{ marginTop: '0.5rem' }}>
|
||||||
|
<div className="form-group">
|
||||||
|
<label className="label">Sire (Father)</label>
|
||||||
|
<select name="sire_id" className="input"
|
||||||
|
value={formData.sire_id || ''} onChange={handleChange}>
|
||||||
|
<option value="">Unknown</option>
|
||||||
|
{males.map(d => <option key={d.id} value={d.id}>{d.name}{d.is_champion ? ' ✪' : ''}</option>)}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div className="form-group">
|
||||||
|
<label className="label">Dam (Mother)</label>
|
||||||
|
<select name="dam_id" className="input"
|
||||||
|
value={formData.dam_id || ''} onChange={handleChange}>
|
||||||
|
<option value="">Unknown</option>
|
||||||
|
{females.map(d => <option key={d.id} value={d.id}>{d.name}{d.is_champion ? ' ✪' : ''}</option>)}
|
||||||
|
</select>
|
||||||
</div>
|
</div>
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div className="form-grid" style={{ marginTop: '0.5rem' }}>
|
|
||||||
<div className="form-group">
|
|
||||||
<label className="label">Sire (Father)</label>
|
|
||||||
<select name="sire_id" className="input"
|
|
||||||
value={formData.sire_id || ''} onChange={handleChange}>
|
|
||||||
<option value="">Unknown</option>
|
|
||||||
{males.map(d => <option key={d.id} value={d.id}>{d.name}{d.is_champion ? ' ✪' : ''}</option>)}
|
|
||||||
</select>
|
|
||||||
</div>
|
</div>
|
||||||
<div className="form-group">
|
)}
|
||||||
<label className="label">Dam (Mother)</label>
|
</div>
|
||||||
<select name="dam_id" className="input"
|
)}
|
||||||
value={formData.dam_id || ''} onChange={handleChange}>
|
|
||||||
<option value="">Unknown</option>
|
|
||||||
{females.map(d => <option key={d.id} value={d.id}>{d.name}{d.is_champion ? ' ✪' : ''}</option>)}
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="form-group" style={{ marginTop: '1rem' }}>
|
<div className="form-group" style={{ marginTop: '1rem' }}>
|
||||||
<label className="label">Notes</label>
|
<label className="label">Notes</label>
|
||||||
@@ -294,7 +325,7 @@ function DogForm({ dog, onClose, onSave }) {
|
|||||||
<div className="modal-footer">
|
<div className="modal-footer">
|
||||||
<button type="button" className="btn btn-secondary" onClick={onClose} disabled={loading}>Cancel</button>
|
<button type="button" className="btn btn-secondary" onClick={onClose} disabled={loading}>Cancel</button>
|
||||||
<button type="submit" className="btn btn-primary" disabled={loading}>
|
<button type="submit" className="btn btn-primary" disabled={loading}>
|
||||||
{loading ? 'Saving...' : dog ? 'Update Dog' : 'Add Dog'}
|
{loading ? 'Saving...' : dog ? 'Update Dog' : effectiveExternal ? 'Add External Dog' : 'Add Dog'}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
|
|||||||
156
client/src/pages/ExternalDogs.jsx
Normal file
156
client/src/pages/ExternalDogs.jsx
Normal file
@@ -0,0 +1,156 @@
|
|||||||
|
import { useState, useEffect } from 'react';
|
||||||
|
import { Users, Plus, Search, ExternalLink, Award, Filter } from 'lucide-react';
|
||||||
|
import DogForm from '../components/DogForm';
|
||||||
|
|
||||||
|
export default function ExternalDogs() {
|
||||||
|
const [dogs, setDogs] = useState([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [search, setSearch] = useState('');
|
||||||
|
const [sexFilter, setSexFilter] = useState('all');
|
||||||
|
const [showAddModal, setShowAddModal] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchDogs();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const fetchDogs = () => {
|
||||||
|
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={() => setShowAddModal(true)}
|
||||||
|
>
|
||||||
|
<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={() => setShowAddModal(true)}>
|
||||||
|
<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} />)}
|
||||||
|
</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} />)}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Add External Dog Modal */}
|
||||||
|
{showAddModal && (
|
||||||
|
<DogForm
|
||||||
|
isExternal={true}
|
||||||
|
onClose={() => setShowAddModal(false)}
|
||||||
|
onSave={() => { fetchDogs(); setShowAddModal(false); }}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function DogCard({ dog }) {
|
||||||
|
const photo = dog.photo_urls?.[0];
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="dog-card dog-card--external"
|
||||||
|
onClick={() => window.location.href = `/dogs/${dog.id}`}
|
||||||
|
role="button"
|
||||||
|
tabIndex={0}
|
||||||
|
onKeyDown={e => e.key === 'Enter' && (window.location.href = `/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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -13,7 +13,8 @@ export default function PairingSimulator() {
|
|||||||
const [relationChecking, setRelationChecking] = useState(false)
|
const [relationChecking, setRelationChecking] = useState(false)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
fetch('/api/dogs')
|
// include_external=1 ensures external sires/dams appear for pairing
|
||||||
|
fetch('/api/dogs?include_external=1')
|
||||||
.then(r => r.json())
|
.then(r => r.json())
|
||||||
.then(data => {
|
.then(data => {
|
||||||
setDogs(Array.isArray(data) ? data : (data.dogs || []))
|
setDogs(Array.isArray(data) ? data : (data.dogs || []))
|
||||||
@@ -54,9 +55,6 @@ export default function PairingSimulator() {
|
|||||||
checkRelation(sireId, val)
|
checkRelation(sireId, val)
|
||||||
}
|
}
|
||||||
|
|
||||||
const males = dogs.filter(d => d.sex === 'male')
|
|
||||||
const females = dogs.filter(d => d.sex === 'female')
|
|
||||||
|
|
||||||
async function handleSimulate(e) {
|
async function handleSimulate(e) {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
if (!sireId || !damId) return
|
if (!sireId || !damId) return
|
||||||
@@ -64,16 +62,14 @@ export default function PairingSimulator() {
|
|||||||
setError(null)
|
setError(null)
|
||||||
setResult(null)
|
setResult(null)
|
||||||
try {
|
try {
|
||||||
const res = await fetch('/api/pedigree/trial-pairing', {
|
const res = await fetch('/api/pedigree/coi', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({ sire_id: parseInt(sireId), dam_id: parseInt(damId) })
|
body: JSON.stringify({ sire_id: parseInt(sireId), dam_id: parseInt(damId) }),
|
||||||
})
|
})
|
||||||
if (!res.ok) {
|
const data = await res.json()
|
||||||
const err = await res.json()
|
if (!res.ok) throw new Error(data.error || 'Simulation failed')
|
||||||
throw new Error(err.error || 'Failed to calculate')
|
setResult(data)
|
||||||
}
|
|
||||||
setResult(await res.json())
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError(err.message)
|
setError(err.message)
|
||||||
} finally {
|
} finally {
|
||||||
@@ -81,204 +77,164 @@ export default function PairingSimulator() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function RiskBadge({ coi, recommendation }) {
|
const males = dogs.filter(d => d.sex === 'male')
|
||||||
const isLow = coi < 5
|
const females = dogs.filter(d => d.sex === 'female')
|
||||||
const isMed = coi >= 5 && coi < 10
|
|
||||||
const isHigh = coi >= 10
|
const coiColor = (coi) => {
|
||||||
return (
|
if (coi < 0.0625) return 'var(--success)'
|
||||||
<div className={`risk-badge risk-${isLow ? 'low' : isMed ? 'med' : 'high'}`}>
|
if (coi < 0.125) return 'var(--warning)'
|
||||||
{isLow && <CheckCircle size={20} />}
|
return 'var(--danger)'
|
||||||
{isMed && <AlertTriangle size={20} />}
|
}
|
||||||
{isHigh && <XCircle size={20} />}
|
|
||||||
<span>{recommendation}</span>
|
const coiLabel = (coi) => {
|
||||||
</div>
|
if (coi < 0.0625) return 'Low'
|
||||||
)
|
if (coi < 0.125) return 'Moderate'
|
||||||
|
if (coi < 0.25) return 'High'
|
||||||
|
return 'Very High'
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="container" style={{ paddingTop: '2rem', paddingBottom: '3rem' }}>
|
<div className="container" style={{ paddingTop: '2rem', paddingBottom: '3rem', maxWidth: '720px' }}>
|
||||||
{/* Header */}
|
<div style={{ display: 'flex', alignItems: 'center', gap: '0.75rem', marginBottom: '0.5rem' }}>
|
||||||
<div style={{ marginBottom: '2rem' }}>
|
<FlaskConical size={28} style={{ color: 'var(--primary)' }} />
|
||||||
<div style={{ display: 'flex', alignItems: 'center', gap: '0.75rem', marginBottom: '0.5rem' }}>
|
<h1 style={{ margin: 0 }}>Pairing Simulator</h1>
|
||||||
<div style={{ width: '2.5rem', height: '2.5rem', borderRadius: 'var(--radius)', background: 'rgba(139,92,246,0.2)', display: 'flex', alignItems: 'center', justifyContent: 'center', color: 'var(--accent)' }}>
|
|
||||||
<FlaskConical size={20} />
|
|
||||||
</div>
|
|
||||||
<h1 style={{ fontSize: '1.75rem', margin: 0 }}>Trial Pairing Simulator</h1>
|
|
||||||
</div>
|
|
||||||
<p style={{ color: 'var(--text-muted)', margin: 0 }}>
|
|
||||||
Select a sire and dam to calculate the estimated inbreeding coefficient (COI) and view common ancestors.
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
|
<p style={{ color: 'var(--text-muted)', marginBottom: '2rem' }}>
|
||||||
|
Estimate the Coefficient of Inbreeding (COI) for a hypothetical pairing before breeding.
|
||||||
|
Includes both kennel and external dogs.
|
||||||
|
</p>
|
||||||
|
|
||||||
{/* Selector Card */}
|
<div className="card" style={{ marginBottom: '1.5rem' }}>
|
||||||
<div className="card" style={{ marginBottom: '1.5rem', maxWidth: '720px' }}>
|
|
||||||
<form onSubmit={handleSimulate}>
|
<form onSubmit={handleSimulate}>
|
||||||
<div className="form-grid" style={{ marginBottom: '1.25rem' }}>
|
<div className="form-grid" style={{ marginBottom: '1rem' }}>
|
||||||
<div className="form-group" style={{ margin: 0 }}>
|
<div className="form-group">
|
||||||
<label className="label">Sire (Male) ♂</label>
|
<label className="label">Sire (Male) *</label>
|
||||||
<select
|
{dogsLoading ? (
|
||||||
value={sireId}
|
<div className="input" style={{ color: 'var(--text-muted)' }}>Loading dogs...</div>
|
||||||
onChange={handleSireChange}
|
) : (
|
||||||
required
|
<select className="input" value={sireId} onChange={handleSireChange} required>
|
||||||
disabled={dogsLoading}
|
<option value="">Select sire...</option>
|
||||||
>
|
{males.map(d => (
|
||||||
<option value="">— Select Sire —</option>
|
<option key={d.id} value={d.id}>
|
||||||
{males.map(d => (
|
{d.name}{d.is_champion ? ' ✪' : ''}{d.is_external ? ' [Ext]' : ''}
|
||||||
<option key={d.id} value={d.id}>
|
</option>
|
||||||
{d.name}{d.breed ? ` · ${d.breed}` : ''}
|
))}
|
||||||
</option>
|
</select>
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
{!dogsLoading && males.length === 0 && (
|
|
||||||
<p style={{ color: 'var(--text-muted)', fontSize: '0.8rem', marginTop: '0.4rem' }}>No male dogs registered.</p>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="form-group" style={{ margin: 0 }}>
|
<div className="form-group">
|
||||||
<label className="label">Dam (Female) ♀</label>
|
<label className="label">Dam (Female) *</label>
|
||||||
<select
|
{dogsLoading ? (
|
||||||
value={damId}
|
<div className="input" style={{ color: 'var(--text-muted)' }}>Loading dogs...</div>
|
||||||
onChange={handleDamChange}
|
) : (
|
||||||
required
|
<select className="input" value={damId} onChange={handleDamChange} required>
|
||||||
disabled={dogsLoading}
|
<option value="">Select dam...</option>
|
||||||
>
|
{females.map(d => (
|
||||||
<option value="">— Select Dam —</option>
|
<option key={d.id} value={d.id}>
|
||||||
{females.map(d => (
|
{d.name}{d.is_champion ? ' ✪' : ''}{d.is_external ? ' [Ext]' : ''}
|
||||||
<option key={d.id} value={d.id}>
|
</option>
|
||||||
{d.name}{d.breed ? ` · ${d.breed}` : ''}
|
))}
|
||||||
</option>
|
</select>
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
{!dogsLoading && females.length === 0 && (
|
|
||||||
<p style={{ color: 'var(--text-muted)', fontSize: '0.8rem', marginTop: '0.4rem' }}>No female dogs registered.</p>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Direct-relation warning banner */}
|
|
||||||
{relationChecking && (
|
{relationChecking && (
|
||||||
<p style={{ fontSize: '0.8125rem', color: 'var(--text-muted)', marginBottom: '1rem' }}>Checking relationship…</p>
|
<div style={{ fontSize: '0.85rem', color: 'var(--text-muted)', marginBottom: '0.75rem' }}>
|
||||||
|
Checking relationship...
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
{!relationChecking && relationWarning && (
|
|
||||||
|
{relationWarning && !relationChecking && (
|
||||||
<div style={{
|
<div style={{
|
||||||
display: 'flex', alignItems: 'flex-start', gap: '0.6rem',
|
display: 'flex', alignItems: 'center', gap: '0.5rem',
|
||||||
background: 'rgba(234,179,8,0.12)', border: '1px solid rgba(234,179,8,0.4)',
|
padding: '0.6rem 1rem', marginBottom: '0.75rem',
|
||||||
borderRadius: 'var(--radius-sm)', padding: '0.75rem 1rem', marginBottom: '1rem'
|
background: 'rgba(239,68,68,0.08)', border: '1px solid rgba(239,68,68,0.3)',
|
||||||
|
borderRadius: 'var(--radius)', fontSize: '0.875rem', color: 'var(--danger)',
|
||||||
}}>
|
}}>
|
||||||
<ShieldAlert size={18} style={{ color: '#eab308', flexShrink: 0, marginTop: '0.1rem' }} />
|
<ShieldAlert size={16} />
|
||||||
<div>
|
<strong>Related:</strong> {relationWarning}
|
||||||
<p style={{ margin: 0, fontWeight: 600, color: '#eab308', fontSize: '0.875rem' }}>Direct Relation Detected</p>
|
|
||||||
<p style={{ margin: '0.2rem 0 0', fontSize: '0.8125rem', color: 'var(--text-secondary)' }}>
|
|
||||||
{relationWarning}. COI will reflect the high inbreeding coefficient for this pairing.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<button
|
<button
|
||||||
type="submit"
|
type="submit"
|
||||||
className="btn btn-primary"
|
className="btn btn-primary"
|
||||||
disabled={!sireId || !damId || loading || relationChecking}
|
disabled={loading || dogsLoading || !sireId || !damId}
|
||||||
style={{ minWidth: '160px' }}
|
style={{ width: '100%' }}
|
||||||
>
|
>
|
||||||
{loading ? 'Calculating…' : <><FlaskConical size={16} /> Simulate Pairing</>}
|
{loading ? 'Simulating...' : <><GitMerge size={16} style={{ marginRight: '0.4rem' }} />Simulate Pairing</>}
|
||||||
</button>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Error */}
|
{error && (
|
||||||
{error && <div className="error" style={{ maxWidth: '720px' }}>{error}</div>}
|
<div className="card" style={{ borderColor: 'var(--danger)', marginBottom: '1.5rem' }}>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: '0.5rem', color: 'var(--danger)' }}>
|
||||||
|
<XCircle size={18} />
|
||||||
|
<strong>Error:</strong> {error}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Results */}
|
|
||||||
{result && (
|
{result && (
|
||||||
<div style={{ maxWidth: '720px' }}>
|
<div className="card">
|
||||||
{/* Direct-relation alert in results */}
|
<h2 style={{ fontSize: '1rem', marginBottom: '1.25rem', color: 'var(--text-muted)', textTransform: 'uppercase', letterSpacing: '0.05em' }}>
|
||||||
{result.directRelation && (
|
Simulation Result
|
||||||
<div style={{
|
</h2>
|
||||||
display: 'flex', alignItems: 'flex-start', gap: '0.6rem',
|
|
||||||
background: 'rgba(239,68,68,0.1)', border: '1px solid rgba(239,68,68,0.35)',
|
<div style={{
|
||||||
borderRadius: 'var(--radius-sm)', padding: '0.75rem 1rem', marginBottom: '1.25rem'
|
display: 'flex', alignItems: 'center', gap: '1rem',
|
||||||
}}>
|
padding: '1.25rem', marginBottom: '1rem',
|
||||||
<ShieldAlert size={18} style={{ color: 'var(--danger)', flexShrink: 0, marginTop: '0.1rem' }} />
|
background: 'var(--bg-primary)', borderRadius: 'var(--radius)',
|
||||||
<div>
|
border: `2px solid ${coiColor(result.coi)}`,
|
||||||
<p style={{ margin: 0, fontWeight: 600, color: 'var(--danger)', fontSize: '0.875rem' }}>Direct Relation — High Inbreeding Risk</p>
|
}}>
|
||||||
<p style={{ margin: '0.2rem 0 0', fontSize: '0.8125rem', color: 'var(--text-secondary)' }}>{result.directRelation}</p>
|
{result.coi < 0.0625
|
||||||
|
? <CheckCircle size={32} style={{ color: coiColor(result.coi), flexShrink: 0 }} />
|
||||||
|
: <AlertTriangle size={32} style={{ color: coiColor(result.coi), flexShrink: 0 }} />
|
||||||
|
}
|
||||||
|
<div>
|
||||||
|
<div style={{ fontSize: '2rem', fontWeight: 700, color: coiColor(result.coi), lineHeight: 1 }}>
|
||||||
|
{(result.coi * 100).toFixed(2)}%
|
||||||
|
</div>
|
||||||
|
<div style={{ color: 'var(--text-muted)', fontSize: '0.875rem' }}>
|
||||||
|
COI — <strong style={{ color: coiColor(result.coi) }}>{coiLabel(result.coi)}</strong>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{result.common_ancestors && result.common_ancestors.length > 0 && (
|
||||||
|
<div>
|
||||||
|
<h3 style={{ fontSize: '0.875rem', color: 'var(--text-muted)', textTransform: 'uppercase', letterSpacing: '0.05em', marginBottom: '0.5rem' }}>
|
||||||
|
Common Ancestors ({result.common_ancestors.length})
|
||||||
|
</h3>
|
||||||
|
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '0.4rem' }}>
|
||||||
|
{result.common_ancestors.map((a, i) => (
|
||||||
|
<span key={i} style={{
|
||||||
|
padding: '0.2rem 0.6rem',
|
||||||
|
background: 'var(--bg-tertiary)',
|
||||||
|
borderRadius: 'var(--radius-sm)',
|
||||||
|
fontSize: '0.8rem',
|
||||||
|
border: '1px solid var(--border)',
|
||||||
|
}}>{a}</span>
|
||||||
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* COI Summary */}
|
{result.recommendation && (
|
||||||
<div className="card" style={{ marginBottom: '1.25rem' }}>
|
<div style={{
|
||||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', flexWrap: 'wrap', gap: '1rem' }}>
|
marginTop: '1rem', padding: '0.75rem 1rem',
|
||||||
<div>
|
background: result.coi < 0.0625 ? 'rgba(34,197,94,0.08)' : 'rgba(239,68,68,0.08)',
|
||||||
<p style={{ color: 'var(--text-muted)', fontSize: '0.8125rem', textTransform: 'uppercase', letterSpacing: '0.05em', fontWeight: 500, marginBottom: '0.25rem' }}>Pairing</p>
|
borderRadius: 'var(--radius)',
|
||||||
<p style={{ fontSize: '1.125rem', fontWeight: 600, margin: 0 }}>
|
border: `1px solid ${result.coi < 0.0625 ? 'rgba(34,197,94,0.3)' : 'rgba(239,68,68,0.3)'}`,
|
||||||
<span style={{ color: '#60a5fa' }}>{result.sire.name}</span>
|
fontSize: '0.875rem',
|
||||||
<span style={{ color: 'var(--text-muted)', margin: '0 0.5rem' }}>×</span>
|
color: 'var(--text-secondary)',
|
||||||
<span style={{ color: '#f472b6' }}>{result.dam.name}</span>
|
}}>
|
||||||
</p>
|
{result.recommendation}
|
||||||
</div>
|
|
||||||
<div style={{ textAlign: 'right' }}>
|
|
||||||
<p style={{ color: 'var(--text-muted)', fontSize: '0.8125rem', textTransform: 'uppercase', letterSpacing: '0.05em', fontWeight: 500, marginBottom: '0.25rem' }}>COI</p>
|
|
||||||
<p style={{
|
|
||||||
fontSize: '2rem', fontWeight: 700, lineHeight: 1,
|
|
||||||
color: result.coi < 5 ? 'var(--success)' : result.coi < 10 ? 'var(--warning)' : 'var(--danger)'
|
|
||||||
}}>
|
|
||||||
{result.coi.toFixed(2)}%
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
<div style={{ marginTop: '1.25rem' }}>
|
|
||||||
<RiskBadge coi={result.coi} recommendation={result.recommendation} />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div style={{ marginTop: '1rem', padding: '0.75rem', background: 'var(--bg-tertiary)', borderRadius: 'var(--radius-sm)', fontSize: '0.8125rem', color: 'var(--text-secondary)' }}>
|
|
||||||
<strong>COI Guide:</strong> <5% Low risk · 5–10% Moderate risk · >10% High risk
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Common Ancestors */}
|
|
||||||
<div className="card">
|
|
||||||
<div style={{ display: 'flex', alignItems: 'center', gap: '0.5rem', marginBottom: '1rem' }}>
|
|
||||||
<GitMerge size={18} style={{ color: 'var(--accent)' }} />
|
|
||||||
<h3 style={{ margin: 0, fontSize: '1rem' }}>Common Ancestors</h3>
|
|
||||||
<span className="badge badge-primary" style={{ marginLeft: 'auto' }}>
|
|
||||||
{result.commonAncestors.length} found
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{result.commonAncestors.length === 0 ? (
|
|
||||||
<p style={{ color: 'var(--text-muted)', textAlign: 'center', padding: '1.5rem 0', margin: 0 }}>
|
|
||||||
No common ancestors found within 6 generations. This pairing has excellent genetic diversity.
|
|
||||||
</p>
|
|
||||||
) : (
|
|
||||||
<div style={{ overflowX: 'auto' }}>
|
|
||||||
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: '0.875rem' }}>
|
|
||||||
<thead>
|
|
||||||
<tr style={{ borderBottom: '1px solid var(--border)' }}>
|
|
||||||
<th style={{ textAlign: 'left', padding: '0.625rem 0.75rem', color: 'var(--text-muted)', fontWeight: 500, fontSize: '0.75rem', textTransform: 'uppercase', letterSpacing: '0.05em' }}>Ancestor</th>
|
|
||||||
<th style={{ textAlign: 'center', padding: '0.625rem 0.75rem', color: 'var(--text-muted)', fontWeight: 500, fontSize: '0.75rem', textTransform: 'uppercase', letterSpacing: '0.05em' }}>Sire Gen</th>
|
|
||||||
<th style={{ textAlign: 'center', padding: '0.625rem 0.75rem', color: 'var(--text-muted)', fontWeight: 500, fontSize: '0.75rem', textTransform: 'uppercase', letterSpacing: '0.05em' }}>Dam Gen</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
{result.commonAncestors.map((anc, i) => (
|
|
||||||
<tr key={i} style={{ borderBottom: '1px solid var(--border)' }}>
|
|
||||||
<td style={{ padding: '0.625rem 0.75rem', fontWeight: 500 }}>{anc.name}</td>
|
|
||||||
<td style={{ padding: '0.625rem 0.75rem', textAlign: 'center' }}>
|
|
||||||
<span className="badge badge-primary">Gen {anc.sireGen}</span>
|
|
||||||
</td>
|
|
||||||
<td style={{ padding: '0.625rem 0.75rem', textAlign: 'center' }}>
|
|
||||||
<span className="badge" style={{ background: 'rgba(244,114,182,0.15)', color: '#f472b6' }}>Gen {anc.damGen}</span>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
))}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -13,33 +13,35 @@ function initDatabase() {
|
|||||||
db.pragma('foreign_keys = ON');
|
db.pragma('foreign_keys = ON');
|
||||||
db.pragma('journal_mode = WAL');
|
db.pragma('journal_mode = WAL');
|
||||||
|
|
||||||
// ── Dogs ────────────────────────────────────────────────────────────
|
// ── Dogs ────────────────────────────────────────────────────────────────
|
||||||
db.exec(`
|
db.exec(`
|
||||||
CREATE TABLE IF NOT EXISTS dogs (
|
CREATE TABLE IF NOT EXISTS dogs (
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
name TEXT NOT NULL,
|
name TEXT NOT NULL,
|
||||||
registration_number TEXT,
|
registration_number TEXT,
|
||||||
breed TEXT NOT NULL,
|
breed TEXT NOT NULL,
|
||||||
sex TEXT NOT NULL CHECK(sex IN ('male', 'female')),
|
sex TEXT NOT NULL CHECK(sex IN ('male', 'female')),
|
||||||
birth_date TEXT,
|
birth_date TEXT,
|
||||||
color TEXT,
|
color TEXT,
|
||||||
microchip TEXT,
|
microchip TEXT,
|
||||||
litter_id INTEGER,
|
litter_id INTEGER,
|
||||||
is_active INTEGER DEFAULT 1,
|
is_active INTEGER DEFAULT 1,
|
||||||
is_champion INTEGER DEFAULT 0,
|
is_champion INTEGER DEFAULT 0,
|
||||||
chic_number TEXT,
|
is_external INTEGER DEFAULT 0,
|
||||||
age_at_death TEXT,
|
chic_number TEXT,
|
||||||
cause_of_death TEXT,
|
age_at_death TEXT,
|
||||||
photo_urls TEXT DEFAULT '[]',
|
cause_of_death TEXT,
|
||||||
notes TEXT,
|
photo_urls TEXT DEFAULT '[]',
|
||||||
created_at TEXT DEFAULT (datetime('now')),
|
notes TEXT,
|
||||||
updated_at TEXT DEFAULT (datetime('now'))
|
created_at TEXT DEFAULT (datetime('now')),
|
||||||
|
updated_at TEXT DEFAULT (datetime('now'))
|
||||||
)
|
)
|
||||||
`);
|
`);
|
||||||
|
|
||||||
// migrate: add columns if missing (safe on existing DBs)
|
// migrate: add columns if missing (safe on existing DBs)
|
||||||
const dogMigrations = [
|
const dogMigrations = [
|
||||||
['is_champion', 'INTEGER DEFAULT 0'],
|
['is_champion', 'INTEGER DEFAULT 0'],
|
||||||
|
['is_external', 'INTEGER DEFAULT 0'],
|
||||||
['chic_number', 'TEXT'],
|
['chic_number', 'TEXT'],
|
||||||
['age_at_death', 'TEXT'],
|
['age_at_death', 'TEXT'],
|
||||||
['cause_of_death', 'TEXT'],
|
['cause_of_death', 'TEXT'],
|
||||||
@@ -48,7 +50,7 @@ function initDatabase() {
|
|||||||
try { db.exec(`ALTER TABLE dogs ADD COLUMN ${col} ${def}`); } catch (_) { /* already exists */ }
|
try { db.exec(`ALTER TABLE dogs ADD COLUMN ${col} ${def}`); } catch (_) { /* already exists */ }
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Parents ─────────────────────────────────────────────────────────
|
// ── Parents ──────────────────────────────────────────────────────────────
|
||||||
db.exec(`
|
db.exec(`
|
||||||
CREATE TABLE IF NOT EXISTS parents (
|
CREATE TABLE IF NOT EXISTS parents (
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
@@ -60,7 +62,7 @@ function initDatabase() {
|
|||||||
)
|
)
|
||||||
`);
|
`);
|
||||||
|
|
||||||
// ── Breeding Records ─────────────────────────────────────────────────
|
// ── Breeding Records ─────────────────────────────────────────────────────
|
||||||
db.exec(`
|
db.exec(`
|
||||||
CREATE TABLE IF NOT EXISTS breeding_records (
|
CREATE TABLE IF NOT EXISTS breeding_records (
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
@@ -77,34 +79,28 @@ function initDatabase() {
|
|||||||
)
|
)
|
||||||
`);
|
`);
|
||||||
|
|
||||||
// ── Litters ──────────────────────────────────────────────────────────
|
// ── Litters ──────────────────────────────────────────────────────────────
|
||||||
db.exec(`
|
db.exec(`
|
||||||
CREATE TABLE IF NOT EXISTS litters (
|
CREATE TABLE IF NOT EXISTS litters (
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
breeding_id INTEGER,
|
breeding_id INTEGER,
|
||||||
sire_id INTEGER NOT NULL,
|
sire_id INTEGER NOT NULL,
|
||||||
dam_id INTEGER NOT NULL,
|
dam_id INTEGER NOT NULL,
|
||||||
whelp_date TEXT,
|
whelp_date TEXT,
|
||||||
total_count INTEGER DEFAULT 0,
|
total_count INTEGER DEFAULT 0,
|
||||||
male_count INTEGER DEFAULT 0,
|
male_count INTEGER DEFAULT 0,
|
||||||
female_count INTEGER DEFAULT 0,
|
female_count INTEGER DEFAULT 0,
|
||||||
stillborn_count INTEGER DEFAULT 0,
|
stillborn_count INTEGER DEFAULT 0,
|
||||||
notes TEXT,
|
notes TEXT,
|
||||||
created_at TEXT DEFAULT (datetime('now')),
|
created_at TEXT DEFAULT (datetime('now')),
|
||||||
updated_at TEXT DEFAULT (datetime('now')),
|
updated_at TEXT DEFAULT (datetime('now')),
|
||||||
FOREIGN KEY (breeding_id) REFERENCES breeding_records(id),
|
FOREIGN KEY (breeding_id) REFERENCES breeding_records(id),
|
||||||
FOREIGN KEY (sire_id) REFERENCES dogs(id),
|
FOREIGN KEY (sire_id) REFERENCES dogs(id),
|
||||||
FOREIGN KEY (dam_id) REFERENCES dogs(id)
|
FOREIGN KEY (dam_id) REFERENCES dogs(id)
|
||||||
)
|
)
|
||||||
`);
|
`);
|
||||||
|
|
||||||
// ── Health Records (OFA-extended) ────────────────────────────────────
|
// ── Health Records (OFA-extended) ─────────────────────────────────────────
|
||||||
// test_type values: hip_ofa | hip_pennhip | elbow_ofa | heart_ofa |
|
|
||||||
// heart_echo | eye_caer | thyroid_ofa | dna_panel | vaccination |
|
|
||||||
// other
|
|
||||||
// ofa_result values: excellent | good | fair | borderline | mild |
|
|
||||||
// moderate | severe | normal | abnormal | pass | fail | carrier |
|
|
||||||
// clear | affected | n/a
|
|
||||||
db.exec(`
|
db.exec(`
|
||||||
CREATE TABLE IF NOT EXISTS health_records (
|
CREATE TABLE IF NOT EXISTS health_records (
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
@@ -130,7 +126,7 @@ function initDatabase() {
|
|||||||
|
|
||||||
// migrate: add OFA-specific columns if missing (covers existing DBs)
|
// migrate: add OFA-specific columns if missing (covers existing DBs)
|
||||||
const healthMigrations = [
|
const healthMigrations = [
|
||||||
['test_type', 'TEXT'],
|
['test_type', 'TEXT'],
|
||||||
['ofa_result', 'TEXT'],
|
['ofa_result', 'TEXT'],
|
||||||
['ofa_number', 'TEXT'],
|
['ofa_number', 'TEXT'],
|
||||||
['performed_by', 'TEXT'],
|
['performed_by', 'TEXT'],
|
||||||
@@ -144,10 +140,7 @@ function initDatabase() {
|
|||||||
try { db.exec(`ALTER TABLE health_records ADD COLUMN ${col} ${def}`); } catch (_) { /* already exists */ }
|
try { db.exec(`ALTER TABLE health_records ADD COLUMN ${col} ${def}`); } catch (_) { /* already exists */ }
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Genetic Tests (DNA Panel) ─────────────────────────────────────────
|
// ── Genetic Tests (DNA Panel) ──────────────────────────────────────────────
|
||||||
// result values: clear | carrier | affected | not_tested
|
|
||||||
// marker examples: PRA1, PRA2, prcd-PRA, GR-PRA1, GR-PRA2, ICH1,
|
|
||||||
// ICH2, NCL, DM, MD
|
|
||||||
db.exec(`
|
db.exec(`
|
||||||
CREATE TABLE IF NOT EXISTS genetic_tests (
|
CREATE TABLE IF NOT EXISTS genetic_tests (
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
@@ -164,23 +157,23 @@ function initDatabase() {
|
|||||||
)
|
)
|
||||||
`);
|
`);
|
||||||
|
|
||||||
// ── Cancer History ───────────────────────────────────────────────────
|
// ── Cancer History ────────────────────────────────────────────────────────
|
||||||
db.exec(`
|
db.exec(`
|
||||||
CREATE TABLE IF NOT EXISTS cancer_history (
|
CREATE TABLE IF NOT EXISTS cancer_history (
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
dog_id INTEGER NOT NULL,
|
dog_id INTEGER NOT NULL,
|
||||||
cancer_type TEXT,
|
cancer_type TEXT,
|
||||||
age_at_diagnosis TEXT,
|
age_at_diagnosis TEXT,
|
||||||
age_at_death TEXT,
|
age_at_death TEXT,
|
||||||
cause_of_death TEXT,
|
cause_of_death TEXT,
|
||||||
notes TEXT,
|
notes TEXT,
|
||||||
created_at TEXT DEFAULT (datetime('now')),
|
created_at TEXT DEFAULT (datetime('now')),
|
||||||
updated_at TEXT DEFAULT (datetime('now')),
|
updated_at TEXT DEFAULT (datetime('now')),
|
||||||
FOREIGN KEY (dog_id) REFERENCES dogs(id)
|
FOREIGN KEY (dog_id) REFERENCES dogs(id)
|
||||||
)
|
)
|
||||||
`);
|
`);
|
||||||
|
|
||||||
// ── Settings ─────────────────────────────────────────────────────────
|
// ── Settings ──────────────────────────────────────────────────────────────
|
||||||
db.exec(`
|
db.exec(`
|
||||||
CREATE TABLE IF NOT EXISTS settings (
|
CREATE TABLE IF NOT EXISTS settings (
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
|||||||
@@ -31,15 +31,67 @@ const upload = multer({
|
|||||||
|
|
||||||
const emptyToNull = (v) => (v === '' || v === undefined) ? null : v;
|
const emptyToNull = (v) => (v === '' || v === undefined) ? null : v;
|
||||||
|
|
||||||
// ── Shared SELECT columns ────────────────────────────────────────────
|
// ── Shared SELECT columns ────────────────────────────────────────────────
|
||||||
const DOG_COLS = `
|
const DOG_COLS = `
|
||||||
id, name, registration_number, breed, sex, birth_date,
|
id, name, registration_number, breed, sex, birth_date,
|
||||||
color, microchip, photo_urls, notes, litter_id, is_active,
|
color, microchip, photo_urls, notes, litter_id, is_active,
|
||||||
is_champion, created_at, updated_at
|
is_champion, is_external, created_at, updated_at
|
||||||
`;
|
`;
|
||||||
|
|
||||||
// ── GET all dogs ─────────────────────────────────────────────────────
|
// ── Helper: attach parents to a list of dogs ─────────────────────────────
|
||||||
|
function attachParents(db, dogs) {
|
||||||
|
const parentStmt = db.prepare(`
|
||||||
|
SELECT p.parent_type, d.id, d.name, d.is_champion, d.is_external
|
||||||
|
FROM parents p
|
||||||
|
JOIN dogs d ON p.parent_id = d.id
|
||||||
|
WHERE p.dog_id = ?
|
||||||
|
`);
|
||||||
|
dogs.forEach(dog => {
|
||||||
|
dog.photo_urls = dog.photo_urls ? JSON.parse(dog.photo_urls) : [];
|
||||||
|
const parents = parentStmt.all(dog.id);
|
||||||
|
dog.sire = parents.find(p => p.parent_type === 'sire') || null;
|
||||||
|
dog.dam = parents.find(p => p.parent_type === 'dam') || null;
|
||||||
|
});
|
||||||
|
return dogs;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── GET dogs
|
||||||
|
// Default: kennel dogs only (is_external = 0)
|
||||||
|
// ?include_external=1 : all active dogs (kennel + external)
|
||||||
|
// ?external_only=1 : external dogs only
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────
|
||||||
router.get('/', (req, res) => {
|
router.get('/', (req, res) => {
|
||||||
|
try {
|
||||||
|
const db = getDatabase();
|
||||||
|
const includeExternal = req.query.include_external === '1' || req.query.include_external === 'true';
|
||||||
|
const externalOnly = req.query.external_only === '1' || req.query.external_only === 'true';
|
||||||
|
|
||||||
|
let whereClause;
|
||||||
|
if (externalOnly) {
|
||||||
|
whereClause = 'WHERE is_active = 1 AND is_external = 1';
|
||||||
|
} else if (includeExternal) {
|
||||||
|
whereClause = 'WHERE is_active = 1';
|
||||||
|
} else {
|
||||||
|
whereClause = 'WHERE is_active = 1 AND is_external = 0';
|
||||||
|
}
|
||||||
|
|
||||||
|
const dogs = db.prepare(`
|
||||||
|
SELECT ${DOG_COLS}
|
||||||
|
FROM dogs
|
||||||
|
${whereClause}
|
||||||
|
ORDER BY name
|
||||||
|
`).all();
|
||||||
|
|
||||||
|
res.json(attachParents(db, dogs));
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error fetching dogs:', error);
|
||||||
|
res.status(500).json({ error: error.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── GET all dogs (kennel + external) for dropdowns/pairing/pedigree ──────────
|
||||||
|
// Kept for backwards-compat; equivalent to GET /?include_external=1
|
||||||
|
router.get('/all', (req, res) => {
|
||||||
try {
|
try {
|
||||||
const db = getDatabase();
|
const db = getDatabase();
|
||||||
const dogs = db.prepare(`
|
const dogs = db.prepare(`
|
||||||
@@ -48,29 +100,32 @@ router.get('/', (req, res) => {
|
|||||||
WHERE is_active = 1
|
WHERE is_active = 1
|
||||||
ORDER BY name
|
ORDER BY name
|
||||||
`).all();
|
`).all();
|
||||||
|
res.json(attachParents(db, dogs));
|
||||||
const parentStmt = db.prepare(`
|
|
||||||
SELECT p.parent_type, d.id, d.name, d.is_champion
|
|
||||||
FROM parents p
|
|
||||||
JOIN dogs d ON p.parent_id = d.id
|
|
||||||
WHERE p.dog_id = ?
|
|
||||||
`);
|
|
||||||
|
|
||||||
dogs.forEach(dog => {
|
|
||||||
dog.photo_urls = dog.photo_urls ? JSON.parse(dog.photo_urls) : [];
|
|
||||||
const parents = parentStmt.all(dog.id);
|
|
||||||
dog.sire = parents.find(p => p.parent_type === 'sire') || null;
|
|
||||||
dog.dam = parents.find(p => p.parent_type === 'dam') || null;
|
|
||||||
});
|
|
||||||
|
|
||||||
res.json(dogs);
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error fetching dogs:', error);
|
console.error('Error fetching all dogs:', error);
|
||||||
res.status(500).json({ error: error.message });
|
res.status(500).json({ error: error.message });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// ── GET single dog (with parents + offspring) ────────────────────────
|
// ── GET external dogs only (is_external = 1) ──────────────────────────────
|
||||||
|
// Kept for backwards-compat; equivalent to GET /?external_only=1
|
||||||
|
router.get('/external', (req, res) => {
|
||||||
|
try {
|
||||||
|
const db = getDatabase();
|
||||||
|
const dogs = db.prepare(`
|
||||||
|
SELECT ${DOG_COLS}
|
||||||
|
FROM dogs
|
||||||
|
WHERE is_active = 1 AND is_external = 1
|
||||||
|
ORDER BY name
|
||||||
|
`).all();
|
||||||
|
res.json(attachParents(db, dogs));
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error fetching external dogs:', error);
|
||||||
|
res.status(500).json({ error: error.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── GET single dog (with parents + offspring) ──────────────────────────
|
||||||
router.get('/:id', (req, res) => {
|
router.get('/:id', (req, res) => {
|
||||||
try {
|
try {
|
||||||
const db = getDatabase();
|
const db = getDatabase();
|
||||||
@@ -81,7 +136,7 @@ router.get('/:id', (req, res) => {
|
|||||||
dog.photo_urls = dog.photo_urls ? JSON.parse(dog.photo_urls) : [];
|
dog.photo_urls = dog.photo_urls ? JSON.parse(dog.photo_urls) : [];
|
||||||
|
|
||||||
const parents = db.prepare(`
|
const parents = db.prepare(`
|
||||||
SELECT p.parent_type, d.id, d.name, d.is_champion
|
SELECT p.parent_type, d.id, d.name, d.is_champion, d.is_external
|
||||||
FROM parents p
|
FROM parents p
|
||||||
JOIN dogs d ON p.parent_id = d.id
|
JOIN dogs d ON p.parent_id = d.id
|
||||||
WHERE p.dog_id = ?
|
WHERE p.dog_id = ?
|
||||||
@@ -91,7 +146,7 @@ router.get('/:id', (req, res) => {
|
|||||||
dog.dam = parents.find(p => p.parent_type === 'dam') || null;
|
dog.dam = parents.find(p => p.parent_type === 'dam') || null;
|
||||||
|
|
||||||
dog.offspring = db.prepare(`
|
dog.offspring = db.prepare(`
|
||||||
SELECT d.id, d.name, d.sex, d.is_champion
|
SELECT d.id, d.name, d.sex, d.is_champion, d.is_external
|
||||||
FROM dogs d
|
FROM dogs d
|
||||||
JOIN parents p ON d.id = p.dog_id
|
JOIN parents p ON d.id = p.dog_id
|
||||||
WHERE p.parent_id = ? AND d.is_active = 1
|
WHERE p.parent_id = ? AND d.is_active = 1
|
||||||
@@ -104,13 +159,11 @@ router.get('/:id', (req, res) => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// ── POST create dog ──────────────────────────────────────────────────
|
// ── POST create dog ─────────────────────────────────────────────────────
|
||||||
router.post('/', (req, res) => {
|
router.post('/', (req, res) => {
|
||||||
try {
|
try {
|
||||||
const { name, registration_number, breed, sex, birth_date, color,
|
const { name, registration_number, breed, sex, birth_date, color,
|
||||||
microchip, notes, sire_id, dam_id, litter_id, is_champion } = req.body;
|
microchip, notes, sire_id, dam_id, litter_id, is_champion, is_external } = req.body;
|
||||||
|
|
||||||
console.log('Creating dog:', { name, breed, sex, sire_id, dam_id, litter_id, is_champion });
|
|
||||||
|
|
||||||
if (!name || !breed || !sex) {
|
if (!name || !breed || !sex) {
|
||||||
return res.status(400).json({ error: 'Name, breed, and sex are required' });
|
return res.status(400).json({ error: 'Name, breed, and sex are required' });
|
||||||
@@ -119,8 +172,8 @@ router.post('/', (req, res) => {
|
|||||||
const db = getDatabase();
|
const db = getDatabase();
|
||||||
const result = db.prepare(`
|
const result = db.prepare(`
|
||||||
INSERT INTO dogs (name, registration_number, breed, sex, birth_date, color,
|
INSERT INTO dogs (name, registration_number, breed, sex, birth_date, color,
|
||||||
microchip, notes, litter_id, photo_urls, is_champion)
|
microchip, notes, litter_id, photo_urls, is_champion, is_external)
|
||||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
`).run(
|
`).run(
|
||||||
name,
|
name,
|
||||||
emptyToNull(registration_number),
|
emptyToNull(registration_number),
|
||||||
@@ -131,11 +184,11 @@ router.post('/', (req, res) => {
|
|||||||
emptyToNull(notes),
|
emptyToNull(notes),
|
||||||
emptyToNull(litter_id),
|
emptyToNull(litter_id),
|
||||||
'[]',
|
'[]',
|
||||||
is_champion ? 1 : 0
|
is_champion ? 1 : 0,
|
||||||
|
is_external ? 1 : 0
|
||||||
);
|
);
|
||||||
|
|
||||||
const dogId = result.lastInsertRowid;
|
const dogId = result.lastInsertRowid;
|
||||||
console.log(`✔ Dog inserted with ID: ${dogId}`);
|
|
||||||
|
|
||||||
if (sire_id && sire_id !== '' && sire_id !== null) {
|
if (sire_id && sire_id !== '' && sire_id !== null) {
|
||||||
db.prepare('INSERT INTO parents (dog_id, parent_id, parent_type) VALUES (?, ?, ?)').run(dogId, sire_id, 'sire');
|
db.prepare('INSERT INTO parents (dog_id, parent_id, parent_type) VALUES (?, ?, ?)').run(dogId, sire_id, 'sire');
|
||||||
@@ -147,7 +200,7 @@ router.post('/', (req, res) => {
|
|||||||
const dog = db.prepare(`SELECT ${DOG_COLS} FROM dogs WHERE id = ?`).get(dogId);
|
const dog = db.prepare(`SELECT ${DOG_COLS} FROM dogs WHERE id = ?`).get(dogId);
|
||||||
dog.photo_urls = [];
|
dog.photo_urls = [];
|
||||||
|
|
||||||
console.log(`✔ Dog created: ${dog.name} (ID: ${dogId})`);
|
console.log(`✔ Dog created: ${dog.name} (ID: ${dogId}, external: ${dog.is_external})`);
|
||||||
res.status(201).json(dog);
|
res.status(201).json(dog);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error creating dog:', error);
|
console.error('Error creating dog:', error);
|
||||||
@@ -155,20 +208,18 @@ router.post('/', (req, res) => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// ── PUT update dog ───────────────────────────────────────────────────
|
// ── PUT update dog ───────────────────────────────────────────────────────
|
||||||
router.put('/:id', (req, res) => {
|
router.put('/:id', (req, res) => {
|
||||||
try {
|
try {
|
||||||
const { name, registration_number, breed, sex, birth_date, color,
|
const { name, registration_number, breed, sex, birth_date, color,
|
||||||
microchip, notes, sire_id, dam_id, litter_id, is_champion } = req.body;
|
microchip, notes, sire_id, dam_id, litter_id, is_champion, is_external } = req.body;
|
||||||
|
|
||||||
console.log(`Updating dog ${req.params.id}:`, { name, breed, sex, sire_id, dam_id, is_champion });
|
|
||||||
|
|
||||||
const db = getDatabase();
|
const db = getDatabase();
|
||||||
db.prepare(`
|
db.prepare(`
|
||||||
UPDATE dogs
|
UPDATE dogs
|
||||||
SET name = ?, registration_number = ?, breed = ?, sex = ?,
|
SET name = ?, registration_number = ?, breed = ?, sex = ?,
|
||||||
birth_date = ?, color = ?, microchip = ?, notes = ?,
|
birth_date = ?, color = ?, microchip = ?, notes = ?,
|
||||||
litter_id = ?, is_champion = ?, updated_at = datetime('now')
|
litter_id = ?, is_champion = ?, is_external = ?, updated_at = datetime('now')
|
||||||
WHERE id = ?
|
WHERE id = ?
|
||||||
`).run(
|
`).run(
|
||||||
name,
|
name,
|
||||||
@@ -180,6 +231,7 @@ router.put('/:id', (req, res) => {
|
|||||||
emptyToNull(notes),
|
emptyToNull(notes),
|
||||||
emptyToNull(litter_id),
|
emptyToNull(litter_id),
|
||||||
is_champion ? 1 : 0,
|
is_champion ? 1 : 0,
|
||||||
|
is_external ? 1 : 0,
|
||||||
req.params.id
|
req.params.id
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -202,10 +254,7 @@ router.put('/:id', (req, res) => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// ── DELETE dog (hard delete with cascade) ────────────────────────────
|
// ── DELETE dog (hard delete with cascade) ───────────────────────────────
|
||||||
// Removes: parent relationships (both directions), health records,
|
|
||||||
// heat cycles, and the dog record itself.
|
|
||||||
// Photo files on disk are NOT removed here — run a gc job if needed.
|
|
||||||
router.delete('/:id', (req, res) => {
|
router.delete('/:id', (req, res) => {
|
||||||
try {
|
try {
|
||||||
const db = getDatabase();
|
const db = getDatabase();
|
||||||
@@ -213,13 +262,11 @@ router.delete('/:id', (req, res) => {
|
|||||||
if (!existing) return res.status(404).json({ error: 'Dog not found' });
|
if (!existing) return res.status(404).json({ error: 'Dog not found' });
|
||||||
|
|
||||||
const id = req.params.id;
|
const id = req.params.id;
|
||||||
|
db.prepare('DELETE FROM parents WHERE parent_id = ?').run(id);
|
||||||
// Cascade cleanup
|
db.prepare('DELETE FROM parents WHERE dog_id = ?').run(id);
|
||||||
db.prepare('DELETE FROM parents WHERE parent_id = ?').run(id); // remove as parent
|
db.prepare('DELETE FROM health_records WHERE dog_id = ?').run(id);
|
||||||
db.prepare('DELETE FROM parents WHERE dog_id = ?').run(id); // remove own parents
|
db.prepare('DELETE FROM heat_cycles WHERE dog_id = ?').run(id);
|
||||||
db.prepare('DELETE FROM health_records WHERE dog_id = ?').run(id);
|
db.prepare('DELETE FROM dogs WHERE id = ?').run(id);
|
||||||
db.prepare('DELETE FROM heat_cycles WHERE dog_id = ?').run(id);
|
|
||||||
db.prepare('DELETE FROM dogs WHERE id = ?').run(id);
|
|
||||||
|
|
||||||
console.log(`✔ Dog #${id} (${existing.name}) permanently deleted`);
|
console.log(`✔ Dog #${id} (${existing.name}) permanently deleted`);
|
||||||
res.json({ success: true, message: `${existing.name} has been deleted` });
|
res.json({ success: true, message: `${existing.name} has been deleted` });
|
||||||
@@ -229,7 +276,7 @@ router.delete('/:id', (req, res) => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// ── POST upload photo ────────────────────────────────────────────────
|
// ── POST upload photo ────────────────────────────────────────────────────
|
||||||
router.post('/:id/photos', upload.single('photo'), (req, res) => {
|
router.post('/:id/photos', upload.single('photo'), (req, res) => {
|
||||||
try {
|
try {
|
||||||
if (!req.file) return res.status(400).json({ error: 'No file uploaded' });
|
if (!req.file) return res.status(400).json({ error: 'No file uploaded' });
|
||||||
@@ -249,7 +296,7 @@ router.post('/:id/photos', upload.single('photo'), (req, res) => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// ── DELETE photo ─────────────────────────────────────────────────────
|
// ── DELETE photo ──────────────────────────────────────────────────────
|
||||||
router.delete('/:id/photos/:photoIndex', (req, res) => {
|
router.delete('/:id/photos/:photoIndex', (req, res) => {
|
||||||
try {
|
try {
|
||||||
const db = getDatabase();
|
const db = getDatabase();
|
||||||
|
|||||||
Reference in New Issue
Block a user