feat: add LitterDetail page with puppy roster and add/remove/create puppy
This commit is contained in:
344
client/src/pages/LitterDetail.jsx
Normal file
344
client/src/pages/LitterDetail.jsx
Normal file
@@ -0,0 +1,344 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useParams, useNavigate } from 'react-router-dom'
|
||||
import { ArrowLeft, Plus, X, ExternalLink, Dog } from 'lucide-react'
|
||||
import axios from 'axios'
|
||||
import LitterForm from '../components/LitterForm'
|
||||
|
||||
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') // 'existing' | 'new'
|
||||
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 || '',
|
||||
})
|
||||
const createdDog = res.data
|
||||
await axios.post(`/api/litters/${id}/puppies/${createdDog.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).toLocaleDateString()}
|
||||
{litter.whelping_date && ` • Whelped: ${new Date(litter.whelping_date).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>
|
||||
|
||||
{/* 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(220px, 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).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>
|
||||
</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>}
|
||||
|
||||
{/* 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>
|
||||
</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).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).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>
|
||||
)}
|
||||
|
||||
{/* Edit Litter Modal */}
|
||||
{showEditForm && (
|
||||
<LitterForm
|
||||
litter={litter}
|
||||
onClose={() => setShowEditForm(false)}
|
||||
onSave={fetchLitter}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default LitterDetail
|
||||
Reference in New Issue
Block a user