diff --git a/client/src/components/HealthRecordForm.jsx b/client/src/components/HealthRecordForm.jsx index 6916974..670a925 100644 --- a/client/src/components/HealthRecordForm.jsx +++ b/client/src/components/HealthRecordForm.jsx @@ -1,41 +1,168 @@ 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, + })) + + 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 +182,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 +206,8 @@ export default function HealthRecordForm({ dogId, record, onClose, onSave }) { {/* Record type */}
- setRecordType(e.target.value)}> + {RECORD_TYPES.map(t => )}
@@ -91,16 +215,22 @@ export default function HealthRecordForm({ dogId, record, onClose, onSave }) { <>
- - setTestType(e.target.value)}> + {OFA_TEST_KEYS.map(k => )}
- - + + {ofaTest?.results ? ( + + ) : ( + set('ofa_result', e.target.value)} /> + )}
@@ -118,54 +248,90 @@ export default function HealthRecordForm({ dogId, record, onClose, onSave }) {
- set('test_date', e.target.value)} />
-
- - set('expires_at', e.target.value)} /> -
+ {ofaTest?.recurs ? ( +
+ + set('expires_at', e.target.value)} /> + This test is typically valid ~12 months. +
+ ) : ( +
+ + + Permanent certification — no expiry. + +
+ )}
) : ( <>
- - {cfg.nameLabel} + set('test_name', e.target.value)} />
- - {cfg.dateLabel} * + set('test_date', e.target.value)} />
-
- - set('next_due', e.target.value)} /> -
+ {cfg.nextDue && ( +
+ + set('next_due', e.target.value)} /> +
+ )}
+ {cfg.result && ( +
+ + set('result', e.target.value)} /> +
+ )}
- - set('result', e.target.value)} /> -
-
- - set('vet_name', e.target.value)} /> + + set('performed_by', e.target.value)} />
)}
- - set('document_url', e.target.value)} /> + +
+ set('document_url', e.target.value)} /> + +
+ {form.document_url && ( + + View attached document + {' · '} + + + )}
diff --git a/client/src/pages/DogDetail.jsx b/client/src/pages/DogDetail.jsx index 558456c..2ffd296 100644 --- a/client/src/pages/DogDetail.jsx +++ b/client/src/pages/DogDetail.jsx @@ -97,6 +97,17 @@ 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 (!confirm(`Delete health record "${label}"?`)) return + try { + await axios.delete(`/api/health/${rec.id}`) + fetchHealth() + } catch (error) { + console.error('Error deleting health record:', error) + alert('Failed to delete health record') + } + } if (loading) return
Loading...
if (!dog) return
Dog not found
@@ -356,9 +367,9 @@ function DogDetail() { {rec.test_name || (rec.test_type ? rec.test_type.replace(/_/g, ' ') : rec.record_type)} - {rec.ofa_result && ( + {(rec.ofa_result || rec.result) && ( - {rec.ofa_result}{rec.ofa_number ? ` · ${rec.ofa_number}` : ''} + {rec.ofa_result || rec.result}{rec.ofa_number ? ` · ${rec.ofa_number}` : ''} )}
@@ -368,6 +379,9 @@ function DogDetail() { +
))} @@ -428,6 +442,7 @@ function DogDetail() { {showHealthForm && ( { + 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 {