Compare commits

..

8 Commits

Author SHA1 Message Date
Jason Stedwell f7a5e8ecb8 Docs: API.md + ROADMAP.md updates for the health-record overhaul
Build and Push Docker Image / build (push) Successful in 33s
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-06 15:07:04 -05:00
Jason Stedwell f3fc01f31c Polish: live OFA age warning, puppy sorting, responsive fixes, form hints
- HealthRecordForm shows a live amber warning under Test Date when the dog is
  under the OFA minimum certification age (submit-time block unchanged)
- LitterDetail puppies sortable by name / sex / birth date
- Responsive at 375px: DogDetail photos+info grid stacks, header wraps;
  puppy grid uses min(220px,100%); navbar wraps with scrollable icon row;
  calendar uses minmax(0,1fr) columns so cells shrink instead of overflowing
- LitterForm explains why sire/dam selects are locked when editing
- Add .claude/launch.json for the vite dev server

Verified in-browser at 375px and desktop: no horizontal overflow on any page.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-06 15:06:32 -05:00
Jason Stedwell 55d636b512 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>
2026-07-06 14:55:32 -05:00
Jason Stedwell 31b1d903f4 Remove dead debug scripts test_app.js / test_express.js
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-06 14:47:41 -05:00
Jason Stedwell 5e45031789 Harden dog endpoints: parent validation, 404s, safe photo handling, transactions
- safeParsePhotos(): corrupted photo_urls JSON no longer crashes every request
  that loads the dog
- DELETE /photos/:idx rejects NaN/out-of-range indexes with 400 (was silent no-op)
- PUT /:id returns 404 for missing dogs (was silent success)
- POST/PUT validate sire/dam exist, have the right sex, and aren't the dog itself
- Dog create (dog+parents), update (dog+parent swap), and cascade delete now run
  in transactions — no more partial writes

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-06 14:47:41 -05:00
Jason Stedwell 1f1e0c3e5a Deps: bump better-sqlite3 to ^11 (Node 18-24 prebuilds), drop unused auth deps
bcrypt, jsonwebtoken, express-validator, express-rate-limit were never imported.
better-sqlite3 9.x fails to compile on Node >=22.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-06 14:45:22 -05:00
Jason Stedwell ce287f63d1 Fix schema drift: fresh installs were broken
- Create heat_cycles table in init.js (breeding.js used it but nothing created it)
- Add litters columns the routes actually use (breeding_date, whelping_date, puppy_count)
- Add health_records columns used by puppy logs (record_date, description)
- Puppy log insert now also sets NOT NULL test_date
- Migration 002 no-ops on a fresh DB instead of crashing on missing dogs table
- init.js honors DB_PATH and creates the data dir, so migrations and the app
  always target the same file

Verified: full heat-cycle/litter/puppy-log CRUD passes against a virgin database.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-06 14:45:22 -05:00
Jason Stedwell c9d2aa734e Health records: typed fields per record type, document upload, delete button
- HealthRecordForm: per-type field configs (vaccination/exam/surgery/medication),
  expanded OFA test catalog with result scales, min-age validation, expiry rules
- Document upload (PDF/image, 15MB) via new POST /api/health/upload
- Delete button for health records on DogDetail
- Fold legacy vet_name into performed_by; normalize payload per record type

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-06 14:39:45 -05:00
27 changed files with 3078 additions and 245 deletions
+12
View File
@@ -0,0 +1,12 @@
{
"version": "0.0.1",
"configurations": [
{
"name": "client",
"runtimeExecutable": "npm",
"runtimeArgs": ["run", "dev"],
"cwd": "client",
"port": 5173
}
]
}
+31 -1
View File
@@ -86,19 +86,49 @@ Add a weight/health log entry.
Manage OFA clearances and veterinary records.
**Record types** (`record_type`): `ofa_clearance`, `vaccination`, `exam`, `surgery`, `medication`, `other`.
**Valid `test_type` values** (OFA clearances only): `hip_ofa`, `hip_pennhip`, `elbow_ofa`, `patella_ofa`, `heart_ofa`, `heart_echo`, `eye_caer`, `thyroid_ofa`.
> DNA is **not** a health test type — DNA panels are managed exclusively by the Genetics module (`/api/genetics`).
### `GET /dog/:dogId`
Get all health records for a dog.
### `GET /dog/:dogId/clearance-summary`
Get GRCA core clearance status (Hip, Elbow, Heart, Eyes).
- **Response**: `{ summary, grca_eligible, age_eligible, chic_number }`
- Only recurring tests (CAER eyes, advanced/echo cardiac) carry an `expires_at`; permanent certifications (hip, elbow, etc.) never report `expired`/`expiring_soon`.
### `GET /dog/:dogId/chic-eligible`
Check if a dog has all required CHIC tests.
### `GET /:id`
Get a single health record.
### `POST /`
Create health record.
- **Body**: `dog_id`, `record_type`, `test_date` (required), `test_type`, `test_name`, `ofa_result`, `ofa_number`, etc.
- **Body**: `dog_id`, `record_type`, `test_date` (all required), plus optional `test_type`, `test_name`, `ofa_result`, `ofa_number`, `performed_by`, `expires_at`, `result`, `next_due`, `document_url`, `notes`.
- **Validation**: `test_type`, if present, must be one of the valid values above (else `400`).
- **Note**: `performed_by` is the single clinician field (the legacy `vet_name` column is deprecated).
### `PUT /:id`
Update an existing health record. Same body as `POST /` (minus `dog_id`).
### `DELETE /:id`
Delete a health record.
- **Response**: `{ "message": "Health record deleted successfully" }`
### `POST /upload`
Upload a supporting document (PDF or image, ≤ 15 MB) and get back a URL to store in a record's `document_url`. Record-agnostic — works before the record itself is saved.
- **Form-Data**: `document` (file)
- **Response**: `{ "url": "/uploads/..." }`
### `GET /dog/:dogId/cancer-history`
Get cancer-history records for a dog.
### `POST /cancer-history`
Add a cancer-history record.
- **Body**: `dog_id`, `cancer_type` (required), `age_at_diagnosis`, `age_at_death`, `cause_of_death`, `notes`.
---
+9
View File
@@ -52,6 +52,15 @@
- DNA genetic panel (PRA, ICH, NCL, DM, MD variants)
- Cancer lineage & longevity tracking
- Breeding eligibility checker (GRCA + CHIC gates)
- **Health Record modal overhaul** (July 6, 2026):
- [x] Per-record-type field sets (OFA / vaccination / exam / surgery / medication / other)
- [x] Result options scoped to the selected test; blank default (no fabricated clearances)
- [x] Added Thyroid & Patella OFA tests; expiry only shown for recurring tests (CAER, advanced cardiac)
- [x] Date & 24-month age validation on OFA hip/elbow
- [x] Unified clinician field (`performed_by`); deprecated `vet_name`
- [x] DNA removed as a health `test_type` (owned solely by Genetics module)
- [x] Delete action on the health records list
- [x] Document upload (PDF/image) via `POST /api/health/upload`
- **v0.6.1** (March 10, 2026) - COI Direct-Relation Fix
- Fixed `calculateCOI` to correctly compute coefficient for parent×offspring pairings (~25%)
+11 -3
View File
@@ -139,14 +139,22 @@
height: 4rem;
}
.nav-links {
.navbar .container {
flex-wrap: wrap;
gap: 0.25rem;
}
.nav-links {
gap: 0.25rem;
max-width: 100%;
overflow-x: auto;
-webkit-overflow-scrolling: touch;
}
.nav-link span {
display: none;
}
.nav-link {
padding: 0.625rem;
}
+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>
)
}
+236 -59
View File
@@ -1,41 +1,176 @@
import { useState } from 'react'
import { X } from 'lucide-react'
import { X, Upload } from 'lucide-react'
import axios from 'axios'
const RECORD_TYPES = ['ofa_clearance', 'vaccination', 'exam', 'surgery', 'medication', 'other']
const OFA_TEST_TYPES = [
{ value: 'hip_ofa', label: 'Hip - OFA' },
{ value: 'hip_pennhip', label: 'Hip - PennHIP' },
{ value: 'elbow_ofa', label: 'Elbow - OFA' },
{ value: 'heart_ofa', label: 'Heart - OFA' },
{ value: 'heart_echo', label: 'Heart - Echo' },
{ value: 'eye_caer', label: 'Eyes - CAER' },
const RECORD_TYPES = [
{ value: 'ofa_clearance', label: 'OFA Clearance' },
{ value: 'vaccination', label: 'Vaccination' },
{ value: 'exam', label: 'Exam' },
{ value: 'surgery', label: 'Surgery' },
{ value: 'medication', label: 'Medication' },
{ value: 'other', label: 'Other' },
]
const OFA_RESULTS = ['Excellent', 'Good', 'Fair', 'Mild', 'Moderate', 'Severe', 'Normal', 'Abnormal', 'Pass', 'Fail']
// OFA / orthopedic test types. `results` drives the result dropdown (null = free-text,
// e.g. a PennHIP distraction index). `recurs` = result has an expiry and must be
// re-tested; permanent certifications (hip/elbow/etc.) do not. `minAgeMonths` triggers
// an age warning when the test date implies the dog was too young for final certification.
const OFA_TESTS = {
hip_ofa: { label: 'Hip OFA', results: ['Excellent', 'Good', 'Fair', 'Borderline', 'Mild', 'Moderate', 'Severe'], recurs: false, minAgeMonths: 24 },
hip_pennhip: { label: 'Hip PennHIP (DI)', results: null, recurs: false },
elbow_ofa: { label: 'Elbow OFA', results: ['Normal', 'Grade I', 'Grade II', 'Grade III'], recurs: false, minAgeMonths: 24 },
patella_ofa: { label: 'Patella OFA', results: ['Normal', 'Grade I', 'Grade II', 'Grade III', 'Grade IV'], recurs: false },
heart_ofa: { label: 'Heart OFA Basic', results: ['Normal', 'Equivocal', 'Abnormal'], recurs: false },
heart_echo: { label: 'Heart Advanced (Echo)', results: ['Normal', 'Equivocal', 'Abnormal'], recurs: true },
eye_caer: { label: 'Eyes CAER', results: ['Normal', 'Abnormal'], recurs: true },
thyroid_ofa: { label: 'Thyroid OFA', results: ['Normal', 'Equivocal', 'Autoimmune Thyroiditis', 'Idiopathic Hypothyroidism'], recurs: false },
}
const OFA_TEST_KEYS = Object.keys(OFA_TESTS)
// Field configuration per non-OFA record type. Reuses existing columns
// (test_name / result / next_due / performed_by) with type-appropriate labels
// so no schema change is needed.
const TYPE_FIELDS = {
vaccination: {
nameLabel: 'Vaccine', namePlaceholder: 'e.g. Rabies, DHPP, Bordetella',
dateLabel: 'Date Administered',
nextDue: 'Booster Due',
clinicianLabel: 'Administered By',
},
exam: {
nameLabel: 'Exam Type', namePlaceholder: 'e.g. Annual wellness, Cardiac',
dateLabel: 'Exam Date',
nextDue: 'Follow-up Due',
result: 'Findings', resultPlaceholder: 'Normal, or note abnormal findings',
clinicianLabel: 'Veterinarian',
},
surgery: {
nameLabel: 'Procedure', namePlaceholder: 'e.g. Spay, TPLO',
dateLabel: 'Surgery Date',
result: 'Outcome', resultPlaceholder: 'e.g. Successful, complications',
clinicianLabel: 'Surgeon / Vet',
},
medication: {
nameLabel: 'Medication', namePlaceholder: 'e.g. Apoquel, Carprofen',
dateLabel: 'Start Date',
nextDue: 'End / Refill Date',
result: 'Dosage & Frequency', resultPlaceholder: 'e.g. 16mg once daily',
clinicianLabel: 'Prescribed By',
},
other: {
nameLabel: 'Name', namePlaceholder: 'Record name',
dateLabel: 'Date',
nextDue: 'Next Due',
result: 'Result', resultPlaceholder: 'Result',
clinicianLabel: 'Performed By',
},
}
const EMPTY = {
record_type: 'ofa_clearance', test_type: 'hip_ofa', test_name: '',
test_date: '', ofa_result: 'Good', ofa_number: '', performed_by: '',
expires_at: '', result: '', vet_name: '', next_due: '', notes: '', document_url: '',
test_date: '', ofa_result: '', ofa_number: '', performed_by: '',
expires_at: '', result: '', next_due: '', notes: '', document_url: '',
}
export default function HealthRecordForm({ dogId, record, onClose, onSave }) {
const [form, setForm] = useState(record || { ...EMPTY, dog_id: dogId })
const [saving, setSaving] = useState(false)
const [error, setError] = useState(null)
const today = () => new Date().toISOString().slice(0, 10)
const isOFA = form.record_type === 'ofa_clearance'
const set = (k, v) => setForm(f => ({ ...f, [k]: v }))
export default function HealthRecordForm({ dogId, dogBirthDate, record, onClose, onSave }) {
// Migrate legacy records: the clinician used to live in `vet_name`; fold it into performed_by.
const initial = record
? { ...record, performed_by: record.performed_by || record.vet_name || '' }
: { ...EMPTY, dog_id: dogId }
const [form, setForm] = useState(initial)
const [saving, setSaving] = useState(false)
const [uploading, setUploading] = useState(false)
const [error, setError] = useState(null)
const isOFA = form.record_type === 'ofa_clearance'
const ofaTest = isOFA ? OFA_TESTS[form.test_type] : null
const cfg = !isOFA ? (TYPE_FIELDS[form.record_type] || TYPE_FIELDS.other) : null
const set = (k, v) => setForm(f => ({ ...f, [k]: v }))
// Switching the OFA test resets the result, since result scales differ per test.
const setTestType = (v) => setForm(f => ({ ...f, test_type: v, ofa_result: '', expires_at: '' }))
// Switching record type clears type-specific fields to avoid stale/mismatched data.
const setRecordType = (v) => setForm(f => ({
...f, record_type: v,
ofa_result: '', result: '', test_name: '', expires_at: '', next_due: '',
test_type: v === 'ofa_clearance' ? (f.test_type || 'hip_ofa') : f.test_type,
}))
// Live warning while typing — validate() still hard-blocks on submit.
const ageWarning = (() => {
if (!isOFA || !ofaTest?.minAgeMonths || !dogBirthDate || !form.test_date) return null
const months = (new Date(form.test_date) - new Date(dogBirthDate)) / (1000 * 60 * 60 * 24 * 30.44)
if (months < 0 || months >= ofaTest.minAgeMonths) return null
return `Dog was ~${Math.floor(months)} months old on this date — final ${ofaTest.label} certification requires ${ofaTest.minAgeMonths} months.`
})()
const validate = () => {
if (!form.test_date) return 'A date is required.'
if (form.test_date > today()) return 'Date cannot be in the future.'
if (form.expires_at && form.expires_at < form.test_date) return 'Expiry date cannot be before the test date.'
if (form.next_due && form.next_due < form.test_date) return 'Next-due date cannot be before the test date.'
if (isOFA && !form.ofa_result?.toString().trim()) return 'Select a result for this clearance.'
if (isOFA && ofaTest?.minAgeMonths && dogBirthDate) {
const months = (new Date(form.test_date) - new Date(dogBirthDate)) / (1000 * 60 * 60 * 24 * 30.44)
if (months < ofaTest.minAgeMonths) {
return `Final ${ofaTest.label} certification requires the dog to be at least ${ofaTest.minAgeMonths} months old on the test date.`
}
}
return null
}
const handleUpload = async (file) => {
if (!file) return
setUploading(true)
setError(null)
try {
const fd = new FormData()
fd.append('document', file)
const { data } = await axios.post('/api/health/upload', fd)
set('document_url', data.url)
} catch (err) {
setError(err.response?.data?.error || 'Failed to upload document')
} finally {
setUploading(false)
}
}
const handleSubmit = async (e) => {
e.preventDefault()
const v = validate()
if (v) { setError(v); return }
setSaving(true)
setError(null)
// Normalize the payload: only send fields relevant to the chosen type so we don't
// persist stale values from a different branch. `performed_by` is the single clinician field.
const payload = {
dog_id: dogId,
record_type: form.record_type,
test_date: form.test_date,
performed_by: form.performed_by || null,
document_url: form.document_url || null,
notes: form.notes || null,
// OFA-only
test_type: isOFA ? form.test_type : null,
ofa_number: isOFA ? (form.ofa_number || null) : null,
ofa_result: isOFA ? (form.ofa_result || null) : null,
expires_at: isOFA && ofaTest?.recurs ? (form.expires_at || null) : null,
// Non-OFA
test_name: !isOFA ? (form.test_name || null) : null,
result: !isOFA && cfg?.result ? (form.result || null) : null,
next_due: !isOFA && cfg?.nextDue ? (form.next_due || null) : null,
}
try {
if (record && record.id) {
await axios.put(`/api/health/${record.id}`, form)
await axios.put(`/api/health/${record.id}`, payload)
} else {
await axios.post('/api/health', { ...form, dog_id: dogId })
await axios.post('/api/health', payload)
}
onSave()
} catch (err) {
@@ -55,6 +190,7 @@ export default function HealthRecordForm({ dogId, record, onClose, onSave }) {
padding: '0.5rem 0.75rem', color: 'var(--text-primary)', fontSize: '0.9rem',
boxSizing: 'border-box',
}
const hintStyle = { fontSize: '0.72rem', color: 'var(--text-muted)', marginTop: '0.2rem' }
const fw = { display: 'flex', flexDirection: 'column', gap: '0.25rem' }
const grid2 = { display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '1rem' }
@@ -78,12 +214,8 @@ export default function HealthRecordForm({ dogId, record, onClose, onSave }) {
{/* Record type */}
<div style={fw}>
<label style={labelStyle}>Record Type</label>
<select style={inputStyle} value={form.record_type} onChange={e => set('record_type', e.target.value)}>
{RECORD_TYPES.map(t => (
<option key={t} value={t}>
{t.replace(/_/g, ' ').replace(/\b\w/g, c => c.toUpperCase())}
</option>
))}
<select style={inputStyle} value={form.record_type} onChange={e => setRecordType(e.target.value)}>
{RECORD_TYPES.map(t => <option key={t.value} value={t.value}>{t.label}</option>)}
</select>
</div>
@@ -91,16 +223,22 @@ export default function HealthRecordForm({ dogId, record, onClose, onSave }) {
<>
<div style={grid2}>
<div style={fw}>
<label style={labelStyle}>OFA Test Type</label>
<select style={inputStyle} value={form.test_type} onChange={e => set('test_type', e.target.value)}>
{OFA_TEST_TYPES.map(t => <option key={t.value} value={t.value}>{t.label}</option>)}
<label style={labelStyle}>Test Type</label>
<select style={inputStyle} value={form.test_type} onChange={e => setTestType(e.target.value)}>
{OFA_TEST_KEYS.map(k => <option key={k} value={k}>{OFA_TESTS[k].label}</option>)}
</select>
</div>
<div style={fw}>
<label style={labelStyle}>OFA Result</label>
<select style={inputStyle} value={form.ofa_result} onChange={e => set('ofa_result', e.target.value)}>
{OFA_RESULTS.map(r => <option key={r} value={r}>{r}</option>)}
</select>
<label style={labelStyle}>Result *</label>
{ofaTest?.results ? (
<select style={inputStyle} value={form.ofa_result} onChange={e => set('ofa_result', e.target.value)}>
<option value="">Select result</option>
{ofaTest.results.map(r => <option key={r} value={r}>{r}</option>)}
</select>
) : (
<input style={inputStyle} placeholder="Distraction Index, e.g. 0.42"
value={form.ofa_result} onChange={e => set('ofa_result', e.target.value)} />
)}
</div>
</div>
<div style={grid2}>
@@ -118,54 +256,93 @@ export default function HealthRecordForm({ dogId, record, onClose, onSave }) {
<div style={grid2}>
<div style={fw}>
<label style={labelStyle}>Test Date *</label>
<input style={inputStyle} type="date" required value={form.test_date}
<input style={inputStyle} type="date" required max={today()} value={form.test_date}
onChange={e => set('test_date', e.target.value)} />
{ageWarning && (
<span style={{ ...hintStyle, color: 'var(--warning)' }}>{ageWarning}</span>
)}
</div>
<div style={fw}>
<label style={labelStyle}>Expires At</label>
<input style={inputStyle} type="date" value={form.expires_at}
onChange={e => set('expires_at', e.target.value)} />
</div>
{ofaTest?.recurs ? (
<div style={fw}>
<label style={labelStyle}>Expires / Re-test Due</label>
<input style={inputStyle} type="date" min={form.test_date} value={form.expires_at}
onChange={e => set('expires_at', e.target.value)} />
<span style={hintStyle}>This test is typically valid ~12 months.</span>
</div>
) : (
<div style={fw}>
<label style={labelStyle}>&nbsp;</label>
<span style={{ ...hintStyle, marginTop: 0, alignSelf: 'center' }}>
Permanent certification no expiry.
</span>
</div>
)}
</div>
</>
) : (
<>
<div style={fw}>
<label style={labelStyle}>Test / Procedure Name</label>
<input style={inputStyle} placeholder="e.g. Rabies, Bordetella..." value={form.test_name}
<label style={labelStyle}>{cfg.nameLabel}</label>
<input style={inputStyle} placeholder={cfg.namePlaceholder} value={form.test_name}
onChange={e => set('test_name', e.target.value)} />
</div>
<div style={grid2}>
<div style={fw}>
<label style={labelStyle}>Date *</label>
<input style={inputStyle} type="date" required value={form.test_date}
<label style={labelStyle}>{cfg.dateLabel} *</label>
<input style={inputStyle} type="date" required max={today()} value={form.test_date}
onChange={e => set('test_date', e.target.value)} />
</div>
<div style={fw}>
<label style={labelStyle}>Next Due</label>
<input style={inputStyle} type="date" value={form.next_due}
onChange={e => set('next_due', e.target.value)} />
</div>
{cfg.nextDue && (
<div style={fw}>
<label style={labelStyle}>{cfg.nextDue}</label>
<input style={inputStyle} type="date" min={form.test_date} value={form.next_due}
onChange={e => set('next_due', e.target.value)} />
</div>
)}
</div>
<div style={grid2}>
{cfg.result && (
<div style={fw}>
<label style={labelStyle}>{cfg.result}</label>
<input style={inputStyle} placeholder={cfg.resultPlaceholder} value={form.result}
onChange={e => set('result', e.target.value)} />
</div>
)}
<div style={fw}>
<label style={labelStyle}>Result</label>
<input style={inputStyle} placeholder="Normal, Pass, etc." value={form.result}
onChange={e => set('result', e.target.value)} />
</div>
<div style={fw}>
<label style={labelStyle}>Vet Name</label>
<input style={inputStyle} placeholder="Dr. Smith" value={form.vet_name}
onChange={e => set('vet_name', e.target.value)} />
<label style={labelStyle}>{cfg.clinicianLabel}</label>
<input style={inputStyle} placeholder="Dr. Smith" value={form.performed_by}
onChange={e => set('performed_by', e.target.value)} />
</div>
</div>
</>
)}
<div style={fw}>
<label style={labelStyle}>Document URL (optional)</label>
<input style={inputStyle} type="url" placeholder="https://ofa.org/..." value={form.document_url}
onChange={e => set('document_url', e.target.value)} />
<label style={labelStyle}>Document (optional)</label>
<div style={{ display: 'flex', gap: '0.5rem' }}>
<input style={{ ...inputStyle, flex: 1 }} type="url" placeholder="Paste a URL or upload a file"
value={form.document_url} onChange={e => set('document_url', e.target.value)} />
<label className="btn btn-ghost" style={{
whiteSpace: 'nowrap', cursor: uploading ? 'wait' : 'pointer',
display: 'inline-flex', alignItems: 'center', gap: '0.35rem',
}}>
<Upload size={14} />
{uploading ? 'Uploading…' : 'Upload'}
<input type="file" accept=".pdf,image/*" style={{ display: 'none' }} disabled={uploading}
onChange={e => handleUpload(e.target.files?.[0])} />
</label>
</div>
{form.document_url && (
<span style={hintStyle}>
<a href={form.document_url} target="_blank" rel="noopener noreferrer"
style={{ color: 'var(--text-primary)' }}>View attached document</a>
{' · '}
<button type="button" onClick={() => set('document_url', '')} style={{
background: 'none', border: 'none', color: 'var(--danger)',
cursor: 'pointer', padding: 0, fontSize: '0.72rem',
}}>Remove</button>
</span>
)}
</div>
<div style={fw}>
+5
View File
@@ -116,6 +116,11 @@ function LitterForm({ litter, prefill, onClose, onSave }) {
<option key={d.id} value={d.id}>{d.name} {d.registration_number ? `(${d.registration_number})` : ''}</option>
))}
</select>
{!!litter && (
<p style={{ fontSize: '0.78rem', color: 'var(--text-muted)', marginTop: '0.25rem' }}>
Parents can't be changed after the litter is created.
</p>
)}
</div>
<div className="form-group">
+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); }
+41 -17
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' }}>
@@ -510,7 +534,7 @@ export default function BreedingCalendar() {
</div>
{/* Day headers */}
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(7, 1fr)', borderBottom: '1px solid var(--border)' }}>
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(7, minmax(0, 1fr))', borderBottom: '1px solid var(--border)' }}>
{DAY_NAMES.map(d => (
<div key={d} style={{ padding: '0.5rem', textAlign: 'center', fontSize: '0.75rem', fontWeight: 600, color: 'var(--text-muted)', textTransform: 'uppercase', letterSpacing: '0.05em' }}>{d}</div>
))}
@@ -520,7 +544,7 @@ export default function BreedingCalendar() {
{loading ? (
<div className="loading" style={{ minHeight: 280 }}>Loading calendar</div>
) : (
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(7, 1fr)' }}>
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(7, minmax(0, 1fr))' }}>
{Array.from({ length: rows * 7 }).map((_, idx) => {
const dayNum = idx - startPad + 1
const isValid = dayNum >= 1 && dayNum <= lastDay.getDate()
+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">
+30 -7
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')
}
}
@@ -97,6 +101,21 @@ function DogDetail() {
const openEditHealth = (rec) => { setEditingRecord(rec); setShowHealthForm(true) }
const closeHealthForm = () => { setShowHealthForm(false); setEditingRecord(null) }
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 (!(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)
toast.error(error.response?.data?.error || 'Failed to delete health record')
}
}
if (loading) return <div className="container loading">Loading...</div>
if (!dog) return <div className="container">Dog not found</div>
@@ -107,7 +126,7 @@ function DogDetail() {
return (
<div className="container" style={{ paddingTop: '2rem', paddingBottom: '3rem' }}>
{/* Header */}
<div style={{ display: 'flex', alignItems: 'center', gap: '1rem', marginBottom: '2rem' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: '1rem', marginBottom: '2rem', flexWrap: 'wrap' }}>
<button className="btn-icon" onClick={() => navigate('/dogs')} style={{ marginRight: '0.5rem' }}>
<ArrowLeft size={20} />
</button>
@@ -141,7 +160,7 @@ function DogDetail() {
</div>
</div>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 2fr', gap: '1.5rem', marginBottom: '1.5rem' }}>
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(min(320px, 100%), 1fr))', gap: '1.5rem', marginBottom: '1.5rem' }}>
{/* Photo Section */}
<div className="card" style={{ padding: '1rem' }}>
<div style={{ marginBottom: '0.75rem', display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
@@ -356,9 +375,9 @@ function DogDetail() {
<span style={{ fontWeight: 500, fontSize: '0.875rem' }}>
{rec.test_name || (rec.test_type ? rec.test_type.replace(/_/g, ' ') : rec.record_type)}
</span>
{rec.ofa_result && (
{(rec.ofa_result || rec.result) && (
<span style={{ marginLeft: '0.5rem', fontSize: '0.75rem', color: 'var(--text-muted)' }}>
{rec.ofa_result}{rec.ofa_number ? ` · ${rec.ofa_number}` : ''}
{rec.ofa_result || rec.result}{rec.ofa_number ? ` · ${rec.ofa_number}` : ''}
</span>
)}
</div>
@@ -368,6 +387,9 @@ function DogDetail() {
<button className="btn-icon" style={{ padding: '0.2rem' }} onClick={() => openEditHealth(rec)}>
<Edit size={14} />
</button>
<button className="btn-icon" style={{ padding: '0.2rem' }} onClick={() => handleDeleteHealth(rec)} title="Delete record">
<Trash2 size={14} color="var(--danger)" />
</button>
</div>
))}
</div>
@@ -428,6 +450,7 @@ function DogDetail() {
{showHealthForm && (
<HealthRecordForm
dogId={id}
dogBirthDate={dog?.birth_date}
record={editingRecord}
onClose={closeHealthForm}
onSave={handleHealthSaved}
+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)
}
+53 -12
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,9 @@ function LitterDetail() {
const [addMode, setAddMode] = useState('existing')
const [error, setError] = useState('')
const [saving, setSaving] = useState(false)
const [puppySort, setPuppySort] = useState('name')
const toast = useToast()
const confirm = useConfirm()
useEffect(() => { fetchLitter(); fetchAllDogs() }, [id])
@@ -320,11 +333,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>
@@ -332,6 +352,12 @@ function LitterDetail() {
const puppyCount = litter.puppies?.length ?? 0
const sortedPuppies = [...(litter.puppies || [])].sort((a, b) => {
if (puppySort === 'sex') return (a.sex || '').localeCompare(b.sex || '') || (a.name || '').localeCompare(b.name || '')
if (puppySort === 'dob') return (a.date_of_birth || '').localeCompare(b.date_of_birth || '') || (a.name || '').localeCompare(b.name || '')
return (a.name || '').localeCompare(b.name || '')
})
return (
<div className="container">
{/* Header */}
@@ -386,12 +412,27 @@ function LitterDetail() {
)}
{/* Puppies section */}
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '1rem' }}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '1rem', flexWrap: 'wrap', gap: '0.75rem' }}>
<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 style={{ display: 'flex', alignItems: 'center', gap: '0.75rem' }}>
{puppyCount > 1 && (
<select
value={puppySort}
onChange={e => setPuppySort(e.target.value)}
className="input"
style={{ width: 'auto', fontSize: '0.85rem', padding: '0.4rem 0.6rem' }}
aria-label="Sort puppies"
>
<option value="name">Sort: Name</option>
<option value="sex">Sort: Sex</option>
<option value="dob">Sort: Birth Date</option>
</select>
)}
<button className="btn btn-primary" onClick={() => { setShowAddPuppy(true); setError('') }}>
<Plus size={16} style={{ marginRight: '0.4rem' }} />
Add Puppy
</button>
</div>
</div>
{puppyCount === 0 ? (
@@ -400,8 +441,8 @@ function LitterDetail() {
<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 style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(min(220px, 100%), 1fr))', gap: '1rem' }}>
{sortedPuppies.map(puppy => (
<div key={puppy.id} className="card" style={{ position: 'relative' }}>
<button
className="btn-icon"
+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')
}
}
+2177
View File
File diff suppressed because it is too large Load Diff
+1 -5
View File
@@ -17,13 +17,9 @@
"dependencies": {
"express": "^4.18.2",
"cors": "^2.8.5",
"better-sqlite3": "^9.4.3",
"better-sqlite3": "^11.10.0",
"multer": "^1.4.5-lts.1",
"bcrypt": "^5.1.1",
"jsonwebtoken": "^9.0.2",
"express-validator": "^7.0.1",
"helmet": "^7.1.0",
"express-rate-limit": "^7.1.5",
"dotenv": "^16.4.5"
},
"devDependencies": {
+37 -2
View File
@@ -2,8 +2,11 @@ const Database = require('better-sqlite3');
const path = require('path');
const fs = require('fs');
const dbPath = path.join(__dirname, '../../data');
const db = new Database(path.join(dbPath, 'breedr.db'));
// Honor DB_PATH (used by Docker and the migration runner in index.js) so
// migrations and the app always operate on the same file.
const dbFile = process.env.DB_PATH || path.join(__dirname, '../../data/breedr.db');
fs.mkdirSync(path.dirname(dbFile), { recursive: true });
const db = new Database(dbFile);
function getDatabase() {
return db;
@@ -91,6 +94,9 @@ function initDatabase() {
male_count INTEGER DEFAULT 0,
female_count INTEGER DEFAULT 0,
stillborn_count INTEGER DEFAULT 0,
breeding_date TEXT,
whelping_date TEXT,
puppy_count INTEGER DEFAULT 0,
notes TEXT,
created_at TEXT DEFAULT (datetime('now')),
updated_at TEXT DEFAULT (datetime('now')),
@@ -100,6 +106,33 @@ function initDatabase() {
)
`);
// migrate: columns the litters routes use (routes/litters.js) that older
// schemas lack
const litterMigrations = [
['breeding_date', 'TEXT'],
['whelping_date', 'TEXT'],
['puppy_count', 'INTEGER DEFAULT 0'],
];
for (const [col, def] of litterMigrations) {
try { db.exec(`ALTER TABLE litters ADD COLUMN ${col} ${def}`); } catch (_) { /* already exists */ }
}
// ── Heat Cycles ───────────────────────────────────────────────────────────
db.exec(`
CREATE TABLE IF NOT EXISTS heat_cycles (
id INTEGER PRIMARY KEY AUTOINCREMENT,
dog_id INTEGER NOT NULL,
start_date TEXT NOT NULL,
end_date TEXT,
breeding_date TEXT,
breeding_successful INTEGER DEFAULT 0,
notes TEXT,
created_at TEXT DEFAULT (datetime('now')),
updated_at TEXT DEFAULT (datetime('now')),
FOREIGN KEY (dog_id) REFERENCES dogs(id)
)
`);
// ── Health Records (OFA-extended) ─────────────────────────────────────────
db.exec(`
CREATE TABLE IF NOT EXISTS health_records (
@@ -135,6 +168,8 @@ function initDatabase() {
['result', 'TEXT'],
['vet_name', 'TEXT'],
['next_due', 'TEXT'],
['record_date', 'TEXT'],
['description', 'TEXT'],
];
for (const [col, def] of healthMigrations) {
try { db.exec(`ALTER TABLE health_records ADD COLUMN ${col} ${def}`); } catch (_) { /* already exists */ }
+13 -1
View File
@@ -52,6 +52,13 @@ class MigrationRunner {
return columns.some(col => col.name === 'sire' || col.name === 'dam');
}
// Check if a table exists (fresh DBs have none — migrations must no-op)
tableExists(name) {
return !!this.db.prepare(
"SELECT name FROM sqlite_master WHERE type='table' AND name = ?"
).get(name);
}
// Check if litter_id column exists
hasLitterIdColumn() {
const columns = this.db.prepare("PRAGMA table_info(dogs)").all();
@@ -187,7 +194,12 @@ class MigrationRunner {
// Migration 2: Add litter_id column if missing
migration002_addLitterIdColumn() {
console.log('[Migration 002] Checking for litter_id column...');
if (!this.tableExists('dogs')) {
console.log('[Migration 002] Fresh database (no dogs table yet), skipping — init will create it');
return;
}
if (this.hasLitterIdColumn()) {
console.log('[Migration 002] litter_id column already exists, skipping');
return;
+117 -69
View File
@@ -31,6 +31,32 @@ const upload = multer({
const emptyToNull = (v) => (v === '' || v === undefined) ? null : v;
// photo_urls is stored as a JSON array string; never let one corrupted row
// crash every request that loads the dog.
function safeParsePhotos(raw) {
if (!raw) return [];
try {
const parsed = JSON.parse(raw);
return Array.isArray(parsed) ? parsed : [];
} catch (_) {
return [];
}
}
// Sire/dam references must exist and have the right sex — bad parent links
// corrupt the pedigree graph and COI math. Returns an error string or null.
function validateParents(db, sire_id, dam_id, dogId = null) {
const checks = [[sire_id, 'male', 'Sire'], [dam_id, 'female', 'Dam']];
for (const [pid, wantSex, label] of checks) {
if (!pid || pid === '') continue;
if (dogId !== null && String(pid) === String(dogId)) return `${label} cannot be the dog itself`;
const parent = db.prepare('SELECT sex FROM dogs WHERE id = ?').get(pid);
if (!parent) return `${label} does not exist (id ${pid})`;
if (parent.sex !== wantSex) return `${label} must be a ${wantSex} dog`;
}
return null;
}
// ── Shared SELECT columns ────────────────────────────────────────────────
const DOG_COLS = `
id, name, registration_number, breed, sex, birth_date,
@@ -47,7 +73,7 @@ function attachParents(db, dogs) {
WHERE p.dog_id = ?
`);
dogs.forEach(dog => {
dog.photo_urls = dog.photo_urls ? JSON.parse(dog.photo_urls) : [];
dog.photo_urls = safeParsePhotos(dog.photo_urls);
const parents = parentStmt.all(dog.id);
dog.sire = parents.find(p => p.parent_type === 'sire') || null;
dog.dam = parents.find(p => p.parent_type === 'dam') || null;
@@ -171,7 +197,7 @@ router.get('/:id', (req, res) => {
if (!dog) return res.status(404).json({ error: 'Dog not found' });
dog.photo_urls = dog.photo_urls ? JSON.parse(dog.photo_urls) : [];
dog.photo_urls = safeParsePhotos(dog.photo_urls);
const parents = db.prepare(`
SELECT p.parent_type, d.id, d.name, d.is_champion, d.is_external
@@ -208,32 +234,40 @@ router.post('/', (req, res) => {
}
const db = getDatabase();
const result = db.prepare(`
INSERT INTO dogs (name, registration_number, breed, sex, birth_date, color,
microchip, notes, litter_id, photo_urls, is_champion, is_external)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`).run(
name,
emptyToNull(registration_number),
breed, sex,
emptyToNull(birth_date),
emptyToNull(color),
emptyToNull(microchip),
emptyToNull(notes),
emptyToNull(litter_id),
'[]',
is_champion ? 1 : 0,
is_external ? 1 : 0
);
const dogId = result.lastInsertRowid;
const parentError = validateParents(db, sire_id, dam_id);
if (parentError) return res.status(400).json({ error: parentError });
if (sire_id && sire_id !== '' && sire_id !== null) {
db.prepare('INSERT INTO parents (dog_id, parent_id, parent_type) VALUES (?, ?, ?)').run(dogId, sire_id, 'sire');
}
if (dam_id && dam_id !== '' && dam_id !== null) {
db.prepare('INSERT INTO parents (dog_id, parent_id, parent_type) VALUES (?, ?, ?)').run(dogId, dam_id, 'dam');
}
const createDog = db.transaction(() => {
const result = db.prepare(`
INSERT INTO dogs (name, registration_number, breed, sex, birth_date, color,
microchip, notes, litter_id, photo_urls, is_champion, is_external)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`).run(
name,
emptyToNull(registration_number),
breed, sex,
emptyToNull(birth_date),
emptyToNull(color),
emptyToNull(microchip),
emptyToNull(notes),
emptyToNull(litter_id),
'[]',
is_champion ? 1 : 0,
is_external ? 1 : 0
);
const id = result.lastInsertRowid;
if (sire_id && sire_id !== '' && sire_id !== null) {
db.prepare('INSERT INTO parents (dog_id, parent_id, parent_type) VALUES (?, ?, ?)').run(id, sire_id, 'sire');
}
if (dam_id && dam_id !== '' && dam_id !== null) {
db.prepare('INSERT INTO parents (dog_id, parent_id, parent_type) VALUES (?, ?, ?)').run(id, dam_id, 'dam');
}
return id;
});
const dogId = createDog();
const dog = db.prepare(`SELECT ${DOG_COLS} FROM dogs WHERE id = ?`).get(dogId);
dog.photo_urls = [];
@@ -253,36 +287,46 @@ router.put('/:id', (req, res) => {
microchip, notes, sire_id, dam_id, litter_id, is_champion, is_external } = req.body;
const db = getDatabase();
db.prepare(`
UPDATE dogs
SET name = ?, registration_number = ?, breed = ?, sex = ?,
birth_date = ?, color = ?, microchip = ?, notes = ?,
litter_id = ?, is_champion = ?, is_external = ?, updated_at = datetime('now')
WHERE id = ?
`).run(
name,
emptyToNull(registration_number),
breed, sex,
emptyToNull(birth_date),
emptyToNull(color),
emptyToNull(microchip),
emptyToNull(notes),
emptyToNull(litter_id),
is_champion ? 1 : 0,
is_external ? 1 : 0,
req.params.id
);
db.prepare('DELETE FROM parents WHERE dog_id = ?').run(req.params.id);
if (sire_id && sire_id !== '' && sire_id !== null) {
db.prepare('INSERT INTO parents (dog_id, parent_id, parent_type) VALUES (?, ?, ?)').run(req.params.id, sire_id, 'sire');
}
if (dam_id && dam_id !== '' && dam_id !== null) {
db.prepare('INSERT INTO parents (dog_id, parent_id, parent_type) VALUES (?, ?, ?)').run(req.params.id, dam_id, 'dam');
}
const existing = db.prepare('SELECT id FROM dogs WHERE id = ?').get(req.params.id);
if (!existing) return res.status(404).json({ error: 'Dog not found' });
const parentError = validateParents(db, sire_id, dam_id, req.params.id);
if (parentError) return res.status(400).json({ error: parentError });
const updateDog = db.transaction(() => {
db.prepare(`
UPDATE dogs
SET name = ?, registration_number = ?, breed = ?, sex = ?,
birth_date = ?, color = ?, microchip = ?, notes = ?,
litter_id = ?, is_champion = ?, is_external = ?, updated_at = datetime('now')
WHERE id = ?
`).run(
name,
emptyToNull(registration_number),
breed, sex,
emptyToNull(birth_date),
emptyToNull(color),
emptyToNull(microchip),
emptyToNull(notes),
emptyToNull(litter_id),
is_champion ? 1 : 0,
is_external ? 1 : 0,
req.params.id
);
db.prepare('DELETE FROM parents WHERE dog_id = ?').run(req.params.id);
if (sire_id && sire_id !== '' && sire_id !== null) {
db.prepare('INSERT INTO parents (dog_id, parent_id, parent_type) VALUES (?, ?, ?)').run(req.params.id, sire_id, 'sire');
}
if (dam_id && dam_id !== '' && dam_id !== null) {
db.prepare('INSERT INTO parents (dog_id, parent_id, parent_type) VALUES (?, ?, ?)').run(req.params.id, dam_id, 'dam');
}
});
updateDog();
const dog = db.prepare(`SELECT ${DOG_COLS} FROM dogs WHERE id = ?`).get(req.params.id);
dog.photo_urls = dog.photo_urls ? JSON.parse(dog.photo_urls) : [];
dog.photo_urls = safeParsePhotos(dog.photo_urls);
console.log(`✔ Dog updated: ${dog.name} (ID: ${req.params.id})`);
res.json(dog);
@@ -300,11 +344,13 @@ router.delete('/:id', (req, res) => {
if (!existing) return res.status(404).json({ error: 'Dog not found' });
const id = req.params.id;
db.prepare('DELETE FROM parents WHERE parent_id = ?').run(id);
db.prepare('DELETE FROM parents WHERE dog_id = ?').run(id);
db.prepare('DELETE FROM health_records WHERE dog_id = ?').run(id);
db.prepare('DELETE FROM heat_cycles WHERE dog_id = ?').run(id);
db.prepare('DELETE FROM dogs WHERE id = ?').run(id);
db.transaction(() => {
db.prepare('DELETE FROM parents WHERE parent_id = ?').run(id);
db.prepare('DELETE FROM parents WHERE dog_id = ?').run(id);
db.prepare('DELETE FROM health_records WHERE dog_id = ?').run(id);
db.prepare('DELETE FROM heat_cycles WHERE dog_id = ?').run(id);
db.prepare('DELETE FROM dogs WHERE id = ?').run(id);
})();
console.log(`✔ Dog #${id} (${existing.name}) permanently deleted`);
res.json({ success: true, message: `${existing.name} has been deleted` });
@@ -323,7 +369,7 @@ router.post('/:id/photos', upload.single('photo'), (req, res) => {
const dog = db.prepare('SELECT photo_urls FROM dogs WHERE id = ?').get(req.params.id);
if (!dog) return res.status(404).json({ error: 'Dog not found' });
const photoUrls = dog.photo_urls ? JSON.parse(dog.photo_urls) : [];
const photoUrls = safeParsePhotos(dog.photo_urls);
photoUrls.push(`/uploads/${req.file.filename}`);
db.prepare('UPDATE dogs SET photo_urls = ? WHERE id = ?').run(JSON.stringify(photoUrls), req.params.id);
@@ -341,19 +387,21 @@ router.delete('/:id/photos/:photoIndex', (req, res) => {
const dog = db.prepare('SELECT photo_urls FROM dogs WHERE id = ?').get(req.params.id);
if (!dog) return res.status(404).json({ error: 'Dog not found' });
const photoUrls = dog.photo_urls ? JSON.parse(dog.photo_urls) : [];
const photoIndex = parseInt(req.params.photoIndex);
const photoUrls = safeParsePhotos(dog.photo_urls);
const photoIndex = parseInt(req.params.photoIndex, 10);
if (photoIndex >= 0 && photoIndex < photoUrls.length) {
const photoPath = path.join(
process.env.UPLOAD_PATH || path.join(__dirname, '../../uploads'),
path.basename(photoUrls[photoIndex])
);
if (fs.existsSync(photoPath)) fs.unlinkSync(photoPath);
photoUrls.splice(photoIndex, 1);
db.prepare('UPDATE dogs SET photo_urls = ? WHERE id = ?').run(JSON.stringify(photoUrls), req.params.id);
if (!Number.isInteger(photoIndex) || photoIndex < 0 || photoIndex >= photoUrls.length) {
return res.status(400).json({ error: 'Invalid photo index' });
}
const photoPath = path.join(
process.env.UPLOAD_PATH || path.join(__dirname, '../../uploads'),
path.basename(photoUrls[photoIndex])
);
if (fs.existsSync(photoPath)) fs.unlinkSync(photoPath);
photoUrls.splice(photoIndex, 1);
db.prepare('UPDATE dogs SET photo_urls = ? WHERE id = ?').run(JSON.stringify(photoUrls), req.params.id);
res.json({ photos: photoUrls });
} catch (error) {
console.error('Error deleting photo:', error);
+39 -1
View File
@@ -1,6 +1,31 @@
const express = require('express');
const router = express.Router();
const { getDatabase } = require('../db/init');
const multer = require('multer');
const path = require('path');
const storage = multer.diskStorage({
destination: (req, file, cb) => {
const uploadPath = process.env.UPLOAD_PATH || path.join(__dirname, '../../uploads');
cb(null, uploadPath);
},
filename: (req, file, cb) => {
const uniqueName = `${Date.now()}-${Math.random().toString(36).substring(7)}${path.extname(file.originalname)}`;
cb(null, uniqueName);
}
});
const upload = multer({
storage,
limits: { fileSize: 15 * 1024 * 1024 },
fileFilter: (req, file, cb) => {
const allowed = /pdf|jpeg|jpg|png|gif|webp/;
const extOk = allowed.test(path.extname(file.originalname).toLowerCase());
const mimeOk = allowed.test(file.mimetype);
if (extOk && mimeOk) cb(null, true);
else cb(new Error('Only PDF or image files are allowed'));
}
});
// OFA tests that count toward GRCA eligibility
const GRCA_REQUIRED = ['hip_ofa', 'hip_pennhip', 'elbow_ofa', 'heart_ofa', 'heart_echo', 'eye_caer'];
@@ -10,7 +35,9 @@ const GRCA_CORE = {
heart: ['heart_ofa', 'heart_echo'],
eye: ['eye_caer'],
};
const VALID_TEST_TYPES = ['hip_ofa', 'hip_pennhip', 'elbow_ofa', 'heart_ofa', 'heart_echo', 'eye_caer', 'thyroid_ofa', 'dna_panel'];
// DNA is owned exclusively by the genetics module (genetic_tests table), so it is
// intentionally NOT a valid health test type.
const VALID_TEST_TYPES = ['hip_ofa', 'hip_pennhip', 'elbow_ofa', 'patella_ofa', 'heart_ofa', 'heart_echo', 'eye_caer', 'thyroid_ofa'];
// Helper: compute clearance summary for a dog
function getClearanceSummary(db, dogId) {
@@ -142,6 +169,17 @@ router.post('/', (req, res) => {
}
});
// POST upload a supporting document (PDF/image). Returns a URL to store in
// document_url. Works for new or existing records — the URL is saved via the
// record's normal POST/PUT, so no record id is needed here.
router.post('/upload', (req, res) => {
upload.single('document')(req, res, (err) => {
if (err) return res.status(400).json({ error: err.message });
if (!req.file) return res.status(400).json({ error: 'No file uploaded' });
res.json({ url: `/uploads/${req.file.filename}` });
});
});
// PUT update health record
router.put('/:id', (req, res) => {
try {
+4 -3
View File
@@ -214,10 +214,11 @@ router.post('/:litterId/puppies/:puppyId/logs', (req, res) => {
notes: notes || ''
});
// test_date mirrors record_date: the column is NOT NULL on fresh schemas
const result = db.prepare(`
INSERT INTO health_records (dog_id, record_type, record_date, description, vet_name)
VALUES (?, ?, ?, ?, ?)
`).run(puppyId, record_type || 'weight_log', record_date, description, null);
INSERT INTO health_records (dog_id, record_type, record_date, test_date, description, vet_name)
VALUES (?, ?, ?, ?, ?, ?)
`).run(puppyId, record_type || 'weight_log', record_date, record_date, description, null);
const log = db.prepare('SELECT * FROM health_records WHERE id = ?').get(result.lastInsertRowid);
res.status(201).json(log);
-26
View File
@@ -1,26 +0,0 @@
const app = require('./index');
const http = require('http');
// Start temporary server
const server = http.createServer(app);
server.listen(3030, async () => {
console.log('Server started on 3030');
try {
const res = await fetch('http://localhost:3030/api/pedigree/relations/1/2');
const text = await res.text();
console.log('GET /api/pedigree/relations/1/2 RESPONSE:', res.status, text.substring(0, 150));
const postRes = await fetch('http://localhost:3030/api/pedigree/trial-pairing', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ sire_id: 1, dam_id: 2 })
});
const postText = await postRes.text();
console.log('POST /api/pedigree/trial-pairing RESPONSE:', postRes.status, postText.substring(0, 150));
} catch (err) {
console.error('Fetch error:', err);
} finally {
server.close();
process.exit(0);
}
});
-8
View File
@@ -1,8 +0,0 @@
const express = require('express');
const router = express.Router();
router.post(['/a', '/b'], (req, res) => {
res.send('ok');
});
console.log('Started successfully');