feat/litter-management-ui #26
@@ -1,9 +1,254 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
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 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() {
|
||||
const { id } = useParams()
|
||||
const navigate = useNavigate()
|
||||
@@ -14,14 +259,11 @@ function LitterDetail() {
|
||||
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 [addMode, setAddMode] = useState('existing')
|
||||
const [error, setError] = useState('')
|
||||
const [saving, setSaving] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
fetchLitter()
|
||||
fetchAllDogs()
|
||||
}, [id])
|
||||
useEffect(() => { fetchLitter(); fetchAllDogs() }, [id])
|
||||
|
||||
const fetchLitter = async () => {
|
||||
try {
|
||||
@@ -38,9 +280,7 @@ function LitterDetail() {
|
||||
try {
|
||||
const res = await axios.get('/api/dogs')
|
||||
setAllDogs(res.data)
|
||||
} catch (err) {
|
||||
console.error('Error fetching dogs:', err)
|
||||
}
|
||||
} catch (err) { console.error('Error fetching dogs:', err) }
|
||||
}
|
||||
|
||||
const unlinkedDogs = allDogs.filter(d => {
|
||||
@@ -52,47 +292,31 @@ function LitterDetail() {
|
||||
|
||||
const handleLinkPuppy = async () => {
|
||||
if (!selectedPuppyId) return
|
||||
setSaving(true)
|
||||
setError('')
|
||||
setSaving(true); setError('')
|
||||
try {
|
||||
await axios.post(`/api/litters/${id}/puppies/${selectedPuppyId}`)
|
||||
setSelectedPuppyId('')
|
||||
setShowAddPuppy(false)
|
||||
fetchLitter()
|
||||
setSelectedPuppyId(''); setShowAddPuppy(false); fetchLitter()
|
||||
} catch (err) {
|
||||
setError(err.response?.data?.error || 'Failed to link puppy')
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
} finally { setSaving(false) }
|
||||
}
|
||||
|
||||
const handleCreateAndLink = async () => {
|
||||
if (!newPuppy.name) {
|
||||
setError('Puppy name is required')
|
||||
return
|
||||
}
|
||||
setSaving(true)
|
||||
setError('')
|
||||
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,
|
||||
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}`)
|
||||
await axios.post(`/api/litters/${id}/puppies/${res.data.id}`)
|
||||
setNewPuppy({ name: '', sex: 'male', color: '', dob: '' })
|
||||
setShowAddPuppy(false)
|
||||
fetchLitter()
|
||||
fetchAllDogs()
|
||||
setShowAddPuppy(false); fetchLitter(); fetchAllDogs()
|
||||
} catch (err) {
|
||||
setError(err.response?.data?.error || 'Failed to create puppy')
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
} finally { setSaving(false) }
|
||||
}
|
||||
|
||||
const handleUnlinkPuppy = async (puppyId) => {
|
||||
@@ -100,9 +324,7 @@ function LitterDetail() {
|
||||
try {
|
||||
await axios.delete(`/api/litters/${id}/puppies/${puppyId}`)
|
||||
fetchLitter()
|
||||
} catch (err) {
|
||||
console.error('Error unlinking puppy:', err)
|
||||
}
|
||||
} catch (err) { console.error('Error unlinking puppy:', err) }
|
||||
}
|
||||
|
||||
if (loading) return <div className="container loading">Loading litter...</div>
|
||||
@@ -120,13 +342,11 @@ function LitterDetail() {
|
||||
<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()}`}
|
||||
Bred: {new Date(litter.breeding_date + 'T00:00:00').toLocaleDateString()}
|
||||
{litter.whelping_date && ` • Whelped: ${new Date(litter.whelping_date + 'T00:00:00').toLocaleDateString()}`}
|
||||
</p>
|
||||
</div>
|
||||
<button className="btn btn-secondary" onClick={() => setShowEditForm(true)}>
|
||||
Edit Litter
|
||||
</button>
|
||||
<button className="btn btn-secondary" onClick={() => setShowEditForm(true)}>Edit Litter</button>
|
||||
</div>
|
||||
|
||||
{/* Stats row */}
|
||||
@@ -155,6 +375,9 @@ function LitterDetail() {
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Projected whelping window */}
|
||||
<WhelpingBanner breedingDate={litter.breeding_date} whelpingDate={litter.whelping_date} />
|
||||
|
||||
{/* Notes */}
|
||||
{litter.notes && (
|
||||
<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>
|
||||
</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 => (
|
||||
<div key={puppy.id} className="card" style={{ position: 'relative' }}>
|
||||
<button
|
||||
@@ -197,7 +420,7 @@ function LitterDetail() {
|
||||
</div>
|
||||
{puppy.date_of_birth && (
|
||||
<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>
|
||||
)}
|
||||
<button
|
||||
@@ -208,6 +431,13 @@ function LitterDetail() {
|
||||
<ExternalLink size={12} style={{ marginRight: '0.3rem' }} />
|
||||
View Profile
|
||||
</button>
|
||||
|
||||
{/* Weight / Health Log collapsible */}
|
||||
<PuppyLogPanel
|
||||
litterId={id}
|
||||
puppy={puppy}
|
||||
whelpingDate={litter.whelping_date || litter.breeding_date}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
@@ -223,37 +453,22 @@ function LitterDetail() {
|
||||
</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>
|
||||
<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)}
|
||||
>
|
||||
<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()}` : ''})
|
||||
{d.name} ({d.sex}{d.date_of_birth ? `, born ${new Date(d.date_of_birth + 'T00:00:00').toLocaleDateString()}` : ''})
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
@@ -267,46 +482,33 @@ function LitterDetail() {
|
||||
<div style={{ display: 'grid', gap: '1rem' }}>
|
||||
<div className="form-group">
|
||||
<label className="label">Puppy Name *</label>
|
||||
<input
|
||||
className="input"
|
||||
value={newPuppy.name}
|
||||
<input className="input" value={newPuppy.name}
|
||||
onChange={e => setNewPuppy(p => ({ ...p, name: e.target.value }))}
|
||||
placeholder="e.g. Blue Collar"
|
||||
/>
|
||||
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 }))}
|
||||
>
|
||||
<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}
|
||||
<input className="input" value={newPuppy.color}
|
||||
onChange={e => setNewPuppy(p => ({ ...p, color: e.target.value }))}
|
||||
placeholder="e.g. Black & Tan"
|
||||
/>
|
||||
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 }))}
|
||||
/>
|
||||
<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()}
|
||||
Will default to whelping date: {new Date(litter.whelping_date + 'T00:00:00').toLocaleDateString()}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
@@ -314,9 +516,7 @@ function LitterDetail() {
|
||||
)}
|
||||
</div>
|
||||
<div className="modal-footer">
|
||||
<button className="btn btn-secondary" onClick={() => setShowAddPuppy(false)} disabled={saving}>
|
||||
Cancel
|
||||
</button>
|
||||
<button className="btn btn-secondary" onClick={() => setShowAddPuppy(false)} disabled={saving}>Cancel</button>
|
||||
<button
|
||||
className="btn btn-primary"
|
||||
disabled={saving || (addMode === 'existing' && !selectedPuppyId)}
|
||||
@@ -329,7 +529,6 @@ function LitterDetail() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Edit Litter Modal */}
|
||||
{showEditForm && (
|
||||
<LitterForm
|
||||
litter={litter}
|
||||
|
||||
@@ -16,17 +16,13 @@ router.get('/', (req, res) => {
|
||||
ORDER BY l.breeding_date DESC
|
||||
`).all();
|
||||
|
||||
// Get puppies for each litter using litter_id
|
||||
litters.forEach(litter => {
|
||||
litter.puppies = db.prepare(`
|
||||
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;
|
||||
});
|
||||
|
||||
@@ -36,14 +32,14 @@ router.get('/', (req, res) => {
|
||||
}
|
||||
});
|
||||
|
||||
// GET single litter
|
||||
// GET single litter with puppies
|
||||
router.get('/:id', (req, res) => {
|
||||
try {
|
||||
const db = getDatabase();
|
||||
const litter = db.prepare(`
|
||||
SELECT l.*,
|
||||
s.*, s.name as sire_name,
|
||||
d.*, d.name as dam_name
|
||||
SELECT l.*,
|
||||
s.name as sire_name, s.registration_number as sire_reg, s.breed as sire_breed,
|
||||
d.name as dam_name, d.registration_number as dam_reg, d.breed as dam_breed
|
||||
FROM litters l
|
||||
JOIN dogs s ON l.sire_id = s.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' });
|
||||
}
|
||||
|
||||
// Get puppies using litter_id
|
||||
litter.puppies = db.prepare(`
|
||||
SELECT * FROM dogs WHERE litter_id = ? AND is_active = 1
|
||||
`).all(litter.id);
|
||||
@@ -74,7 +69,7 @@ router.get('/:id', (req, res) => {
|
||||
// POST create new litter
|
||||
router.post('/', (req, res) => {
|
||||
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) {
|
||||
return res.status(400).json({ error: 'Sire, dam, and breeding date are required' });
|
||||
@@ -82,7 +77,6 @@ router.post('/', (req, res) => {
|
||||
|
||||
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 dam = db.prepare('SELECT sex FROM dogs WHERE id = ?').get(dam_id);
|
||||
|
||||
@@ -94,12 +88,11 @@ router.post('/', (req, res) => {
|
||||
}
|
||||
|
||||
const result = db.prepare(`
|
||||
INSERT INTO litters (sire_id, dam_id, breeding_date, whelping_date, notes)
|
||||
VALUES (?, ?, ?, ?, ?)
|
||||
`).run(sire_id, dam_id, breeding_date, whelping_date, notes);
|
||||
INSERT INTO litters (sire_id, dam_id, breeding_date, whelping_date, puppy_count, notes)
|
||||
VALUES (?, ?, ?, ?, ?, ?)
|
||||
`).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);
|
||||
|
||||
res.status(201).json(litter);
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: error.message });
|
||||
@@ -110,13 +103,12 @@ router.post('/', (req, res) => {
|
||||
router.put('/:id', (req, res) => {
|
||||
try {
|
||||
const { breeding_date, whelping_date, puppy_count, notes } = req.body;
|
||||
|
||||
const db = getDatabase();
|
||||
db.prepare(`
|
||||
UPDATE litters
|
||||
SET breeding_date = ?, whelping_date = ?, puppy_count = ?, notes = ?
|
||||
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);
|
||||
res.json(litter);
|
||||
@@ -131,22 +123,14 @@ router.post('/:id/puppies/:puppyId', (req, res) => {
|
||||
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' });
|
||||
}
|
||||
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' });
|
||||
}
|
||||
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');
|
||||
@@ -169,26 +153,77 @@ 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 });
|
||||
}
|
||||
});
|
||||
|
||||
// ─── 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
|
||||
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 });
|
||||
|
||||
Reference in New Issue
Block a user