Files
breedr/client/src/pages/LitterDetail.jsx

544 lines
24 KiB
JavaScript
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'
// ─── 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()
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)
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')
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 (!window.confirm('Remove this puppy from the litter? The dog record will not be deleted.')) return
try {
await axios.delete(`/api/litters/${id}/puppies/${puppyId}`)
fetchLitter()
} catch (err) { console.error('Error unlinking puppy:', err) }
}
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
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' }}>
<h2 style={{ margin: 0 }}>Puppies</h2>
<button className="btn btn-primary" onClick={() => { setShowAddPuppy(true); setError('') }}>
<Plus size={16} style={{ marginRight: '0.4rem' }} />
Add Puppy
</button>
</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(240px, 1fr))', gap: '1rem' }}>
{litter.puppies.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