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>
This commit is contained in:
@@ -1,41 +1,168 @@
|
|||||||
import { useState } from 'react'
|
import { useState } from 'react'
|
||||||
import { X } from 'lucide-react'
|
import { X, Upload } from 'lucide-react'
|
||||||
import axios from 'axios'
|
import axios from 'axios'
|
||||||
|
|
||||||
const RECORD_TYPES = ['ofa_clearance', 'vaccination', 'exam', 'surgery', 'medication', 'other']
|
const RECORD_TYPES = [
|
||||||
const OFA_TEST_TYPES = [
|
{ value: 'ofa_clearance', label: 'OFA Clearance' },
|
||||||
{ value: 'hip_ofa', label: 'Hip - OFA' },
|
{ value: 'vaccination', label: 'Vaccination' },
|
||||||
{ value: 'hip_pennhip', label: 'Hip - PennHIP' },
|
{ value: 'exam', label: 'Exam' },
|
||||||
{ value: 'elbow_ofa', label: 'Elbow - OFA' },
|
{ value: 'surgery', label: 'Surgery' },
|
||||||
{ value: 'heart_ofa', label: 'Heart - OFA' },
|
{ value: 'medication', label: 'Medication' },
|
||||||
{ value: 'heart_echo', label: 'Heart - Echo' },
|
{ value: 'other', label: 'Other' },
|
||||||
{ value: 'eye_caer', label: 'Eyes - CAER' },
|
|
||||||
]
|
]
|
||||||
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 = {
|
const EMPTY = {
|
||||||
record_type: 'ofa_clearance', test_type: 'hip_ofa', test_name: '',
|
record_type: 'ofa_clearance', test_type: 'hip_ofa', test_name: '',
|
||||||
test_date: '', ofa_result: 'Good', ofa_number: '', performed_by: '',
|
test_date: '', ofa_result: '', ofa_number: '', performed_by: '',
|
||||||
expires_at: '', result: '', vet_name: '', next_due: '', notes: '', document_url: '',
|
expires_at: '', result: '', next_due: '', notes: '', document_url: '',
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function HealthRecordForm({ dogId, record, onClose, onSave }) {
|
const today = () => new Date().toISOString().slice(0, 10)
|
||||||
const [form, setForm] = useState(record || { ...EMPTY, dog_id: dogId })
|
|
||||||
|
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 [saving, setSaving] = useState(false)
|
||||||
|
const [uploading, setUploading] = useState(false)
|
||||||
const [error, setError] = useState(null)
|
const [error, setError] = useState(null)
|
||||||
|
|
||||||
const isOFA = form.record_type === 'ofa_clearance'
|
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 }))
|
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) => {
|
const handleSubmit = async (e) => {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
|
const v = validate()
|
||||||
|
if (v) { setError(v); return }
|
||||||
setSaving(true)
|
setSaving(true)
|
||||||
setError(null)
|
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 {
|
try {
|
||||||
if (record && record.id) {
|
if (record && record.id) {
|
||||||
await axios.put(`/api/health/${record.id}`, form)
|
await axios.put(`/api/health/${record.id}`, payload)
|
||||||
} else {
|
} else {
|
||||||
await axios.post('/api/health', { ...form, dog_id: dogId })
|
await axios.post('/api/health', payload)
|
||||||
}
|
}
|
||||||
onSave()
|
onSave()
|
||||||
} catch (err) {
|
} 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',
|
padding: '0.5rem 0.75rem', color: 'var(--text-primary)', fontSize: '0.9rem',
|
||||||
boxSizing: 'border-box',
|
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 fw = { display: 'flex', flexDirection: 'column', gap: '0.25rem' }
|
||||||
const grid2 = { display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '1rem' }
|
const grid2 = { display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '1rem' }
|
||||||
|
|
||||||
@@ -78,12 +206,8 @@ export default function HealthRecordForm({ dogId, record, onClose, onSave }) {
|
|||||||
{/* Record type */}
|
{/* Record type */}
|
||||||
<div style={fw}>
|
<div style={fw}>
|
||||||
<label style={labelStyle}>Record Type</label>
|
<label style={labelStyle}>Record Type</label>
|
||||||
<select style={inputStyle} value={form.record_type} onChange={e => set('record_type', e.target.value)}>
|
<select style={inputStyle} value={form.record_type} onChange={e => setRecordType(e.target.value)}>
|
||||||
{RECORD_TYPES.map(t => (
|
{RECORD_TYPES.map(t => <option key={t.value} value={t.value}>{t.label}</option>)}
|
||||||
<option key={t} value={t}>
|
|
||||||
{t.replace(/_/g, ' ').replace(/\b\w/g, c => c.toUpperCase())}
|
|
||||||
</option>
|
|
||||||
))}
|
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -91,16 +215,22 @@ export default function HealthRecordForm({ dogId, record, onClose, onSave }) {
|
|||||||
<>
|
<>
|
||||||
<div style={grid2}>
|
<div style={grid2}>
|
||||||
<div style={fw}>
|
<div style={fw}>
|
||||||
<label style={labelStyle}>OFA Test Type</label>
|
<label style={labelStyle}>Test Type</label>
|
||||||
<select style={inputStyle} value={form.test_type} onChange={e => set('test_type', e.target.value)}>
|
<select style={inputStyle} value={form.test_type} onChange={e => setTestType(e.target.value)}>
|
||||||
{OFA_TEST_TYPES.map(t => <option key={t.value} value={t.value}>{t.label}</option>)}
|
{OFA_TEST_KEYS.map(k => <option key={k} value={k}>{OFA_TESTS[k].label}</option>)}
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<div style={fw}>
|
<div style={fw}>
|
||||||
<label style={labelStyle}>OFA Result</label>
|
<label style={labelStyle}>Result *</label>
|
||||||
|
{ofaTest?.results ? (
|
||||||
<select style={inputStyle} value={form.ofa_result} onChange={e => set('ofa_result', e.target.value)}>
|
<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>)}
|
<option value="">Select result…</option>
|
||||||
|
{ofaTest.results.map(r => <option key={r} value={r}>{r}</option>)}
|
||||||
</select>
|
</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>
|
</div>
|
||||||
<div style={grid2}>
|
<div style={grid2}>
|
||||||
@@ -118,54 +248,90 @@ export default function HealthRecordForm({ dogId, record, onClose, onSave }) {
|
|||||||
<div style={grid2}>
|
<div style={grid2}>
|
||||||
<div style={fw}>
|
<div style={fw}>
|
||||||
<label style={labelStyle}>Test Date *</label>
|
<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)} />
|
onChange={e => set('test_date', e.target.value)} />
|
||||||
</div>
|
</div>
|
||||||
|
{ofaTest?.recurs ? (
|
||||||
<div style={fw}>
|
<div style={fw}>
|
||||||
<label style={labelStyle}>Expires At</label>
|
<label style={labelStyle}>Expires / Re-test Due</label>
|
||||||
<input style={inputStyle} type="date" value={form.expires_at}
|
<input style={inputStyle} type="date" min={form.test_date} value={form.expires_at}
|
||||||
onChange={e => set('expires_at', e.target.value)} />
|
onChange={e => set('expires_at', e.target.value)} />
|
||||||
|
<span style={hintStyle}>This test is typically valid ~12 months.</span>
|
||||||
</div>
|
</div>
|
||||||
|
) : (
|
||||||
|
<div style={fw}>
|
||||||
|
<label style={labelStyle}> </label>
|
||||||
|
<span style={{ ...hintStyle, marginTop: 0, alignSelf: 'center' }}>
|
||||||
|
Permanent certification — no expiry.
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<div style={fw}>
|
<div style={fw}>
|
||||||
<label style={labelStyle}>Test / Procedure Name</label>
|
<label style={labelStyle}>{cfg.nameLabel}</label>
|
||||||
<input style={inputStyle} placeholder="e.g. Rabies, Bordetella..." value={form.test_name}
|
<input style={inputStyle} placeholder={cfg.namePlaceholder} value={form.test_name}
|
||||||
onChange={e => set('test_name', e.target.value)} />
|
onChange={e => set('test_name', e.target.value)} />
|
||||||
</div>
|
</div>
|
||||||
<div style={grid2}>
|
<div style={grid2}>
|
||||||
<div style={fw}>
|
<div style={fw}>
|
||||||
<label style={labelStyle}>Date *</label>
|
<label style={labelStyle}>{cfg.dateLabel} *</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)} />
|
onChange={e => set('test_date', e.target.value)} />
|
||||||
</div>
|
</div>
|
||||||
|
{cfg.nextDue && (
|
||||||
<div style={fw}>
|
<div style={fw}>
|
||||||
<label style={labelStyle}>Next Due</label>
|
<label style={labelStyle}>{cfg.nextDue}</label>
|
||||||
<input style={inputStyle} type="date" value={form.next_due}
|
<input style={inputStyle} type="date" min={form.test_date} value={form.next_due}
|
||||||
onChange={e => set('next_due', e.target.value)} />
|
onChange={e => set('next_due', e.target.value)} />
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div style={grid2}>
|
<div style={grid2}>
|
||||||
|
{cfg.result && (
|
||||||
<div style={fw}>
|
<div style={fw}>
|
||||||
<label style={labelStyle}>Result</label>
|
<label style={labelStyle}>{cfg.result}</label>
|
||||||
<input style={inputStyle} placeholder="Normal, Pass, etc." value={form.result}
|
<input style={inputStyle} placeholder={cfg.resultPlaceholder} value={form.result}
|
||||||
onChange={e => set('result', e.target.value)} />
|
onChange={e => set('result', e.target.value)} />
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
<div style={fw}>
|
<div style={fw}>
|
||||||
<label style={labelStyle}>Vet Name</label>
|
<label style={labelStyle}>{cfg.clinicianLabel}</label>
|
||||||
<input style={inputStyle} placeholder="Dr. Smith" value={form.vet_name}
|
<input style={inputStyle} placeholder="Dr. Smith" value={form.performed_by}
|
||||||
onChange={e => set('vet_name', e.target.value)} />
|
onChange={e => set('performed_by', e.target.value)} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div style={fw}>
|
<div style={fw}>
|
||||||
<label style={labelStyle}>Document URL (optional)</label>
|
<label style={labelStyle}>Document (optional)</label>
|
||||||
<input style={inputStyle} type="url" placeholder="https://ofa.org/..." value={form.document_url}
|
<div style={{ display: 'flex', gap: '0.5rem' }}>
|
||||||
onChange={e => set('document_url', e.target.value)} />
|
<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>
|
||||||
|
|
||||||
<div style={fw}>
|
<div style={fw}>
|
||||||
|
|||||||
@@ -97,6 +97,17 @@ function DogDetail() {
|
|||||||
const openEditHealth = (rec) => { setEditingRecord(rec); setShowHealthForm(true) }
|
const openEditHealth = (rec) => { setEditingRecord(rec); setShowHealthForm(true) }
|
||||||
const closeHealthForm = () => { setShowHealthForm(false); setEditingRecord(null) }
|
const closeHealthForm = () => { setShowHealthForm(false); setEditingRecord(null) }
|
||||||
const handleHealthSaved = () => { closeHealthForm(); fetchHealth() }
|
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 <div className="container loading">Loading...</div>
|
if (loading) return <div className="container loading">Loading...</div>
|
||||||
if (!dog) return <div className="container">Dog not found</div>
|
if (!dog) return <div className="container">Dog not found</div>
|
||||||
@@ -356,9 +367,9 @@ function DogDetail() {
|
|||||||
<span style={{ fontWeight: 500, fontSize: '0.875rem' }}>
|
<span style={{ fontWeight: 500, fontSize: '0.875rem' }}>
|
||||||
{rec.test_name || (rec.test_type ? rec.test_type.replace(/_/g, ' ') : rec.record_type)}
|
{rec.test_name || (rec.test_type ? rec.test_type.replace(/_/g, ' ') : rec.record_type)}
|
||||||
</span>
|
</span>
|
||||||
{rec.ofa_result && (
|
{(rec.ofa_result || rec.result) && (
|
||||||
<span style={{ marginLeft: '0.5rem', fontSize: '0.75rem', color: 'var(--text-muted)' }}>
|
<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>
|
</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -368,6 +379,9 @@ function DogDetail() {
|
|||||||
<button className="btn-icon" style={{ padding: '0.2rem' }} onClick={() => openEditHealth(rec)}>
|
<button className="btn-icon" style={{ padding: '0.2rem' }} onClick={() => openEditHealth(rec)}>
|
||||||
<Edit size={14} />
|
<Edit size={14} />
|
||||||
</button>
|
</button>
|
||||||
|
<button className="btn-icon" style={{ padding: '0.2rem' }} onClick={() => handleDeleteHealth(rec)} title="Delete record">
|
||||||
|
<Trash2 size={14} color="var(--danger)" />
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
@@ -428,6 +442,7 @@ function DogDetail() {
|
|||||||
{showHealthForm && (
|
{showHealthForm && (
|
||||||
<HealthRecordForm
|
<HealthRecordForm
|
||||||
dogId={id}
|
dogId={id}
|
||||||
|
dogBirthDate={dog?.birth_date}
|
||||||
record={editingRecord}
|
record={editingRecord}
|
||||||
onClose={closeHealthForm}
|
onClose={closeHealthForm}
|
||||||
onSave={handleHealthSaved}
|
onSave={handleHealthSaved}
|
||||||
|
|||||||
+39
-1
@@ -1,6 +1,31 @@
|
|||||||
const express = require('express');
|
const express = require('express');
|
||||||
const router = express.Router();
|
const router = express.Router();
|
||||||
const { getDatabase } = require('../db/init');
|
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
|
// OFA tests that count toward GRCA eligibility
|
||||||
const GRCA_REQUIRED = ['hip_ofa', 'hip_pennhip', 'elbow_ofa', 'heart_ofa', 'heart_echo', 'eye_caer'];
|
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'],
|
heart: ['heart_ofa', 'heart_echo'],
|
||||||
eye: ['eye_caer'],
|
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
|
// Helper: compute clearance summary for a dog
|
||||||
function getClearanceSummary(db, dogId) {
|
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
|
// PUT update health record
|
||||||
router.put('/:id', (req, res) => {
|
router.put('/:id', (req, res) => {
|
||||||
try {
|
try {
|
||||||
|
|||||||
Reference in New Issue
Block a user