Merge pull request 'feat/litter-management-ui' (#25) from feat/litter-management-ui into master
Some checks failed
Build & Publish Docker Image / build-and-push (push) Has been cancelled
Some checks failed
Build & Publish Docker Image / build-and-push (push) Has been cancelled
Reviewed-on: #25
This commit was merged in pull request #25.
This commit is contained in:
@@ -5,6 +5,7 @@ import DogList from './pages/DogList'
|
||||
import DogDetail from './pages/DogDetail'
|
||||
import PedigreeView from './pages/PedigreeView'
|
||||
import LitterList from './pages/LitterList'
|
||||
import LitterDetail from './pages/LitterDetail'
|
||||
import BreedingCalendar from './pages/BreedingCalendar'
|
||||
import PairingSimulator from './pages/PairingSimulator'
|
||||
import './App.css'
|
||||
@@ -55,6 +56,7 @@ function App() {
|
||||
<Route path="/dogs/:id" element={<DogDetail />} />
|
||||
<Route path="/pedigree/:id" element={<PedigreeView />} />
|
||||
<Route path="/litters" element={<LitterList />} />
|
||||
<Route path="/litters/:id" element={<LitterDetail />} />
|
||||
<Route path="/breeding" element={<BreedingCalendar />} />
|
||||
<Route path="/pairing" element={<PairingSimulator />} />
|
||||
</Routes>
|
||||
|
||||
@@ -2,7 +2,7 @@ import { useState, useEffect } from 'react'
|
||||
import { X } from 'lucide-react'
|
||||
import axios from 'axios'
|
||||
|
||||
function LitterForm({ litter, onClose, onSave }) {
|
||||
function LitterForm({ litter, prefill, onClose, onSave }) {
|
||||
const [formData, setFormData] = useState({
|
||||
sire_id: '',
|
||||
dam_id: '',
|
||||
@@ -26,8 +26,16 @@ function LitterForm({ litter, onClose, onSave }) {
|
||||
puppy_count: litter.puppy_count || 0,
|
||||
notes: litter.notes || ''
|
||||
})
|
||||
} else if (prefill) {
|
||||
// Pre-populate from BreedingCalendar "Record Litter" flow
|
||||
setFormData(prev => ({
|
||||
...prev,
|
||||
dam_id: prefill.dam_id ? String(prefill.dam_id) : '',
|
||||
breeding_date: prefill.breeding_date || '',
|
||||
whelping_date: prefill.whelping_date || '',
|
||||
}))
|
||||
}
|
||||
}, [litter])
|
||||
}, [litter, prefill])
|
||||
|
||||
const fetchDogs = async () => {
|
||||
try {
|
||||
@@ -69,7 +77,7 @@ function LitterForm({ litter, onClose, onSave }) {
|
||||
<div className="modal-overlay" onClick={onClose}>
|
||||
<div className="modal-content" onClick={(e) => e.stopPropagation()}>
|
||||
<div className="modal-header">
|
||||
<h2>{litter ? 'Edit Litter' : 'Create New Litter'}</h2>
|
||||
<h2>{litter ? 'Edit Litter' : prefill ? `Record Litter — ${prefill.dam_name || 'Dam pre-selected'}` : 'Create New Litter'}</h2>
|
||||
<button className="btn-icon" onClick={onClose}>
|
||||
<X size={24} />
|
||||
</button>
|
||||
@@ -78,6 +86,20 @@ function LitterForm({ litter, onClose, onSave }) {
|
||||
<form onSubmit={handleSubmit} className="modal-body">
|
||||
{error && <div className="error">{error}</div>}
|
||||
|
||||
{prefill && !litter && (
|
||||
<div style={{
|
||||
background: 'rgba(16,185,129,0.08)',
|
||||
border: '1px solid rgba(16,185,129,0.3)',
|
||||
borderRadius: 'var(--radius-sm)',
|
||||
padding: '0.6rem 0.875rem',
|
||||
marginBottom: '1rem',
|
||||
fontSize: '0.85rem',
|
||||
color: 'var(--success)'
|
||||
}}>
|
||||
🐾 Pre-filled from heat cycle — select a sire to complete the litter record.
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="form-grid">
|
||||
<div className="form-group">
|
||||
<label className="label">Sire (Father) *</label>
|
||||
@@ -111,6 +133,11 @@ function LitterForm({ litter, onClose, onSave }) {
|
||||
<option key={d.id} value={d.id}>{d.name} {d.registration_number ? `(${d.registration_number})` : ''}</option>
|
||||
))}
|
||||
</select>
|
||||
{prefill?.dam_name && !litter && (
|
||||
<p style={{ fontSize: '0.78rem', color: 'var(--success)', marginTop: '0.25rem' }}>
|
||||
✓ Pre-selected: {prefill.dam_name}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="form-group">
|
||||
|
||||
@@ -1,15 +1,17 @@
|
||||
import { useEffect, useState, useCallback } from 'react'
|
||||
import {
|
||||
Heart, ChevronLeft, ChevronRight, Plus, X,
|
||||
CalendarDays, FlaskConical, Baby, AlertCircle, CheckCircle2
|
||||
CalendarDays, FlaskConical, Baby, AlertCircle, CheckCircle2, Activity
|
||||
} from 'lucide-react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import axios from 'axios'
|
||||
|
||||
// ─── Date helpers ────────────────────────────────────────────────────────────
|
||||
const toISO = d => d.toISOString().split('T')[0]
|
||||
const addDays = (dateStr, n) => {
|
||||
const d = new Date(dateStr); d.setDate(d.getDate() + n); return toISO(d)
|
||||
}
|
||||
const fmt = str => str ? new Date(str + 'T00:00:00').toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' }) : '—'
|
||||
const fmt = str => str ? new Date(str + 'T00:00:00').toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' }) : '–'
|
||||
const today = toISO(new Date())
|
||||
|
||||
// ─── Cycle window classifier ─────────────────────────────────────────────────
|
||||
@@ -74,7 +76,7 @@ function StartCycleModal({ females, onClose, onSaved }) {
|
||||
<div className="form-group">
|
||||
<label className="label">Female Dog *</label>
|
||||
<select value={dogId} onChange={e => setDogId(e.target.value)} required>
|
||||
<option value="">— Select Female —</option>
|
||||
<option value="">– Select Female –</option>
|
||||
{females.map(d => (
|
||||
<option key={d.id} value={d.id}>
|
||||
{d.name}{d.breed ? ` · ${d.breed}` : ''}
|
||||
@@ -105,7 +107,7 @@ function StartCycleModal({ females, onClose, onSaved }) {
|
||||
}
|
||||
|
||||
// ─── Cycle Detail Modal ───────────────────────────────────────────────────────
|
||||
function CycleDetailModal({ cycle, onClose, onDeleted }) {
|
||||
function CycleDetailModal({ cycle, onClose, onDeleted, onRecordLitter }) {
|
||||
const [suggestions, setSuggestions] = useState(null)
|
||||
const [breedingDate, setBreedingDate] = useState(cycle.breeding_date || '')
|
||||
const [savingBreed, setSavingBreed] = useState(false)
|
||||
@@ -146,6 +148,7 @@ function CycleDetailModal({ cycle, onClose, onDeleted }) {
|
||||
}
|
||||
|
||||
const whelp = suggestions?.whelping
|
||||
const hasBreedingDate = !!(breedingDate && breedingDate === cycle.breeding_date)
|
||||
|
||||
return (
|
||||
<div className="modal-overlay" onClick={e => e.target === e.currentTarget && onClose()}>
|
||||
@@ -221,7 +224,7 @@ function CycleDetailModal({ cycle, onClose, onDeleted }) {
|
||||
|
||||
{/* Whelping estimate */}
|
||||
{whelp && (
|
||||
<div style={{ background: 'rgba(16,185,129,0.08)', border: '1px solid rgba(16,185,129,0.3)', borderRadius: 'var(--radius)', padding: '1rem' }}>
|
||||
<div style={{ background: 'rgba(16,185,129,0.08)', border: '1px solid rgba(16,185,129,0.3)', borderRadius: 'var(--radius)', padding: '1rem', marginBottom: '1rem' }}>
|
||||
<h3 style={{ fontSize: '0.9375rem', marginBottom: '0.75rem', display: 'flex', alignItems: 'center', gap: '0.4rem', color: 'var(--success)' }}>
|
||||
<Baby size={16} /> Whelping Estimate
|
||||
</h3>
|
||||
@@ -235,6 +238,39 @@ function CycleDetailModal({ cycle, onClose, onDeleted }) {
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Record Litter CTA — shown when breeding date is saved */}
|
||||
{hasBreedingDate && (
|
||||
<div style={{
|
||||
background: 'rgba(16,185,129,0.06)',
|
||||
border: '1px dashed rgba(16,185,129,0.5)',
|
||||
borderRadius: 'var(--radius)',
|
||||
padding: '0.875rem 1rem',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
gap: '1rem',
|
||||
flexWrap: 'wrap'
|
||||
}}>
|
||||
<div>
|
||||
<div style={{ fontWeight: 600, fontSize: '0.9rem' }}>🐾 Ready to record the litter?</div>
|
||||
<div style={{ fontSize: '0.8rem', color: 'var(--text-secondary)', marginTop: '0.2rem' }}>
|
||||
Breeding date logged on {fmt(cycle.breeding_date)}. Create a litter record to track puppies.
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
className="btn btn-primary"
|
||||
style={{ whiteSpace: 'nowrap', fontSize: '0.85rem' }}
|
||||
onClick={() => {
|
||||
onClose()
|
||||
onRecordLitter(cycle)
|
||||
}}
|
||||
>
|
||||
<Activity size={14} style={{ marginRight: '0.4rem' }} />
|
||||
Record Litter
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="modal-footer" style={{ justifyContent: 'space-between' }}>
|
||||
<button className="btn btn-danger" onClick={deleteCycle} disabled={deleting}>
|
||||
@@ -265,6 +301,8 @@ export default function BreedingCalendar() {
|
||||
const [showStartModal, setShowStartModal] = useState(false)
|
||||
const [selectedCycle, setSelectedCycle] = useState(null)
|
||||
const [selectedDay, setSelectedDay] = useState(null)
|
||||
const [pendingLitterCycle, setPendingLitterCycle] = useState(null)
|
||||
const navigate = useNavigate()
|
||||
|
||||
const load = useCallback(async () => {
|
||||
setLoading(true)
|
||||
@@ -287,6 +325,23 @@ export default function BreedingCalendar() {
|
||||
|
||||
useEffect(() => { load() }, [load])
|
||||
|
||||
// When user clicks Record Litter from cycle detail, create litter and navigate
|
||||
const handleRecordLitter = useCallback(async (cycle) => {
|
||||
try {
|
||||
// We need sire_id — navigate to litters page with pre-filled dam
|
||||
// Store cycle info in sessionStorage so LitterList can pre-fill
|
||||
sessionStorage.setItem('prefillLitter', JSON.stringify({
|
||||
dam_id: cycle.dog_id,
|
||||
dam_name: cycle.dog_name,
|
||||
breeding_date: cycle.breeding_date,
|
||||
whelping_date: cycle.whelping_date || ''
|
||||
}))
|
||||
navigate('/litters')
|
||||
} catch (err) {
|
||||
console.error(err)
|
||||
}
|
||||
}, [navigate])
|
||||
|
||||
// ── Build calendar grid ──
|
||||
const firstDay = new Date(year, month, 1)
|
||||
const lastDay = new Date(year, month + 1, 0)
|
||||
@@ -306,7 +361,6 @@ export default function BreedingCalendar() {
|
||||
else setMonth(m => m + 1)
|
||||
}
|
||||
|
||||
// Find cycles that overlap a given date
|
||||
function cyclesForDate(dateStr) {
|
||||
return cycles.filter(c => {
|
||||
const s = c.start_date
|
||||
@@ -321,16 +375,12 @@ export default function BreedingCalendar() {
|
||||
if (dayCycles.length === 1) {
|
||||
setSelectedCycle(dayCycles[0])
|
||||
} else if (dayCycles.length > 1) {
|
||||
// show first — could be upgraded to a picker
|
||||
setSelectedCycle(dayCycles[0])
|
||||
} else {
|
||||
// Empty day click — open start modal with date pre-filled would be nice
|
||||
// but we just open start modal; user picks date
|
||||
setShowStartModal(true)
|
||||
}
|
||||
}
|
||||
|
||||
// Active cycles (in current month or ongoing)
|
||||
const activeCycles = cycles.filter(c => {
|
||||
const s = c.start_date; if (!s) return false
|
||||
const end = c.end_date || addDays(s, 28)
|
||||
@@ -394,7 +444,6 @@ export default function BreedingCalendar() {
|
||||
const dayCycles = dateStr ? cyclesForDate(dateStr) : []
|
||||
const isToday = dateStr === today
|
||||
|
||||
// Pick dominant window color for background
|
||||
let cellBg = 'transparent'
|
||||
let cellBorder = 'var(--border)'
|
||||
if (dayCycles.length > 0) {
|
||||
@@ -522,6 +571,7 @@ export default function BreedingCalendar() {
|
||||
cycle={selectedCycle}
|
||||
onClose={() => setSelectedCycle(null)}
|
||||
onDeleted={() => { setSelectedCycle(null); load() }}
|
||||
onRecordLitter={handleRecordLitter}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
344
client/src/pages/LitterDetail.jsx
Normal file
344
client/src/pages/LitterDetail.jsx
Normal file
@@ -0,0 +1,344 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useParams, useNavigate } from 'react-router-dom'
|
||||
import { ArrowLeft, Plus, X, ExternalLink, Dog } from 'lucide-react'
|
||||
import axios from 'axios'
|
||||
import LitterForm from '../components/LitterForm'
|
||||
|
||||
function LitterDetail() {
|
||||
const { id } = useParams()
|
||||
const navigate = useNavigate()
|
||||
const [litter, setLitter] = useState(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [showEditForm, setShowEditForm] = useState(false)
|
||||
const [showAddPuppy, setShowAddPuppy] = useState(false)
|
||||
const [allDogs, setAllDogs] = useState([])
|
||||
const [selectedPuppyId, setSelectedPuppyId] = useState('')
|
||||
const [newPuppy, setNewPuppy] = useState({ name: '', sex: 'male', color: '', dob: '' })
|
||||
const [addMode, setAddMode] = useState('existing') // 'existing' | 'new'
|
||||
const [error, setError] = useState('')
|
||||
const [saving, setSaving] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
fetchLitter()
|
||||
fetchAllDogs()
|
||||
}, [id])
|
||||
|
||||
const fetchLitter = async () => {
|
||||
try {
|
||||
const res = await axios.get(`/api/litters/${id}`)
|
||||
setLitter(res.data)
|
||||
} catch (err) {
|
||||
console.error('Error fetching litter:', err)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const fetchAllDogs = async () => {
|
||||
try {
|
||||
const res = await axios.get('/api/dogs')
|
||||
setAllDogs(res.data)
|
||||
} catch (err) {
|
||||
console.error('Error fetching dogs:', err)
|
||||
}
|
||||
}
|
||||
|
||||
const unlinkedDogs = allDogs.filter(d => {
|
||||
if (!litter) return false
|
||||
const alreadyInLitter = litter.puppies?.some(p => p.id === d.id)
|
||||
const isSireOrDam = d.id === litter.sire_id || d.id === litter.dam_id
|
||||
return !alreadyInLitter && !isSireOrDam
|
||||
})
|
||||
|
||||
const handleLinkPuppy = async () => {
|
||||
if (!selectedPuppyId) return
|
||||
setSaving(true)
|
||||
setError('')
|
||||
try {
|
||||
await axios.post(`/api/litters/${id}/puppies/${selectedPuppyId}`)
|
||||
setSelectedPuppyId('')
|
||||
setShowAddPuppy(false)
|
||||
fetchLitter()
|
||||
} catch (err) {
|
||||
setError(err.response?.data?.error || 'Failed to link puppy')
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleCreateAndLink = async () => {
|
||||
if (!newPuppy.name) {
|
||||
setError('Puppy name is required')
|
||||
return
|
||||
}
|
||||
setSaving(true)
|
||||
setError('')
|
||||
try {
|
||||
const dob = newPuppy.dob || litter.whelping_date || litter.breeding_date
|
||||
const res = await axios.post('/api/dogs', {
|
||||
name: newPuppy.name,
|
||||
sex: newPuppy.sex,
|
||||
color: newPuppy.color,
|
||||
date_of_birth: dob,
|
||||
breed: litter.dam_breed || '',
|
||||
})
|
||||
const createdDog = res.data
|
||||
await axios.post(`/api/litters/${id}/puppies/${createdDog.id}`)
|
||||
setNewPuppy({ name: '', sex: 'male', color: '', dob: '' })
|
||||
setShowAddPuppy(false)
|
||||
fetchLitter()
|
||||
fetchAllDogs()
|
||||
} catch (err) {
|
||||
setError(err.response?.data?.error || 'Failed to create puppy')
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleUnlinkPuppy = async (puppyId) => {
|
||||
if (!window.confirm('Remove this puppy from the litter? The dog record will not be deleted.')) return
|
||||
try {
|
||||
await axios.delete(`/api/litters/${id}/puppies/${puppyId}`)
|
||||
fetchLitter()
|
||||
} catch (err) {
|
||||
console.error('Error unlinking puppy:', err)
|
||||
}
|
||||
}
|
||||
|
||||
if (loading) return <div className="container loading">Loading litter...</div>
|
||||
if (!litter) return <div className="container"><p>Litter not found.</p></div>
|
||||
|
||||
const puppyCount = litter.puppies?.length ?? 0
|
||||
|
||||
return (
|
||||
<div className="container">
|
||||
{/* Header */}
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '1rem', marginBottom: '2rem' }}>
|
||||
<button className="btn-icon" onClick={() => navigate('/litters')}>
|
||||
<ArrowLeft size={20} />
|
||||
</button>
|
||||
<div style={{ flex: 1 }}>
|
||||
<h1 style={{ margin: 0 }}>🐾 {litter.sire_name} × {litter.dam_name}</h1>
|
||||
<p style={{ color: 'var(--text-secondary)', margin: '0.25rem 0 0' }}>
|
||||
Bred: {new Date(litter.breeding_date).toLocaleDateString()}
|
||||
{litter.whelping_date && ` • Whelped: ${new Date(litter.whelping_date).toLocaleDateString()}`}
|
||||
</p>
|
||||
</div>
|
||||
<button className="btn btn-secondary" onClick={() => setShowEditForm(true)}>
|
||||
Edit Litter
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Stats row */}
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(140px, 1fr))', gap: '1rem', marginBottom: '2rem' }}>
|
||||
<div className="card" style={{ textAlign: 'center', padding: '1rem' }}>
|
||||
<div style={{ fontSize: '2rem', fontWeight: 700, color: 'var(--accent)' }}>{puppyCount}</div>
|
||||
<div style={{ color: 'var(--text-secondary)', fontSize: '0.85rem' }}>Puppies Linked</div>
|
||||
</div>
|
||||
<div className="card" style={{ textAlign: 'center', padding: '1rem' }}>
|
||||
<div style={{ fontSize: '2rem', fontWeight: 700, color: 'var(--accent)' }}>
|
||||
{litter.puppies?.filter(p => p.sex === 'male').length ?? 0}
|
||||
</div>
|
||||
<div style={{ color: 'var(--text-secondary)', fontSize: '0.85rem' }}>Males</div>
|
||||
</div>
|
||||
<div className="card" style={{ textAlign: 'center', padding: '1rem' }}>
|
||||
<div style={{ fontSize: '2rem', fontWeight: 700, color: 'var(--accent)' }}>
|
||||
{litter.puppies?.filter(p => p.sex === 'female').length ?? 0}
|
||||
</div>
|
||||
<div style={{ color: 'var(--text-secondary)', fontSize: '0.85rem' }}>Females</div>
|
||||
</div>
|
||||
{litter.puppy_count > 0 && (
|
||||
<div className="card" style={{ textAlign: 'center', padding: '1rem' }}>
|
||||
<div style={{ fontSize: '2rem', fontWeight: 700 }}>{litter.puppy_count}</div>
|
||||
<div style={{ color: 'var(--text-secondary)', fontSize: '0.85rem' }}>Expected</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Notes */}
|
||||
{litter.notes && (
|
||||
<div className="card" style={{ marginBottom: '2rem', padding: '1rem', borderLeft: '3px solid var(--accent)' }}>
|
||||
<p style={{ margin: 0, fontStyle: 'italic', color: 'var(--text-secondary)' }}>{litter.notes}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Puppies section */}
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '1rem' }}>
|
||||
<h2 style={{ margin: 0 }}>Puppies</h2>
|
||||
<button className="btn btn-primary" onClick={() => { setShowAddPuppy(true); setError('') }}>
|
||||
<Plus size={16} style={{ marginRight: '0.4rem' }} />
|
||||
Add Puppy
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{puppyCount === 0 ? (
|
||||
<div className="card" style={{ textAlign: 'center', padding: '3rem' }}>
|
||||
<Dog size={48} style={{ color: 'var(--text-secondary)', margin: '0 auto 1rem' }} />
|
||||
<p style={{ color: 'var(--text-secondary)' }}>No puppies linked yet. Add puppies to this litter.</p>
|
||||
</div>
|
||||
) : (
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(220px, 1fr))', gap: '1rem' }}>
|
||||
{litter.puppies.map(puppy => (
|
||||
<div key={puppy.id} className="card" style={{ position: 'relative' }}>
|
||||
<button
|
||||
className="btn-icon"
|
||||
onClick={() => handleUnlinkPuppy(puppy.id)}
|
||||
title="Remove from litter"
|
||||
style={{ position: 'absolute', top: '0.75rem', right: '0.75rem', color: '#e53e3e' }}
|
||||
>
|
||||
<X size={14} />
|
||||
</button>
|
||||
<div style={{ fontSize: '2.5rem', marginBottom: '0.5rem' }}>
|
||||
{puppy.sex === 'male' ? '🐦' : '🐥'}
|
||||
</div>
|
||||
<div style={{ fontWeight: 600, marginBottom: '0.25rem' }}>{puppy.name}</div>
|
||||
<div style={{ fontSize: '0.8rem', color: 'var(--text-secondary)' }}>
|
||||
{puppy.sex} {puppy.color && `• ${puppy.color}`}
|
||||
</div>
|
||||
{puppy.date_of_birth && (
|
||||
<div style={{ fontSize: '0.8rem', color: 'var(--text-secondary)' }}>
|
||||
Born: {new Date(puppy.date_of_birth).toLocaleDateString()}
|
||||
</div>
|
||||
)}
|
||||
<button
|
||||
className="btn btn-secondary"
|
||||
style={{ marginTop: '0.75rem', width: '100%', fontSize: '0.8rem', padding: '0.4rem' }}
|
||||
onClick={() => navigate(`/dogs/${puppy.id}`)}
|
||||
>
|
||||
<ExternalLink size={12} style={{ marginRight: '0.3rem' }} />
|
||||
View Profile
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Add Puppy Modal */}
|
||||
{showAddPuppy && (
|
||||
<div className="modal-overlay" onClick={() => setShowAddPuppy(false)}>
|
||||
<div className="modal-content" onClick={e => e.stopPropagation()} style={{ maxWidth: '480px' }}>
|
||||
<div className="modal-header">
|
||||
<h2>Add Puppy to Litter</h2>
|
||||
<button className="btn-icon" onClick={() => setShowAddPuppy(false)}><X size={24} /></button>
|
||||
</div>
|
||||
<div className="modal-body">
|
||||
{error && <div className="error" style={{ marginBottom: '1rem' }}>{error}</div>}
|
||||
|
||||
{/* Mode toggle */}
|
||||
<div style={{ display: 'flex', gap: '0.5rem', marginBottom: '1.5rem' }}>
|
||||
<button
|
||||
className={`btn ${addMode === 'existing' ? 'btn-primary' : 'btn-secondary'}`}
|
||||
onClick={() => setAddMode('existing')}
|
||||
style={{ flex: 1 }}
|
||||
>
|
||||
Link Existing Dog
|
||||
</button>
|
||||
<button
|
||||
className={`btn ${addMode === 'new' ? 'btn-primary' : 'btn-secondary'}`}
|
||||
onClick={() => setAddMode('new')}
|
||||
style={{ flex: 1 }}
|
||||
>
|
||||
Create New Puppy
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{addMode === 'existing' ? (
|
||||
<div className="form-group">
|
||||
<label className="label">Select Dog</label>
|
||||
<select
|
||||
className="input"
|
||||
value={selectedPuppyId}
|
||||
onChange={e => setSelectedPuppyId(e.target.value)}
|
||||
>
|
||||
<option value="">-- Select a dog --</option>
|
||||
{unlinkedDogs.map(d => (
|
||||
<option key={d.id} value={d.id}>
|
||||
{d.name} ({d.sex}{d.date_of_birth ? `, born ${new Date(d.date_of_birth).toLocaleDateString()}` : ''})
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
{unlinkedDogs.length === 0 && (
|
||||
<p style={{ color: 'var(--text-secondary)', fontSize: '0.85rem', marginTop: '0.5rem' }}>
|
||||
No unlinked dogs available. Use "Create New Puppy" instead.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div style={{ display: 'grid', gap: '1rem' }}>
|
||||
<div className="form-group">
|
||||
<label className="label">Puppy Name *</label>
|
||||
<input
|
||||
className="input"
|
||||
value={newPuppy.name}
|
||||
onChange={e => setNewPuppy(p => ({ ...p, name: e.target.value }))}
|
||||
placeholder="e.g. Blue Collar"
|
||||
/>
|
||||
</div>
|
||||
<div className="form-grid">
|
||||
<div className="form-group">
|
||||
<label className="label">Sex</label>
|
||||
<select
|
||||
className="input"
|
||||
value={newPuppy.sex}
|
||||
onChange={e => setNewPuppy(p => ({ ...p, sex: e.target.value }))}
|
||||
>
|
||||
<option value="male">Male</option>
|
||||
<option value="female">Female</option>
|
||||
</select>
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label className="label">Color / Markings</label>
|
||||
<input
|
||||
className="input"
|
||||
value={newPuppy.color}
|
||||
onChange={e => setNewPuppy(p => ({ ...p, color: e.target.value }))}
|
||||
placeholder="e.g. Black & Tan"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label className="label">Date of Birth</label>
|
||||
<input
|
||||
type="date"
|
||||
className="input"
|
||||
value={newPuppy.dob}
|
||||
onChange={e => setNewPuppy(p => ({ ...p, dob: e.target.value }))}
|
||||
/>
|
||||
{litter.whelping_date && !newPuppy.dob && (
|
||||
<p style={{ fontSize: '0.8rem', color: 'var(--text-secondary)', marginTop: '0.25rem' }}>
|
||||
Will default to whelping date: {new Date(litter.whelping_date).toLocaleDateString()}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="modal-footer">
|
||||
<button className="btn btn-secondary" onClick={() => setShowAddPuppy(false)} disabled={saving}>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
className="btn btn-primary"
|
||||
disabled={saving || (addMode === 'existing' && !selectedPuppyId)}
|
||||
onClick={addMode === 'existing' ? handleLinkPuppy : handleCreateAndLink}
|
||||
>
|
||||
{saving ? 'Saving...' : addMode === 'existing' ? 'Link Puppy' : 'Create & Link'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Edit Litter Modal */}
|
||||
{showEditForm && (
|
||||
<LitterForm
|
||||
litter={litter}
|
||||
onClose={() => setShowEditForm(false)}
|
||||
onSave={fetchLitter}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default LitterDetail
|
||||
@@ -1,60 +1,156 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { Activity } from 'lucide-react'
|
||||
import { Activity, Plus, Edit2, Trash2, ChevronRight } from 'lucide-react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import axios from 'axios'
|
||||
import LitterForm from '../components/LitterForm'
|
||||
|
||||
function LitterList() {
|
||||
const [litters, setLitters] = useState([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [showForm, setShowForm] = useState(false)
|
||||
const [editingLitter, setEditingLitter] = useState(null)
|
||||
const [prefill, setPrefill] = useState(null)
|
||||
const navigate = useNavigate()
|
||||
|
||||
useEffect(() => {
|
||||
fetchLitters()
|
||||
// Auto-open form with prefill from BreedingCalendar "Record Litter" CTA
|
||||
const stored = sessionStorage.getItem('prefillLitter')
|
||||
if (stored) {
|
||||
try {
|
||||
const data = JSON.parse(stored)
|
||||
setPrefill(data)
|
||||
setEditingLitter(null)
|
||||
setShowForm(true)
|
||||
} catch (e) { /* ignore */ }
|
||||
sessionStorage.removeItem('prefillLitter')
|
||||
}
|
||||
}, [])
|
||||
|
||||
const fetchLitters = async () => {
|
||||
try {
|
||||
const res = await axios.get('/api/litters')
|
||||
setLitters(res.data)
|
||||
setLoading(false)
|
||||
} catch (error) {
|
||||
console.error('Error fetching litters:', error)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleCreate = () => {
|
||||
setEditingLitter(null)
|
||||
setPrefill(null)
|
||||
setShowForm(true)
|
||||
}
|
||||
|
||||
const handleEdit = (e, litter) => {
|
||||
e.stopPropagation()
|
||||
setEditingLitter(litter)
|
||||
setPrefill(null)
|
||||
setShowForm(true)
|
||||
}
|
||||
|
||||
const handleDelete = async (e, id) => {
|
||||
e.stopPropagation()
|
||||
if (!window.confirm('Delete this litter record? Puppies will be unlinked but not deleted.')) return
|
||||
try {
|
||||
await axios.delete(`/api/litters/${id}`)
|
||||
fetchLitters()
|
||||
} catch (error) {
|
||||
console.error('Error deleting litter:', error)
|
||||
}
|
||||
}
|
||||
|
||||
const handleSave = () => {
|
||||
fetchLitters()
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return <div className="container loading">Loading litters...</div>
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="container">
|
||||
<h1 style={{ marginBottom: '2rem' }}>Litters</h1>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '2rem' }}>
|
||||
<h1>Litters</h1>
|
||||
<button className="btn btn-primary" onClick={handleCreate}>
|
||||
<Plus size={18} style={{ marginRight: '0.5rem' }} />
|
||||
New Litter
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{litters.length === 0 ? (
|
||||
<div className="card" style={{ textAlign: 'center', padding: '4rem' }}>
|
||||
<Activity size={64} style={{ color: 'var(--text-secondary)', margin: '0 auto 1rem' }} />
|
||||
<h2>No litters recorded yet</h2>
|
||||
<p style={{ color: 'var(--text-secondary)' }}>Start tracking breeding records</p>
|
||||
<p style={{ color: 'var(--text-secondary)', marginBottom: '1.5rem' }}>Create a litter after a breeding cycle to track puppies</p>
|
||||
<button className="btn btn-primary" onClick={handleCreate}>
|
||||
<Plus size={18} style={{ marginRight: '0.5rem' }} />
|
||||
Create First Litter
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<div style={{ display: 'grid', gap: '1rem' }}>
|
||||
{litters.map(litter => (
|
||||
<div key={litter.id} className="card">
|
||||
<h3>{litter.sire_name} × {litter.dam_name}</h3>
|
||||
<p style={{ color: 'var(--text-secondary)', marginTop: '0.5rem' }}>
|
||||
Breeding Date: {new Date(litter.breeding_date).toLocaleDateString()}
|
||||
</p>
|
||||
<div
|
||||
key={litter.id}
|
||||
className="card"
|
||||
style={{ cursor: 'pointer', transition: 'border-color 0.2s' }}
|
||||
onClick={() => navigate(`/litters/${litter.id}`)}
|
||||
>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start' }}>
|
||||
<div style={{ flex: 1 }}>
|
||||
<h3 style={{ marginBottom: '0.5rem' }}>
|
||||
🐾 {litter.sire_name} × {litter.dam_name}
|
||||
</h3>
|
||||
<div style={{ display: 'flex', gap: '1.5rem', flexWrap: 'wrap', color: 'var(--text-secondary)', fontSize: '0.9rem' }}>
|
||||
<span>📅 Bred: {new Date(litter.breeding_date).toLocaleDateString()}</span>
|
||||
{litter.whelping_date && (
|
||||
<p style={{ color: 'var(--text-secondary)' }}>
|
||||
Whelping Date: {new Date(litter.whelping_date).toLocaleDateString()}
|
||||
<span>💕 Whelped: {new Date(litter.whelping_date).toLocaleDateString()}</span>
|
||||
)}
|
||||
<span style={{ color: 'var(--accent)', fontWeight: 600 }}>
|
||||
{litter.actual_puppy_count ?? litter.puppies?.length ?? litter.puppy_count ?? 0} puppies
|
||||
</span>
|
||||
</div>
|
||||
{litter.notes && (
|
||||
<p style={{ marginTop: '0.5rem', fontSize: '0.85rem', color: 'var(--text-secondary)', fontStyle: 'italic' }}>
|
||||
{litter.notes}
|
||||
</p>
|
||||
)}
|
||||
<p style={{ marginTop: '0.5rem' }}>
|
||||
<strong>Puppies:</strong> {litter.puppy_count || litter.puppies?.length || 0}
|
||||
</p>
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: '0.5rem', alignItems: 'center' }}>
|
||||
<button
|
||||
className="btn-icon"
|
||||
title="Edit litter"
|
||||
onClick={(e) => handleEdit(e, litter)}
|
||||
>
|
||||
<Edit2 size={16} />
|
||||
</button>
|
||||
<button
|
||||
className="btn-icon"
|
||||
title="Delete litter"
|
||||
onClick={(e) => handleDelete(e, litter.id)}
|
||||
style={{ color: '#e53e3e' }}
|
||||
>
|
||||
<Trash2 size={16} />
|
||||
</button>
|
||||
<ChevronRight size={20} style={{ color: 'var(--text-secondary)' }} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{showForm && (
|
||||
<LitterForm
|
||||
litter={editingLitter}
|
||||
prefill={prefill}
|
||||
onClose={() => setShowForm(false)}
|
||||
onSave={handleSave}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user