Files
breedr/client/src/pages/LitterDetail.jsx
T
Jason Stedwell f3fc01f31c Polish: live OFA age warning, puppy sorting, responsive fixes, form hints
- HealthRecordForm shows a live amber warning under Test Date when the dog is
  under the OFA minimum certification age (submit-time block unchanged)
- LitterDetail puppies sortable by name / sex / birth date
- Responsive at 375px: DogDetail photos+info grid stacks, header wraps;
  puppy grid uses min(220px,100%); navbar wraps with scrollable icon row;
  calendar uses minmax(0,1fr) columns so cells shrink instead of overflowing
- LitterForm explains why sire/dam selects are locked when editing
- Add .claude/launch.json for the vite dev server

Verified in-browser at 375px and desktop: no horizontal overflow on any page.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-06 15:06:32 -05:00

585 lines
25 KiB
React
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { useEffect, useState } from 'react'
import { useParams, useNavigate } from 'react-router-dom'
import { ArrowLeft, Plus, X, ExternalLink, Dog, Weight, ChevronDown, ChevronUp, Trash2 } from 'lucide-react'
import axios from 'axios'
import LitterForm from '../components/LitterForm'
import { useToast } from '../components/Toast'
import { useConfirm } from '../components/ConfirmDialog'
// ─── 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)
const toast = useToast()
const confirm = useConfirm()
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)
toast.error(e.response?.data?.error || 'Failed to save log entry')
}
finally { setSaving(false) }
}
const handleDelete = async (logId) => {
if (!(await confirm({ title: 'Delete log entry?', message: `This log entry for ${puppy.name} will be permanently removed.` }))) return
try {
await axios.delete(`/api/litters/${litterId}/puppies/${puppy.id}/logs/${logId}`)
fetchLogs()
} catch (e) {
console.error(e)
toast.error('Failed to delete log entry')
}
}
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()
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')
const [error, setError] = useState('')
const [saving, setSaving] = useState(false)
const [puppySort, setPuppySort] = useState('name')
const toast = useToast()
const confirm = useConfirm()
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/all')
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 || '',
})
await axios.post(`/api/litters/${id}/puppies/${res.data.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 (!(await confirm({
title: 'Remove puppy from litter?',
message: 'The dog record itself will not be deleted.',
confirmLabel: 'Remove',
}))) return
try {
await axios.delete(`/api/litters/${id}/puppies/${puppyId}`)
fetchLitter()
} catch (err) {
console.error('Error unlinking puppy:', err)
toast.error(err.response?.data?.error || 'Failed to remove puppy from litter')
}
}
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
const sortedPuppies = [...(litter.puppies || [])].sort((a, b) => {
if (puppySort === 'sex') return (a.sex || '').localeCompare(b.sex || '') || (a.name || '').localeCompare(b.name || '')
if (puppySort === 'dob') return (a.date_of_birth || '').localeCompare(b.date_of_birth || '') || (a.name || '').localeCompare(b.name || '')
return (a.name || '').localeCompare(b.name || '')
})
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 + '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>
</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>
{/* 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)' }}>
<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', flexWrap: 'wrap', gap: '0.75rem' }}>
<h2 style={{ margin: 0 }}>Puppies</h2>
<div style={{ display: 'flex', alignItems: 'center', gap: '0.75rem' }}>
{puppyCount > 1 && (
<select
value={puppySort}
onChange={e => setPuppySort(e.target.value)}
className="input"
style={{ width: 'auto', fontSize: '0.85rem', padding: '0.4rem 0.6rem' }}
aria-label="Sort puppies"
>
<option value="name">Sort: Name</option>
<option value="sex">Sort: Sex</option>
<option value="dob">Sort: Birth Date</option>
</select>
)}
<button className="btn btn-primary" onClick={() => { setShowAddPuppy(true); setError('') }}>
<Plus size={16} style={{ marginRight: '0.4rem' }} />
Add Puppy
</button>
</div>
</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(min(220px, 100%), 1fr))', gap: '1rem' }}>
{sortedPuppies.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 + 'T00:00:00').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>
{/* Weight / Health Log collapsible */}
<PuppyLogPanel
litterId={id}
puppy={puppy}
whelpingDate={litter.whelping_date || litter.breeding_date}
/>
</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>}
<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 + 'T00:00:00').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 + 'T00:00:00').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>
)}
{showEditForm && (
<LitterForm
litter={litter}
onClose={() => setShowEditForm(false)}
onSave={fetchLitter}
/>
)}
</div>
)
}
export default LitterDetail