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:
+35
-5
@@ -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>
|
||||
<ToastProvider>
|
||||
<ConfirmProvider>
|
||||
<AppInner />
|
||||
</ConfirmProvider>
|
||||
</ToastProvider>
|
||||
</Router>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
|
||||
@@ -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); }
|
||||
|
||||
@@ -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')
|
||||
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}`))),
|
||||
])
|
||||
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)
|
||||
|
||||
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' }}>
|
||||
|
||||
@@ -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([
|
||||
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
|
||||
const dogData = dogsRes.status === 'fulfilled' ? dogsRes.value.data : null
|
||||
setStats({
|
||||
totalDogs: dogStats?.total ?? 0,
|
||||
males: dogStats?.males ?? 0,
|
||||
females: dogStats?.females ?? 0,
|
||||
totalLitters: littersRes.data.total,
|
||||
activeHeatCycles: heatCyclesRes.data.length
|
||||
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">
|
||||
|
||||
@@ -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,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,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)
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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')
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user