From 421b875661d44143094888dfd4273ceaee0427a1 Mon Sep 17 00:00:00 2001 From: jason Date: Mon, 9 Mar 2026 21:06:03 -0500 Subject: [PATCH] feat: add PuppyLogPanel weight/health logs + projected whelping window to LitterDetail --- client/src/pages/LitterDetail.jsx | 385 ++++++++++++++++++++++-------- 1 file changed, 292 insertions(+), 93 deletions(-) diff --git a/client/src/pages/LitterDetail.jsx b/client/src/pages/LitterDetail.jsx index ee58a88..0023c01 100644 --- a/client/src/pages/LitterDetail.jsx +++ b/client/src/pages/LitterDetail.jsx @@ -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 ( +
+ + + {open && ( +
+ {loading ? ( +

Loading...

+ ) : logs.length === 0 ? ( +

No logs yet.

+ ) : ( +
+ {logs.map(l => ( +
+
+ + {new Date(l.record_date + 'T00:00:00').toLocaleDateString()} + + {' • '} + + {TYPES.find(t => t.value === l.record_type)?.label || l.record_type} + + {l._data?.weight_oz && — {l._data.weight_oz} oz} + {l._data?.weight_lbs && ({l._data.weight_lbs} lbs)} + {l._data?.notes && ( +
+ {l._data.notes} +
+ )} +
+ +
+ ))} +
+ )} + + {showAdd ? ( +
+
+ setForm(f => ({ ...f, record_date: e.target.value }))} + /> + +
+ {form.record_type === 'weight_log' && ( +
+ setForm(f => ({ ...f, weight_oz: e.target.value }))} + /> + setForm(f => ({ ...f, weight_lbs: e.target.value }))} + /> +
+ )} + setForm(f => ({ ...f, notes: e.target.value }))} + /> +
+ + +
+
+ ) : ( + + )} +
+ )} +
+ ) +} + +// ─── 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 ( +
+
+ 💕 Projected Whelping Window + + {statusLabel} + +
+
+
+ Earliest (Day 58) +
{fmt(earliest)} +
+
+ Expected (Day 63) +
{fmt(expected)} +
+
+ Latest (Day 68) +
{fmt(latest)} +
+
+
+ ) +} + +// ─── 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
Loading litter...
@@ -120,13 +342,11 @@ function LitterDetail() {

🐾 {litter.sire_name} × {litter.dam_name}

- 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()}`}

- + {/* Stats row */} @@ -155,6 +375,9 @@ function LitterDetail() { )} + {/* Projected whelping window */} + + {/* Notes */} {litter.notes && (
@@ -177,7 +400,7 @@ function LitterDetail() {

No puppies linked yet. Add puppies to this litter.

) : ( -
+
{litter.puppies.map(puppy => (
+ + {/* Weight / Health Log collapsible */} +
))}
@@ -223,37 +453,22 @@ function LitterDetail() {
{error &&
{error}
} - - {/* Mode toggle */}
- - + +
{addMode === 'existing' ? (
- setSelectedPuppyId(e.target.value)}> {unlinkedDogs.map(d => ( ))} @@ -267,46 +482,33 @@ function LitterDetail() {
- setNewPuppy(p => ({ ...p, name: e.target.value }))} - placeholder="e.g. Blue Collar" - /> + placeholder="e.g. Blue Collar" />
- setNewPuppy(p => ({ ...p, sex: e.target.value }))}>
- setNewPuppy(p => ({ ...p, color: e.target.value }))} - placeholder="e.g. Black & Tan" - /> + placeholder="e.g. Black & Tan" />
- setNewPuppy(p => ({ ...p, dob: e.target.value }))} - /> + setNewPuppy(p => ({ ...p, dob: e.target.value }))} /> {litter.whelping_date && !newPuppy.dob && (

- 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()}

)}
@@ -314,9 +516,7 @@ function LitterDetail() { )}
- +