From 286b9c9bd01b66e78fcc43fe1cfcdd5b28d3ab95 Mon Sep 17 00:00:00 2001 From: jason Date: Mon, 9 Mar 2026 23:19:15 -0500 Subject: [PATCH 1/8] feat: expand Phase 4b roadmap with full health clearance & genetics system --- ROADMAP.md | 240 +++++++++++++++++++++++++++++++++++++++-------------- 1 file changed, 179 insertions(+), 61 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index bc3f46b..d6c240c 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -114,7 +114,7 @@ - [x] `GET /api/breeding/heat-cycles` endpoint - [x] `GET /api/breeding/heat-cycles/:id/suggestions` endpoint -- [x] **Projected Whelping Calendar Identifier** ✅ *(March 9, 2026 — v0.5.1)* +- [x] **Projected Whelping Calendar Identifier** ✅ *(March 9, 2026 − v0.5.1)* - [x] Gestation constants: earliest=58, expected=63, latest=65 days - [x] `getWwhelpDates(cycle)` client-side helper (no extra API call) - [x] Indigo whelp window cells (days 58–63) on calendar grid @@ -129,7 +129,7 @@ --- -## ✅ Phase 4a: Champion & Settings (COMPLETE — v0.6.0) +## ✅ Phase 4a: Champion & Settings (COMPLETE − v0.6.0) ### Champion Bloodline Tracking - [x] `is_champion INTEGER DEFAULT 0` column on `dogs` table @@ -145,48 +145,147 @@ - [x] `settings` table: `kennel_name`, `kennel_tagline`, `kennel_address`, `kennel_phone`, `kennel_email`, `kennel_website`, `kennel_akc_id`, `kennel_breed`, `owner_name` - [x] Safe `ALTER TABLE settings ADD COLUMN` migration loop for all kennel fields - [x] Auto-seed default row (`kennel_name = 'BREEDR'`) if table is empty -- [x] `GET /api/settings` — returns single-row as flat JSON object -- [x] `PUT /api/settings` — partial update via `ALLOWED_KEYS` whitelist +- [x] `GET /api/settings` − returns single-row as flat JSON object +- [x] `PUT /api/settings` − partial update via `ALLOWED_KEYS` whitelist - [x] `SettingsProvider` / `useSettings` React context hook - [x] Kennel name displayed in navbar from settings - [x] `SettingsPage` component for editing kennel info ### Build & Runtime Fixes (v0.6.0) -- [x] `useSettings.js` → `useSettings.jsx` — Vite build failed because JSX in `.js` file -- [x] `server/index.js` — `initDatabase()` called with no args (was passing `DB_PATH`, now path is internal) -- [x] `server/index.js` — removed duplicate `app.get('/api/health')` inline route -- [x] `server/index.js` — `DATA_DIR` env var replaces `path.dirname(DB_PATH)` for directory creation -- [x] `server/routes/settings.js` — rewrote from double-encoded base64 + old key/value schema to correct single-row column schema +- [x] `useSettings.js` → `useSettings.jsx` − Vite build failed because JSX in `.js` file +- [x] `server/index.js` − `initDatabase()` called with no args (was passing `DB_PATH`, now path is internal) +- [x] `server/index.js` − removed duplicate `app.get('/api/health')` inline route +- [x] `server/index.js` − `DATA_DIR` env var replaces `path.dirname(DB_PATH)` for directory creation +- [x] `server/routes/settings.js` − rewrote from double-encoded base64 + old key/value schema to correct single-row column schema --- -## 📋 Phase 4b: Health & Genetics (NEXT UP) +## 📋 Phase 4b: Health & Genetics (NEXT UP − v0.7.0) -### Health Records *(Priority 1)* 🚨 -- [ ] Health records list view per dog -- [ ] Add/edit health test results -- [ ] Vaccination tracking with expiry alerts -- [ ] Medical history timeline view -- [ ] Document uploads (PDFs, images) -- [ ] Health clearance status badges on dog cards +> **Context:** Golden Retriever health clearances follow GRCA Code of Ethics and OFA/CHIC standards. +> This phase builds a structured, breed-aware health tracking system aligned with those requirements. + +### Tier 1 — OFA Health Clearances *(Priority 1)* 🩺 + +The four GRCA-required clearances that must be on record in the public OFA database before breeding. + +**Database (schema additions to `health_records` table):** +- [ ] Add `test_type` ENUM-style field: `hip_ofa`, `hip_pennhip`, `elbow_ofa`, `heart_ofa`, `heart_echo`, `eye_caer`, `thyroid_ofa`, `dna_panel` +- [ ] Add `result` field: `pass`, `fail`, `carrier`, `clear`, `excellent`, `good`, `fair`, `borderline` +- [ ] Add `ofa_number` VARCHAR — official OFA certification number +- [ ] Add `chic_number` VARCHAR — CHIC certification number (dog-level field on `dogs` table) +- [ ] Add `performed_by` VARCHAR — vet or specialist name +- [ ] Add `expires_at` DATE — for annually-renewed tests (eyes, heart) +- [ ] Add `document_url` VARCHAR — path to uploaded PDF/image +- [ ] Safe ALTER TABLE migration guards for all new columns + +**API:** +- [ ] `GET /api/health/:dogId` — list all health records for a dog +- [ ] `POST /api/health` — create health record +- [ ] `PUT /api/health/:id` — update health record +- [ ] `DELETE /api/health/:id` — delete health record +- [ ] `GET /api/health/:dogId/clearance-summary` — returns pass/fail/missing for all 4 OFA tiers +- [ ] `GET /api/health/:dogId/chic-eligible` — returns boolean + missing tests + +**UI Components:** +- [ ] `HealthRecordForm` modal — test type dropdown, result, OFA#, date, performed-by, expiry, document upload +- [ ] `HealthTimeline` component — chronological list of all health events per dog on DogDetail page +- [ ] `ClearanceSummaryCard` — shows OFA Hip / Elbow / Heart / Eyes status in a 2x2 grid with color badges (green=pass, yellow=expiring, red=missing/fail) +- [ ] `ChicStatusBadge` — amber badge on dog cards and DogDetail if CHIC number is on file +- [ ] Expiry alert: yellow badge on dog card if any annual test expires within 90 days; red if expired +- [ ] Document upload support (PDF/image) tied to individual health records + +**Clearance Tiers Tracked:** +| Test | OFA Minimum Age | Renewal | Notes | +|---|---|---|---| +| Hip Dysplasia | 24 months | Once (final) | OFA eval or PennHIP | +| Elbow Dysplasia | 24 months | Once (final) | OFA eval | +| Cardiac (Heart) | 12 months | Annual recommended | Echo preferred over auscultation | +| Eyes (CAER) | 12 months | **Annual** | Board-certified ACVO ophthalmologist | +| Thyroid (OFA) | 12 months | Annual recommended | Bonus/Tier 2 | **Complexity:** Medium | **Impact:** High | **User Value:** Excellent +**Estimated Time:** 8–10 hours -**Why this is recommended:** -- Natural complement to existing dog profiles -- Vaccination expiry alerts are high day-to-day utility -- Clearance badges on dog cards improve trust at a glance -- Builds toward breeding decision support +--- -**Estimated Time:** 6-8 hours +### Tier 2 — DNA Genetic Panel *(Priority 2)* 🧬 -### Genetic Trait Tracking *(Priority 2)* -- [ ] Track inherited traits -- [ ] Color genetics calculator -- [ ] Health clearance status -- [ ] Link traits to ancestors +Embark or equivalent panel results per dog. Allows carrier × clear pairing without producing affected offspring. -**Estimated Time:** 5-7 hours +**Database:** +- [ ] `genetic_tests` table: `id`, `dog_id`, `test_provider` (embark/optigen/etc), `test_name`, `result` (clear/carrier/affected), `test_date`, `document_url`, `created_at` +- [ ] Safe `CREATE TABLE IF NOT EXISTS` guard + +**Golden Retriever Panel — Key Markers:** +- [ ] PRA1 (Progressive Retinal Atrophy type 1) +- [ ] PRA2 (Progressive Retinal Atrophy type 2) +- [ ] prcd-PRA (Progressive Rod-Cone Degeneration) +- [ ] ICH1 / ICH2 (Ichthyosis — very common in Goldens) +- [ ] NCL (Neuronal Ceroid Lipofuscinosis — fatal neurological) +- [ ] DM (Degenerative Myelopathy) +- [ ] MD (Muscular Dystrophy) +- [ ] GR-PRA1, GR-PRA2 (Golden-specific PRA variants) + +**API:** +- [ ] `GET /api/genetics/:dogId` — list all genetic test results +- [ ] `POST /api/genetics` — add genetic result +- [ ] `PUT /api/genetics/:id` — update +- [ ] `DELETE /api/genetics/:id` — delete +- [ ] `GET /api/genetics/pairing-risk?sireId=&damId=` — returns at-risk combinations for a trial pairing + +**UI Components:** +- [ ] `GeneticTestForm` modal — provider, marker, result (clear/carrier/affected), date, upload +- [ ] `GeneticPanelCard` on DogDetail — color-coded grid of all markers (green=clear, yellow=carrier, red=affected, gray=not tested) +- [ ] Pairing risk overlay on Trial Pairing Simulator — flag if sire+dam are both carriers for same marker +- [ ] "Not Tested" indicator on dog cards when no DNA panel on file + +**Complexity:** Medium | **Impact:** High | **User Value:** Excellent +**Estimated Time:** 6–8 hours + +--- + +### Tier 3 — Cancer Lineage & Longevity Tracking *(Priority 3)* 📊 + +Golden Retrievers have ~60% cancer mortality rate. Lineage-based cancer history is a major differentiator for responsible breeders. + +**Database:** +- [ ] `cancer_history` table: `id`, `dog_id`, `cancer_type`, `age_at_diagnosis`, `age_at_death`, `cause_of_death`, `notes`, `created_at` +- [ ] Add `age_at_death` and `cause_of_death` optional fields to `dogs` table + +**API:** +- [ ] `GET /api/health/:dogId/cancer-history` +- [ ] `POST /api/health/cancer-history` +- [ ] `GET /api/pedigree/:dogId/cancer-lineage` — walks ancestors and returns cancer incidence summary + +**UI:** +- [ ] Longevity section on DogDetail — age at death, cause of death +- [ ] Cancer lineage indicator on Trial Pairing Simulator — "X of 8 ancestors had cancer history" +- [ ] Optional cancer history entry on DogForm + +**Complexity:** Low-Medium | **Impact:** Medium | **User Value:** High (differentiator) +**Estimated Time:** 4–5 hours + +--- + +### Tier 4 — Breeding Eligibility Checker *(Priority 4)* ✅ + +Automatic litter eligibility gate based on health clearance status of sire and dam. + +**Logic:** +- [ ] Dog is "GRCA eligible" if: Hip OFA ✅ + Elbow OFA ✅ + Heart ✅ + Eyes (non-expired) ✅ + age ≥ 24 months +- [ ] Dog is "CHIC eligible" if all four tests are in OFA public database (CHIC number on file) +- [ ] Warning flags in Trial Pairing Simulator if sire or dam is missing required clearances +- [ ] Block litter creation (with override) if either parent fails eligibility check + +**UI:** +- [ ] Eligibility badge on dog cards: `GRCA Eligible` (green) / `Incomplete` (yellow) / `Not Eligible` (red) +- [ ] Eligibility breakdown tooltip on hover — shows which tests are missing +- [ ] Pre-litter warning modal when creating a litter with non-eligible parents +- [ ] CHIC number field + verification note on DogDetail + +**Complexity:** Low | **Impact:** High | **User Value:** Excellent +**Estimated Time:** 3–4 hours --- @@ -266,9 +365,10 @@ - [ ] Export to Excel/CSV - [ ] Integration with kennel clubs - [ ] Backup to cloud storage +- [ ] OFA database lookup by registration number ### Advanced Genetics -- [ ] DNA test result tracking +- [ ] DNA test result tracking (full Embark import) - [ ] Genetic diversity analysis - [ ] Breed-specific calculators - [ ] Health risk predictions @@ -281,48 +381,58 @@ --- -## 📅 Current Sprint: v0.7.0 +## 🏃 Current Sprint: v0.7.0 (Phase 4b) ### ✅ Completed This Sprint (v0.6.0) -- [x] `is_champion` flag — DB column, API, DogForm toggle, offspring badge, parent dropdown `✪` -- [x] Kennel Settings — `settings` table with all kennel fields, `GET/PUT /api/settings`, `SettingsProvider`, navbar kennel name +- [x] `is_champion` flag − DB column, API, DogForm toggle, offspring badge, parent dropdown `✪` +- [x] Kennel Settings − `settings` table with all kennel fields, `GET/PUT /api/settings`, `SettingsProvider`, navbar kennel name - [x] `useSettings.jsx` rename (Vite build fix) -- [x] `server/index.js` fix — `initDatabase()` no-arg call, duplicate health route removed -- [x] `server/routes/settings.js` rewrite — fixed double-encoded base64 + wrong key/value schema +- [x] `server/index.js` fix − `initDatabase()` no-arg, duplicate health route removed +- [x] `server/routes/settings.js` rewrite: double-encoded base64 + wrong schema fixed ### ✅ Previously Completed (v0.5.1) -- [x] Projected Whelping Calendar Identifier — indigo whelp window cells, due label, active card range, jump-to-month button +- [x] Projected Whelping Calendar Identifier − indigo whelp window cells, due label, active card range, jump-to-month button - [x] Live whelp preview in Cycle Detail modal (client-side, no save required) - [x] Full-width whelping banner for months with projected whelps - [x] "Projected Whelp" legend entry + updated page subtitle -### 🔜 Next Up (Priority Order) +### 🔜 Next Up — Phase 4b Build Order -#### Option 1: Health Records System (Recommended) 🚨 -**Complexity:** Medium | **Impact:** High | **User Value:** Excellent +#### Step 1: DB Schema Extensions +- [ ] Extend `health_records` table with OFA-specific columns (test_type, result, ofa_number, chic_number, expires_at, document_url) +- [ ] Create `genetic_tests` table (PRA, ICH, NCL, DM, MD, GR-PRA variants) +- [ ] Create `cancer_history` table +- [ ] Add `chic_number`, `age_at_death`, `cause_of_death` to `dogs` table +- [ ] All changes via safe ALTER TABLE / CREATE TABLE IF NOT EXISTS guards -**Tasks:** -- Create `HealthRecordForm` component -- Health records list/timeline per dog on DogDetail page -- Vaccination tracking with expiry date + alert badge -- Health clearance status badges (OFA, CERF, etc.) -- Optional document/PDF upload +#### Step 2: API Layer +- [ ] `GET|POST|PUT|DELETE /api/health/:dogId` (OFA records) +- [ ] `GET /api/health/:dogId/clearance-summary` +- [ ] `GET /api/health/:dogId/chic-eligible` +- [ ] `GET|POST|PUT|DELETE /api/genetics/:dogId` +- [ ] `GET /api/genetics/pairing-risk` (sire + dam carrier check) +- [ ] Cancer history endpoints -**Estimated Time:** 6-8 hours +#### Step 3: Core UI — Health Records +- [ ] `HealthRecordForm` modal (test type, result, OFA#, expiry, doc upload) +- [ ] `HealthTimeline` on DogDetail page +- [ ] `ClearanceSummaryCard` 2×2 grid (Hip / Elbow / Heart / Eyes) +- [ ] `ChicStatusBadge` on dog cards +- [ ] Expiry alert badges (90-day warning, expired) ---- +#### Step 4: Core UI — Genetics Panel +- [ ] `GeneticTestForm` modal +- [ ] `GeneticPanelCard` on DogDetail (color-coded markers) +- [ ] Pairing risk overlay on Trial Pairing Simulator -#### Option 2: Genetic Trait Tracking -**Complexity:** Medium | **Impact:** Medium | **User Value:** Good +#### Step 5: Eligibility Checker +- [ ] Eligibility logic (`grca_eligible`, `chic_eligible` computed fields) +- [ ] Eligibility badge on dog cards +- [ ] Pre-litter eligibility warning modal -**Tasks:** -- Trait entry form (coat color, pattern, carried traits) -- Display traits on dog detail page -- Predicted trait calculator for trial pairings - -**Estimated Time:** 5-7 hours - ---- +#### Step 6: Cancer / Longevity (Stretch) +- [ ] Cancer history form + lineage summary on Trial Pairing page +- [ ] Age at death / cause of death on DogDetail ### Testing Needed - [x] Add/edit dog forms with litter selection @@ -335,13 +445,15 @@ - [x] Brand logo display and sizing - [x] Gradient title rendering - [x] Static asset serving in prod and dev -- [ ] Champion toggle — DogForm save/load round-trip -- [ ] Champion badge — offspring card display -- [ ] Kennel settings — save + navbar name update +- [ ] Champion toggle − DogForm save/load round-trip +- [ ] Champion badge − offspring card display +- [ ] Kennel settings − save + navbar name update - [ ] Trial pairing simulator (end-to-end) - [ ] Heat cycle calendar (start cycle, detail modal, whelping) - [ ] Projected whelping calendar identifier (whelp cells, due label, banner) -- [ ] Health records +- [ ] Health records — OFA clearance CRUD +- [ ] Genetic panel — DNA marker entry and display +- [ ] Eligibility checker — badge and litter gate ### Known Issues - None currently @@ -358,6 +470,12 @@ ## Version History +- **v0.7.0** (In Progress) - Phase 4b: Health & Genetics + - OFA clearance tracking (Hip, Elbow, Heart, Eyes + CHIC number) + - DNA genetic panel (PRA, ICH, NCL, DM, MD variants) + - Cancer lineage & longevity tracking + - Breeding eligibility checker (GRCA + CHIC gates) + - **v0.6.0** (March 9, 2026) - Champion Bloodline, Settings, Build Fixes - `is_champion` flag on dogs table with ALTER TABLE migration guard - Champion toggle in DogForm; `✪` suffix in parent dropdowns; offspring badge From 91ad50655c7bb6294f27569b07d8f37b46958ea4 Mon Sep 17 00:00:00 2001 From: jason Date: Mon, 9 Mar 2026 23:21:44 -0500 Subject: [PATCH 2/8] =?UTF-8?q?feat(db):=20Phase=204b=20schema=20=E2=80=94?= =?UTF-8?q?=20OFA=20clearances,=20genetic=5Ftests,=20cancer=5Fhistory,=20e?= =?UTF-8?q?ligibility=20fields?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- server/db/init.js | 161 ++++++++++++++++++++++++++++++++-------------- 1 file changed, 113 insertions(+), 48 deletions(-) diff --git a/server/db/init.js b/server/db/init.js index bc7d937..44685f4 100644 --- a/server/db/init.js +++ b/server/db/init.js @@ -16,28 +16,37 @@ function initDatabase() { // ── Dogs ──────────────────────────────────────────────────────────── db.exec(` CREATE TABLE IF NOT EXISTS dogs ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - name TEXT NOT NULL, - registration_number TEXT, - breed TEXT NOT NULL, - sex TEXT NOT NULL CHECK(sex IN ('male', 'female')), - birth_date TEXT, - color TEXT, - microchip TEXT, - litter_id INTEGER, - is_active INTEGER DEFAULT 1, - is_champion INTEGER DEFAULT 0, - photo_urls TEXT DEFAULT '[]', - notes TEXT, - created_at TEXT DEFAULT (datetime('now')), - updated_at TEXT DEFAULT (datetime('now')) + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT NOT NULL, + registration_number TEXT, + breed TEXT NOT NULL, + sex TEXT NOT NULL CHECK(sex IN ('male', 'female')), + birth_date TEXT, + color TEXT, + microchip TEXT, + litter_id INTEGER, + is_active INTEGER DEFAULT 1, + is_champion INTEGER DEFAULT 0, + chic_number TEXT, + age_at_death TEXT, + cause_of_death TEXT, + photo_urls TEXT DEFAULT '[]', + notes TEXT, + created_at TEXT DEFAULT (datetime('now')), + updated_at TEXT DEFAULT (datetime('now')) ) `); - // migrate: add is_champion if missing (safe on existing DBs) - try { - db.exec(`ALTER TABLE dogs ADD COLUMN is_champion INTEGER DEFAULT 0`); - } catch (_) { /* column already exists */ } + // migrate: add columns if missing (safe on existing DBs) + const dogMigrations = [ + ['is_champion', 'INTEGER DEFAULT 0'], + ['chic_number', 'TEXT'], + ['age_at_death', 'TEXT'], + ['cause_of_death', 'TEXT'], + ]; + for (const [col, def] of dogMigrations) { + try { db.exec(`ALTER TABLE dogs ADD COLUMN ${col} ${def}`); } catch (_) { /* already exists */ } + } // ── Parents ───────────────────────────────────────────────────────── db.exec(` @@ -51,24 +60,24 @@ function initDatabase() { ) `); - // ── Breeding Records ──────────────────────────────────────────────── + // ── Breeding Records ───────────────────────────────────────────────── db.exec(` CREATE TABLE IF NOT EXISTS breeding_records ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - sire_id INTEGER NOT NULL, - dam_id INTEGER NOT NULL, - breeding_date TEXT, - due_date TEXT, - conception_method TEXT CHECK(conception_method IN ('natural', 'ai', 'frozen', 'surgical')), - notes TEXT, - created_at TEXT DEFAULT (datetime('now')), - updated_at TEXT DEFAULT (datetime('now')), + id INTEGER PRIMARY KEY AUTOINCREMENT, + sire_id INTEGER NOT NULL, + dam_id INTEGER NOT NULL, + breeding_date TEXT, + due_date TEXT, + conception_method TEXT CHECK(conception_method IN ('natural', 'ai', 'frozen', 'surgical')), + notes TEXT, + created_at TEXT DEFAULT (datetime('now')), + updated_at TEXT DEFAULT (datetime('now')), FOREIGN KEY (sire_id) REFERENCES dogs(id), FOREIGN KEY (dam_id) REFERENCES dogs(id) ) `); - // ── Litters ───────────────────────────────────────────────────────── + // ── Litters ────────────────────────────────────────────────────────── db.exec(` CREATE TABLE IF NOT EXISTS litters ( id INTEGER PRIMARY KEY AUTOINCREMENT, @@ -89,21 +98,81 @@ function initDatabase() { ) `); - // ── Health Records ────────────────────────────────────────────────── + // ── Health Records (OFA-extended) ──────────────────────────────────── + // test_type values: hip_ofa | hip_pennhip | elbow_ofa | heart_ofa | + // heart_echo | eye_caer | thyroid_ofa | dna_panel | vaccination | + // other + // ofa_result values: excellent | good | fair | borderline | mild | + // moderate | severe | normal | abnormal | pass | fail | carrier | + // clear | affected | n/a db.exec(` CREATE TABLE IF NOT EXISTS health_records ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - dog_id INTEGER NOT NULL, - record_type TEXT NOT NULL, - date TEXT NOT NULL, - title TEXT NOT NULL, - description TEXT, - vet_name TEXT, - notes TEXT, - result TEXT, - next_due TEXT, - created_at TEXT DEFAULT (datetime('now')), - updated_at TEXT DEFAULT (datetime('now')), + id INTEGER PRIMARY KEY AUTOINCREMENT, + dog_id INTEGER NOT NULL, + record_type TEXT NOT NULL, + test_type TEXT, + test_name TEXT, + test_date TEXT NOT NULL, + ofa_result TEXT, + ofa_number TEXT, + performed_by TEXT, + expires_at TEXT, + document_url TEXT, + result TEXT, + vet_name TEXT, + next_due TEXT, + notes TEXT, + created_at TEXT DEFAULT (datetime('now')), + updated_at TEXT DEFAULT (datetime('now')), + FOREIGN KEY (dog_id) REFERENCES dogs(id) + ) + `); + + // migrate: add OFA-specific columns if missing + const healthMigrations = [ + ['test_type', 'TEXT'], + ['ofa_result', 'TEXT'], + ['ofa_number', 'TEXT'], + ['performed_by', 'TEXT'], + ['expires_at', 'TEXT'], + ['document_url', 'TEXT'], + ]; + for (const [col, def] of healthMigrations) { + try { db.exec(`ALTER TABLE health_records ADD COLUMN ${col} ${def}`); } catch (_) { /* already exists */ } + } + + // ── Genetic Tests (DNA Panel) ───────────────────────────────────────── + // result values: clear | carrier | affected | not_tested + // marker examples: PRA1, PRA2, prcd-PRA, GR-PRA1, GR-PRA2, ICH1, + // ICH2, NCL, DM, MD + db.exec(` + CREATE TABLE IF NOT EXISTS genetic_tests ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + dog_id INTEGER NOT NULL, + test_provider TEXT, + marker TEXT NOT NULL, + result TEXT NOT NULL CHECK(result IN ('clear', 'carrier', 'affected', 'not_tested')), + test_date TEXT, + document_url TEXT, + notes TEXT, + created_at TEXT DEFAULT (datetime('now')), + updated_at TEXT DEFAULT (datetime('now')), + FOREIGN KEY (dog_id) REFERENCES dogs(id) + ) + `); + + // ── Cancer History ─────────────────────────────────────────────────── + db.exec(` + CREATE TABLE IF NOT EXISTS cancer_history ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + dog_id INTEGER NOT NULL, + cancer_type TEXT, + age_at_diagnosis TEXT, + age_at_death TEXT, + cause_of_death TEXT, + notes TEXT, + created_at TEXT DEFAULT (datetime('now')), + updated_at TEXT DEFAULT (datetime('now')), FOREIGN KEY (dog_id) REFERENCES dogs(id) ) `); @@ -126,7 +195,6 @@ function initDatabase() { ) `); - // migrate: add new kennel columns if missing (safe on existing DBs) const kennelCols = [ ['kennel_name', "TEXT DEFAULT 'BREEDR'"], ['kennel_tagline', 'TEXT'], @@ -139,12 +207,9 @@ function initDatabase() { ['owner_name', 'TEXT'], ]; for (const [col, def] of kennelCols) { - try { - db.exec(`ALTER TABLE settings ADD COLUMN ${col} ${def}`); - } catch (_) { /* already exists */ } + try { db.exec(`ALTER TABLE settings ADD COLUMN ${col} ${def}`); } catch (_) { /* already exists */ } } - // Seed a default settings row if none exists const existing = db.prepare('SELECT id FROM settings LIMIT 1').get(); if (!existing) { db.prepare(`INSERT INTO settings (kennel_name) VALUES (?)`).run('BREEDR'); From 863548333262b930e9a3bc78752057d8377e1ba8 Mon Sep 17 00:00:00 2001 From: jason Date: Mon, 9 Mar 2026 23:22:55 -0500 Subject: [PATCH 3/8] feat(api): rewrite health.js with OFA clearance fields, clearance-summary, chic-eligible endpoints --- server/routes/health.js | 151 +++++++++++++++++++++++++++++++++------- 1 file changed, 127 insertions(+), 24 deletions(-) diff --git a/server/routes/health.js b/server/routes/health.js index 3080730..75cde00 100644 --- a/server/routes/health.js +++ b/server/routes/health.js @@ -2,32 +2,113 @@ const express = require('express'); const router = express.Router(); const { getDatabase } = require('../db/init'); +// OFA tests that count toward GRCA eligibility +const GRCA_REQUIRED = ['hip_ofa', 'hip_pennhip', 'elbow_ofa', 'heart_ofa', 'heart_echo', 'eye_caer']; +const GRCA_CORE = { + hip: ['hip_ofa', 'hip_pennhip'], + elbow: ['elbow_ofa'], + heart: ['heart_ofa', 'heart_echo'], + eye: ['eye_caer'], +}; + +// Helper: compute clearance summary for a dog +function getClearanceSummary(db, dogId) { + const records = db.prepare(` + SELECT test_type, ofa_result, ofa_number, expires_at, test_date + FROM health_records + WHERE dog_id = ? AND test_type IS NOT NULL + ORDER BY test_date DESC + `).all(dogId); + + const today = new Date(); + const in90 = new Date(); in90.setDate(today.getDate() + 90); + + const summary = {}; + for (const [group, types] of Object.entries(GRCA_CORE)) { + const match = records.find(r => types.includes(r.test_type)); + if (!match) { + summary[group] = { status: 'missing', record: null }; + } else { + let status = 'pass'; + if (match.expires_at) { + const exp = new Date(match.expires_at); + if (exp < today) status = 'expired'; + else if (exp <= in90) status = 'expiring_soon'; + } + summary[group] = { status, record: match }; + } + } + return summary; +} + // GET all health records for a dog router.get('/dog/:dogId', (req, res) => { try { const db = getDatabase(); const records = db.prepare(` - SELECT * FROM health_records - WHERE dog_id = ? + SELECT * FROM health_records + WHERE dog_id = ? ORDER BY test_date DESC `).all(req.params.dogId); - res.json(records); } catch (error) { res.status(500).json({ error: error.message }); } }); +// GET clearance summary (Hip / Elbow / Heart / Eyes) for a dog +router.get('/dog/:dogId/clearance-summary', (req, res) => { + try { + const db = getDatabase(); + const dog = db.prepare('SELECT id, birth_date, chic_number FROM dogs WHERE id = ?').get(req.params.dogId); + if (!dog) return res.status(404).json({ error: 'Dog not found' }); + + const summary = getClearanceSummary(db, dog.id); + + // Age check: must be >= 24 months for hip/elbow + let ageEligible = false; + if (dog.birth_date) { + const months = (new Date() - new Date(dog.birth_date)) / (1000 * 60 * 60 * 24 * 30.44); + ageEligible = months >= 24; + } + + const allPass = Object.values(summary).every(s => ['pass', 'expiring_soon'].includes(s.status)); + const grca_eligible = allPass && ageEligible; + + res.json({ summary, grca_eligible, age_eligible: ageEligible, chic_number: dog.chic_number }); + } catch (error) { + res.status(500).json({ error: error.message }); + } +}); + +// GET CHIC eligibility check +router.get('/dog/:dogId/chic-eligible', (req, res) => { + try { + const db = getDatabase(); + const dog = db.prepare('SELECT id, chic_number FROM dogs WHERE id = ?').get(req.params.dogId); + if (!dog) return res.status(404).json({ error: 'Dog not found' }); + + const summary = getClearanceSummary(db, dog.id); + const missing = Object.entries(summary) + .filter(([, v]) => v.status === 'missing') + .map(([k]) => k); + + res.json({ + chic_eligible: missing.length === 0, + chic_number: dog.chic_number || null, + missing_tests: missing, + }); + } catch (error) { + res.status(500).json({ error: error.message }); + } +}); + // GET single health record router.get('/:id', (req, res) => { try { const db = getDatabase(); const record = db.prepare('SELECT * FROM health_records WHERE id = ?').get(req.params.id); - - if (!record) { - return res.status(404).json({ error: 'Health record not found' }); - } - + if (!record) return res.status(404).json({ error: 'Health record not found' }); res.json(record); } catch (error) { res.status(500).json({ error: error.message }); @@ -37,20 +118,30 @@ router.get('/:id', (req, res) => { // POST create health record router.post('/', (req, res) => { try { - const { dog_id, record_type, test_name, test_date, result, document_url, notes } = req.body; - + const { + dog_id, record_type, test_type, test_name, test_date, + ofa_result, ofa_number, performed_by, expires_at, + document_url, result, vet_name, next_due, notes + } = req.body; + if (!dog_id || !record_type || !test_date) { - return res.status(400).json({ error: 'Dog ID, record type, and test date are required' }); + return res.status(400).json({ error: 'dog_id, record_type, and test_date are required' }); } - + const db = getDatabase(); const dbResult = db.prepare(` - INSERT INTO health_records (dog_id, record_type, test_name, test_date, result, document_url, notes) - VALUES (?, ?, ?, ?, ?, ?, ?) - `).run(dog_id, record_type, test_name, test_date, result, document_url, notes); - + INSERT INTO health_records + (dog_id, record_type, test_type, test_name, test_date, + ofa_result, ofa_number, performed_by, expires_at, + document_url, result, vet_name, next_due, notes) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + `).run( + dog_id, record_type, test_type || null, test_name || null, test_date, + ofa_result || null, ofa_number || null, performed_by || null, expires_at || null, + document_url || null, result || null, vet_name || null, next_due || null, notes || null + ); + const record = db.prepare('SELECT * FROM health_records WHERE id = ?').get(dbResult.lastInsertRowid); - res.status(201).json(record); } catch (error) { res.status(500).json({ error: error.message }); @@ -60,15 +151,27 @@ router.post('/', (req, res) => { // PUT update health record router.put('/:id', (req, res) => { try { - const { record_type, test_name, test_date, result, document_url, notes } = req.body; - + const { + record_type, test_type, test_name, test_date, + ofa_result, ofa_number, performed_by, expires_at, + document_url, result, vet_name, next_due, notes + } = req.body; + const db = getDatabase(); db.prepare(` - UPDATE health_records - SET record_type = ?, test_name = ?, test_date = ?, result = ?, document_url = ?, notes = ? + UPDATE health_records + SET record_type = ?, test_type = ?, test_name = ?, test_date = ?, + ofa_result = ?, ofa_number = ?, performed_by = ?, expires_at = ?, + document_url = ?, result = ?, vet_name = ?, next_due = ?, notes = ?, + updated_at = datetime('now') WHERE id = ? - `).run(record_type, test_name, test_date, result, document_url, notes, req.params.id); - + `).run( + record_type, test_type || null, test_name || null, test_date, + ofa_result || null, ofa_number || null, performed_by || null, expires_at || null, + document_url || null, result || null, vet_name || null, next_due || null, notes || null, + req.params.id + ); + const record = db.prepare('SELECT * FROM health_records WHERE id = ?').get(req.params.id); res.json(record); } catch (error) { @@ -87,4 +190,4 @@ router.delete('/:id', (req, res) => { } }); -module.exports = router; \ No newline at end of file +module.exports = router; From d9cd0bec58ec05abc2db1610acfa04740c3916f7 Mon Sep 17 00:00:00 2001 From: jason Date: Mon, 9 Mar 2026 23:23:46 -0500 Subject: [PATCH 4/8] =?UTF-8?q?feat(api):=20add=20genetics.js=20=E2=80=94?= =?UTF-8?q?=20DNA=20panel=20CRUD=20+=20pairing-risk=20endpoint?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- server/routes/genetics.js | 158 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 158 insertions(+) create mode 100644 server/routes/genetics.js diff --git a/server/routes/genetics.js b/server/routes/genetics.js new file mode 100644 index 0000000..4437b62 --- /dev/null +++ b/server/routes/genetics.js @@ -0,0 +1,158 @@ +const express = require('express'); +const router = express.Router(); +const { getDatabase } = require('../db/init'); + +// Golden Retriever panel markers tracked by Breedr +const GR_MARKERS = [ + 'PRA1', 'PRA2', 'prcd-PRA', 'GR-PRA1', 'GR-PRA2', + 'ICH1', 'ICH2', 'NCL', 'DM', 'MD' +]; + +// GET all genetic tests for a dog +router.get('/dog/:dogId', (req, res) => { + try { + const db = getDatabase(); + const tests = db.prepare(` + SELECT * FROM genetic_tests + WHERE dog_id = ? + ORDER BY marker ASC + `).all(req.params.dogId); + + // Return a full panel including not_tested placeholders + const byMarker = {}; + for (const t of tests) byMarker[t.marker] = t; + + const panel = GR_MARKERS.map(marker => ({ + marker, + ...(byMarker[marker] || { result: 'not_tested', dog_id: Number(req.params.dogId) }) + })); + + res.json({ tests, panel }); + } catch (error) { + res.status(500).json({ error: error.message }); + } +}); + +// GET pairing risk — compare sire + dam carrier status +// Usage: GET /api/genetics/pairing-risk?sireId=1&damId=2 +router.get('/pairing-risk', (req, res) => { + try { + const { sireId, damId } = req.query; + if (!sireId || !damId) { + return res.status(400).json({ error: 'sireId and damId are required' }); + } + + const db = getDatabase(); + + const getResults = (dogId) => { + const rows = db.prepare('SELECT marker, result FROM genetic_tests WHERE dog_id = ?').all(dogId); + const map = {}; + for (const r of rows) map[r.marker] = r.result; + return map; + }; + + const sireResults = getResults(sireId); + const damResults = getResults(damId); + + const risks = []; + for (const marker of GR_MARKERS) { + const s = sireResults[marker] || 'not_tested'; + const d = damResults[marker] || 'not_tested'; + + // Both affected or carrier x carrier = risk + if ( + (s === 'affected' || d === 'affected') || + (s === 'carrier' && d === 'carrier') + ) { + risks.push({ + marker, + sire_result: s, + dam_result: d, + risk_level: (s === 'affected' || d === 'affected') ? 'high' : 'moderate', + note: s === 'affected' || d === 'affected' + ? 'One or both parents are affected — do not breed' + : 'Both parents are carriers — 25% chance of affected offspring', + }); + } + } + + res.json({ + sire_id: Number(sireId), + dam_id: Number(damId), + risks, + safe_to_pair: risks.length === 0, + }); + } catch (error) { + res.status(500).json({ error: error.message }); + } +}); + +// GET single genetic test +router.get('/:id', (req, res) => { + try { + const db = getDatabase(); + const test = db.prepare('SELECT * FROM genetic_tests WHERE id = ?').get(req.params.id); + if (!test) return res.status(404).json({ error: 'Genetic test not found' }); + res.json(test); + } catch (error) { + res.status(500).json({ error: error.message }); + } +}); + +// POST create genetic test +router.post('/', (req, res) => { + try { + const { dog_id, test_provider, marker, result, test_date, document_url, notes } = req.body; + + if (!dog_id || !marker || !result) { + return res.status(400).json({ error: 'dog_id, marker, and result are required' }); + } + if (!['clear', 'carrier', 'affected', 'not_tested'].includes(result)) { + return res.status(400).json({ error: 'result must be: clear | carrier | affected | not_tested' }); + } + + const db = getDatabase(); + const dbResult = db.prepare(` + INSERT INTO genetic_tests (dog_id, test_provider, marker, result, test_date, document_url, notes) + VALUES (?, ?, ?, ?, ?, ?, ?) + `).run(dog_id, test_provider || null, marker, result, test_date || null, document_url || null, notes || null); + + const test = db.prepare('SELECT * FROM genetic_tests WHERE id = ?').get(dbResult.lastInsertRowid); + res.status(201).json(test); + } catch (error) { + res.status(500).json({ error: error.message }); + } +}); + +// PUT update genetic test +router.put('/:id', (req, res) => { + try { + const { test_provider, marker, result, test_date, document_url, notes } = req.body; + + const db = getDatabase(); + db.prepare(` + UPDATE genetic_tests + SET test_provider = ?, marker = ?, result = ?, test_date = ?, + document_url = ?, notes = ?, updated_at = datetime('now') + WHERE id = ? + `).run(test_provider || null, marker, result, test_date || null, document_url || null, notes || null, req.params.id); + + const test = db.prepare('SELECT * FROM genetic_tests WHERE id = ?').get(req.params.id); + res.json(test); + } catch (error) { + res.status(500).json({ error: error.message }); + } +}); + +// DELETE genetic test +router.delete('/:id', (req, res) => { + try { + const db = getDatabase(); + db.prepare('DELETE FROM genetic_tests WHERE id = ?').run(req.params.id); + res.json({ message: 'Genetic test deleted successfully' }); + } catch (error) { + res.status(500).json({ error: error.message }); + } +}); + +module.exports = router; From 97efc937c0062cef3c53374f00229b2d9b8382f9 Mon Sep 17 00:00:00 2001 From: jason Date: Mon, 9 Mar 2026 23:25:15 -0500 Subject: [PATCH 5/8] feat(server): register /api/genetics route for Phase 4b --- server/index.js | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/server/index.js b/server/index.js index 363d8e6..b7c32a4 100644 --- a/server/index.js +++ b/server/index.js @@ -22,7 +22,7 @@ console.log('Initializing database...'); initDatabase(); console.log('✓ Database ready!\n'); -// ── Middleware ───────────────────────────────────────────────────────── +// ── Middleware ────────────────────────────────────────────────────────── app.use(helmet({ contentSecurityPolicy: false })); app.use(cors()); app.use(express.json()); @@ -38,6 +38,7 @@ app.use('/static', (_req, res) => res.status(404).json({ error: 'Static asset n app.use('/api/dogs', require('./routes/dogs')); app.use('/api/litters', require('./routes/litters')); app.use('/api/health', require('./routes/health')); +app.use('/api/genetics', require('./routes/genetics')); app.use('/api/pedigree', require('./routes/pedigree')); app.use('/api/breeding', require('./routes/breeding')); app.use('/api/settings', require('./routes/settings')); @@ -61,15 +62,15 @@ app.use((err, _req, res, _next) => { }); app.listen(PORT, '0.0.0.0', () => { - console.log(`\n\U0001f415 BREEDR Server Running`); - console.log(`=========================================`); + console.log(`\n🐕 BREEDR Server Running`); + console.log(`=============================================`); console.log(`Environment : ${process.env.NODE_ENV || 'development'}`); console.log(`Port : ${PORT}`); console.log(`Data dir : ${DATA_DIR}`); console.log(`Uploads : ${UPLOAD_PATH}`); console.log(`Static : ${STATIC_PATH}`); console.log(`Access : http://localhost:${PORT}`); - console.log(`=========================================\n`); + console.log(`=============================================\n`); }); module.exports = app; From bc7f54b0848a73b93d6047a8b78ffe85026dcd6b Mon Sep 17 00:00:00 2001 From: jason Date: Mon, 9 Mar 2026 23:30:47 -0500 Subject: [PATCH 6/8] feat(ui): add ClearanceSummaryCard with OFA clearance chips and GRCA eligibility badge --- .../src/components/ClearanceSummaryCard.jsx | 126 ++++++++++++++++++ 1 file changed, 126 insertions(+) create mode 100644 client/src/components/ClearanceSummaryCard.jsx diff --git a/client/src/components/ClearanceSummaryCard.jsx b/client/src/components/ClearanceSummaryCard.jsx new file mode 100644 index 0000000..708cfcf --- /dev/null +++ b/client/src/components/ClearanceSummaryCard.jsx @@ -0,0 +1,126 @@ +import { useEffect, useState } from 'react' +import { ShieldCheck, ShieldAlert, ShieldX, Clock, AlertTriangle, Plus } from 'lucide-react' +import axios from 'axios' + +const STATUS_CONFIG = { + pass: { icon: ShieldCheck, color: 'var(--success)', label: 'Clear', bg: 'rgba(52,199,89,0.1)' }, + expiring_soon: { icon: Clock, color: 'var(--warning)', label: 'Expiring Soon', bg: 'rgba(255,159,10,0.1)' }, + expired: { icon: ShieldX, color: 'var(--danger)', label: 'Expired', bg: 'rgba(255,59,48,0.1)' }, + missing: { icon: ShieldAlert, color: 'var(--text-muted)', label: 'Missing', bg: 'var(--bg-primary)' }, +} + +const GROUP_LABELS = { hip: 'Hips', elbow: 'Elbows', heart: 'Heart', eye: 'Eyes' } + +function ClearanceChip({ group, status, record }) { + const cfg = STATUS_CONFIG[status] || STATUS_CONFIG.missing + const Icon = cfg.icon + const tip = record + ? `OFA #${record.ofa_number || '-'} - ${record.ofa_result || record.result || ''}` + : 'No record on file' + return ( +
+ +
+
+ {GROUP_LABELS[group]} +
+
+ {cfg.label} +
+
+
+ ) +} + +export default function ClearanceSummaryCard({ dogId, onAddRecord }) { + const [data, setData] = useState(null) + const [error, setError] = useState(null) + + useEffect(() => { + axios.get(`/api/health/dog/${dogId}/clearance-summary`) + .then(r => setData(r.data)) + .catch(() => setError(true)) + }, [dogId]) + + if (error || !data) return null + + const { summary, grca_eligible, age_eligible, chic_number } = data + const hasMissing = Object.values(summary).some(s => s.status === 'missing') + const hasExpiring = Object.values(summary).some(s => s.status === 'expiring_soon') + + return ( +
+ {/* Header row */} +
+

+ OFA Clearances +

+
+ {grca_eligible && ( + GRCA Eligible + )} + {!age_eligible && ( + Under 24mo + )} + {chic_number && ( + CHIC #{chic_number} + )} +
+
+ + {/* Clearance chips */} +
+ {Object.entries(summary).map(([group, { status, record }]) => ( + + ))} +
+ + {/* Expiry warning */} + {hasExpiring && ( +
+ + One or more clearances expire within 90 days. Schedule re-testing. +
+ )} + + {/* CTA */} + {(hasMissing || onAddRecord) && ( + + )} +
+ ) +} From 56458340ea2f90515686f9b85d882536ad6bc191 Mon Sep 17 00:00:00 2001 From: jason Date: Mon, 9 Mar 2026 23:31:26 -0500 Subject: [PATCH 7/8] feat(ui): add HealthRecordForm modal with OFA and general record support --- client/src/components/HealthRecordForm.jsx | 194 +++++++++++++++++++++ 1 file changed, 194 insertions(+) create mode 100644 client/src/components/HealthRecordForm.jsx diff --git a/client/src/components/HealthRecordForm.jsx b/client/src/components/HealthRecordForm.jsx new file mode 100644 index 0000000..6916974 --- /dev/null +++ b/client/src/components/HealthRecordForm.jsx @@ -0,0 +1,194 @@ +import { useState } from 'react' +import { X } 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 OFA_RESULTS = ['Excellent', 'Good', 'Fair', 'Mild', 'Moderate', 'Severe', 'Normal', 'Abnormal', 'Pass', 'Fail'] + +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: '', +} + +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 isOFA = form.record_type === 'ofa_clearance' + const set = (k, v) => setForm(f => ({ ...f, [k]: v })) + + const handleSubmit = async (e) => { + e.preventDefault() + setSaving(true) + setError(null) + try { + if (record && record.id) { + await axios.put(`/api/health/${record.id}`, form) + } else { + await axios.post('/api/health', { ...form, dog_id: dogId }) + } + onSave() + } catch (err) { + setError(err.response?.data?.error || 'Failed to save 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' } + const grid2 = { display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '1rem' } + + return ( +
+
+
+

{record && record.id ? 'Edit' : 'Add'} Health Record

+ +
+ +
+ + {/* Record type */} +
+ + +
+ + {isOFA ? ( + <> +
+
+ + +
+
+ + +
+
+
+
+ + set('ofa_number', e.target.value)} /> +
+
+ + set('performed_by', e.target.value)} /> +
+
+
+
+ + set('test_date', e.target.value)} /> +
+
+ + set('expires_at', e.target.value)} /> +
+
+ + ) : ( + <> +
+ + set('test_name', e.target.value)} /> +
+
+
+ + set('test_date', e.target.value)} /> +
+
+ + set('next_due', e.target.value)} /> +
+
+
+
+ + set('result', e.target.value)} /> +
+
+ + set('vet_name', e.target.value)} /> +
+
+ + )} + +
+ + set('document_url', e.target.value)} /> +
+ +
+ +