@@ -227,4 +310,4 @@ function DogForm({ dog, onClose, onSave }) {
)
}
-export default DogForm
\ No newline at end of file
+export default DogForm
diff --git a/client/src/components/LitterForm.jsx b/client/src/components/LitterForm.jsx
new file mode 100644
index 0000000..a86430a
--- /dev/null
+++ b/client/src/components/LitterForm.jsx
@@ -0,0 +1,178 @@
+import { useState, useEffect } from 'react'
+import { X } from 'lucide-react'
+import axios from 'axios'
+
+function LitterForm({ litter, onClose, onSave }) {
+ const [formData, setFormData] = useState({
+ sire_id: '',
+ dam_id: '',
+ breeding_date: '',
+ whelping_date: '',
+ puppy_count: 0,
+ notes: ''
+ })
+ const [dogs, setDogs] = useState([])
+ const [loading, setLoading] = useState(false)
+ const [error, setError] = useState('')
+
+ useEffect(() => {
+ fetchDogs()
+ if (litter) {
+ setFormData({
+ sire_id: litter.sire_id || '',
+ dam_id: litter.dam_id || '',
+ breeding_date: litter.breeding_date || '',
+ whelping_date: litter.whelping_date || '',
+ puppy_count: litter.puppy_count || 0,
+ notes: litter.notes || ''
+ })
+ }
+ }, [litter])
+
+ const fetchDogs = async () => {
+ try {
+ const res = await axios.get('/api/dogs')
+ setDogs(res.data)
+ } catch (error) {
+ console.error('Error fetching dogs:', error)
+ }
+ }
+
+ const handleChange = (e) => {
+ const { name, value } = e.target
+ setFormData(prev => ({ ...prev, [name]: value }))
+ }
+
+ const handleSubmit = async (e) => {
+ e.preventDefault()
+ setError('')
+ setLoading(true)
+
+ try {
+ if (litter) {
+ await axios.put(`/api/litters/${litter.id}`, formData)
+ } else {
+ await axios.post('/api/litters', formData)
+ }
+ onSave()
+ onClose()
+ } catch (error) {
+ setError(error.response?.data?.error || 'Failed to save litter')
+ setLoading(false)
+ }
+ }
+
+ const males = dogs.filter(d => d.sex === 'male')
+ const females = dogs.filter(d => d.sex === 'female')
+
+ return (
+
+
e.stopPropagation()}>
+
+
{litter ? 'Edit Litter' : 'Create New Litter'}
+
+
+
+
+
+
+ )
+}
+
+export default LitterForm
diff --git a/client/src/components/PedigreeView.css b/client/src/components/PedigreeView.css
new file mode 100644
index 0000000..c059b89
--- /dev/null
+++ b/client/src/components/PedigreeView.css
@@ -0,0 +1,137 @@
+.pedigree-modal {
+ position: relative;
+ width: 95vw;
+ height: 90vh;
+ background: white;
+ border-radius: 12px;
+ display: flex;
+ flex-direction: column;
+ overflow: hidden;
+}
+
+.pedigree-container {
+ flex: 1;
+ background: linear-gradient(to bottom, #f8fafc 0%, #e2e8f0 100%);
+ position: relative;
+ overflow: hidden;
+}
+
+.pedigree-controls {
+ display: flex;
+ gap: 0.5rem;
+ align-items: center;
+}
+
+.pedigree-legend {
+ display: flex;
+ gap: 2rem;
+ padding: 0.75rem 1.5rem;
+ background: #f1f5f9;
+ border-bottom: 1px solid #e2e8f0;
+ justify-content: center;
+}
+
+.legend-item {
+ display: flex;
+ align-items: center;
+ gap: 0.5rem;
+ font-size: 0.875rem;
+ color: #475569;
+}
+
+.legend-color {
+ width: 20px;
+ height: 20px;
+ border-radius: 50%;
+ border: 2px solid #fff;
+ box-shadow: 0 2px 4px rgba(0,0,0,0.1);
+}
+
+.legend-color.male {
+ background: #3b82f6;
+}
+
+.legend-color.female {
+ background: #ec4899;
+}
+
+.pedigree-info {
+ padding: 0.75rem 1.5rem;
+ background: #f8fafc;
+ border-top: 1px solid #e2e8f0;
+ font-size: 0.875rem;
+ color: #64748b;
+ text-align: center;
+}
+
+.pedigree-info p {
+ margin: 0;
+}
+
+.pedigree-info strong {
+ color: #334155;
+}
+
+/* Override react-d3-tree styles */
+.rd3t-tree-container {
+ width: 100%;
+ height: 100%;
+}
+
+.rd3t-link {
+ stroke: #94a3b8;
+ stroke-width: 2;
+ fill: none;
+}
+
+.rd3t-node {
+ cursor: pointer;
+}
+
+.rd3t-node:hover circle {
+ filter: brightness(1.1);
+}
+
+.rd3t-label__title {
+ font-weight: 600;
+ fill: #1e293b;
+}
+
+.rd3t-label__attributes {
+ font-size: 0.875rem;
+ fill: #64748b;
+}
+
+/* Loading state */
+.pedigree-modal .loading {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ height: 400px;
+ font-size: 1.125rem;
+ color: #64748b;
+}
+
+/* Error state */
+.pedigree-modal .error {
+ margin: 2rem;
+ padding: 1rem;
+ background: #fee;
+ border: 1px solid #fcc;
+ border-radius: 8px;
+ color: #c00;
+}
+
+/* Responsive adjustments */
+@media (max-width: 768px) {
+ .pedigree-modal {
+ width: 100vw;
+ height: 100vh;
+ border-radius: 0;
+ }
+
+ .pedigree-legend {
+ flex-wrap: wrap;
+ gap: 1rem;
+ }
+}
diff --git a/client/src/components/PedigreeView.jsx b/client/src/components/PedigreeView.jsx
new file mode 100644
index 0000000..bba0b0b
--- /dev/null
+++ b/client/src/components/PedigreeView.jsx
@@ -0,0 +1,234 @@
+import { useState, useEffect, useCallback } from 'react'
+import { X, ZoomIn, ZoomOut, Maximize2 } from 'lucide-react'
+import Tree from 'react-d3-tree'
+import axios from 'axios'
+import './PedigreeView.css'
+
+function PedigreeView({ dogId, onClose }) {
+ const [treeData, setTreeData] = useState(null)
+ const [loading, setLoading] = useState(true)
+ const [error, setError] = useState('')
+ const [translate, setTranslate] = useState({ x: 0, y: 0 })
+ const [zoom, setZoom] = useState(0.8)
+ const [dimensions, setDimensions] = useState({ width: 0, height: 0 })
+
+ useEffect(() => {
+ fetchPedigree()
+ }, [dogId])
+
+ useEffect(() => {
+ const updateDimensions = () => {
+ const container = document.querySelector('.pedigree-container')
+ if (container) {
+ const width = container.offsetWidth
+ const height = container.offsetHeight
+ setDimensions({ width, height })
+ setTranslate({ x: width / 4, y: height / 2 })
+ }
+ }
+
+ updateDimensions()
+ window.addEventListener('resize', updateDimensions)
+ return () => window.removeEventListener('resize', updateDimensions)
+ }, [])
+
+ const fetchPedigree = async () => {
+ try {
+ setLoading(true)
+ const response = await axios.get(`/api/pedigree/${dogId}?generations=5`)
+ const formatted = formatTreeData(response.data)
+ setTreeData(formatted)
+ } catch (err) {
+ setError(err.response?.data?.error || 'Failed to load pedigree')
+ } finally {
+ setLoading(false)
+ }
+ }
+
+ const formatTreeData = (dog) => {
+ if (!dog) return null
+
+ const children = []
+ if (dog.sire) children.push(formatTreeData(dog.sire))
+ if (dog.dam) children.push(formatTreeData(dog.dam))
+
+ return {
+ name: dog.name,
+ attributes: {
+ sex: dog.sex,
+ birth_date: dog.birth_date,
+ registration: dog.registration_number,
+ breed: dog.breed,
+ color: dog.color,
+ generation: dog.generation
+ },
+ children: children.length > 0 ? children : undefined
+ }
+ }
+
+ const handleNodeClick = useCallback((nodeData) => {
+ console.log('Node clicked:', nodeData)
+ }, [])
+
+ const handleZoomIn = () => {
+ setZoom(prev => Math.min(prev + 0.2, 2))
+ }
+
+ const handleZoomOut = () => {
+ setZoom(prev => Math.max(prev - 0.2, 0.4))
+ }
+
+ const handleReset = () => {
+ setZoom(0.8)
+ setTranslate({ x: dimensions.width / 4, y: dimensions.height / 2 })
+ }
+
+ const renderCustomNode = ({ nodeDatum, toggleNode }) => (
+
+
+
+ {nodeDatum.attributes.sex === 'male' ? '♂' : '♀'}
+
+
+ {nodeDatum.name}
+
+ {nodeDatum.attributes.registration && (
+
+ {nodeDatum.attributes.registration}
+
+ )}
+ {nodeDatum.attributes.birth_date && (
+
+ Born: {new Date(nodeDatum.attributes.birth_date).getFullYear()}
+
+ )}
+
+ )
+
+ if (loading) {
+ return (
+
+ )
+ }
+
+ if (error) {
+ return (
+
+
e.stopPropagation()}>
+
+
Pedigree Tree
+
+
+
{error}
+
+
+ )
+ }
+
+ return (
+
+
e.stopPropagation()}>
+
+
Pedigree Tree - {treeData?.name}
+
+
+
+
+
+
+
+
+
+
+
+ Male
+
+
+
+ Female
+
+
+
+
+ {treeData && dimensions.width > 0 && (
+
+ )}
+
+
+
+
+ Tip: Use mouse wheel to zoom, click and drag to pan.
+ Click on nodes to view details.
+
+
+
+
+ )
+}
+
+export default PedigreeView
diff --git a/server/db/migrate_litter_id.js b/server/db/migrate_litter_id.js
new file mode 100644
index 0000000..0fa4dcf
--- /dev/null
+++ b/server/db/migrate_litter_id.js
@@ -0,0 +1,52 @@
+const Database = require('better-sqlite3');
+const path = require('path');
+
+function migrateLitterId(dbPath) {
+ console.log('Running litter_id migration...');
+
+ const db = new Database(dbPath);
+ db.pragma('foreign_keys = ON');
+
+ try {
+ // Check if litter_id column already exists
+ const tableInfo = db.prepare("PRAGMA table_info(dogs)").all();
+ const hasLitterId = tableInfo.some(col => col.name === 'litter_id');
+
+ if (hasLitterId) {
+ console.log('litter_id column already exists. Skipping migration.');
+ db.close();
+ return;
+ }
+
+ // Add litter_id column to dogs table
+ db.exec(`
+ ALTER TABLE dogs ADD COLUMN litter_id INTEGER;
+ `);
+
+ // Create index for litter_id
+ db.exec(`
+ CREATE INDEX IF NOT EXISTS idx_dogs_litter ON dogs(litter_id);
+ `);
+
+ // Add foreign key relationship (SQLite doesn't support ALTER TABLE ADD CONSTRAINT)
+ // So we'll rely on application-level constraint checking
+
+ console.log('✓ Added litter_id column to dogs table');
+ console.log('✓ Created index on litter_id');
+ console.log('Migration completed successfully!');
+
+ db.close();
+ } catch (error) {
+ console.error('Migration failed:', error.message);
+ db.close();
+ throw error;
+ }
+}
+
+module.exports = { migrateLitterId };
+
+// Run migration if called directly
+if (require.main === module) {
+ const dbPath = process.env.DB_PATH || path.join(__dirname, '../../data/breedr.db');
+ migrateLitterId(dbPath);
+}
diff --git a/server/routes/litters.js b/server/routes/litters.js
index e7832a6..e8cf492 100644
--- a/server/routes/litters.js
+++ b/server/routes/litters.js
@@ -16,18 +16,18 @@ router.get('/', (req, res) => {
ORDER BY l.breeding_date DESC
`).all();
- // Get puppies for each litter
+ // Get puppies for each litter using litter_id
litters.forEach(litter => {
litter.puppies = db.prepare(`
- SELECT d.* FROM dogs d
- JOIN parents ps ON d.id = ps.dog_id
- JOIN parents pd ON d.id = pd.dog_id
- WHERE ps.parent_id = ? AND pd.parent_id = ?
- `).all(litter.sire_id, litter.dam_id);
+ SELECT * FROM dogs WHERE litter_id = ? AND is_active = 1
+ `).all(litter.id);
litter.puppies.forEach(puppy => {
puppy.photo_urls = puppy.photo_urls ? JSON.parse(puppy.photo_urls) : [];
});
+
+ // Update puppy_count based on actual puppies
+ litter.actual_puppy_count = litter.puppies.length;
});
res.json(litters);
@@ -54,17 +54,17 @@ router.get('/:id', (req, res) => {
return res.status(404).json({ error: 'Litter not found' });
}
+ // Get puppies using litter_id
litter.puppies = db.prepare(`
- SELECT d.* FROM dogs d
- JOIN parents ps ON d.id = ps.dog_id
- JOIN parents pd ON d.id = pd.dog_id
- WHERE ps.parent_id = ? AND pd.parent_id = ?
- `).all(litter.sire_id, litter.dam_id);
+ SELECT * FROM dogs WHERE litter_id = ? AND is_active = 1
+ `).all(litter.id);
litter.puppies.forEach(puppy => {
puppy.photo_urls = puppy.photo_urls ? JSON.parse(puppy.photo_urls) : [];
});
+ litter.actual_puppy_count = litter.puppies.length;
+
res.json(litter);
} catch (error) {
res.status(500).json({ error: error.message });
@@ -125,15 +125,74 @@ router.put('/:id', (req, res) => {
}
});
+// POST link puppy to litter
+router.post('/:id/puppies/:puppyId', (req, res) => {
+ try {
+ const { id: litterId, puppyId } = req.params;
+ const db = getDatabase();
+
+ // Verify litter exists
+ const litter = db.prepare('SELECT sire_id, dam_id FROM litters WHERE id = ?').get(litterId);
+ if (!litter) {
+ return res.status(404).json({ error: 'Litter not found' });
+ }
+
+ // Verify puppy exists
+ const puppy = db.prepare('SELECT id FROM dogs WHERE id = ?').get(puppyId);
+ if (!puppy) {
+ return res.status(404).json({ error: 'Puppy not found' });
+ }
+
+ // Link puppy to litter
+ db.prepare('UPDATE dogs SET litter_id = ? WHERE id = ?').run(litterId, puppyId);
+
+ // Also update parent relationships if not set
+ const existingParents = db.prepare('SELECT parent_type FROM parents WHERE dog_id = ?').all(puppyId);
+ const hasSire = existingParents.some(p => p.parent_type === 'sire');
+ const hasDam = existingParents.some(p => p.parent_type === 'dam');
+
+ if (!hasSire) {
+ db.prepare('INSERT OR IGNORE INTO parents (dog_id, parent_id, parent_type) VALUES (?, ?, \'sire\')').run(puppyId, litter.sire_id);
+ }
+ if (!hasDam) {
+ db.prepare('INSERT OR IGNORE INTO parents (dog_id, parent_id, parent_type) VALUES (?, ?, \'dam\')').run(puppyId, litter.dam_id);
+ }
+
+ res.json({ message: 'Puppy linked to litter successfully' });
+ } catch (error) {
+ res.status(500).json({ error: error.message });
+ }
+});
+
+// DELETE remove puppy from litter
+router.delete('/:id/puppies/:puppyId', (req, res) => {
+ try {
+ const { puppyId } = req.params;
+ const db = getDatabase();
+
+ db.prepare('UPDATE dogs SET litter_id = NULL WHERE id = ?').run(puppyId);
+
+ res.json({ message: 'Puppy removed from litter' });
+ } catch (error) {
+ res.status(500).json({ error: error.message });
+ }
+});
+
// DELETE litter
router.delete('/:id', (req, res) => {
try {
const db = getDatabase();
+
+ // Remove litter_id from associated puppies
+ db.prepare('UPDATE dogs SET litter_id = NULL WHERE litter_id = ?').run(req.params.id);
+
+ // Delete the litter
db.prepare('DELETE FROM litters WHERE id = ?').run(req.params.id);
+
res.json({ message: 'Litter deleted successfully' });
} catch (error) {
res.status(500).json({ error: error.message });
}
});
-module.exports = router;
\ No newline at end of file
+module.exports = router;