Client UX: toast system, confirm dialogs, load-error banners, nav fix, 404 page

- New ToastProvider/useToast (self-built, no dep): success/error toasts replace
  all five alert() calls, with server error messages surfaced
- New ConfirmProvider/useConfirm promise-based dialog replaces all six
  window.confirm sites, showing what is being deleted; Escape/overlay cancels
- Dashboard + BreedingCalendar loaders use Promise.allSettled with a
  danger banner + Retry instead of silently blanking on fetch failure
- Settings load failure now surfaces a toast instead of vanishing
- Fix BreedingCalendar females list: read paginated {data} shape (was .dogs,
  so 'Start Heat Cycle' never listed any females)
- Nav links highlight on detail routes (/dogs/123, /pedigree/5)
- Catch-all 404 route with link back to Dashboard

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Jason Stedwell
2026-07-06 14:55:32 -05:00
parent 31b1d903f4
commit 55d636b512
12 changed files with 338 additions and 56 deletions
+36 -6
View File
@@ -1,5 +1,6 @@
import { useEffect } from 'react'
import { BrowserRouter as Router, Routes, Route, Link, useLocation } from 'react-router-dom'
import { Home, PawPrint, Activity, Heart, FlaskConical, Settings, ExternalLink } from 'lucide-react'
import { Home, PawPrint, Activity, Heart, FlaskConical, Settings, ExternalLink, SearchX } from 'lucide-react'
import Dashboard from './pages/Dashboard'
import DogList from './pages/DogList'
import DogDetail from './pages/DogDetail'
@@ -11,11 +12,17 @@ import PairingSimulator from './pages/PairingSimulator'
import SettingsPage from './pages/SettingsPage'
import ExternalDogs from './pages/ExternalDogs'
import { useSettings } from './hooks/useSettings'
import { ToastProvider, useToast } from './components/Toast'
import { ConfirmProvider } from './components/ConfirmDialog'
import './App.css'
function NavLink({ to, icon: Icon, label }) {
// `match` lets a nav item claim extra path prefixes (e.g. Dogs owns /pedigree/:id)
function NavLink({ to, icon: Icon, label, match = [] }) {
const location = useLocation()
const isActive = location.pathname === to
const prefixes = [to, ...match]
const isActive = to === '/'
? location.pathname === '/'
: prefixes.some(p => location.pathname === p || location.pathname.startsWith(p + '/'))
return (
<Link to={to} className={`nav-link${isActive ? ' active' : ''}`}>
<Icon size={20} />
@@ -24,10 +31,28 @@ function NavLink({ to, icon: Icon, label }) {
)
}
function NotFound() {
return (
<div className="container" style={{ textAlign: 'center', padding: '4rem 1rem' }}>
<SearchX size={48} color="var(--text-muted)" style={{ marginBottom: '1rem' }} />
<h2 style={{ marginBottom: '0.5rem' }}>Page not found</h2>
<p style={{ color: 'var(--text-muted)', marginBottom: '1.5rem' }}>
That page doesn't exist or may have been moved.
</p>
<Link to="/" className="btn btn-primary">Back to Dashboard</Link>
</div>
)
}
function AppInner() {
const { settings } = useSettings()
const { settings, loadError } = useSettings()
const toast = useToast()
const kennelName = settings?.kennel_name || 'BREEDR'
useEffect(() => {
if (loadError) toast.error('Failed to load kennel settings using defaults')
}, [loadError, toast])
return (
<div className="app">
<nav className="navbar">
@@ -42,7 +67,7 @@ function AppInner() {
</div>
<div className="nav-links">
<NavLink to="/" icon={Home} label="Dashboard" />
<NavLink to="/dogs" icon={PawPrint} label="Dogs" />
<NavLink to="/dogs" icon={PawPrint} label="Dogs" match={['/pedigree']} />
<NavLink to="/external" icon={ExternalLink} label="External" />
<NavLink to="/litters" icon={Activity} label="Litters" />
<NavLink to="/breeding" icon={Heart} label="Breeding" />
@@ -64,6 +89,7 @@ function AppInner() {
<Route path="/breeding" element={<BreedingCalendar />} />
<Route path="/pairing" element={<PairingSimulator />} />
<Route path="/settings" element={<SettingsPage />} />
<Route path="*" element={<NotFound />} />
</Routes>
</main>
</div>
@@ -73,7 +99,11 @@ function AppInner() {
function App() {
return (
<Router>
<AppInner />
<ToastProvider>
<ConfirmProvider>
<AppInner />
</ConfirmProvider>
</ToastProvider>
</Router>
)
}
+73
View File
@@ -0,0 +1,73 @@
import { createContext, useCallback, useContext, useEffect, useRef, useState } from 'react'
import { AlertTriangle } from 'lucide-react'
const ConfirmContext = createContext(null)
// Promise-based replacement for window.confirm:
// const confirm = useConfirm()
// if (!(await confirm({ title: 'Delete dog?', message: `Delete "${dog.name}"? ...` }))) return
export function useConfirm() {
const ctx = useContext(ConfirmContext)
if (!ctx) throw new Error('useConfirm must be used within ConfirmProvider')
return ctx
}
export function ConfirmProvider({ children }) {
const [dialog, setDialog] = useState(null)
const resolverRef = useRef(null)
const confirm = useCallback((opts) => new Promise(resolve => {
resolverRef.current = resolve
setDialog(typeof opts === 'string' ? { message: opts } : opts)
}), [])
const close = useCallback((result) => {
resolverRef.current?.(result)
resolverRef.current = null
setDialog(null)
}, [])
useEffect(() => {
if (!dialog) return
const onKey = (e) => { if (e.key === 'Escape') close(false) }
window.addEventListener('keydown', onKey)
return () => window.removeEventListener('keydown', onKey)
}, [dialog, close])
return (
<ConfirmContext.Provider value={confirm}>
{children}
{dialog && (
<div className="modal-overlay" onClick={() => close(false)}>
<div
className="modal-content"
style={{ maxWidth: '420px' }}
onClick={e => e.stopPropagation()}
role="alertdialog"
aria-modal="true"
>
<div className="modal-header" style={{ padding: '1.25rem 1.5rem' }}>
<h2 style={{ display: 'flex', alignItems: 'center', gap: '0.5rem', fontSize: '1.1rem' }}>
<AlertTriangle size={20} color="var(--danger)" />
{dialog.title || 'Are you sure?'}
</h2>
</div>
<div className="modal-body" style={{ padding: '1.25rem 1.5rem' }}>
<p style={{ margin: 0, color: 'var(--text-secondary)', lineHeight: 1.5 }}>
{dialog.message}
</p>
</div>
<div className="modal-footer" style={{ padding: '1rem 1.5rem' }}>
<button className="btn btn-secondary" onClick={() => close(false)}>
{dialog.cancelLabel || 'Cancel'}
</button>
<button className="btn btn-danger" onClick={() => close(true)} autoFocus>
{dialog.confirmLabel || 'Delete'}
</button>
</div>
</div>
</div>
)}
</ConfirmContext.Provider>
)
}
+50
View File
@@ -0,0 +1,50 @@
import { createContext, useCallback, useContext, useMemo, useRef, useState } from 'react'
import { CheckCircle, AlertCircle, X } from 'lucide-react'
const ToastContext = createContext(null)
export function useToast() {
const ctx = useContext(ToastContext)
if (!ctx) throw new Error('useToast must be used within ToastProvider')
return ctx
}
export function ToastProvider({ children }) {
const [toasts, setToasts] = useState([])
const idRef = useRef(0)
const dismiss = useCallback((id) => {
setToasts(list => list.filter(t => t.id !== id))
}, [])
const push = useCallback((type, message) => {
const id = ++idRef.current
setToasts(list => [...list, { id, type, message }])
// errors linger longer so they can actually be read
setTimeout(() => dismiss(id), type === 'error' ? 6000 : 3500)
}, [dismiss])
const toast = useMemo(() => ({
success: (message) => push('success', message),
error: (message) => push('error', message),
}), [push])
return (
<ToastContext.Provider value={toast}>
{children}
<div className="toast-container">
{toasts.map(t => (
<div key={t.id} className={`toast toast-${t.type}`} role="status">
{t.type === 'success'
? <CheckCircle size={18} color="var(--success)" />
: <AlertCircle size={18} color="var(--danger)" />}
<span className="toast-message">{t.message}</span>
<button className="toast-close" onClick={() => dismiss(t.id)} aria-label="Dismiss">
<X size={14} />
</button>
</div>
))}
</div>
</ToastContext.Provider>
)
}
+7 -2
View File
@@ -9,13 +9,18 @@ export function SettingsProvider({ children }) {
kennel_tagline: '',
})
const [loading, setLoading] = useState(true)
// Surfaced by consumers inside the ToastProvider (this provider sits above it)
const [loadError, setLoadError] = useState(false)
useEffect(() => {
axios.get('/api/settings')
.then(res => {
setSettings(prev => ({ ...prev, ...res.data }))
})
.catch(() => {})
.catch((err) => {
console.error('Failed to load settings:', err)
setLoadError(true)
})
.finally(() => setLoading(false))
}, [])
@@ -25,7 +30,7 @@ export function SettingsProvider({ children }) {
}
return (
<SettingsContext.Provider value={{ settings, saveSettings, loading }}>
<SettingsContext.Provider value={{ settings, saveSettings, loading, loadError }}>
{children}
</SettingsContext.Provider>
)
+43
View File
@@ -537,3 +537,46 @@ select {
color: var(--danger);
border: 1px solid rgba(239, 68, 68, 0.3);
}
/* Toasts */
.toast-container {
position: fixed;
bottom: 1.5rem;
right: 1.5rem;
display: flex;
flex-direction: column;
gap: 0.5rem;
z-index: 2000;
max-width: min(380px, calc(100vw - 2rem));
}
.toast {
display: flex;
align-items: center;
gap: 0.6rem;
padding: 0.75rem 1rem;
background: var(--bg-elevated);
border: 1px solid var(--border);
border-radius: var(--radius);
box-shadow: var(--shadow-lg);
animation: slideUp 0.25s ease-out;
font-size: 0.875rem;
color: var(--text-primary);
}
.toast-success { border-left: 3px solid var(--success); }
.toast-error { border-left: 3px solid var(--danger); }
.toast-message { flex: 1; }
.toast-close {
background: none;
border: none;
color: var(--text-muted);
cursor: pointer;
padding: 0.15rem;
display: flex;
align-items: center;
}
.toast-close:hover { color: var(--text-primary); }
+39 -15
View File
@@ -5,6 +5,7 @@ import {
} from 'lucide-react'
import { useNavigate } from 'react-router-dom'
import axios from 'axios'
import { useConfirm } from '../components/ConfirmDialog'
// ─── Date helpers ────────────────────────────────────────────────────────────
const toISO = d => d.toISOString().split('T')[0]
@@ -136,6 +137,7 @@ function CycleDetailModal({ cycle, onClose, onDeleted, onRecordLitter }) {
const [savingBreed, setSavingBreed] = useState(false)
const [deleting, setDeleting] = useState(false)
const [error, setError] = useState(null)
const confirm = useConfirm()
useEffect(() => {
fetch(`/api/breeding/heat-cycles/${cycle.id}/suggestions`)
@@ -162,7 +164,10 @@ function CycleDetailModal({ cycle, onClose, onDeleted, onRecordLitter }) {
}
async function deleteCycle() {
if (!window.confirm(`Delete heat cycle for ${cycle.dog_name}? This cannot be undone.`)) return
if (!(await confirm({
title: 'Delete heat cycle?',
message: `The heat cycle for ${cycle.dog_name} starting ${cycle.start_date} will be permanently removed.`,
}))) return
setDeleting(true)
try {
await fetch(`/api/breeding/heat-cycles/${cycle.id}`, { method: 'DELETE' })
@@ -349,25 +354,33 @@ export default function BreedingCalendar() {
const [selectedCycle, setSelectedCycle] = useState(null)
const [selectedDay, setSelectedDay] = useState(null)
const [pendingLitterCycle, setPendingLitterCycle] = useState(null)
const [loadError, setLoadError] = useState(false)
const navigate = useNavigate()
// allSettled: cycles still render if the dogs fetch fails (and vice versa)
const load = useCallback(async () => {
setLoading(true)
try {
const [cyclesRes, dogsRes] = await Promise.all([
fetch('/api/breeding/heat-cycles'),
fetch('/api/dogs')
])
const allCycles = await cyclesRes.json()
const dogsData = await dogsRes.json()
const allDogs = Array.isArray(dogsData) ? dogsData : (dogsData.dogs || [])
setCycles(Array.isArray(allCycles) ? allCycles : [])
setFemales(allDogs.filter(d => d.sex === 'female'))
} catch (e) {
console.error(e)
} finally {
setLoading(false)
setLoadError(false)
const [cyclesRes, dogsRes] = await Promise.allSettled([
fetch('/api/breeding/heat-cycles').then(r => r.ok ? r.json() : Promise.reject(new Error(`HTTP ${r.status}`))),
fetch('/api/dogs?sex=female&limit=200').then(r => r.ok ? r.json() : Promise.reject(new Error(`HTTP ${r.status}`))),
])
if (cyclesRes.status === 'fulfilled' && Array.isArray(cyclesRes.value)) {
setCycles(cyclesRes.value)
}
if (dogsRes.status === 'fulfilled') {
// paginated endpoint returns { data, total, ... }
const dogs = Array.isArray(dogsRes.value) ? dogsRes.value : (dogsRes.value.data || [])
setFemales(dogs.filter(d => d.sex === 'female'))
}
const failures = [cyclesRes, dogsRes].filter(r => r.status === 'rejected')
if (failures.length) {
console.error('Calendar load failures:', failures.map(f => f.reason))
setLoadError(true)
}
setLoading(false)
}, [])
useEffect(() => { load() }, [load])
@@ -470,6 +483,17 @@ export default function BreedingCalendar() {
return (
<div className="container" style={{ paddingTop: '2rem', paddingBottom: '3rem' }}>
{loadError && (
<div className="card" style={{
borderColor: 'var(--danger)', padding: '0.75rem 1rem', marginBottom: '1.5rem',
display: 'flex', alignItems: 'center', gap: '0.75rem',
}}>
<AlertCircle size={18} color="var(--danger)" />
<span style={{ flex: 1, fontSize: '0.9rem' }}>Some calendar data failed to load.</span>
<button className="btn btn-secondary" onClick={load}>Retry</button>
</div>
)}
{/* Header */}
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: '1.5rem', flexWrap: 'wrap', gap: '1rem' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: '0.75rem' }}>
+35 -20
View File
@@ -1,6 +1,6 @@
import { useEffect, useState } from 'react'
import { Link } from 'react-router-dom'
import { Dog, Activity, Heart, Calendar, Hash, ArrowRight } from 'lucide-react'
import { Dog, Activity, Heart, Calendar, Hash, ArrowRight, AlertCircle } from 'lucide-react'
import axios from 'axios'
function Dashboard() {
@@ -13,34 +13,38 @@ function Dashboard() {
})
const [recentDogs, setRecentDogs] = useState([])
const [loading, setLoading] = useState(true)
const [loadError, setLoadError] = useState(false)
useEffect(() => {
fetchDashboardData()
}, [])
// allSettled: one failing endpoint shouldn't blank the whole dashboard
const fetchDashboardData = async () => {
try {
const [dogsRes, littersRes, heatCyclesRes] = await Promise.all([
axios.get('/api/dogs', { params: { page: 1, limit: 8 } }),
axios.get('/api/litters', { params: { page: 1, limit: 1 } }),
axios.get('/api/breeding/heat-cycles/active')
])
setLoading(true)
setLoadError(false)
const [dogsRes, littersRes, heatCyclesRes] = await Promise.allSettled([
axios.get('/api/dogs', { params: { page: 1, limit: 8 } }),
axios.get('/api/litters', { params: { page: 1, limit: 1 } }),
axios.get('/api/breeding/heat-cycles/active')
])
const { data: recentDogsList, stats: dogStats } = dogsRes.data
setStats({
totalDogs: dogStats?.total ?? 0,
males: dogStats?.males ?? 0,
females: dogStats?.females ?? 0,
totalLitters: littersRes.data.total,
activeHeatCycles: heatCyclesRes.data.length
})
const dogData = dogsRes.status === 'fulfilled' ? dogsRes.value.data : null
setStats({
totalDogs: dogData?.stats?.total ?? 0,
males: dogData?.stats?.males ?? 0,
females: dogData?.stats?.females ?? 0,
totalLitters: littersRes.status === 'fulfilled' ? littersRes.value.data.total : 0,
activeHeatCycles: heatCyclesRes.status === 'fulfilled' ? heatCyclesRes.value.data.length : 0
})
setRecentDogs(dogData?.data ?? [])
setRecentDogs(recentDogsList)
setLoading(false)
} catch (error) {
console.error('Error fetching dashboard data:', error)
setLoading(false)
const failures = [dogsRes, littersRes, heatCyclesRes].filter(r => r.status === 'rejected')
if (failures.length) {
console.error('Error fetching dashboard data:', failures.map(f => f.reason))
setLoadError(true)
}
setLoading(false)
}
const calculateAge = (birthDate) => {
@@ -68,6 +72,17 @@ function Dashboard() {
<div className="container" style={{ paddingTop: '2rem', paddingBottom: '3rem' }}>
<h1 style={{ marginBottom: '2rem' }}>Dashboard</h1>
{loadError && (
<div className="card" style={{
borderColor: 'var(--danger)', padding: '0.75rem 1rem', marginBottom: '1.5rem',
display: 'flex', alignItems: 'center', gap: '0.75rem',
}}>
<AlertCircle size={18} color="var(--danger)" />
<span style={{ flex: 1, fontSize: '0.9rem' }}>Some dashboard data failed to load.</span>
<button className="btn btn-secondary" onClick={fetchDashboardData}>Retry</button>
</div>
)}
{/* Stats Grid */}
<div className="grid grid-4" style={{ marginBottom: '3rem' }}>
<div className="card stat-card">
+13 -5
View File
@@ -8,6 +8,8 @@ import ClearanceSummaryCard from '../components/ClearanceSummaryCard'
import HealthRecordForm from '../components/HealthRecordForm'
import GeneticPanelCard from '../components/GeneticPanelCard'
import { ShieldCheck } from 'lucide-react'
import { useToast } from '../components/Toast'
import { useConfirm } from '../components/ConfirmDialog'
function DogDetail() {
const { id } = useParams()
@@ -18,6 +20,8 @@ function DogDetail() {
const [uploading, setUploading] = useState(false)
const [selectedPhoto, setSelectedPhoto] = useState(0)
const fileInputRef = useRef(null)
const toast = useToast()
const confirm = useConfirm()
// Health records state
const [healthRecords, setHealthRecords] = useState([])
@@ -57,7 +61,7 @@ function DogDetail() {
fetchDog()
} catch (error) {
console.error('Error uploading photo:', error)
alert('Failed to upload photo')
toast.error(error.response?.data?.error || 'Failed to upload photo')
} finally {
setUploading(false)
if (fileInputRef.current) fileInputRef.current.value = ''
@@ -65,7 +69,7 @@ function DogDetail() {
}
const handleDeletePhoto = async (photoIndex) => {
if (!confirm('Delete this photo?')) return
if (!(await confirm({ title: 'Delete photo?', message: 'This photo will be permanently removed.' }))) return
try {
await axios.delete(`/api/dogs/${id}/photos/${photoIndex}`)
fetchDog()
@@ -74,7 +78,7 @@ function DogDetail() {
}
} catch (error) {
console.error('Error deleting photo:', error)
alert('Failed to delete photo')
toast.error(error.response?.data?.error || 'Failed to delete photo')
}
}
@@ -99,13 +103,17 @@ function DogDetail() {
const handleHealthSaved = () => { closeHealthForm(); fetchHealth() }
const handleDeleteHealth = async (rec) => {
const label = rec.test_name || (rec.test_type ? rec.test_type.replace(/_/g, ' ') : rec.record_type)
if (!confirm(`Delete health record "${label}"?`)) return
if (!(await confirm({
title: 'Delete health record?',
message: `"${label}"${rec.test_date ? ` from ${rec.test_date}` : ''} will be permanently removed.`,
}))) return
try {
await axios.delete(`/api/health/${rec.id}`)
toast.success('Health record deleted')
fetchHealth()
} catch (error) {
console.error('Error deleting health record:', error)
alert('Failed to delete health record')
toast.error(error.response?.data?.error || 'Failed to delete health record')
}
}
+4 -1
View File
@@ -4,6 +4,7 @@ import { Dog, Plus, Search, Calendar, Hash, ArrowRight, Trash2 } from 'lucide-re
import axios from 'axios'
import DogForm from '../components/DogForm'
import { ChampionBadge, ChampionBloodlineBadge } from '../components/ChampionBadge'
import { useToast } from '../components/Toast'
const LIMIT = 50
@@ -17,6 +18,7 @@ function DogList() {
const [showAddModal, setShowAddModal] = useState(false)
const [deleteTarget, setDeleteTarget] = useState(null) // { id, name }
const [deleting, setDeleting] = useState(false)
const toast = useToast()
const searchTimerRef = useRef(null)
useEffect(() => { fetchDogs(1, '', 'all') }, []) // eslint-disable-line
@@ -62,11 +64,12 @@ function DogList() {
setDeleting(true)
try {
await axios.delete(`/api/dogs/${deleteTarget.id}`)
toast.success(`${deleteTarget.name} deleted`)
setDeleteTarget(null)
fetchDogs(page, search, sexFilter)
} catch (err) {
console.error('Delete failed:', err)
alert('Failed to delete dog. Please try again.')
toast.error(err.response?.data?.error || 'Failed to delete dog. Please try again.')
} finally {
setDeleting(false)
}
+4 -1
View File
@@ -4,6 +4,7 @@ import { Dog, Plus, Search, Calendar, Hash, ArrowRight, Trash2, ExternalLink } f
import axios from 'axios'
import DogForm from '../components/DogForm'
import { ChampionBadge, ChampionBloodlineBadge } from '../components/ChampionBadge'
import { useToast } from '../components/Toast'
function ExternalDogs() {
const [dogs, setDogs] = useState([])
@@ -14,6 +15,7 @@ function ExternalDogs() {
const [showAddModal, setShowAddModal] = useState(false)
const [deleteTarget, setDeleteTarget] = useState(null) // { id, name }
const [deleting, setDeleting] = useState(false)
const toast = useToast()
useEffect(() => { fetchDogs() }, [])
useEffect(() => { filterDogs() }, [dogs, search, sexFilter])
@@ -49,11 +51,12 @@ function ExternalDogs() {
setDeleting(true)
try {
await axios.delete(`/api/dogs/${deleteTarget.id}`)
toast.success(`${deleteTarget.name} deleted`)
setDogs(prev => prev.filter(d => d.id !== deleteTarget.id))
setDeleteTarget(null)
} catch (err) {
console.error('Delete failed:', err)
alert('Failed to delete dog. Please try again.')
toast.error(err.response?.data?.error || 'Failed to delete dog. Please try again.')
} finally {
setDeleting(false)
}
+24 -5
View File
@@ -3,6 +3,8 @@ 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 }) {
@@ -18,6 +20,8 @@ function PuppyLogPanel({ litterId, puppy, whelpingDate }) {
record_type: 'weight_log'
})
const [saving, setSaving] = useState(false)
const toast = useToast()
const confirm = useConfirm()
useEffect(() => { if (open) fetchLogs() }, [open])
@@ -41,16 +45,22 @@ function PuppyLogPanel({ litterId, puppy, whelpingDate }) {
setShowAdd(false)
setForm(f => ({ ...f, weight_oz: '', weight_lbs: '', notes: '' }))
fetchLogs()
} catch (e) { console.error(e) }
} catch (e) {
console.error(e)
toast.error(e.response?.data?.error || 'Failed to save log entry')
}
finally { setSaving(false) }
}
const handleDelete = async (logId) => {
if (!window.confirm('Delete this log entry?')) return
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) }
} catch (e) {
console.error(e)
toast.error('Failed to delete log entry')
}
}
const TYPES = [
@@ -262,6 +272,8 @@ function LitterDetail() {
const [addMode, setAddMode] = useState('existing')
const [error, setError] = useState('')
const [saving, setSaving] = useState(false)
const toast = useToast()
const confirm = useConfirm()
useEffect(() => { fetchLitter(); fetchAllDogs() }, [id])
@@ -320,11 +332,18 @@ function LitterDetail() {
}
const handleUnlinkPuppy = async (puppyId) => {
if (!window.confirm('Remove this puppy from the litter? The dog record will not be deleted.')) return
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) }
} 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>
+10 -1
View File
@@ -3,6 +3,8 @@ import { Activity, Plus, Edit2, Trash2, ChevronRight } from 'lucide-react'
import { useNavigate } from 'react-router-dom'
import axios from 'axios'
import LitterForm from '../components/LitterForm'
import { useToast } from '../components/Toast'
import { useConfirm } from '../components/ConfirmDialog'
const LIMIT = 50
@@ -15,6 +17,8 @@ function LitterList() {
const [editingLitter, setEditingLitter] = useState(null)
const [prefill, setPrefill] = useState(null)
const navigate = useNavigate()
const toast = useToast()
const confirm = useConfirm()
useEffect(() => {
fetchLitters(1)
@@ -61,12 +65,17 @@ function LitterList() {
const handleDelete = async (e, id) => {
e.stopPropagation()
if (!window.confirm('Delete this litter record? Puppies will be unlinked but not deleted.')) return
if (!(await confirm({
title: 'Delete litter record?',
message: 'Puppies will be unlinked but their dog records are kept.',
}))) return
try {
await axios.delete(`/api/litters/${id}`)
toast.success('Litter deleted')
fetchLitters(page)
} catch (error) {
console.error('Error deleting litter:', error)
toast.error(error.response?.data?.error || 'Failed to delete litter')
}
}