feat: add PuppyLogPanel weight/health logs + projected whelping window to LitterDetail

This commit is contained in:
2026-03-09 21:06:03 -05:00
parent 6e37abf6e8
commit 421b875661

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}