Roadmap 2,3,4

This commit is contained in:
2026-03-11 23:48:35 -05:00
parent 17b008a674
commit 13185a5281
9 changed files with 575 additions and 79 deletions

View File

@@ -0,0 +1,97 @@
import { useState, useEffect } from 'react'
import { Dna, Plus } from 'lucide-react'
import axios from 'axios'
import GeneticTestForm from './GeneticTestForm'
const RESULT_STYLES = {
clear: { bg: 'rgba(52,199,89,0.15)', color: 'var(--success)' },
carrier: { bg: 'rgba(255,159,10,0.15)', color: 'var(--warning)' },
affected: { bg: 'rgba(255,59,48,0.15)', color: 'var(--danger)' },
not_tested: { bg: 'var(--bg-tertiary)', color: 'var(--text-muted)' }
}
export default function GeneticPanelCard({ dogId }) {
const [data, setData] = useState(null)
const [error, setError] = useState(false)
const [loading, setLoading] = useState(true)
const [showForm, setShowForm] = useState(false)
const [editingRecord, setEditingRecord] = useState(null)
const fetchGenetics = () => {
setLoading(true)
axios.get(`/api/genetics/dog/${dogId}`)
.then(res => setData(res.data))
.catch(() => setError(true))
.finally(() => setLoading(false))
}
useEffect(() => { fetchGenetics() }, [dogId])
const openAdd = () => { setEditingRecord(null); setShowForm(true) }
const openEdit = (rec) => { setEditingRecord(rec); setShowForm(true) }
const handleSaved = () => { setShowForm(false); fetchGenetics() }
if (error || (!loading && !data)) return null
const panel = data?.panel || []
return (
<div className="card" style={{ marginBottom: '1.5rem' }}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '1rem' }}>
<h2 style={{ fontSize: '1rem', color: 'var(--text-muted)', textTransform: 'uppercase', letterSpacing: '0.05em', margin: 0, display: 'flex', alignItems: 'center', gap: '0.5rem' }}>
<Dna size={18} /> DNA Genetics Panel
</h2>
<button className="btn btn-ghost" style={{ fontSize: '0.8rem', padding: '0.35rem 0.75rem' }} onClick={openAdd}>
<Plus size={14} /> Update Marker
</button>
</div>
{loading ? (
<div style={{ fontSize: '0.875rem', color: 'var(--text-muted)' }}>Loading...</div>
) : (
<div style={{
display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(130px, 1fr))', gap: '0.5rem'
}}>
{panel.map(item => {
const style = RESULT_STYLES[item.result] || RESULT_STYLES.not_tested
// Pass the whole test record if it exists so we can edit it
const record = item.id ? item : { marker: item.marker, result: 'not_tested' }
return (
<div
key={item.marker}
onClick={() => openEdit(record)}
style={{
padding: '0.5rem 0.75rem',
background: style.bg,
border: `1px solid ${style.color}44`,
borderRadius: 'var(--radius-sm)',
cursor: 'pointer',
transition: 'transform 0.1s ease',
}}
onMouseEnter={e => e.currentTarget.style.transform = 'translateY(-2px)'}
onMouseLeave={e => e.currentTarget.style.transform = 'translateY(0)'}
>
<div style={{ fontSize: '0.75rem', color: 'var(--text-muted)', marginBottom: '0.2rem', fontWeight: 500 }}>
{item.marker}
</div>
<div style={{ fontSize: '0.875rem', color: style.color, fontWeight: 600, textTransform: 'capitalize' }}>
{item.result.replace('_', ' ')}
</div>
</div>
)
})}
</div>
)}
{showForm && (
<GeneticTestForm
dogId={dogId}
record={editingRecord}
onClose={() => setShowForm(false)}
onSave={handleSaved}
/>
)}
</div>
)
}

View File

@@ -0,0 +1,157 @@
import { useState } from 'react'
import { X } from 'lucide-react'
import axios from 'axios'
const GR_MARKERS = [
{ value: 'PRA1', label: 'PRA1' },
{ value: 'PRA2', label: 'PRA2' },
{ value: 'prcd-PRA', label: 'prcd-PRA' },
{ value: 'GR-PRA1', label: 'GR-PRA1' },
{ value: 'GR-PRA2', label: 'GR-PRA2' },
{ value: 'ICH1', label: 'ICH1 (Ichthyosis 1)' },
{ value: 'ICH2', label: 'ICH2 (Ichthyosis 2)' },
{ value: 'NCL', label: 'Neuronal Ceroid Lipofuscinosis' },
{ value: 'DM', label: 'Degenerative Myelopathy' },
{ value: 'MD', label: 'Muscular Dystrophy' }
]
const RESULTS = [
{ value: 'clear', label: 'Clear / Normal' },
{ value: 'carrier', label: 'Carrier (1 copy)' },
{ value: 'affected', label: 'Affected / At Risk (2 copies)' },
{ value: 'not_tested', label: 'Not Tested' }
]
const EMPTY = {
test_provider: 'Embark',
marker: 'PRA1',
result: 'clear',
test_date: '',
document_url: '',
notes: ''
}
export default function GeneticTestForm({ dogId, record, onClose, onSave }) {
const [form, setForm] = useState(record || { ...EMPTY, dog_id: dogId })
const [saving, setSaving] = useState(false)
const [error, setError] = useState(null)
const set = (k, v) => setForm(f => ({ ...f, [k]: v }))
const handleSubmit = async (e) => {
e.preventDefault()
setSaving(true)
setError(null)
// If not tested, don't save
if (form.result === 'not_tested' && !record) {
setError('Cannot save a "Not Tested" result. Please just delete the record if it exists.')
setSaving(false)
return
}
try {
if (record && record.id) {
if (form.result === 'not_tested') {
// If changed to not_tested, just delete it
await axios.delete(`/api/genetics/${record.id}`)
} else {
await axios.put(`/api/genetics/${record.id}`, form)
}
} else {
await axios.post('/api/genetics', { ...form, dog_id: dogId })
}
onSave()
} catch (err) {
setError(err.response?.data?.error || 'Failed to save genetic record')
} finally {
setSaving(false)
}
}
const labelStyle = {
fontSize: '0.8rem', color: 'var(--text-muted)',
marginBottom: '0.25rem', display: 'block',
}
const inputStyle = {
width: '100%', background: 'var(--bg-primary)',
border: '1px solid var(--border)', borderRadius: 'var(--radius-sm)',
padding: '0.5rem 0.75rem', color: 'var(--text-primary)', fontSize: '0.9rem',
boxSizing: 'border-box',
}
const fw = { display: 'flex', flexDirection: 'column', gap: '0.25rem' }
return (
<div style={{
position: 'fixed', inset: 0, background: 'rgba(0,0,0,0.7)',
backdropFilter: 'blur(4px)', display: 'flex', alignItems: 'center',
justifyContent: 'center', zIndex: 1000, padding: '1rem',
}}>
<div className="card" style={{
width: '100%', maxWidth: '500px', maxHeight: '90vh',
overflowY: 'auto', position: 'relative',
}}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '1.5rem' }}>
<h2 style={{ margin: 0 }}>{record && record.id ? 'Edit' : 'Add'} Genetic Result</h2>
<button className="btn-icon" onClick={onClose}><X size={20} /></button>
</div>
<form onSubmit={handleSubmit} style={{ display: 'flex', flexDirection: 'column', gap: '1rem' }}>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '1rem' }}>
<div style={fw}>
<label style={labelStyle}>Marker *</label>
<select style={inputStyle} value={form.marker} onChange={e => set('marker', e.target.value)} disabled={!!record}>
{GR_MARKERS.map(m => <option key={m.value} value={m.value}>{m.label}</option>)}
</select>
</div>
<div style={fw}>
<label style={labelStyle}>Result *</label>
<select style={inputStyle} value={form.result} onChange={e => set('result', e.target.value)}>
{RESULTS.map(r => <option key={r.value} value={r.value}>{r.label}</option>)}
</select>
</div>
</div>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '1rem' }}>
<div style={fw}>
<label style={labelStyle}>Provider</label>
<input style={inputStyle} placeholder="Embark, PawPrint, etc." value={form.test_provider}
onChange={e => set('test_provider', e.target.value)} />
</div>
<div style={fw}>
<label style={labelStyle}>Test Date</label>
<input style={inputStyle} type="date" value={form.test_date}
onChange={e => set('test_date', e.target.value)} />
</div>
</div>
<div style={fw}>
<label style={labelStyle}>Document URL</label>
<input style={inputStyle} type="url" placeholder="Link to PDF or result page" value={form.document_url}
onChange={e => set('document_url', e.target.value)} />
</div>
<div style={fw}>
<label style={labelStyle}>Notes</label>
<textarea style={{ ...inputStyle, minHeight: '60px', resize: 'vertical' }}
value={form.notes} onChange={e => set('notes', e.target.value)} />
</div>
{error && (
<div style={{
color: 'var(--danger)', fontSize: '0.85rem', padding: '0.5rem 0.75rem',
background: 'rgba(255,59,48,0.1)', borderRadius: 'var(--radius-sm)',
}}>{error}</div>
)}
<div style={{ display: 'flex', gap: '0.75rem', justifyContent: 'flex-end', marginTop: '0.5rem' }}>
<button type="button" className="btn btn-ghost" onClick={onClose}>Cancel</button>
<button type="submit" className="btn btn-primary" disabled={saving}>
{saving ? 'Saving...' : 'Save Result'}
</button>
</div>
</form>
</div>
</div>
)
}

View File

@@ -17,19 +17,24 @@ function PedigreeView({ dogId, onClose }) {
}, [dogId])
useEffect(() => {
const container = document.querySelector('.pedigree-container')
if (!container) return
const updateDimensions = () => {
const container = document.querySelector('.pedigree-container')
if (container) {
const width = container.offsetWidth
const height = container.offsetHeight
setDimensions({ width, height })
setTranslate({ x: width / 4, y: height / 2 })
}
const width = container.offsetWidth
const height = container.offsetHeight
setDimensions({ width, height })
setTranslate({ x: width / 4, y: height / 2 })
}
updateDimensions()
window.addEventListener('resize', updateDimensions)
return () => window.removeEventListener('resize', updateDimensions)
const resizeObserver = new ResizeObserver(() => {
updateDimensions()
})
resizeObserver.observe(container)
return () => resizeObserver.disconnect()
}, [])
const fetchPedigree = async () => {

View File

@@ -6,6 +6,8 @@ import DogForm from '../components/DogForm'
import { ChampionBadge, ChampionBloodlineBadge } from '../components/ChampionBadge'
import ClearanceSummaryCard from '../components/ClearanceSummaryCard'
import HealthRecordForm from '../components/HealthRecordForm'
import GeneticPanelCard from '../components/GeneticPanelCard'
import { ShieldCheck } from 'lucide-react'
function DogDetail() {
const { id } = useParams()
@@ -262,6 +264,18 @@ function DogDetail() {
<span className="info-value" style={{ fontFamily: 'monospace' }}>{dog.registration_number}</span>
</div>
)}
{dog.chic_number && (
<div className="info-row">
<span className="info-label"><ShieldCheck size={14} style={{ display: 'inline', marginRight: '0.25rem' }} />CHIC Status</span>
<span className="info-value">
<span style={{
fontSize: '0.75rem', fontWeight: 600, padding: '0.2rem 0.6rem',
background: 'rgba(99,102,241,0.15)', color: '#818cf8',
borderRadius: '999px', border: '1px solid rgba(99,102,241,0.3)'
}}>CHIC #{dog.chic_number}</span>
</span>
</div>
)}
{dog.microchip && (
<div className="info-row">
<span className="info-label"><Hash size={14} style={{ display: 'inline', marginRight: '0.25rem' }} />Microchip</span>
@@ -317,6 +331,9 @@ function DogDetail() {
{/* OFA Clearance Summary */}
<ClearanceSummaryCard dogId={id} onAddRecord={openAddHealth} />
{/* DNA Genetics Panel */}
<GeneticPanelCard dogId={id} />
{/* Health Records List */}
{healthRecords.length > 0 && (
<div className="card" style={{ marginBottom: '1.5rem' }}>

View File

@@ -259,6 +259,19 @@ function DogList() {
{dog.registration_number}
</div>
)}
{dog.chic_number && (
<div style={{
display: 'inline-flex', alignItems: 'center', gap: '0.25rem',
padding: '0.25rem 0.5rem',
background: 'rgba(99,102,241,0.1)',
border: '1px solid rgba(99,102,241,0.3)',
borderRadius: 'var(--radius-sm)',
fontSize: '0.75rem', fontWeight: 600,
color: '#818cf8', marginLeft: '0.5rem'
}}>
CHIC #{dog.chic_number}
</div>
)}
</Link>
{/* Actions */}

View File

@@ -11,6 +11,8 @@ export default function PairingSimulator() {
const [dogsLoading, setDogsLoading] = useState(true)
const [relationWarning, setRelationWarning] = useState(null)
const [relationChecking, setRelationChecking] = useState(false)
const [geneticRisk, setGeneticRisk] = useState(null)
const [geneticChecking, setGeneticChecking] = useState(false)
useEffect(() => {
// include_external=1 ensures external sires/dams appear for pairing
@@ -27,17 +29,28 @@ export default function PairingSimulator() {
const checkRelation = useCallback(async (sid, did) => {
if (!sid || !did) {
setRelationWarning(null)
setGeneticRisk(null)
return
}
setRelationChecking(true)
setGeneticChecking(true)
try {
const res = await fetch(`/api/pedigree/relations/${sid}/${did}`)
const data = await res.json()
setRelationWarning(data.related ? data.relationship : null)
const [relRes, genRes] = await Promise.all([
fetch(`/api/pedigree/relations/${sid}/${did}`),
fetch(`/api/genetics/pairing-risk?sireId=${sid}&damId=${did}`)
])
const relData = await relRes.json()
setRelationWarning(relData.related ? relData.relationship : null)
const genData = await genRes.json()
setGeneticRisk(genData)
} catch {
setRelationWarning(null)
setGeneticRisk(null)
} finally {
setRelationChecking(false)
setGeneticChecking(false)
}
}, [])
@@ -142,7 +155,7 @@ export default function PairingSimulator() {
{relationChecking && (
<div style={{ fontSize: '0.85rem', color: 'var(--text-muted)', marginBottom: '0.75rem' }}>
Checking relationship...
Checking relationship and genetics...
</div>
)}
@@ -158,6 +171,31 @@ export default function PairingSimulator() {
</div>
)}
{geneticRisk && geneticRisk.risks && geneticRisk.risks.length > 0 && !geneticChecking && (
<div style={{
padding: '0.6rem 1rem', marginBottom: '0.75rem',
background: 'rgba(255,159,10,0.08)', border: '1px solid rgba(255,159,10,0.3)',
borderRadius: 'var(--radius)', fontSize: '0.875rem', color: 'var(--warning)',
}}>
<div style={{ display: 'flex', alignItems: 'center', gap: '0.5rem', marginBottom: '0.25rem', fontWeight: 600 }}>
<ShieldAlert size={16} /> Genetic Risks Detected
</div>
<ul style={{ margin: 0, paddingLeft: '1.5rem' }}>
{geneticRisk.risks.map(r => (
<li key={r.marker}>
<strong>{r.marker}</strong>: {r.message}
</li>
))}
</ul>
</div>
)}
{geneticRisk && geneticRisk.missing_data && !geneticChecking && (
<div style={{ fontSize: '0.8rem', color: 'var(--text-muted)', marginBottom: '0.75rem', fontStyle: 'italic' }}>
* Sire or dam has missing genetic tests. Clearances cannot be fully verified.
</div>
)}
<button
type="submit"
className="btn btn-primary"