Merge pull request 'feat/litter-management-ui' (#26) from feat/litter-management-ui into master
Some checks failed
Build & Publish Docker Image / build-and-push (push) Has been cancelled

Reviewed-on: #26
This commit was merged in pull request #26.
This commit is contained in:
2026-03-09 21:11:10 -05:00
2 changed files with 361 additions and 127 deletions

View File

@@ -1,9 +1,254 @@
import { useEffect, useState } from 'react' import { useEffect, useState } from 'react'
import { useParams, useNavigate } from 'react-router-dom' import { useParams, useNavigate } from 'react-router-dom'
import { ArrowLeft, Plus, X, ExternalLink, Dog } from 'lucide-react' import { ArrowLeft, Plus, X, ExternalLink, Dog, Weight, ChevronDown, ChevronUp, Trash2 } from 'lucide-react'
import axios from 'axios' import axios from 'axios'
import LitterForm from '../components/LitterForm' import LitterForm from '../components/LitterForm'
// ─── Puppy Log Panel ────────────────────────────────────────────────────────────
function PuppyLogPanel({ litterId, puppy, whelpingDate }) {
const [open, setOpen] = useState(false)
const [logs, setLogs] = useState([])
const [loading, setLoading] = useState(false)
const [showAdd, setShowAdd] = useState(false)
const [form, setForm] = useState({
record_date: whelpingDate || '',
weight_oz: '',
weight_lbs: '',
notes: '',
record_type: 'weight_log'
})
const [saving, setSaving] = useState(false)
useEffect(() => { if (open) fetchLogs() }, [open])
const fetchLogs = async () => {
setLoading(true)
try {
const res = await axios.get(`/api/litters/${litterId}/puppies/${puppy.id}/logs`)
const parsed = res.data.map(l => {
try { return { ...l, _data: JSON.parse(l.description) } } catch { return { ...l, _data: {} } }
})
setLogs(parsed)
} catch (e) { console.error(e) }
finally { setLoading(false) }
}
const handleAdd = async () => {
if (!form.record_date) return
setSaving(true)
try {
await axios.post(`/api/litters/${litterId}/puppies/${puppy.id}/logs`, form)
setShowAdd(false)
setForm(f => ({ ...f, weight_oz: '', weight_lbs: '', notes: '' }))
fetchLogs()
} catch (e) { console.error(e) }
finally { setSaving(false) }
}
const handleDelete = async (logId) => {
if (!window.confirm('Delete this log entry?')) return
try {
await axios.delete(`/api/litters/${litterId}/puppies/${puppy.id}/logs/${logId}`)
fetchLogs()
} catch (e) { console.error(e) }
}
const TYPES = [
{ value: 'weight_log', label: '⚖️ Weight Check' },
{ value: 'health_note', label: '📝 Health Note' },
{ value: 'deworming', label: '🐛 Deworming' },
{ value: 'vaccination', label: '💉 Vaccination' },
]
return (
<div style={{ borderTop: '1px solid var(--border)', marginTop: '0.5rem' }}>
<button
onClick={() => setOpen(o => !o)}
style={{
width: '100%', display: 'flex', alignItems: 'center', justifyContent: 'space-between',
background: 'none', border: 'none', cursor: 'pointer', padding: '0.5rem 0',
color: 'var(--text-secondary)', fontSize: '0.8rem'
}}
>
<span style={{ display: 'flex', alignItems: 'center', gap: '0.3rem' }}>
<Weight size={13} /> Logs {logs.length > 0 && `(${logs.length})`}
</span>
{open ? <ChevronUp size={13} /> : <ChevronDown size={13} />}
</button>
{open && (
<div style={{ paddingBottom: '0.5rem' }}>
{loading ? (
<p style={{ fontSize: '0.78rem', color: 'var(--text-secondary)' }}>Loading...</p>
) : logs.length === 0 ? (
<p style={{ fontSize: '0.78rem', color: 'var(--text-secondary)' }}>No logs yet.</p>
) : (
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.35rem', marginBottom: '0.5rem' }}>
{logs.map(l => (
<div key={l.id} style={{
display: 'flex', alignItems: 'flex-start', justifyContent: 'space-between',
background: 'var(--bg-tertiary)', borderRadius: 'var(--radius-sm)',
padding: '0.4rem 0.6rem', gap: '0.5rem'
}}>
<div style={{ fontSize: '0.75rem', flex: 1 }}>
<span style={{ fontWeight: 600 }}>
{new Date(l.record_date + 'T00:00:00').toLocaleDateString()}
</span>
{' • '}
<span style={{ color: 'var(--accent)' }}>
{TYPES.find(t => t.value === l.record_type)?.label || l.record_type}
</span>
{l._data?.weight_oz && <span> {l._data.weight_oz} oz</span>}
{l._data?.weight_lbs && <span> ({l._data.weight_lbs} lbs)</span>}
{l._data?.notes && (
<div style={{ color: 'var(--text-secondary)', marginTop: '0.1rem' }}>
{l._data.notes}
</div>
)}
</div>
<button
onClick={() => handleDelete(l.id)}
style={{ background: 'none', border: 'none', cursor: 'pointer', color: '#e53e3e', padding: 0, flexShrink: 0 }}
>
<Trash2 size={12} />
</button>
</div>
))}
</div>
)}
{showAdd ? (
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.4rem' }}>
<div style={{ display: 'flex', gap: '0.4rem', flexWrap: 'wrap' }}>
<input
type="date" className="input"
style={{ fontSize: '0.78rem', padding: '0.3rem 0.5rem', flex: '1 1 120px' }}
value={form.record_date}
onChange={e => setForm(f => ({ ...f, record_date: e.target.value }))}
/>
<select
className="input"
style={{ fontSize: '0.78rem', padding: '0.3rem 0.5rem', flex: '1 1 130px' }}
value={form.record_type}
onChange={e => setForm(f => ({ ...f, record_type: e.target.value }))}
>
{TYPES.map(t => <option key={t.value} value={t.value}>{t.label}</option>)}
</select>
</div>
{form.record_type === 'weight_log' && (
<div style={{ display: 'flex', gap: '0.4rem' }}>
<input
type="number" className="input" placeholder="oz" step="0.1" min="0"
style={{ fontSize: '0.78rem', padding: '0.3rem 0.5rem', flex: 1 }}
value={form.weight_oz}
onChange={e => setForm(f => ({ ...f, weight_oz: e.target.value }))}
/>
<input
type="number" className="input" placeholder="lbs" step="0.01" min="0"
style={{ fontSize: '0.78rem', padding: '0.3rem 0.5rem', flex: 1 }}
value={form.weight_lbs}
onChange={e => setForm(f => ({ ...f, weight_lbs: e.target.value }))}
/>
</div>
)}
<input
className="input" placeholder="Notes (optional)"
style={{ fontSize: '0.78rem', padding: '0.3rem 0.5rem' }}
value={form.notes}
onChange={e => setForm(f => ({ ...f, notes: e.target.value }))}
/>
<div style={{ display: 'flex', gap: '0.4rem' }}>
<button
className="btn btn-primary"
style={{ fontSize: '0.75rem', padding: '0.3rem 0.75rem' }}
onClick={handleAdd} disabled={saving}
>
{saving ? 'Saving...' : 'Save'}
</button>
<button
className="btn btn-secondary"
style={{ fontSize: '0.75rem', padding: '0.3rem 0.75rem' }}
onClick={() => setShowAdd(false)}
>
Cancel
</button>
</div>
</div>
) : (
<button
className="btn btn-secondary"
style={{ fontSize: '0.75rem', padding: '0.3rem 0.75rem', width: '100%' }}
onClick={() => setShowAdd(true)}
>
<Plus size={12} style={{ marginRight: '0.3rem' }} /> Add Log Entry
</button>
)}
</div>
)}
</div>
)
}
// ─── Whelping Window Banner ───────────────────────────────────────────────
function addDays(dateStr, n) {
const d = new Date(dateStr + 'T00:00:00')
d.setDate(d.getDate() + n)
return d
}
function fmt(d) { return d.toLocaleDateString(undefined, { month: 'short', day: 'numeric', year: 'numeric' }) }
function WhelpingBanner({ breedingDate, whelpingDate }) {
if (whelpingDate) return null // already whelped, no need for estimate
if (!breedingDate) return null
const earliest = addDays(breedingDate, 58)
const expected = addDays(breedingDate, 63)
const latest = addDays(breedingDate, 68)
const today = new Date()
const daysUntil = Math.ceil((expected - today) / 86400000)
let urgency = 'var(--success)'
let urgencyBg = 'rgba(16,185,129,0.06)'
let statusLabel = `~${daysUntil} days away`
if (daysUntil <= 7 && daysUntil > 0) {
urgency = '#d97706'; urgencyBg = 'rgba(217,119,6,0.08)'
statusLabel = `⚠️ ${daysUntil} days — prepare whelping area!`
} else if (daysUntil <= 0) {
urgency = '#e53e3e'; urgencyBg = 'rgba(229,62,62,0.08)'
statusLabel = '🔴 Expected date has passed — confirm or update whelping date'
}
return (
<div className="card" style={{
marginBottom: '2rem', padding: '1rem',
borderLeft: `3px solid ${urgency}`,
background: urgencyBg
}}>
<div style={{ fontWeight: 600, marginBottom: '0.5rem', color: urgency }}>
💕 Projected Whelping Window
<span style={{ fontWeight: 400, fontSize: '0.82rem', marginLeft: '0.75rem', color: 'var(--text-secondary)' }}>
{statusLabel}
</span>
</div>
<div style={{ display: 'flex', gap: '2rem', flexWrap: 'wrap', fontSize: '0.875rem' }}>
<div>
<span style={{ color: 'var(--text-secondary)', fontSize: '0.78rem' }}>Earliest (Day 58)</span>
<br /><strong>{fmt(earliest)}</strong>
</div>
<div>
<span style={{ color: 'var(--text-secondary)', fontSize: '0.78rem' }}>Expected (Day 63)</span>
<br /><strong style={{ color: urgency, fontSize: '1rem' }}>{fmt(expected)}</strong>
</div>
<div>
<span style={{ color: 'var(--text-secondary)', fontSize: '0.78rem' }}>Latest (Day 68)</span>
<br /><strong>{fmt(latest)}</strong>
</div>
</div>
</div>
)
}
// ─── Main LitterDetail ─────────────────────────────────────────────────────────
function LitterDetail() { function LitterDetail() {
const { id } = useParams() const { id } = useParams()
const navigate = useNavigate() const navigate = useNavigate()
@@ -14,14 +259,11 @@ function LitterDetail() {
const [allDogs, setAllDogs] = useState([]) const [allDogs, setAllDogs] = useState([])
const [selectedPuppyId, setSelectedPuppyId] = useState('') const [selectedPuppyId, setSelectedPuppyId] = useState('')
const [newPuppy, setNewPuppy] = useState({ name: '', sex: 'male', color: '', dob: '' }) const [newPuppy, setNewPuppy] = useState({ name: '', sex: 'male', color: '', dob: '' })
const [addMode, setAddMode] = useState('existing') // 'existing' | 'new' const [addMode, setAddMode] = useState('existing')
const [error, setError] = useState('') const [error, setError] = useState('')
const [saving, setSaving] = useState(false) const [saving, setSaving] = useState(false)
useEffect(() => { useEffect(() => { fetchLitter(); fetchAllDogs() }, [id])
fetchLitter()
fetchAllDogs()
}, [id])
const fetchLitter = async () => { const fetchLitter = async () => {
try { try {
@@ -38,9 +280,7 @@ function LitterDetail() {
try { try {
const res = await axios.get('/api/dogs') const res = await axios.get('/api/dogs')
setAllDogs(res.data) setAllDogs(res.data)
} catch (err) { } catch (err) { console.error('Error fetching dogs:', err) }
console.error('Error fetching dogs:', err)
}
} }
const unlinkedDogs = allDogs.filter(d => { const unlinkedDogs = allDogs.filter(d => {
@@ -52,47 +292,31 @@ function LitterDetail() {
const handleLinkPuppy = async () => { const handleLinkPuppy = async () => {
if (!selectedPuppyId) return if (!selectedPuppyId) return
setSaving(true) setSaving(true); setError('')
setError('')
try { try {
await axios.post(`/api/litters/${id}/puppies/${selectedPuppyId}`) await axios.post(`/api/litters/${id}/puppies/${selectedPuppyId}`)
setSelectedPuppyId('') setSelectedPuppyId(''); setShowAddPuppy(false); fetchLitter()
setShowAddPuppy(false)
fetchLitter()
} catch (err) { } catch (err) {
setError(err.response?.data?.error || 'Failed to link puppy') setError(err.response?.data?.error || 'Failed to link puppy')
} finally { } finally { setSaving(false) }
setSaving(false)
}
} }
const handleCreateAndLink = async () => { const handleCreateAndLink = async () => {
if (!newPuppy.name) { if (!newPuppy.name) { setError('Puppy name is required'); return }
setError('Puppy name is required') setSaving(true); setError('')
return
}
setSaving(true)
setError('')
try { try {
const dob = newPuppy.dob || litter.whelping_date || litter.breeding_date const dob = newPuppy.dob || litter.whelping_date || litter.breeding_date
const res = await axios.post('/api/dogs', { const res = await axios.post('/api/dogs', {
name: newPuppy.name, name: newPuppy.name, sex: newPuppy.sex,
sex: newPuppy.sex, color: newPuppy.color, date_of_birth: dob,
color: newPuppy.color,
date_of_birth: dob,
breed: litter.dam_breed || '', breed: litter.dam_breed || '',
}) })
const createdDog = res.data await axios.post(`/api/litters/${id}/puppies/${res.data.id}`)
await axios.post(`/api/litters/${id}/puppies/${createdDog.id}`)
setNewPuppy({ name: '', sex: 'male', color: '', dob: '' }) setNewPuppy({ name: '', sex: 'male', color: '', dob: '' })
setShowAddPuppy(false) setShowAddPuppy(false); fetchLitter(); fetchAllDogs()
fetchLitter()
fetchAllDogs()
} catch (err) { } catch (err) {
setError(err.response?.data?.error || 'Failed to create puppy') setError(err.response?.data?.error || 'Failed to create puppy')
} finally { } finally { setSaving(false) }
setSaving(false)
}
} }
const handleUnlinkPuppy = async (puppyId) => { const handleUnlinkPuppy = async (puppyId) => {
@@ -100,9 +324,7 @@ function LitterDetail() {
try { try {
await axios.delete(`/api/litters/${id}/puppies/${puppyId}`) await axios.delete(`/api/litters/${id}/puppies/${puppyId}`)
fetchLitter() fetchLitter()
} catch (err) { } catch (err) { console.error('Error unlinking puppy:', err) }
console.error('Error unlinking puppy:', err)
}
} }
if (loading) return <div className="container loading">Loading litter...</div> if (loading) return <div className="container loading">Loading litter...</div>
@@ -120,13 +342,11 @@ function LitterDetail() {
<div style={{ flex: 1 }}> <div style={{ flex: 1 }}>
<h1 style={{ margin: 0 }}>🐾 {litter.sire_name} × {litter.dam_name}</h1> <h1 style={{ margin: 0 }}>🐾 {litter.sire_name} × {litter.dam_name}</h1>
<p style={{ color: 'var(--text-secondary)', margin: '0.25rem 0 0' }}> <p style={{ color: 'var(--text-secondary)', margin: '0.25rem 0 0' }}>
Bred: {new Date(litter.breeding_date).toLocaleDateString()} Bred: {new Date(litter.breeding_date + 'T00:00:00').toLocaleDateString()}
{litter.whelping_date && ` • Whelped: ${new Date(litter.whelping_date).toLocaleDateString()}`} {litter.whelping_date && ` • Whelped: ${new Date(litter.whelping_date + 'T00:00:00').toLocaleDateString()}`}
</p> </p>
</div> </div>
<button className="btn btn-secondary" onClick={() => setShowEditForm(true)}> <button className="btn btn-secondary" onClick={() => setShowEditForm(true)}>Edit Litter</button>
Edit Litter
</button>
</div> </div>
{/* Stats row */} {/* Stats row */}
@@ -155,6 +375,9 @@ function LitterDetail() {
)} )}
</div> </div>
{/* Projected whelping window */}
<WhelpingBanner breedingDate={litter.breeding_date} whelpingDate={litter.whelping_date} />
{/* Notes */} {/* Notes */}
{litter.notes && ( {litter.notes && (
<div className="card" style={{ marginBottom: '2rem', padding: '1rem', borderLeft: '3px solid var(--accent)' }}> <div className="card" style={{ marginBottom: '2rem', padding: '1rem', borderLeft: '3px solid var(--accent)' }}>
@@ -177,7 +400,7 @@ function LitterDetail() {
<p style={{ color: 'var(--text-secondary)' }}>No puppies linked yet. Add puppies to this litter.</p> <p style={{ color: 'var(--text-secondary)' }}>No puppies linked yet. Add puppies to this litter.</p>
</div> </div>
) : ( ) : (
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(220px, 1fr))', gap: '1rem' }}> <div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(240px, 1fr))', gap: '1rem' }}>
{litter.puppies.map(puppy => ( {litter.puppies.map(puppy => (
<div key={puppy.id} className="card" style={{ position: 'relative' }}> <div key={puppy.id} className="card" style={{ position: 'relative' }}>
<button <button
@@ -197,7 +420,7 @@ function LitterDetail() {
</div> </div>
{puppy.date_of_birth && ( {puppy.date_of_birth && (
<div style={{ fontSize: '0.8rem', color: 'var(--text-secondary)' }}> <div style={{ fontSize: '0.8rem', color: 'var(--text-secondary)' }}>
Born: {new Date(puppy.date_of_birth).toLocaleDateString()} Born: {new Date(puppy.date_of_birth + 'T00:00:00').toLocaleDateString()}
</div> </div>
)} )}
<button <button
@@ -208,6 +431,13 @@ function LitterDetail() {
<ExternalLink size={12} style={{ marginRight: '0.3rem' }} /> <ExternalLink size={12} style={{ marginRight: '0.3rem' }} />
View Profile View Profile
</button> </button>
{/* Weight / Health Log collapsible */}
<PuppyLogPanel
litterId={id}
puppy={puppy}
whelpingDate={litter.whelping_date || litter.breeding_date}
/>
</div> </div>
))} ))}
</div> </div>
@@ -223,37 +453,22 @@ function LitterDetail() {
</div> </div>
<div className="modal-body"> <div className="modal-body">
{error && <div className="error" style={{ marginBottom: '1rem' }}>{error}</div>} {error && <div className="error" style={{ marginBottom: '1rem' }}>{error}</div>}
{/* Mode toggle */}
<div style={{ display: 'flex', gap: '0.5rem', marginBottom: '1.5rem' }}> <div style={{ display: 'flex', gap: '0.5rem', marginBottom: '1.5rem' }}>
<button <button className={`btn ${addMode === 'existing' ? 'btn-primary' : 'btn-secondary'}`}
className={`btn ${addMode === 'existing' ? 'btn-primary' : 'btn-secondary'}`} onClick={() => setAddMode('existing')} style={{ flex: 1 }}>Link Existing Dog</button>
onClick={() => setAddMode('existing')} <button className={`btn ${addMode === 'new' ? 'btn-primary' : 'btn-secondary'}`}
style={{ flex: 1 }} onClick={() => setAddMode('new')} style={{ flex: 1 }}>Create New Puppy</button>
>
Link Existing Dog
</button>
<button
className={`btn ${addMode === 'new' ? 'btn-primary' : 'btn-secondary'}`}
onClick={() => setAddMode('new')}
style={{ flex: 1 }}
>
Create New Puppy
</button>
</div> </div>
{addMode === 'existing' ? ( {addMode === 'existing' ? (
<div className="form-group"> <div className="form-group">
<label className="label">Select Dog</label> <label className="label">Select Dog</label>
<select <select className="input" value={selectedPuppyId}
className="input" onChange={e => setSelectedPuppyId(e.target.value)}>
value={selectedPuppyId}
onChange={e => setSelectedPuppyId(e.target.value)}
>
<option value="">-- Select a dog --</option> <option value="">-- Select a dog --</option>
{unlinkedDogs.map(d => ( {unlinkedDogs.map(d => (
<option key={d.id} value={d.id}> <option key={d.id} value={d.id}>
{d.name} ({d.sex}{d.date_of_birth ? `, born ${new Date(d.date_of_birth).toLocaleDateString()}` : ''}) {d.name} ({d.sex}{d.date_of_birth ? `, born ${new Date(d.date_of_birth + 'T00:00:00').toLocaleDateString()}` : ''})
</option> </option>
))} ))}
</select> </select>
@@ -267,46 +482,33 @@ function LitterDetail() {
<div style={{ display: 'grid', gap: '1rem' }}> <div style={{ display: 'grid', gap: '1rem' }}>
<div className="form-group"> <div className="form-group">
<label className="label">Puppy Name *</label> <label className="label">Puppy Name *</label>
<input <input className="input" value={newPuppy.name}
className="input"
value={newPuppy.name}
onChange={e => setNewPuppy(p => ({ ...p, name: e.target.value }))} onChange={e => setNewPuppy(p => ({ ...p, name: e.target.value }))}
placeholder="e.g. Blue Collar" placeholder="e.g. Blue Collar" />
/>
</div> </div>
<div className="form-grid"> <div className="form-grid">
<div className="form-group"> <div className="form-group">
<label className="label">Sex</label> <label className="label">Sex</label>
<select <select className="input" value={newPuppy.sex}
className="input" onChange={e => setNewPuppy(p => ({ ...p, sex: e.target.value }))}>
value={newPuppy.sex}
onChange={e => setNewPuppy(p => ({ ...p, sex: e.target.value }))}
>
<option value="male">Male</option> <option value="male">Male</option>
<option value="female">Female</option> <option value="female">Female</option>
</select> </select>
</div> </div>
<div className="form-group"> <div className="form-group">
<label className="label">Color / Markings</label> <label className="label">Color / Markings</label>
<input <input className="input" value={newPuppy.color}
className="input"
value={newPuppy.color}
onChange={e => setNewPuppy(p => ({ ...p, color: e.target.value }))} onChange={e => setNewPuppy(p => ({ ...p, color: e.target.value }))}
placeholder="e.g. Black & Tan" placeholder="e.g. Black & Tan" />
/>
</div> </div>
</div> </div>
<div className="form-group"> <div className="form-group">
<label className="label">Date of Birth</label> <label className="label">Date of Birth</label>
<input <input type="date" className="input" value={newPuppy.dob}
type="date" onChange={e => setNewPuppy(p => ({ ...p, dob: e.target.value }))} />
className="input"
value={newPuppy.dob}
onChange={e => setNewPuppy(p => ({ ...p, dob: e.target.value }))}
/>
{litter.whelping_date && !newPuppy.dob && ( {litter.whelping_date && !newPuppy.dob && (
<p style={{ fontSize: '0.8rem', color: 'var(--text-secondary)', marginTop: '0.25rem' }}> <p style={{ fontSize: '0.8rem', color: 'var(--text-secondary)', marginTop: '0.25rem' }}>
Will default to whelping date: {new Date(litter.whelping_date).toLocaleDateString()} Will default to whelping date: {new Date(litter.whelping_date + 'T00:00:00').toLocaleDateString()}
</p> </p>
)} )}
</div> </div>
@@ -314,9 +516,7 @@ function LitterDetail() {
)} )}
</div> </div>
<div className="modal-footer"> <div className="modal-footer">
<button className="btn btn-secondary" onClick={() => setShowAddPuppy(false)} disabled={saving}> <button className="btn btn-secondary" onClick={() => setShowAddPuppy(false)} disabled={saving}>Cancel</button>
Cancel
</button>
<button <button
className="btn btn-primary" className="btn btn-primary"
disabled={saving || (addMode === 'existing' && !selectedPuppyId)} disabled={saving || (addMode === 'existing' && !selectedPuppyId)}
@@ -329,7 +529,6 @@ function LitterDetail() {
</div> </div>
)} )}
{/* Edit Litter Modal */}
{showEditForm && ( {showEditForm && (
<LitterForm <LitterForm
litter={litter} litter={litter}

View File

@@ -16,17 +16,13 @@ router.get('/', (req, res) => {
ORDER BY l.breeding_date DESC ORDER BY l.breeding_date DESC
`).all(); `).all();
// Get puppies for each litter using litter_id
litters.forEach(litter => { litters.forEach(litter => {
litter.puppies = db.prepare(` litter.puppies = db.prepare(`
SELECT * FROM dogs WHERE litter_id = ? AND is_active = 1 SELECT * FROM dogs WHERE litter_id = ? AND is_active = 1
`).all(litter.id); `).all(litter.id);
litter.puppies.forEach(puppy => { litter.puppies.forEach(puppy => {
puppy.photo_urls = puppy.photo_urls ? JSON.parse(puppy.photo_urls) : []; 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; litter.actual_puppy_count = litter.puppies.length;
}); });
@@ -36,14 +32,14 @@ router.get('/', (req, res) => {
} }
}); });
// GET single litter // GET single litter with puppies
router.get('/:id', (req, res) => { router.get('/:id', (req, res) => {
try { try {
const db = getDatabase(); const db = getDatabase();
const litter = db.prepare(` const litter = db.prepare(`
SELECT l.*, SELECT l.*,
s.*, s.name as sire_name, s.name as sire_name, s.registration_number as sire_reg, s.breed as sire_breed,
d.*, d.name as dam_name d.name as dam_name, d.registration_number as dam_reg, d.breed as dam_breed
FROM litters l FROM litters l
JOIN dogs s ON l.sire_id = s.id JOIN dogs s ON l.sire_id = s.id
JOIN dogs d ON l.dam_id = d.id JOIN dogs d ON l.dam_id = d.id
@@ -54,7 +50,6 @@ router.get('/:id', (req, res) => {
return res.status(404).json({ error: 'Litter not found' }); return res.status(404).json({ error: 'Litter not found' });
} }
// Get puppies using litter_id
litter.puppies = db.prepare(` litter.puppies = db.prepare(`
SELECT * FROM dogs WHERE litter_id = ? AND is_active = 1 SELECT * FROM dogs WHERE litter_id = ? AND is_active = 1
`).all(litter.id); `).all(litter.id);
@@ -74,7 +69,7 @@ router.get('/:id', (req, res) => {
// POST create new litter // POST create new litter
router.post('/', (req, res) => { router.post('/', (req, res) => {
try { try {
const { sire_id, dam_id, breeding_date, whelping_date, notes } = req.body; const { sire_id, dam_id, breeding_date, whelping_date, puppy_count, notes } = req.body;
if (!sire_id || !dam_id || !breeding_date) { if (!sire_id || !dam_id || !breeding_date) {
return res.status(400).json({ error: 'Sire, dam, and breeding date are required' }); return res.status(400).json({ error: 'Sire, dam, and breeding date are required' });
@@ -82,7 +77,6 @@ router.post('/', (req, res) => {
const db = getDatabase(); const db = getDatabase();
// Verify sire is male and dam is female
const sire = db.prepare('SELECT sex FROM dogs WHERE id = ?').get(sire_id); const sire = db.prepare('SELECT sex FROM dogs WHERE id = ?').get(sire_id);
const dam = db.prepare('SELECT sex FROM dogs WHERE id = ?').get(dam_id); const dam = db.prepare('SELECT sex FROM dogs WHERE id = ?').get(dam_id);
@@ -94,12 +88,11 @@ router.post('/', (req, res) => {
} }
const result = db.prepare(` const result = db.prepare(`
INSERT INTO litters (sire_id, dam_id, breeding_date, whelping_date, notes) INSERT INTO litters (sire_id, dam_id, breeding_date, whelping_date, puppy_count, notes)
VALUES (?, ?, ?, ?, ?) VALUES (?, ?, ?, ?, ?, ?)
`).run(sire_id, dam_id, breeding_date, whelping_date, notes); `).run(sire_id, dam_id, breeding_date, whelping_date || null, puppy_count || 0, notes || null);
const litter = db.prepare('SELECT * FROM litters WHERE id = ?').get(result.lastInsertRowid); const litter = db.prepare('SELECT * FROM litters WHERE id = ?').get(result.lastInsertRowid);
res.status(201).json(litter); res.status(201).json(litter);
} catch (error) { } catch (error) {
res.status(500).json({ error: error.message }); res.status(500).json({ error: error.message });
@@ -110,13 +103,12 @@ router.post('/', (req, res) => {
router.put('/:id', (req, res) => { router.put('/:id', (req, res) => {
try { try {
const { breeding_date, whelping_date, puppy_count, notes } = req.body; const { breeding_date, whelping_date, puppy_count, notes } = req.body;
const db = getDatabase(); const db = getDatabase();
db.prepare(` db.prepare(`
UPDATE litters UPDATE litters
SET breeding_date = ?, whelping_date = ?, puppy_count = ?, notes = ? SET breeding_date = ?, whelping_date = ?, puppy_count = ?, notes = ?
WHERE id = ? WHERE id = ?
`).run(breeding_date, whelping_date, puppy_count, notes, req.params.id); `).run(breeding_date, whelping_date || null, puppy_count || 0, notes || null, req.params.id);
const litter = db.prepare('SELECT * FROM litters WHERE id = ?').get(req.params.id); const litter = db.prepare('SELECT * FROM litters WHERE id = ?').get(req.params.id);
res.json(litter); res.json(litter);
@@ -131,22 +123,14 @@ router.post('/:id/puppies/:puppyId', (req, res) => {
const { id: litterId, puppyId } = req.params; const { id: litterId, puppyId } = req.params;
const db = getDatabase(); const db = getDatabase();
// Verify litter exists
const litter = db.prepare('SELECT sire_id, dam_id FROM litters WHERE id = ?').get(litterId); const litter = db.prepare('SELECT sire_id, dam_id FROM litters WHERE id = ?').get(litterId);
if (!litter) { if (!litter) return res.status(404).json({ error: 'Litter not found' });
return res.status(404).json({ error: 'Litter not found' });
}
// Verify puppy exists
const puppy = db.prepare('SELECT id FROM dogs WHERE id = ?').get(puppyId); const puppy = db.prepare('SELECT id FROM dogs WHERE id = ?').get(puppyId);
if (!puppy) { if (!puppy) return res.status(404).json({ error: 'Puppy not found' });
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); 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 existingParents = db.prepare('SELECT parent_type FROM parents WHERE dog_id = ?').all(puppyId);
const hasSire = existingParents.some(p => p.parent_type === 'sire'); const hasSire = existingParents.some(p => p.parent_type === 'sire');
const hasDam = existingParents.some(p => p.parent_type === 'dam'); const hasDam = existingParents.some(p => p.parent_type === 'dam');
@@ -169,26 +153,77 @@ router.delete('/:id/puppies/:puppyId', (req, res) => {
try { try {
const { puppyId } = req.params; const { puppyId } = req.params;
const db = getDatabase(); const db = getDatabase();
db.prepare('UPDATE dogs SET litter_id = NULL WHERE id = ?').run(puppyId); db.prepare('UPDATE dogs SET litter_id = NULL WHERE id = ?').run(puppyId);
res.json({ message: 'Puppy removed from litter' }); res.json({ message: 'Puppy removed from litter' });
} catch (error) { } catch (error) {
res.status(500).json({ error: error.message }); res.status(500).json({ error: error.message });
} }
}); });
// ─── Puppy Weight / Health Log ───────────────────────────────────────────────
// GET weight/health logs for a puppy
router.get('/:litterId/puppies/:puppyId/logs', (req, res) => {
try {
const db = getDatabase();
// Use health_records table with note field to store weight logs
const logs = db.prepare(`
SELECT * FROM health_records
WHERE dog_id = ? AND record_type = 'weight_log'
ORDER BY record_date ASC
`).all(req.params.puppyId);
res.json(logs);
} catch (error) {
res.status(500).json({ error: error.message });
}
});
// POST add weight/health log entry for a puppy
router.post('/:litterId/puppies/:puppyId/logs', (req, res) => {
try {
const { puppyId } = req.params;
const { record_date, weight_oz, weight_lbs, notes, record_type } = req.body;
if (!record_date) return res.status(400).json({ error: 'record_date is required' });
const db = getDatabase();
// Store weight as notes JSON in health_records
const description = JSON.stringify({
weight_oz: weight_oz || null,
weight_lbs: weight_lbs || null,
notes: notes || ''
});
const result = db.prepare(`
INSERT INTO health_records (dog_id, record_type, record_date, description, vet_name)
VALUES (?, ?, ?, ?, ?)
`).run(puppyId, record_type || 'weight_log', record_date, description, null);
const log = db.prepare('SELECT * FROM health_records WHERE id = ?').get(result.lastInsertRowid);
res.status(201).json(log);
} catch (error) {
res.status(500).json({ error: error.message });
}
});
// DELETE weight/health log entry
router.delete('/:litterId/puppies/:puppyId/logs/:logId', (req, res) => {
try {
const db = getDatabase();
db.prepare('DELETE FROM health_records WHERE id = ? AND dog_id = ?').run(req.params.logId, req.params.puppyId);
res.json({ message: 'Log entry deleted' });
} catch (error) {
res.status(500).json({ error: error.message });
}
});
// DELETE litter // DELETE litter
router.delete('/:id', (req, res) => { router.delete('/:id', (req, res) => {
try { try {
const db = getDatabase(); const db = getDatabase();
// Remove litter_id from associated puppies
db.prepare('UPDATE dogs SET litter_id = NULL WHERE litter_id = ?').run(req.params.id); 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); db.prepare('DELETE FROM litters WHERE id = ?').run(req.params.id);
res.json({ message: 'Litter deleted successfully' }); res.json({ message: 'Litter deleted successfully' });
} catch (error) { } catch (error) {
res.status(500).json({ error: error.message }); res.status(500).json({ error: error.message });