diff --git a/client/src/App.jsx b/client/src/App.jsx index ae432f1..11ae2ef 100644 --- a/client/src/App.jsx +++ b/client/src/App.jsx @@ -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 ( @@ -24,10 +31,28 @@ function NavLink({ to, icon: Icon, label }) { ) } +function NotFound() { + return ( +
+ +

Page not found

+

+ That page doesn't exist or may have been moved. +

+ Back to Dashboard +
+ ) +} + 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 (
- + @@ -64,6 +89,7 @@ function AppInner() { } /> } /> } /> + } />
@@ -73,7 +99,11 @@ function AppInner() { function App() { return ( - + + + + + ) } diff --git a/client/src/components/ConfirmDialog.jsx b/client/src/components/ConfirmDialog.jsx new file mode 100644 index 0000000..4b38066 --- /dev/null +++ b/client/src/components/ConfirmDialog.jsx @@ -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 ( + + {children} + {dialog && ( +
close(false)}> +
e.stopPropagation()} + role="alertdialog" + aria-modal="true" + > +
+

+ + {dialog.title || 'Are you sure?'} +

+
+
+

+ {dialog.message} +

+
+
+ + +
+
+
+ )} +
+ ) +} diff --git a/client/src/components/Toast.jsx b/client/src/components/Toast.jsx new file mode 100644 index 0000000..c550a24 --- /dev/null +++ b/client/src/components/Toast.jsx @@ -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 ( + + {children} +
+ {toasts.map(t => ( +
+ {t.type === 'success' + ? + : } + {t.message} + +
+ ))} +
+
+ ) +} diff --git a/client/src/hooks/useSettings.jsx b/client/src/hooks/useSettings.jsx index cafb854..69679a1 100644 --- a/client/src/hooks/useSettings.jsx +++ b/client/src/hooks/useSettings.jsx @@ -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 ( - + {children} ) diff --git a/client/src/index.css b/client/src/index.css index c999e2d..b0a6cd7 100644 --- a/client/src/index.css +++ b/client/src/index.css @@ -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); } diff --git a/client/src/pages/BreedingCalendar.jsx b/client/src/pages/BreedingCalendar.jsx index 1846a35..5cb5066 100644 --- a/client/src/pages/BreedingCalendar.jsx +++ b/client/src/pages/BreedingCalendar.jsx @@ -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 (
+ {loadError && ( +
+ + Some calendar data failed to load. + +
+ )} + {/* Header */}
diff --git a/client/src/pages/Dashboard.jsx b/client/src/pages/Dashboard.jsx index e197c6c..63d898c 100644 --- a/client/src/pages/Dashboard.jsx +++ b/client/src/pages/Dashboard.jsx @@ -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() {

Dashboard

+ {loadError && ( +
+ + Some dashboard data failed to load. + +
+ )} + {/* Stats Grid */}
diff --git a/client/src/pages/DogDetail.jsx b/client/src/pages/DogDetail.jsx index 2ffd296..cfdc17f 100644 --- a/client/src/pages/DogDetail.jsx +++ b/client/src/pages/DogDetail.jsx @@ -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') } } diff --git a/client/src/pages/DogList.jsx b/client/src/pages/DogList.jsx index a9910ce..2480ae1 100644 --- a/client/src/pages/DogList.jsx +++ b/client/src/pages/DogList.jsx @@ -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) } diff --git a/client/src/pages/ExternalDogs.jsx b/client/src/pages/ExternalDogs.jsx index df9b8c8..2696c20 100644 --- a/client/src/pages/ExternalDogs.jsx +++ b/client/src/pages/ExternalDogs.jsx @@ -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) } diff --git a/client/src/pages/LitterDetail.jsx b/client/src/pages/LitterDetail.jsx index 50da907..a912080 100644 --- a/client/src/pages/LitterDetail.jsx +++ b/client/src/pages/LitterDetail.jsx @@ -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
Loading litter...
diff --git a/client/src/pages/LitterList.jsx b/client/src/pages/LitterList.jsx index ec6f324..b5845eb 100644 --- a/client/src/pages/LitterList.jsx +++ b/client/src/pages/LitterList.jsx @@ -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') } }