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 */}