Compare commits
14 Commits
e2e8e00dff
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 1654f712ac | |||
| 5576fe86ef | |||
| 577638b4fa | |||
| be79364aa4 | |||
| 9f446d7b01 | |||
| a06ddf0435 | |||
| f7a5e8ecb8 | |||
| f3fc01f31c | |||
| 55d636b512 | |||
| 31b1d903f4 | |||
| 5e45031789 | |||
| 1f1e0c3e5a | |||
| ce287f63d1 | |||
| c9d2aa734e |
@@ -0,0 +1,12 @@
|
|||||||
|
{
|
||||||
|
"version": "0.0.1",
|
||||||
|
"configurations": [
|
||||||
|
{
|
||||||
|
"name": "client",
|
||||||
|
"runtimeExecutable": "npm",
|
||||||
|
"runtimeArgs": ["run", "dev"],
|
||||||
|
"cwd": "client",
|
||||||
|
"port": 5173
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -12,6 +12,10 @@ jobs:
|
|||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
# Full history so `git rev-list --count HEAD` yields the true commit count
|
||||||
|
# driving both the app version (v1.NNN) and the org.alwisp.version label.
|
||||||
|
fetch-depth: 0
|
||||||
|
|
||||||
- name: Log in to Gitea Container Registry
|
- name: Log in to Gitea Container Registry
|
||||||
uses: docker/login-action@v3
|
uses: docker/login-action@v3
|
||||||
@@ -22,14 +26,39 @@ jobs:
|
|||||||
|
|
||||||
- name: Build and Push
|
- name: Build and Push
|
||||||
run: |
|
run: |
|
||||||
# gitea.repository is already "owner/repo" (e.g. jason/breedr).
|
|
||||||
IMAGE="registry.alwisp.com/${{ gitea.repository }}"
|
IMAGE="registry.alwisp.com/${{ gitea.repository }}"
|
||||||
docker build -t "${IMAGE}:latest" .
|
GIT_SHA="$(git rev-parse --short HEAD)"
|
||||||
|
BUILD_TIME="$(date -u +%Y-%m-%dT%H:%M:%SZ)"
|
||||||
|
COMMIT_COUNT="$(git rev-list --count HEAD)"
|
||||||
|
docker build \
|
||||||
|
--build-arg GIT_SHA="${GIT_SHA}" \
|
||||||
|
--build-arg BUILD_TIME="${BUILD_TIME}" \
|
||||||
|
--build-arg COMMIT_COUNT="${COMMIT_COUNT}" \
|
||||||
|
--label org.alwisp.git-sha="${{ gitea.sha }}" \
|
||||||
|
--label org.alwisp.version="v1.$((COMMIT_COUNT-1))" \
|
||||||
|
--label org.alwisp.repo="${{ gitea.repository }}" \
|
||||||
|
-t "${IMAGE}:latest" .
|
||||||
docker push "${IMAGE}:latest"
|
docker push "${IMAGE}:latest"
|
||||||
|
|
||||||
# Dangling-only prune: removes untagged leftovers from previous builds.
|
# Dangling-only prune: removes untagged leftovers from previous builds. Never
|
||||||
# Never removes the tagged :latest (kept for the Unraid deployment) or
|
# removes the tagged :latest or any image referenced by a running container.
|
||||||
# any image referenced by a container.
|
- name: Push per-SHA tag
|
||||||
|
if: success()
|
||||||
|
run: |
|
||||||
|
IMAGE="registry.alwisp.com/${{ gitea.repository }}"
|
||||||
|
GIT_SHA="$(git rev-parse --short=7 HEAD)"
|
||||||
|
docker tag "${IMAGE}:latest" "${IMAGE}:sha-${GIT_SHA}"
|
||||||
|
docker push "${IMAGE}:sha-${GIT_SHA}"
|
||||||
|
|
||||||
- name: Prune dangling images on host
|
- name: Prune dangling images on host
|
||||||
if: always()
|
if: always()
|
||||||
run: docker image prune -f 2>/dev/null || true
|
run: docker image prune -f 2>/dev/null || true
|
||||||
|
|
||||||
|
- name: Trigger PORT redeploy
|
||||||
|
if: success()
|
||||||
|
run: |
|
||||||
|
NAME="${{ gitea.repository }}"; NAME="${NAME##*/}"
|
||||||
|
curl -fsS -X POST https://port.alwisp.com/hooks/gitea \
|
||||||
|
-H "X-Deploy-Token: ${{ secrets.WEBHOOK_SECRET }}" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d "{\"container\":\"${NAME}\"}" || echo "PORT redeploy trigger failed (non-fatal)"
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
# BREEDR API Documentation (v0.8.0)
|
# BREEDR API Documentation (v0.8.1)
|
||||||
|
|
||||||
Base URL: `/api`
|
Base URL: `/api`
|
||||||
|
|
||||||
All endpoints return JSON responses. Errors follow the format `{ "error": "message" }`.
|
All endpoints return JSON responses. Errors follow the format `{ "error": "message" }`.
|
||||||
|
Validation failures return `400`, missing resources return `404`.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -11,37 +12,46 @@ All endpoints return JSON responses. Errors follow the format `{ "error": "messa
|
|||||||
Manage individual dogs in the kennel.
|
Manage individual dogs in the kennel.
|
||||||
|
|
||||||
### `GET /`
|
### `GET /`
|
||||||
Get active kennel dogs.
|
Get active kennel dogs (paginated).
|
||||||
- **Query Params**:
|
- **Query Params**:
|
||||||
- `include_external=1`: Include external dogs (studs/others)
|
- `include_external=1`: Include external dogs (studs/others)
|
||||||
- `external_only=1`: Only show external dogs
|
- `external_only=1`: Only show external dogs
|
||||||
- **Response**: Array of [Dog](#dog-object) objects with `sire` and `dam` attached.
|
- `page` (default 1), `limit` (default 50, max 200)
|
||||||
|
- `search`: Filter by name or registration number
|
||||||
|
- `sex`: `male` | `female`
|
||||||
|
- **Response**: `{ "data": [Dog...], "total", "page", "limit", "stats": { "total", "males", "females" } }` — each Dog has `sire` and `dam` attached.
|
||||||
|
|
||||||
|
### `GET /all`
|
||||||
|
Get all active dogs (kennel + external, unpaginated). Used for dropdowns/pairing/pedigree.
|
||||||
|
|
||||||
### `GET /:id`
|
### `GET /:id`
|
||||||
Get single dog details.
|
Get single dog details.
|
||||||
- **Response**: [Dog](#dog-object) object including `sire`, `dam`, and `offspring` array.
|
- **Response**: [Dog](#dog-object) object including `sire`, `dam`, and `offspring` array. `404` if not found.
|
||||||
|
|
||||||
### `POST /`
|
### `POST /`
|
||||||
Create a new dog.
|
Create a new dog.
|
||||||
- **Body**: See [Dog](#dog-object) fields. `name`, `breed`, `sex` are required.
|
- **Body**: See [Dog](#dog-object) fields. `name`, `breed`, `sex` are required.
|
||||||
- **Response**: The created Dog object.
|
- **Validation**: If `sire_id`/`dam_id` are provided they must reference an existing dog of the correct sex (sire → male, dam → female), else `400`. The dog + parent links are written in a single transaction.
|
||||||
|
- **Response**: The created Dog object (`201`).
|
||||||
|
|
||||||
### `PUT /:id`
|
### `PUT /:id`
|
||||||
Update an existing dog.
|
Update an existing dog.
|
||||||
- **Body**: Dog fields to update.
|
- **Body**: Dog fields to update.
|
||||||
|
- **Validation**: `404` if the dog doesn't exist. Same sire/dam checks as `POST /`, plus a dog cannot be its own parent.
|
||||||
- **Response**: The updated Dog object.
|
- **Response**: The updated Dog object.
|
||||||
|
|
||||||
### `DELETE /:id`
|
### `DELETE /:id`
|
||||||
Permanently delete a dog and its related records (health, heat, parents).
|
Permanently delete a dog and its related records (health, heat cycles, parent links) in a single transaction.
|
||||||
- **Response**: `{ "success": true, "message": "..." }`
|
- **Response**: `{ "success": true, "message": "..." }` — `404` if not found.
|
||||||
|
|
||||||
### `POST /:id/photos`
|
### `POST /:id/photos`
|
||||||
Upload a photo for a dog.
|
Upload a photo for a dog (JPEG/PNG/GIF/WebP, ≤ 10 MB).
|
||||||
- **Form-Data**: `photo` (file)
|
- **Form-Data**: `photo` (file)
|
||||||
- **Response**: `{ "url": "...", "photos": [...] }`
|
- **Response**: `{ "url": "...", "photos": [...] }`
|
||||||
|
|
||||||
### `DELETE /:id/photos/:photoIndex`
|
### `DELETE /:id/photos/:photoIndex`
|
||||||
Delete a specific photo from a dog's photo array.
|
Delete a specific photo from a dog's photo array.
|
||||||
|
- **Validation**: `400` if `photoIndex` is not an integer within the photo array's bounds.
|
||||||
- **Response**: `{ "photos": [...] }`
|
- **Response**: `{ "photos": [...] }`
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -51,8 +61,9 @@ Delete a specific photo from a dog's photo array.
|
|||||||
Manage breeding litters and puppy logs.
|
Manage breeding litters and puppy logs.
|
||||||
|
|
||||||
### `GET /`
|
### `GET /`
|
||||||
Get all litters.
|
Get all litters (paginated).
|
||||||
- **Response**: Array of [Litter](#litter-object) objects with sire/dam names and puppies.
|
- **Query Params**: `page` (default 1), `limit` (default 50)
|
||||||
|
- **Response**: `{ "data": [Litter...], "total", "page", "limit" }` with sire/dam names and puppies attached.
|
||||||
|
|
||||||
### `GET /:id`
|
### `GET /:id`
|
||||||
Get single litter details.
|
Get single litter details.
|
||||||
@@ -61,10 +72,14 @@ Get single litter details.
|
|||||||
### `POST /`
|
### `POST /`
|
||||||
Create a new litter.
|
Create a new litter.
|
||||||
- **Body**: `sire_id`, `dam_id`, `breeding_date` (required), `whelping_date`, `puppy_count`, `notes`.
|
- **Body**: `sire_id`, `dam_id`, `breeding_date` (required), `whelping_date`, `puppy_count`, `notes`.
|
||||||
|
- **Validation**: Sire must be male, dam must be female.
|
||||||
- **Response**: The created Litter object.
|
- **Response**: The created Litter object.
|
||||||
|
|
||||||
### `PUT /:id`
|
### `PUT /:id`
|
||||||
Update litter details.
|
Update litter details (`breeding_date`, `whelping_date`, `puppy_count`, `notes` — parents cannot change).
|
||||||
|
|
||||||
|
### `DELETE /:id`
|
||||||
|
Delete a litter. Puppies are unlinked (their dog records are kept).
|
||||||
|
|
||||||
### `POST /:id/puppies/:puppyId`
|
### `POST /:id/puppies/:puppyId`
|
||||||
Link a dog to a litter as a puppy.
|
Link a dog to a litter as a puppy.
|
||||||
@@ -77,8 +92,11 @@ Remove a puppy from a litter (sets `litter_id` to NULL).
|
|||||||
Get weight and health logs for a puppy.
|
Get weight and health logs for a puppy.
|
||||||
|
|
||||||
### `POST /:litterId/puppies/:puppyId/logs`
|
### `POST /:litterId/puppies/:puppyId/logs`
|
||||||
Add a weight/health log entry.
|
Add a weight/health log entry (stored in `health_records`; weights serialized into `description`).
|
||||||
- **Body**: `record_date` (required), `weight_oz`, `weight_lbs`, `notes`, `record_type`.
|
- **Body**: `record_date` (required), `weight_oz`, `weight_lbs`, `notes`, `record_type` (default `weight_log`).
|
||||||
|
|
||||||
|
### `DELETE /:litterId/puppies/:puppyId/logs/:logId`
|
||||||
|
Delete a weight/health log entry.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -86,19 +104,49 @@ Add a weight/health log entry.
|
|||||||
|
|
||||||
Manage OFA clearances and veterinary records.
|
Manage OFA clearances and veterinary records.
|
||||||
|
|
||||||
|
**Record types** (`record_type`): `ofa_clearance`, `vaccination`, `exam`, `surgery`, `medication`, `other`.
|
||||||
|
|
||||||
|
**Valid `test_type` values** (OFA clearances only): `hip_ofa`, `hip_pennhip`, `elbow_ofa`, `patella_ofa`, `heart_ofa`, `heart_echo`, `eye_caer`, `thyroid_ofa`.
|
||||||
|
> DNA is **not** a health test type — DNA panels are managed exclusively by the Genetics module (`/api/genetics`).
|
||||||
|
|
||||||
### `GET /dog/:dogId`
|
### `GET /dog/:dogId`
|
||||||
Get all health records for a dog.
|
Get all health records for a dog.
|
||||||
|
|
||||||
### `GET /dog/:dogId/clearance-summary`
|
### `GET /dog/:dogId/clearance-summary`
|
||||||
Get GRCA core clearance status (Hip, Elbow, Heart, Eyes).
|
Get GRCA core clearance status (Hip, Elbow, Heart, Eyes).
|
||||||
- **Response**: `{ summary, grca_eligible, age_eligible, chic_number }`
|
- **Response**: `{ summary, grca_eligible, age_eligible, chic_number }`
|
||||||
|
- Only recurring tests (CAER eyes, advanced/echo cardiac) carry an `expires_at`; permanent certifications (hip, elbow, etc.) never report `expired`/`expiring_soon`.
|
||||||
|
|
||||||
### `GET /dog/:dogId/chic-eligible`
|
### `GET /dog/:dogId/chic-eligible`
|
||||||
Check if a dog has all required CHIC tests.
|
Check if a dog has all required CHIC tests.
|
||||||
|
|
||||||
|
### `GET /:id`
|
||||||
|
Get a single health record.
|
||||||
|
|
||||||
### `POST /`
|
### `POST /`
|
||||||
Create health record.
|
Create health record.
|
||||||
- **Body**: `dog_id`, `record_type`, `test_date` (required), `test_type`, `test_name`, `ofa_result`, `ofa_number`, etc.
|
- **Body**: `dog_id`, `record_type`, `test_date` (all required), plus optional `test_type`, `test_name`, `ofa_result`, `ofa_number`, `performed_by`, `expires_at`, `result`, `next_due`, `document_url`, `notes`.
|
||||||
|
- **Validation**: `test_type`, if present, must be one of the valid values above (else `400`).
|
||||||
|
- **Note**: `performed_by` is the single clinician field (the legacy `vet_name` column is deprecated).
|
||||||
|
|
||||||
|
### `PUT /:id`
|
||||||
|
Update an existing health record. Same body as `POST /` (minus `dog_id`).
|
||||||
|
|
||||||
|
### `DELETE /:id`
|
||||||
|
Delete a health record.
|
||||||
|
- **Response**: `{ "message": "Health record deleted successfully" }`
|
||||||
|
|
||||||
|
### `POST /upload`
|
||||||
|
Upload a supporting document (PDF or image, ≤ 15 MB) and get back a URL to store in a record's `document_url`. Record-agnostic — works before the record itself is saved.
|
||||||
|
- **Form-Data**: `document` (file)
|
||||||
|
- **Response**: `{ "url": "/uploads/..." }`
|
||||||
|
|
||||||
|
### `GET /dog/:dogId/cancer-history`
|
||||||
|
Get cancer-history records for a dog.
|
||||||
|
|
||||||
|
### `POST /cancer-history`
|
||||||
|
Add a cancer-history record.
|
||||||
|
- **Body**: `dog_id`, `cancer_type` (required), `age_at_diagnosis`, `age_at_death`, `cause_of_death`, `notes`.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -113,9 +161,18 @@ Get the full genetic panel for a dog. Returns `tests` (actual records) and `pane
|
|||||||
Check genetic compatibility between two dogs.
|
Check genetic compatibility between two dogs.
|
||||||
- **Response**: `{ risks: [...], safe_to_pair: boolean }`
|
- **Response**: `{ risks: [...], safe_to_pair: boolean }`
|
||||||
|
|
||||||
|
### `GET /:id`
|
||||||
|
Get a single genetic test record.
|
||||||
|
|
||||||
### `POST /`
|
### `POST /`
|
||||||
Add a genetic test result.
|
Add a genetic test result.
|
||||||
- **Body**: `dog_id`, `marker`, `result` (clear|carrier|affected|not_tested).
|
- **Body**: `dog_id`, `marker`, `result` (clear|carrier|affected|not_tested), plus optional `test_provider`, `test_date`, `document_url`, `notes`.
|
||||||
|
|
||||||
|
### `PUT /:id`
|
||||||
|
Update a genetic test result.
|
||||||
|
|
||||||
|
### `DELETE /:id`
|
||||||
|
Delete a genetic test result.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -123,14 +180,27 @@ Add a genetic test result.
|
|||||||
|
|
||||||
Track heat cycles and whelping projections.
|
Track heat cycles and whelping projections.
|
||||||
|
|
||||||
|
### `GET /heat-cycles`
|
||||||
|
Get all heat cycles (with dog info). Optional `?year=YYYY&month=M` filter.
|
||||||
|
|
||||||
### `GET /heat-cycles/active`
|
### `GET /heat-cycles/active`
|
||||||
Get currently active heat cycles.
|
Get currently active heat cycles.
|
||||||
|
|
||||||
|
### `GET /heat-cycles/dog/:dogId`
|
||||||
|
Get all heat cycles for a specific dog.
|
||||||
|
|
||||||
### `GET /heat-cycles/:id/suggestions`
|
### `GET /heat-cycles/:id/suggestions`
|
||||||
Get optimal breeding window (days 9-15) and whelping projections.
|
Get optimal breeding window (days 9-15) and whelping projections.
|
||||||
|
|
||||||
### `POST /heat-cycles`
|
### `POST /heat-cycles`
|
||||||
Log a new heat cycle. Dog must be female.
|
Log a new heat cycle. Dog must be female.
|
||||||
|
- **Body**: `dog_id`, `start_date` (required), `end_date`, `breeding_date`, `breeding_successful`, `notes`.
|
||||||
|
|
||||||
|
### `PUT /heat-cycles/:id`
|
||||||
|
Update a heat cycle (same body as `POST`, minus `dog_id`).
|
||||||
|
|
||||||
|
### `DELETE /heat-cycles/:id`
|
||||||
|
Delete a heat cycle.
|
||||||
|
|
||||||
### `GET /whelping-calculator?breeding_date=YYYY-MM-DD`
|
### `GET /whelping-calculator?breeding_date=YYYY-MM-DD`
|
||||||
Standalone tool to calculate expected whelping window.
|
Standalone tool to calculate expected whelping window.
|
||||||
@@ -148,10 +218,19 @@ Get an interactive pedigree tree (ancestors). Default 5 generations.
|
|||||||
Get a descendant tree. Default 3 generations.
|
Get a descendant tree. Default 3 generations.
|
||||||
|
|
||||||
### `POST /trial-pairing`
|
### `POST /trial-pairing`
|
||||||
Calculate Coefficient of Inbreeding (COI) for a hypothetical mating.
|
Calculate Coefficient of Inbreeding (COI) for a hypothetical mating. (`POST /coi` is an alias.)
|
||||||
- **Body**: `sire_id`, `dam_id`.
|
- **Body**: `sire_id`, `dam_id`.
|
||||||
- **Response**: `{ coi, commonAncestors, directRelation, recommendation }`
|
- **Response**: `{ coi, commonAncestors, directRelation, recommendation }`
|
||||||
|
|
||||||
|
### `GET /:id/coi`
|
||||||
|
Get the COI for an existing dog (computed from its actual parents).
|
||||||
|
|
||||||
|
### `GET /relations/:sireId/:damId`
|
||||||
|
Check the direct relationship between two dogs (parent/offspring, siblings, etc.).
|
||||||
|
|
||||||
|
### `GET /:id/cancer-lineage`
|
||||||
|
Cancer history aggregated across a dog's lineage.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 7. Settings (`/api/settings`)
|
## 7. Settings (`/api/settings`)
|
||||||
@@ -193,9 +272,13 @@ Update kennel settings.
|
|||||||
"id": 5,
|
"id": 5,
|
||||||
"sire_id": 1,
|
"sire_id": 1,
|
||||||
"dam_id": 2,
|
"dam_id": 2,
|
||||||
"breeding_date": "2023-01-01",
|
"sire_name": "Sire Name",
|
||||||
"whelp_date": "2023-03-05",
|
"dam_name": "Dam Name",
|
||||||
"total_count": 8,
|
"breeding_date": "2026-01-01",
|
||||||
"puppies": [ ... ]
|
"whelping_date": "2026-03-05",
|
||||||
|
"puppy_count": 8,
|
||||||
|
"notes": null,
|
||||||
|
"puppies": [ ... ],
|
||||||
|
"actual_puppy_count": 8
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|||||||
+27
-16
@@ -5,10 +5,10 @@ This document provides technical details and guidelines for developing and maint
|
|||||||
## Tech Stack Overview
|
## Tech Stack Overview
|
||||||
|
|
||||||
### Backend
|
### Backend
|
||||||
- **Node.js & Express**: Core API server.
|
- **Node.js & Express**: Core API server. Node 18+ required (better-sqlite3 v11 prebuilds cover Node 18–24; Docker runs node:18).
|
||||||
- **better-sqlite3**: High-performance SQLite driver.
|
- **better-sqlite3**: High-performance SQLite driver. Multi-step writes use `db.transaction()`.
|
||||||
- **Multer**: Multi-part form data handling for photo uploads.
|
- **Multer**: Multi-part form data handling for photo and document uploads (type + size limits enforced).
|
||||||
- **Bcrypt & JWT**: (Planned) Authentication and security.
|
- **Authentication**: Deliberately not implemented — BREEDR is a self-hosted LAN app. Revisit as its own feature if exposure changes (decision: July 2026).
|
||||||
|
|
||||||
### Frontend
|
### Frontend
|
||||||
- **React 18 & Vite**: Modern reactive UI with fast HMR.
|
- **React 18 & Vite**: Modern reactive UI with fast HMR.
|
||||||
@@ -22,7 +22,7 @@ This document provides technical details and guidelines for developing and maint
|
|||||||
## Database Architecture
|
## Database Architecture
|
||||||
|
|
||||||
### SQLite Implementation
|
### SQLite Implementation
|
||||||
The database is a single file located at `data/breedr.db`. This directory is automatically created on startup.
|
The database is a single file at `data/breedr.db` by default; both the migration runner and the app honor `DB_PATH`, and the containing directory is created automatically on startup.
|
||||||
|
|
||||||
### "Parents Table" Approach
|
### "Parents Table" Approach
|
||||||
Parent relationships are managed in a dedicated `parents` table rather than columns in the `dogs` table.
|
Parent relationships are managed in a dedicated `parents` table rather than columns in the `dogs` table.
|
||||||
@@ -33,17 +33,20 @@ Parent relationships are managed in a dedicated `parents` table rather than colu
|
|||||||
**Benefits**: Supports recursive lookups, avoids `ALTER TABLE` complexity for lineage changes, and allows historical mapping of ancestors without full profiles.
|
**Benefits**: Supports recursive lookups, avoids `ALTER TABLE` complexity for lineage changes, and allows historical mapping of ancestors without full profiles.
|
||||||
|
|
||||||
### Safe Migrations
|
### Safe Migrations
|
||||||
BREEDR use a migration-free synchronization approach:
|
BREEDR uses a two-layer synchronization approach, both run at startup (`server/index.js`):
|
||||||
1. `server/db/init.js` defines the latest table structures.
|
1. `server/db/migrations.js` — a versioned migration runner for structural rewrites (e.g. moving sire/dam into the `parents` table). Migrations no-op on a fresh/empty database.
|
||||||
2. Safe `ALTER TABLE` guards inject missing columns on startup.
|
2. `server/db/init.js` — defines the latest table structures (`CREATE TABLE IF NOT EXISTS`) and injects missing columns with safe `ALTER TABLE` guards.
|
||||||
3. This ensures data persistence across updates without manual migration scripts.
|
|
||||||
|
> ⚠️ **Keep init.js in sync with the routes.** The schema drifted once (routes wrote columns init.js never defined) and only worked because the live Docker volume carried an older ad-hoc schema — fresh installs were broken. When adding a column a route writes, add it to the `CREATE TABLE` *and* the ALTER-if-missing list. Verify by booting against a deleted/renamed dev DB.
|
||||||
|
|
||||||
### Key Tables
|
### Key Tables
|
||||||
- `dogs`: Registry for kennel and external dogs.
|
- `dogs`: Registry for kennel and external dogs.
|
||||||
- `parents`: Ancestry relationships.
|
- `parents`: Ancestry relationships.
|
||||||
- `litters`: Produced breeding groups.
|
- `litters`: Produced breeding groups.
|
||||||
- `health_records`: OFA clearances and vet records.
|
- `heat_cycles`: Female heat cycle tracking (calendar + whelping projections).
|
||||||
|
- `health_records`: OFA clearances, vet records, and puppy weight logs.
|
||||||
- `genetic_tests`: DNA panel results.
|
- `genetic_tests`: DNA panel results.
|
||||||
|
- `cancer_history`: Cancer/longevity lineage records.
|
||||||
- `settings`: Kennel-wide configuration (single row).
|
- `settings`: Kennel-wide configuration (single row).
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -70,23 +73,31 @@ The UI follows a modern dark-theme aesthetic using **CSS Variables** defined in
|
|||||||
### Key Components
|
### Key Components
|
||||||
- **PedigreeTree**: horizontal, D3-powered tree with zoom/pan.
|
- **PedigreeTree**: horizontal, D3-powered tree with zoom/pan.
|
||||||
- **DogForm**: Dual-mode (Kennel/External) dog entry with parent selection.
|
- **DogForm**: Dual-mode (Kennel/External) dog entry with parent selection.
|
||||||
|
- **Toast** (`components/Toast.jsx`): `ToastProvider` + `useToast()` — success/error toasts. **Never use `alert()`**; call `toast.error(err.response?.data?.error || 'fallback')`.
|
||||||
|
- **ConfirmDialog** (`components/ConfirmDialog.jsx`): `ConfirmProvider` + `useConfirm()` — promise-based destructive-action confirmation. **Never use `window.confirm()`**; call `if (!(await confirm({ title, message }))) return`.
|
||||||
|
- Both providers wrap the app in `App.jsx`. Page loaders should use `Promise.allSettled` + an error banner with Retry rather than silently swallowing fetch failures (see `Dashboard.jsx`, `BreedingCalendar.jsx`).
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## API & Backend Development
|
## API & Backend Development
|
||||||
|
|
||||||
### Route Modules (`server/routes/`)
|
### Route Modules (`server/routes/`)
|
||||||
- `/api/dogs`: Dog registry and photo uploads.
|
- `/api/dogs`: Dog registry and photo uploads (paginated list, parent validation, transactional writes).
|
||||||
- `/api/litters`: Litter management and puppy linking.
|
- `/api/litters`: Litter management, puppy linking, and puppy weight/health logs.
|
||||||
- `/api/pedigree`: Recursive ancestry/descendant tree generation.
|
- `/api/health`: OFA clearances, vet records, clearance summaries, document upload.
|
||||||
|
- `/api/genetics`: DNA panel results and pairing-risk checks.
|
||||||
|
- `/api/pedigree`: Recursive ancestry/descendant tree generation and COI.
|
||||||
- `/api/breeding`: Heat cycle tracking and whelping projections.
|
- `/api/breeding`: Heat cycle tracking and whelping projections.
|
||||||
|
- `/api/settings`: Kennel-wide configuration.
|
||||||
|
|
||||||
### Environment Variables
|
### Environment Variables
|
||||||
| Variable | Description | Default |
|
| Variable | Description | Default |
|
||||||
|----------|-------------|---------|
|
|----------|-------------|---------|
|
||||||
| `PORT` | Server port | `3000` |
|
| `PORT` | Server port | `3000` |
|
||||||
| `DB_PATH` | Path to .db file | `../data/breedr.db` |
|
| `DB_PATH` | Path to .db file (honored by both migrations and the app) | `data/breedr.db` |
|
||||||
| `UPLOAD_PATH`| Path to photo storage| `../uploads` |
|
| `DATA_DIR` | Data directory (created on boot) | `data/` |
|
||||||
|
| `UPLOAD_PATH`| Path to photo/document storage | `uploads/` |
|
||||||
|
| `STATIC_PATH`| Path to branded static assets | `static/` |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -98,4 +109,4 @@ For deeper technical dives into specific features, refer to the `docs/` director
|
|||||||
- [Microchip Field Unique Constraint Fix](docs/MICROCHIP_FIX.md)
|
- [Microchip Field Unique Constraint Fix](docs/MICROCHIP_FIX.md)
|
||||||
|
|
||||||
---
|
---
|
||||||
*Last Updated: March 12, 2026*
|
*Last Updated: July 6, 2026*
|
||||||
|
|||||||
+5
-5
@@ -113,14 +113,14 @@ Access: http://localhost:3000
|
|||||||
================================
|
================================
|
||||||
```
|
```
|
||||||
|
|
||||||
### Test Health Endpoint
|
### Test the API
|
||||||
```bash
|
```bash
|
||||||
curl http://localhost:3000/api/health
|
curl http://localhost:3000/api/settings
|
||||||
```
|
```
|
||||||
|
|
||||||
Should return:
|
Should return the kennel settings JSON, e.g.:
|
||||||
```json
|
```json
|
||||||
{"status":"ok","timestamp":"..."}
|
{"id":1,"kennel_name":"BREEDR", ...}
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -136,7 +136,7 @@ docker rm breedr
|
|||||||
|
|
||||||
# Pull latest code
|
# Pull latest code
|
||||||
cd /tmp/breedr
|
cd /tmp/breedr
|
||||||
git pull origin master
|
git pull origin main
|
||||||
|
|
||||||
# Rebuild image
|
# Rebuild image
|
||||||
docker build -t breedr:latest .
|
docker build -t breedr:latest .
|
||||||
|
|||||||
@@ -4,10 +4,11 @@ A reactive, interactive dog breeding genealogy mapping system for professional k
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 🌟 Recent Highlights (v0.8.0)
|
## 🌟 Recent Highlights (v0.8.1 — July 2026)
|
||||||
- **✅ Reverse Pedigree** — Toggle between ancestors and descendants view for full lineage tracking.
|
- **✅ Fresh-install fix** — Schema definitions reconciled with the routes; a brand-new database now boots with heat cycles, litters, and puppy logs fully working.
|
||||||
- **✅ External Dog Mapping** — Assign parents to external dogs, allowing for full genealogy of outside lines.
|
- **✅ Health Records overhaul** — Per-type record forms (OFA / vaccination / exam / surgery / medication), document upload (PDF/image), OFA age validation, and delete actions.
|
||||||
- **✅ Universal Parent Selection** — Select any dog (kennel or external) as a sire/dam from any profile.
|
- **✅ UX layer** — Toast notifications, proper confirmation dialogs, load-error banners with retry, a 404 page, and mobile-friendly layouts across all pages.
|
||||||
|
- **✅ Hardened API** — Sire/dam sex validation, transactional writes, and safe photo handling.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -22,11 +23,13 @@ docker-compose up -d --build
|
|||||||
Access at: `http://localhost:3000`
|
Access at: `http://localhost:3000`
|
||||||
|
|
||||||
### 2. Manual Development Setup
|
### 2. Manual Development Setup
|
||||||
|
Requires **Node.js 18+** (better-sqlite3 v11 ships prebuilds for Node 18–24).
|
||||||
```bash
|
```bash
|
||||||
npm install
|
npm install
|
||||||
npm run dev
|
cd client && npm install && cd ..
|
||||||
|
npm run dev # runs API (nodemon, :3000) + client (vite, :5173) together
|
||||||
```
|
```
|
||||||
> **Note:** The database initializes automatically on first boot. No manual migrations are required.
|
> **Note:** The database initializes automatically on first boot — tables and columns are created/added via safe guards. No manual migrations are required.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -42,6 +45,8 @@ npm run dev
|
|||||||
- **Interactive Pedigree**: 5-generation trees with zoom/pan. Toggle the **Reverse Pedigree** switch to see descendant lineage.
|
- **Interactive Pedigree**: 5-generation trees with zoom/pan. Toggle the **Reverse Pedigree** switch to see descendant lineage.
|
||||||
- **Trial Pairing Simulator**: Calculate Wright's Inbreeding Coefficient (COI) instantly. Identifies common ancestors and providing risk badges (Low/Moderate/High).
|
- **Trial Pairing Simulator**: Calculate Wright's Inbreeding Coefficient (COI) instantly. Identifies common ancestors and providing risk badges (Low/Moderate/High).
|
||||||
- **Heat Cycles**: Track female cycles on the calendar. Includes **projected whelping alerts** (indigo windows) and expected due dates.
|
- **Heat Cycles**: Track female cycles on the calendar. Includes **projected whelping alerts** (indigo windows) and expected due dates.
|
||||||
|
- **Health Records**: OFA clearances (hip, elbow, patella, heart, eyes, thyroid), GRCA/CHIC clearance summaries, vaccinations, exams, surgeries, and medications — with supporting-document upload.
|
||||||
|
- **DNA Panel**: Golden Retriever genetic markers (PRA, ICH, NCL, DM, MD) with carrier-risk checks on trial pairings.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -69,8 +74,9 @@ breedr/
|
|||||||
|
|
||||||
## 🕒 Release Summary
|
## 🕒 Release Summary
|
||||||
|
|
||||||
|
- **v0.8.1** (Jul 2026): Fresh-install schema fix, health record overhaul, toast/dialog UX layer, API hardening, mobile responsiveness.
|
||||||
- **v0.8.0** (Mar 2026): Reverse Pedigree & External dog parentage.
|
- **v0.8.0** (Mar 2026): Reverse Pedigree & External dog parentage.
|
||||||
- **v0.7.0** (In Progress): Health & Genetics (OFA clearances, DNA panels).
|
- **v0.7.0**: Health & Genetics (OFA clearances, DNA panels, cancer history).
|
||||||
- **v0.6.1**: COI calculation fix for direct parent×offspring relations.
|
- **v0.6.1**: COI calculation fix for direct parent×offspring relations.
|
||||||
- **v0.6.0**: Champion status tracking & Kennel settings API.
|
- **v0.6.0**: Champion status tracking & Kennel settings API.
|
||||||
|
|
||||||
|
|||||||
+57
-36
@@ -1,39 +1,39 @@
|
|||||||
# BREEDR Development Roadmap (v0.8.0)
|
# BREEDR Development Roadmap (v0.8.1)
|
||||||
|
|
||||||
## 🚀 Current Status: v0.8.0 (Active Development)
|
## 🚀 Current Status: v0.8.1 (Active Development)
|
||||||
|
|
||||||
### 🔜 Next Up — Phase 4b: Health & Genetics Build Order
|
### 🔜 Next Up — Phase 4b: Health & Genetics (remaining items)
|
||||||
> **Context:** Golden Retriever health clearances follow GRCA Code of Ethics and OFA/CHIC standards.
|
> **Context:** Golden Retriever health clearances follow GRCA Code of Ethics and OFA/CHIC standards.
|
||||||
|
|
||||||
#### Step 1: DB Schema Extensions
|
#### Step 1: DB Schema Extensions ✅
|
||||||
- [ ] Extend `health_records` table with OFA-specific columns (test_type, result, ofa_number, chic_number, expires_at, document_url)
|
- [x] Extend `health_records` table with OFA-specific columns (test_type, result, ofa_number, expires_at, document_url)
|
||||||
- [ ] Create `genetic_tests` table (PRA, ICH, NCL, DM, MD, GR-PRA variants)
|
- [x] Create `genetic_tests` table (PRA, ICH, NCL, DM, MD, GR-PRA variants)
|
||||||
- [ ] Create `cancer_history` table
|
- [x] Create `cancer_history` table
|
||||||
- [ ] Add `chic_number`, `age_at_death`, `cause_of_death` to `dogs` table
|
- [x] Add `chic_number`, `age_at_death`, `cause_of_death` to `dogs` table
|
||||||
- [ ] All changes via safe ALTER TABLE / CREATE TABLE IF NOT EXISTS guards
|
- [x] All changes via safe ALTER TABLE / CREATE TABLE IF NOT EXISTS guards
|
||||||
|
|
||||||
#### Step 2: API Layer
|
#### Step 2: API Layer ✅
|
||||||
- [ ] `GET|POST|PUT|DELETE /api/health/:dogId` (OFA records)
|
- [x] Health record CRUD (`/api/health`) + document upload (`POST /api/health/upload`)
|
||||||
- [ ] `GET /api/health/:dogId/clearance-summary`
|
- [x] `GET /api/health/dog/:dogId/clearance-summary`
|
||||||
- [ ] `GET /api/health/:dogId/chic-eligible`
|
- [x] `GET /api/health/dog/:dogId/chic-eligible`
|
||||||
- [ ] `GET|POST|PUT|DELETE /api/genetics/:dogId`
|
- [x] Genetics CRUD (`/api/genetics`)
|
||||||
- [ ] `GET /api/genetics/pairing-risk` (sire + dam carrier check)
|
- [x] `GET /api/genetics/pairing-risk` (sire + dam carrier check)
|
||||||
- [ ] Cancer history endpoints
|
- [x] Cancer history endpoints
|
||||||
|
|
||||||
#### Step 3: Core UI — Health Records
|
#### Step 3: Core UI — Health Records
|
||||||
- [ ] `HealthRecordForm` modal (test type, result, OFA#, expiry, doc upload)
|
- [x] `HealthRecordForm` modal (per-type fields, result, OFA#, expiry, doc upload, age validation)
|
||||||
- [ ] `HealthTimeline` on DogDetail page
|
- [ ] `HealthTimeline` on DogDetail page (records list exists; timeline view pending)
|
||||||
- [ ] `ClearanceSummaryCard` 2×2 grid (Hip / Elbow / Heart / Eyes)
|
- [x] `ClearanceSummaryCard` 2×2 grid (Hip / Elbow / Heart / Eyes)
|
||||||
- [ ] `ChicStatusBadge` on dog cards
|
- [ ] `ChicStatusBadge` on dog cards
|
||||||
- [ ] Expiry alert badges (90-day warning, expired)
|
- [x] Expiry logic for recurring tests (CAER, echo) in clearance summary
|
||||||
|
|
||||||
#### Step 4: Core UI — Genetics Panel
|
#### Step 4: Core UI — Genetics Panel ✅
|
||||||
- [ ] `GeneticTestForm` modal
|
- [x] `GeneticTestForm` modal
|
||||||
- [ ] `GeneticPanelCard` on DogDetail (color-coded markers)
|
- [x] `GeneticPanelCard` on DogDetail (color-coded markers)
|
||||||
- [ ] Pairing risk overlay on Trial Pairing Simulator
|
- [x] Pairing risk overlay on Trial Pairing Simulator
|
||||||
|
|
||||||
#### Step 5: Eligibility Checker
|
#### Step 5: Eligibility Checker
|
||||||
- [ ] Eligibility logic (`grca_eligible`, `chic_eligible` computed fields)
|
- [x] Eligibility logic (`grca_eligible` computed in clearance summary)
|
||||||
- [ ] Eligibility badge on dog cards
|
- [ ] Eligibility badge on dog cards
|
||||||
- [ ] Pre-litter eligibility warning modal
|
- [ ] Pre-litter eligibility warning modal
|
||||||
|
|
||||||
@@ -41,7 +41,15 @@
|
|||||||
|
|
||||||
## 🕒 Version History & Recent Progress
|
## 🕒 Version History & Recent Progress
|
||||||
|
|
||||||
- **v0.8.0** (March 12, 2026) - Reverse Pedigree & External Parentage (LATEST)
|
- **v0.8.1** (July 6, 2026) - Sanity, Hardening & UX Pass (LATEST)
|
||||||
|
- [x] **Fresh-install fix**: `heat_cycles` table + litters/health_records columns added to init.js (schema had drifted from route usage); migration runner no-ops on empty DBs; `DB_PATH` honored consistently
|
||||||
|
- [x] **Deps**: better-sqlite3 9→11 (Node 18–24 prebuilds); removed unused auth deps (bcrypt, jsonwebtoken, express-validator, express-rate-limit) — auth deliberately deferred
|
||||||
|
- [x] **API hardening**: sire/dam existence + sex validation, 404 on missing-dog updates, transactional multi-step writes, safe `photo_urls` parsing, photo-index bounds check
|
||||||
|
- [x] **UX layer**: toast notifications (replaces all `alert()`), ConfirmDialog (replaces all `window.confirm()`), load-error banners with Retry (`Promise.allSettled` loaders), 404 page, nav active-state on detail routes
|
||||||
|
- [x] **Fixes**: Start Heat Cycle female list was always empty (wrong response key); live OFA age warning in health form; puppy sorting; mobile responsiveness (375px clean on all pages)
|
||||||
|
- [x] Removed dead debug scripts (`test_app.js`, `test_express.js`)
|
||||||
|
|
||||||
|
- **v0.8.0** (March 12, 2026) - Reverse Pedigree & External Parentage
|
||||||
- [x] **Reverse Pedigree** (descendants view) toggle on Pedigree page
|
- [x] **Reverse Pedigree** (descendants view) toggle on Pedigree page
|
||||||
- [x] **External dog parentage** improvements (allowed assigning sire/dam to external dogs)
|
- [x] **External dog parentage** improvements (allowed assigning sire/dam to external dogs)
|
||||||
- [x] **Universal parent selection** (sire/dam dropdowns now include all dogs)
|
- [x] **Universal parent selection** (sire/dam dropdowns now include all dogs)
|
||||||
@@ -52,6 +60,15 @@
|
|||||||
- DNA genetic panel (PRA, ICH, NCL, DM, MD variants)
|
- DNA genetic panel (PRA, ICH, NCL, DM, MD variants)
|
||||||
- Cancer lineage & longevity tracking
|
- Cancer lineage & longevity tracking
|
||||||
- Breeding eligibility checker (GRCA + CHIC gates)
|
- Breeding eligibility checker (GRCA + CHIC gates)
|
||||||
|
- **Health Record modal overhaul** (July 6, 2026):
|
||||||
|
- [x] Per-record-type field sets (OFA / vaccination / exam / surgery / medication / other)
|
||||||
|
- [x] Result options scoped to the selected test; blank default (no fabricated clearances)
|
||||||
|
- [x] Added Thyroid & Patella OFA tests; expiry only shown for recurring tests (CAER, advanced cardiac)
|
||||||
|
- [x] Date & 24-month age validation on OFA hip/elbow
|
||||||
|
- [x] Unified clinician field (`performed_by`); deprecated `vet_name`
|
||||||
|
- [x] DNA removed as a health `test_type` (owned solely by Genetics module)
|
||||||
|
- [x] Delete action on the health records list
|
||||||
|
- [x] Document upload (PDF/image) via `POST /api/health/upload`
|
||||||
|
|
||||||
- **v0.6.1** (March 10, 2026) - COI Direct-Relation Fix
|
- **v0.6.1** (March 10, 2026) - COI Direct-Relation Fix
|
||||||
- Fixed `calculateCOI` to correctly compute coefficient for parent×offspring pairings (~25%)
|
- Fixed `calculateCOI` to correctly compute coefficient for parent×offspring pairings (~25%)
|
||||||
@@ -86,10 +103,13 @@
|
|||||||
- [ ] Multi-generation COI analysis
|
- [ ] Multi-generation COI analysis
|
||||||
|
|
||||||
### 📅 Phase 6: Polish & Optimization
|
### 📅 Phase 6: Polish & Optimization
|
||||||
- [ ] **User Experience**: Loading states, better error messages, undo functionality
|
- [x] **User Experience**: Toasts, confirmation dialogs, load-error banners with retry (v0.8.1)
|
||||||
- [ ] **Performance**: Image optimization, lazy loading, API caching
|
- [ ] **User Experience**: Undo functionality
|
||||||
- [ ] **Mobile**: Touch-friendly interface, mobile photo capture
|
- [ ] **Performance**: Image optimization, lazy loading, API caching, pedigree recursive-CTE query (known N+1 on deep trees)
|
||||||
- [ ] **Documentation**: API technical docs, video tutorials
|
- [x] **Mobile**: Responsive layouts across all pages at phone width (v0.8.1)
|
||||||
|
- [ ] **Mobile**: Mobile photo capture
|
||||||
|
- [x] **Documentation**: API reference kept current (API.md)
|
||||||
|
- [ ] **Security**: Authentication/rate limiting if the app is ever exposed beyond the LAN (deps intentionally removed in v0.8.1)
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -118,21 +138,22 @@
|
|||||||
---
|
---
|
||||||
|
|
||||||
## 🏃 Testing & Quality Assurance
|
## 🏃 Testing & Quality Assurance
|
||||||
- [x] Database schema initialization guards
|
- [x] Database schema initialization guards (verified against a virgin database, v0.8.1)
|
||||||
- [x] Pedigree tree rendering & zoom/pan
|
- [x] Pedigree tree rendering & zoom/pan
|
||||||
- [x] Parent relationship creation logic
|
- [x] Parent relationship creation logic (+ sex/existence validation, v0.8.1)
|
||||||
- [x] Static asset serving (prod/dev)
|
- [x] Static asset serving (prod/dev)
|
||||||
|
- [x] Heat cycle CRUD on fresh DB (v0.8.1)
|
||||||
|
- [x] Health records CRUD + document upload (v0.8.1)
|
||||||
- [ ] Champion toggle load/save trip
|
- [ ] Champion toggle load/save trip
|
||||||
- [ ] Heat cycle calendar whelping logic
|
- [ ] Boot against a copy of the production DB after schema changes (pre-deploy check)
|
||||||
- [ ] Health records OFA clearance CRUD (Upcoming)
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## How to Contribute
|
## How to Contribute
|
||||||
1. Pick a feature from "Next Up" above
|
1. Pick a feature from "Next Up" above
|
||||||
2. Create a feature branch off `master`: `feat/feature-name`
|
2. Create a feature branch off `main`: `feat/feature-name`
|
||||||
3. Implement with tests and update this roadmap
|
3. Implement with tests and update this roadmap
|
||||||
4. Submit PR for review
|
4. Submit PR for review
|
||||||
|
|
||||||
---
|
---
|
||||||
*Last Updated: March 12, 2026*
|
*Last Updated: July 6, 2026*
|
||||||
|
|||||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "breedr-client",
|
"name": "breedr-client",
|
||||||
"version": "1.0.0",
|
"version": "0.8.1",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "breedr-client",
|
"name": "breedr-client",
|
||||||
"version": "1.0.0",
|
"version": "0.8.1",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"axios": "^1.6.7",
|
"axios": "^1.6.7",
|
||||||
"d3": "^7.9.0",
|
"d3": "^7.9.0",
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "breedr-client",
|
"name": "breedr-client",
|
||||||
"version": "1.0.0",
|
"version": "0.8.1",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|||||||
+11
-3
@@ -139,14 +139,22 @@
|
|||||||
height: 4rem;
|
height: 4rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.nav-links {
|
.navbar .container {
|
||||||
|
flex-wrap: wrap;
|
||||||
gap: 0.25rem;
|
gap: 0.25rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.nav-links {
|
||||||
|
gap: 0.25rem;
|
||||||
|
max-width: 100%;
|
||||||
|
overflow-x: auto;
|
||||||
|
-webkit-overflow-scrolling: touch;
|
||||||
|
}
|
||||||
|
|
||||||
.nav-link span {
|
.nav-link span {
|
||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.nav-link {
|
.nav-link {
|
||||||
padding: 0.625rem;
|
padding: 0.625rem;
|
||||||
}
|
}
|
||||||
|
|||||||
+36
-6
@@ -1,5 +1,6 @@
|
|||||||
|
import { useEffect } from 'react'
|
||||||
import { BrowserRouter as Router, Routes, Route, Link, useLocation } from 'react-router-dom'
|
import { BrowserRouter as Router, Routes, Route, Link, useLocation } from 'react-router-dom'
|
||||||
import { Home, PawPrint, Activity, Heart, FlaskConical, Settings, ExternalLink } from 'lucide-react'
|
import { Home, PawPrint, Activity, Heart, FlaskConical, Settings, ExternalLink, SearchX } from 'lucide-react'
|
||||||
import Dashboard from './pages/Dashboard'
|
import Dashboard from './pages/Dashboard'
|
||||||
import DogList from './pages/DogList'
|
import DogList from './pages/DogList'
|
||||||
import DogDetail from './pages/DogDetail'
|
import DogDetail from './pages/DogDetail'
|
||||||
@@ -11,11 +12,17 @@ import PairingSimulator from './pages/PairingSimulator'
|
|||||||
import SettingsPage from './pages/SettingsPage'
|
import SettingsPage from './pages/SettingsPage'
|
||||||
import ExternalDogs from './pages/ExternalDogs'
|
import ExternalDogs from './pages/ExternalDogs'
|
||||||
import { useSettings } from './hooks/useSettings'
|
import { useSettings } from './hooks/useSettings'
|
||||||
|
import { ToastProvider, useToast } from './components/Toast'
|
||||||
|
import { ConfirmProvider } from './components/ConfirmDialog'
|
||||||
import './App.css'
|
import './App.css'
|
||||||
|
|
||||||
function NavLink({ to, icon: Icon, label }) {
|
// `match` lets a nav item claim extra path prefixes (e.g. Dogs owns /pedigree/:id)
|
||||||
|
function NavLink({ to, icon: Icon, label, match = [] }) {
|
||||||
const location = useLocation()
|
const location = useLocation()
|
||||||
const isActive = location.pathname === to
|
const prefixes = [to, ...match]
|
||||||
|
const isActive = to === '/'
|
||||||
|
? location.pathname === '/'
|
||||||
|
: prefixes.some(p => location.pathname === p || location.pathname.startsWith(p + '/'))
|
||||||
return (
|
return (
|
||||||
<Link to={to} className={`nav-link${isActive ? ' active' : ''}`}>
|
<Link to={to} className={`nav-link${isActive ? ' active' : ''}`}>
|
||||||
<Icon size={20} />
|
<Icon size={20} />
|
||||||
@@ -24,10 +31,28 @@ function NavLink({ to, icon: Icon, label }) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function NotFound() {
|
||||||
|
return (
|
||||||
|
<div className="container" style={{ textAlign: 'center', padding: '4rem 1rem' }}>
|
||||||
|
<SearchX size={48} color="var(--text-muted)" style={{ marginBottom: '1rem' }} />
|
||||||
|
<h2 style={{ marginBottom: '0.5rem' }}>Page not found</h2>
|
||||||
|
<p style={{ color: 'var(--text-muted)', marginBottom: '1.5rem' }}>
|
||||||
|
That page doesn't exist or may have been moved.
|
||||||
|
</p>
|
||||||
|
<Link to="/" className="btn btn-primary">Back to Dashboard</Link>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
function AppInner() {
|
function AppInner() {
|
||||||
const { settings } = useSettings()
|
const { settings, loadError } = useSettings()
|
||||||
|
const toast = useToast()
|
||||||
const kennelName = settings?.kennel_name || 'BREEDR'
|
const kennelName = settings?.kennel_name || 'BREEDR'
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (loadError) toast.error('Failed to load kennel settings — using defaults')
|
||||||
|
}, [loadError, toast])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="app">
|
<div className="app">
|
||||||
<nav className="navbar">
|
<nav className="navbar">
|
||||||
@@ -42,7 +67,7 @@ function AppInner() {
|
|||||||
</div>
|
</div>
|
||||||
<div className="nav-links">
|
<div className="nav-links">
|
||||||
<NavLink to="/" icon={Home} label="Dashboard" />
|
<NavLink to="/" icon={Home} label="Dashboard" />
|
||||||
<NavLink to="/dogs" icon={PawPrint} label="Dogs" />
|
<NavLink to="/dogs" icon={PawPrint} label="Dogs" match={['/pedigree']} />
|
||||||
<NavLink to="/external" icon={ExternalLink} label="External" />
|
<NavLink to="/external" icon={ExternalLink} label="External" />
|
||||||
<NavLink to="/litters" icon={Activity} label="Litters" />
|
<NavLink to="/litters" icon={Activity} label="Litters" />
|
||||||
<NavLink to="/breeding" icon={Heart} label="Breeding" />
|
<NavLink to="/breeding" icon={Heart} label="Breeding" />
|
||||||
@@ -64,6 +89,7 @@ function AppInner() {
|
|||||||
<Route path="/breeding" element={<BreedingCalendar />} />
|
<Route path="/breeding" element={<BreedingCalendar />} />
|
||||||
<Route path="/pairing" element={<PairingSimulator />} />
|
<Route path="/pairing" element={<PairingSimulator />} />
|
||||||
<Route path="/settings" element={<SettingsPage />} />
|
<Route path="/settings" element={<SettingsPage />} />
|
||||||
|
<Route path="*" element={<NotFound />} />
|
||||||
</Routes>
|
</Routes>
|
||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
@@ -73,7 +99,11 @@ function AppInner() {
|
|||||||
function App() {
|
function App() {
|
||||||
return (
|
return (
|
||||||
<Router>
|
<Router>
|
||||||
<AppInner />
|
<ToastProvider>
|
||||||
|
<ConfirmProvider>
|
||||||
|
<AppInner />
|
||||||
|
</ConfirmProvider>
|
||||||
|
</ToastProvider>
|
||||||
</Router>
|
</Router>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,73 @@
|
|||||||
|
import { createContext, useCallback, useContext, useEffect, useRef, useState } from 'react'
|
||||||
|
import { AlertTriangle } from 'lucide-react'
|
||||||
|
|
||||||
|
const ConfirmContext = createContext(null)
|
||||||
|
|
||||||
|
// Promise-based replacement for window.confirm:
|
||||||
|
// const confirm = useConfirm()
|
||||||
|
// if (!(await confirm({ title: 'Delete dog?', message: `Delete "${dog.name}"? ...` }))) return
|
||||||
|
export function useConfirm() {
|
||||||
|
const ctx = useContext(ConfirmContext)
|
||||||
|
if (!ctx) throw new Error('useConfirm must be used within ConfirmProvider')
|
||||||
|
return ctx
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ConfirmProvider({ children }) {
|
||||||
|
const [dialog, setDialog] = useState(null)
|
||||||
|
const resolverRef = useRef(null)
|
||||||
|
|
||||||
|
const confirm = useCallback((opts) => new Promise(resolve => {
|
||||||
|
resolverRef.current = resolve
|
||||||
|
setDialog(typeof opts === 'string' ? { message: opts } : opts)
|
||||||
|
}), [])
|
||||||
|
|
||||||
|
const close = useCallback((result) => {
|
||||||
|
resolverRef.current?.(result)
|
||||||
|
resolverRef.current = null
|
||||||
|
setDialog(null)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!dialog) return
|
||||||
|
const onKey = (e) => { if (e.key === 'Escape') close(false) }
|
||||||
|
window.addEventListener('keydown', onKey)
|
||||||
|
return () => window.removeEventListener('keydown', onKey)
|
||||||
|
}, [dialog, close])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ConfirmContext.Provider value={confirm}>
|
||||||
|
{children}
|
||||||
|
{dialog && (
|
||||||
|
<div className="modal-overlay" onClick={() => close(false)}>
|
||||||
|
<div
|
||||||
|
className="modal-content"
|
||||||
|
style={{ maxWidth: '420px' }}
|
||||||
|
onClick={e => e.stopPropagation()}
|
||||||
|
role="alertdialog"
|
||||||
|
aria-modal="true"
|
||||||
|
>
|
||||||
|
<div className="modal-header" style={{ padding: '1.25rem 1.5rem' }}>
|
||||||
|
<h2 style={{ display: 'flex', alignItems: 'center', gap: '0.5rem', fontSize: '1.1rem' }}>
|
||||||
|
<AlertTriangle size={20} color="var(--danger)" />
|
||||||
|
{dialog.title || 'Are you sure?'}
|
||||||
|
</h2>
|
||||||
|
</div>
|
||||||
|
<div className="modal-body" style={{ padding: '1.25rem 1.5rem' }}>
|
||||||
|
<p style={{ margin: 0, color: 'var(--text-secondary)', lineHeight: 1.5 }}>
|
||||||
|
{dialog.message}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="modal-footer" style={{ padding: '1rem 1.5rem' }}>
|
||||||
|
<button className="btn btn-secondary" onClick={() => close(false)}>
|
||||||
|
{dialog.cancelLabel || 'Cancel'}
|
||||||
|
</button>
|
||||||
|
<button className="btn btn-danger" onClick={() => close(true)} autoFocus>
|
||||||
|
{dialog.confirmLabel || 'Delete'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</ConfirmContext.Provider>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,41 +1,176 @@
|
|||||||
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 })
|
|
||||||
const [saving, setSaving] = useState(false)
|
|
||||||
const [error, setError] = useState(null)
|
|
||||||
|
|
||||||
const isOFA = form.record_type === 'ofa_clearance'
|
export default function HealthRecordForm({ dogId, dogBirthDate, record, onClose, onSave }) {
|
||||||
const set = (k, v) => setForm(f => ({ ...f, [k]: v }))
|
// 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,
|
||||||
|
}))
|
||||||
|
|
||||||
|
// Live warning while typing — validate() still hard-blocks on submit.
|
||||||
|
const ageWarning = (() => {
|
||||||
|
if (!isOFA || !ofaTest?.minAgeMonths || !dogBirthDate || !form.test_date) return null
|
||||||
|
const months = (new Date(form.test_date) - new Date(dogBirthDate)) / (1000 * 60 * 60 * 24 * 30.44)
|
||||||
|
if (months < 0 || months >= ofaTest.minAgeMonths) return null
|
||||||
|
return `Dog was ~${Math.floor(months)} months old on this date — final ${ofaTest.label} certification requires ${ofaTest.minAgeMonths} months.`
|
||||||
|
})()
|
||||||
|
|
||||||
|
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 +190,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 +214,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 +223,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>
|
||||||
<select style={inputStyle} value={form.ofa_result} onChange={e => set('ofa_result', e.target.value)}>
|
{ofaTest?.results ? (
|
||||||
{OFA_RESULTS.map(r => <option key={r} value={r}>{r}</option>)}
|
<select style={inputStyle} value={form.ofa_result} onChange={e => set('ofa_result', e.target.value)}>
|
||||||
</select>
|
<option value="">Select result…</option>
|
||||||
|
{ofaTest.results.map(r => <option key={r} value={r}>{r}</option>)}
|
||||||
|
</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 +256,93 @@ 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)} />
|
||||||
|
{ageWarning && (
|
||||||
|
<span style={{ ...hintStyle, color: 'var(--warning)' }}>{ageWarning}</span>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div style={fw}>
|
{ofaTest?.recurs ? (
|
||||||
<label style={labelStyle}>Expires At</label>
|
<div style={fw}>
|
||||||
<input style={inputStyle} type="date" value={form.expires_at}
|
<label style={labelStyle}>Expires / Re-test Due</label>
|
||||||
onChange={e => set('expires_at', e.target.value)} />
|
<input style={inputStyle} type="date" min={form.test_date} value={form.expires_at}
|
||||||
</div>
|
onChange={e => set('expires_at', e.target.value)} />
|
||||||
|
<span style={hintStyle}>This test is typically valid ~12 months.</span>
|
||||||
|
</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>
|
||||||
<div style={fw}>
|
{cfg.nextDue && (
|
||||||
<label style={labelStyle}>Next Due</label>
|
<div style={fw}>
|
||||||
<input style={inputStyle} type="date" value={form.next_due}
|
<label style={labelStyle}>{cfg.nextDue}</label>
|
||||||
onChange={e => set('next_due', e.target.value)} />
|
<input style={inputStyle} type="date" min={form.test_date} value={form.next_due}
|
||||||
</div>
|
onChange={e => set('next_due', e.target.value)} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div style={grid2}>
|
<div style={grid2}>
|
||||||
|
{cfg.result && (
|
||||||
|
<div style={fw}>
|
||||||
|
<label style={labelStyle}>{cfg.result}</label>
|
||||||
|
<input style={inputStyle} placeholder={cfg.resultPlaceholder} value={form.result}
|
||||||
|
onChange={e => set('result', e.target.value)} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
<div style={fw}>
|
<div style={fw}>
|
||||||
<label style={labelStyle}>Result</label>
|
<label style={labelStyle}>{cfg.clinicianLabel}</label>
|
||||||
<input style={inputStyle} placeholder="Normal, Pass, etc." value={form.result}
|
<input style={inputStyle} placeholder="Dr. Smith" value={form.performed_by}
|
||||||
onChange={e => set('result', e.target.value)} />
|
onChange={e => set('performed_by', e.target.value)} />
|
||||||
</div>
|
|
||||||
<div style={fw}>
|
|
||||||
<label style={labelStyle}>Vet Name</label>
|
|
||||||
<input style={inputStyle} placeholder="Dr. Smith" value={form.vet_name}
|
|
||||||
onChange={e => set('vet_name', 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}>
|
||||||
|
|||||||
@@ -116,6 +116,11 @@ function LitterForm({ litter, prefill, onClose, onSave }) {
|
|||||||
<option key={d.id} value={d.id}>{d.name} {d.registration_number ? `(${d.registration_number})` : ''}</option>
|
<option key={d.id} value={d.id}>{d.name} {d.registration_number ? `(${d.registration_number})` : ''}</option>
|
||||||
))}
|
))}
|
||||||
</select>
|
</select>
|
||||||
|
{!!litter && (
|
||||||
|
<p style={{ fontSize: '0.78rem', color: 'var(--text-muted)', marginTop: '0.25rem' }}>
|
||||||
|
Parents can't be changed after the litter is created.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="form-group">
|
<div className="form-group">
|
||||||
|
|||||||
@@ -0,0 +1,50 @@
|
|||||||
|
import { createContext, useCallback, useContext, useMemo, useRef, useState } from 'react'
|
||||||
|
import { CheckCircle, AlertCircle, X } from 'lucide-react'
|
||||||
|
|
||||||
|
const ToastContext = createContext(null)
|
||||||
|
|
||||||
|
export function useToast() {
|
||||||
|
const ctx = useContext(ToastContext)
|
||||||
|
if (!ctx) throw new Error('useToast must be used within ToastProvider')
|
||||||
|
return ctx
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ToastProvider({ children }) {
|
||||||
|
const [toasts, setToasts] = useState([])
|
||||||
|
const idRef = useRef(0)
|
||||||
|
|
||||||
|
const dismiss = useCallback((id) => {
|
||||||
|
setToasts(list => list.filter(t => t.id !== id))
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const push = useCallback((type, message) => {
|
||||||
|
const id = ++idRef.current
|
||||||
|
setToasts(list => [...list, { id, type, message }])
|
||||||
|
// errors linger longer so they can actually be read
|
||||||
|
setTimeout(() => dismiss(id), type === 'error' ? 6000 : 3500)
|
||||||
|
}, [dismiss])
|
||||||
|
|
||||||
|
const toast = useMemo(() => ({
|
||||||
|
success: (message) => push('success', message),
|
||||||
|
error: (message) => push('error', message),
|
||||||
|
}), [push])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ToastContext.Provider value={toast}>
|
||||||
|
{children}
|
||||||
|
<div className="toast-container">
|
||||||
|
{toasts.map(t => (
|
||||||
|
<div key={t.id} className={`toast toast-${t.type}`} role="status">
|
||||||
|
{t.type === 'success'
|
||||||
|
? <CheckCircle size={18} color="var(--success)" />
|
||||||
|
: <AlertCircle size={18} color="var(--danger)" />}
|
||||||
|
<span className="toast-message">{t.message}</span>
|
||||||
|
<button className="toast-close" onClick={() => dismiss(t.id)} aria-label="Dismiss">
|
||||||
|
<X size={14} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</ToastContext.Provider>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -9,13 +9,18 @@ export function SettingsProvider({ children }) {
|
|||||||
kennel_tagline: '',
|
kennel_tagline: '',
|
||||||
})
|
})
|
||||||
const [loading, setLoading] = useState(true)
|
const [loading, setLoading] = useState(true)
|
||||||
|
// Surfaced by consumers inside the ToastProvider (this provider sits above it)
|
||||||
|
const [loadError, setLoadError] = useState(false)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
axios.get('/api/settings')
|
axios.get('/api/settings')
|
||||||
.then(res => {
|
.then(res => {
|
||||||
setSettings(prev => ({ ...prev, ...res.data }))
|
setSettings(prev => ({ ...prev, ...res.data }))
|
||||||
})
|
})
|
||||||
.catch(() => {})
|
.catch((err) => {
|
||||||
|
console.error('Failed to load settings:', err)
|
||||||
|
setLoadError(true)
|
||||||
|
})
|
||||||
.finally(() => setLoading(false))
|
.finally(() => setLoading(false))
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
@@ -25,7 +30,7 @@ export function SettingsProvider({ children }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<SettingsContext.Provider value={{ settings, saveSettings, loading }}>
|
<SettingsContext.Provider value={{ settings, saveSettings, loading, loadError }}>
|
||||||
{children}
|
{children}
|
||||||
</SettingsContext.Provider>
|
</SettingsContext.Provider>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -537,3 +537,46 @@ select {
|
|||||||
color: var(--danger);
|
color: var(--danger);
|
||||||
border: 1px solid rgba(239, 68, 68, 0.3);
|
border: 1px solid rgba(239, 68, 68, 0.3);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Toasts */
|
||||||
|
.toast-container {
|
||||||
|
position: fixed;
|
||||||
|
bottom: 1.5rem;
|
||||||
|
right: 1.5rem;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.5rem;
|
||||||
|
z-index: 2000;
|
||||||
|
max-width: min(380px, calc(100vw - 2rem));
|
||||||
|
}
|
||||||
|
|
||||||
|
.toast {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.6rem;
|
||||||
|
padding: 0.75rem 1rem;
|
||||||
|
background: var(--bg-elevated);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
box-shadow: var(--shadow-lg);
|
||||||
|
animation: slideUp 0.25s ease-out;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.toast-success { border-left: 3px solid var(--success); }
|
||||||
|
.toast-error { border-left: 3px solid var(--danger); }
|
||||||
|
|
||||||
|
.toast-message { flex: 1; }
|
||||||
|
|
||||||
|
.toast-close {
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
color: var(--text-muted);
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 0.15rem;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toast-close:hover { color: var(--text-primary); }
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import {
|
|||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
import { useNavigate } from 'react-router-dom'
|
import { useNavigate } from 'react-router-dom'
|
||||||
import axios from 'axios'
|
import axios from 'axios'
|
||||||
|
import { useConfirm } from '../components/ConfirmDialog'
|
||||||
|
|
||||||
// ─── Date helpers ────────────────────────────────────────────────────────────
|
// ─── Date helpers ────────────────────────────────────────────────────────────
|
||||||
const toISO = d => d.toISOString().split('T')[0]
|
const toISO = d => d.toISOString().split('T')[0]
|
||||||
@@ -136,6 +137,7 @@ function CycleDetailModal({ cycle, onClose, onDeleted, onRecordLitter }) {
|
|||||||
const [savingBreed, setSavingBreed] = useState(false)
|
const [savingBreed, setSavingBreed] = useState(false)
|
||||||
const [deleting, setDeleting] = useState(false)
|
const [deleting, setDeleting] = useState(false)
|
||||||
const [error, setError] = useState(null)
|
const [error, setError] = useState(null)
|
||||||
|
const confirm = useConfirm()
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
fetch(`/api/breeding/heat-cycles/${cycle.id}/suggestions`)
|
fetch(`/api/breeding/heat-cycles/${cycle.id}/suggestions`)
|
||||||
@@ -162,7 +164,10 @@ function CycleDetailModal({ cycle, onClose, onDeleted, onRecordLitter }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function deleteCycle() {
|
async function deleteCycle() {
|
||||||
if (!window.confirm(`Delete heat cycle for ${cycle.dog_name}? This cannot be undone.`)) return
|
if (!(await confirm({
|
||||||
|
title: 'Delete heat cycle?',
|
||||||
|
message: `The heat cycle for ${cycle.dog_name} starting ${cycle.start_date} will be permanently removed.`,
|
||||||
|
}))) return
|
||||||
setDeleting(true)
|
setDeleting(true)
|
||||||
try {
|
try {
|
||||||
await fetch(`/api/breeding/heat-cycles/${cycle.id}`, { method: 'DELETE' })
|
await fetch(`/api/breeding/heat-cycles/${cycle.id}`, { method: 'DELETE' })
|
||||||
@@ -349,25 +354,33 @@ export default function BreedingCalendar() {
|
|||||||
const [selectedCycle, setSelectedCycle] = useState(null)
|
const [selectedCycle, setSelectedCycle] = useState(null)
|
||||||
const [selectedDay, setSelectedDay] = useState(null)
|
const [selectedDay, setSelectedDay] = useState(null)
|
||||||
const [pendingLitterCycle, setPendingLitterCycle] = useState(null)
|
const [pendingLitterCycle, setPendingLitterCycle] = useState(null)
|
||||||
|
const [loadError, setLoadError] = useState(false)
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
|
|
||||||
|
// allSettled: cycles still render if the dogs fetch fails (and vice versa)
|
||||||
const load = useCallback(async () => {
|
const load = useCallback(async () => {
|
||||||
setLoading(true)
|
setLoading(true)
|
||||||
try {
|
setLoadError(false)
|
||||||
const [cyclesRes, dogsRes] = await Promise.all([
|
const [cyclesRes, dogsRes] = await Promise.allSettled([
|
||||||
fetch('/api/breeding/heat-cycles'),
|
fetch('/api/breeding/heat-cycles').then(r => r.ok ? r.json() : Promise.reject(new Error(`HTTP ${r.status}`))),
|
||||||
fetch('/api/dogs')
|
fetch('/api/dogs?sex=female&limit=200').then(r => r.ok ? r.json() : Promise.reject(new Error(`HTTP ${r.status}`))),
|
||||||
])
|
])
|
||||||
const allCycles = await cyclesRes.json()
|
|
||||||
const dogsData = await dogsRes.json()
|
if (cyclesRes.status === 'fulfilled' && Array.isArray(cyclesRes.value)) {
|
||||||
const allDogs = Array.isArray(dogsData) ? dogsData : (dogsData.dogs || [])
|
setCycles(cyclesRes.value)
|
||||||
setCycles(Array.isArray(allCycles) ? allCycles : [])
|
|
||||||
setFemales(allDogs.filter(d => d.sex === 'female'))
|
|
||||||
} catch (e) {
|
|
||||||
console.error(e)
|
|
||||||
} finally {
|
|
||||||
setLoading(false)
|
|
||||||
}
|
}
|
||||||
|
if (dogsRes.status === 'fulfilled') {
|
||||||
|
// paginated endpoint returns { data, total, ... }
|
||||||
|
const dogs = Array.isArray(dogsRes.value) ? dogsRes.value : (dogsRes.value.data || [])
|
||||||
|
setFemales(dogs.filter(d => d.sex === 'female'))
|
||||||
|
}
|
||||||
|
|
||||||
|
const failures = [cyclesRes, dogsRes].filter(r => r.status === 'rejected')
|
||||||
|
if (failures.length) {
|
||||||
|
console.error('Calendar load failures:', failures.map(f => f.reason))
|
||||||
|
setLoadError(true)
|
||||||
|
}
|
||||||
|
setLoading(false)
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
useEffect(() => { load() }, [load])
|
useEffect(() => { load() }, [load])
|
||||||
@@ -470,6 +483,17 @@ export default function BreedingCalendar() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="container" style={{ paddingTop: '2rem', paddingBottom: '3rem' }}>
|
<div className="container" style={{ paddingTop: '2rem', paddingBottom: '3rem' }}>
|
||||||
|
{loadError && (
|
||||||
|
<div className="card" style={{
|
||||||
|
borderColor: 'var(--danger)', padding: '0.75rem 1rem', marginBottom: '1.5rem',
|
||||||
|
display: 'flex', alignItems: 'center', gap: '0.75rem',
|
||||||
|
}}>
|
||||||
|
<AlertCircle size={18} color="var(--danger)" />
|
||||||
|
<span style={{ flex: 1, fontSize: '0.9rem' }}>Some calendar data failed to load.</span>
|
||||||
|
<button className="btn btn-secondary" onClick={load}>Retry</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: '1.5rem', flexWrap: 'wrap', gap: '1rem' }}>
|
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: '1.5rem', flexWrap: 'wrap', gap: '1rem' }}>
|
||||||
<div style={{ display: 'flex', alignItems: 'center', gap: '0.75rem' }}>
|
<div style={{ display: 'flex', alignItems: 'center', gap: '0.75rem' }}>
|
||||||
@@ -510,7 +534,7 @@ export default function BreedingCalendar() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Day headers */}
|
{/* Day headers */}
|
||||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(7, 1fr)', borderBottom: '1px solid var(--border)' }}>
|
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(7, minmax(0, 1fr))', borderBottom: '1px solid var(--border)' }}>
|
||||||
{DAY_NAMES.map(d => (
|
{DAY_NAMES.map(d => (
|
||||||
<div key={d} style={{ padding: '0.5rem', textAlign: 'center', fontSize: '0.75rem', fontWeight: 600, color: 'var(--text-muted)', textTransform: 'uppercase', letterSpacing: '0.05em' }}>{d}</div>
|
<div key={d} style={{ padding: '0.5rem', textAlign: 'center', fontSize: '0.75rem', fontWeight: 600, color: 'var(--text-muted)', textTransform: 'uppercase', letterSpacing: '0.05em' }}>{d}</div>
|
||||||
))}
|
))}
|
||||||
@@ -520,7 +544,7 @@ export default function BreedingCalendar() {
|
|||||||
{loading ? (
|
{loading ? (
|
||||||
<div className="loading" style={{ minHeight: 280 }}>Loading calendar…</div>
|
<div className="loading" style={{ minHeight: 280 }}>Loading calendar…</div>
|
||||||
) : (
|
) : (
|
||||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(7, 1fr)' }}>
|
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(7, minmax(0, 1fr))' }}>
|
||||||
{Array.from({ length: rows * 7 }).map((_, idx) => {
|
{Array.from({ length: rows * 7 }).map((_, idx) => {
|
||||||
const dayNum = idx - startPad + 1
|
const dayNum = idx - startPad + 1
|
||||||
const isValid = dayNum >= 1 && dayNum <= lastDay.getDate()
|
const isValid = dayNum >= 1 && dayNum <= lastDay.getDate()
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { useEffect, useState } from 'react'
|
import { useEffect, useState } from 'react'
|
||||||
import { Link } from 'react-router-dom'
|
import { Link } from 'react-router-dom'
|
||||||
import { Dog, Activity, Heart, Calendar, Hash, ArrowRight } from 'lucide-react'
|
import { Dog, Activity, Heart, Calendar, Hash, ArrowRight, AlertCircle } from 'lucide-react'
|
||||||
import axios from 'axios'
|
import axios from 'axios'
|
||||||
|
|
||||||
function Dashboard() {
|
function Dashboard() {
|
||||||
@@ -13,34 +13,38 @@ function Dashboard() {
|
|||||||
})
|
})
|
||||||
const [recentDogs, setRecentDogs] = useState([])
|
const [recentDogs, setRecentDogs] = useState([])
|
||||||
const [loading, setLoading] = useState(true)
|
const [loading, setLoading] = useState(true)
|
||||||
|
const [loadError, setLoadError] = useState(false)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
fetchDashboardData()
|
fetchDashboardData()
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
|
// allSettled: one failing endpoint shouldn't blank the whole dashboard
|
||||||
const fetchDashboardData = async () => {
|
const fetchDashboardData = async () => {
|
||||||
try {
|
setLoading(true)
|
||||||
const [dogsRes, littersRes, heatCyclesRes] = await Promise.all([
|
setLoadError(false)
|
||||||
axios.get('/api/dogs', { params: { page: 1, limit: 8 } }),
|
const [dogsRes, littersRes, heatCyclesRes] = await Promise.allSettled([
|
||||||
axios.get('/api/litters', { params: { page: 1, limit: 1 } }),
|
axios.get('/api/dogs', { params: { page: 1, limit: 8 } }),
|
||||||
axios.get('/api/breeding/heat-cycles/active')
|
axios.get('/api/litters', { params: { page: 1, limit: 1 } }),
|
||||||
])
|
axios.get('/api/breeding/heat-cycles/active')
|
||||||
|
])
|
||||||
|
|
||||||
const { data: recentDogsList, stats: dogStats } = dogsRes.data
|
const dogData = dogsRes.status === 'fulfilled' ? dogsRes.value.data : null
|
||||||
setStats({
|
setStats({
|
||||||
totalDogs: dogStats?.total ?? 0,
|
totalDogs: dogData?.stats?.total ?? 0,
|
||||||
males: dogStats?.males ?? 0,
|
males: dogData?.stats?.males ?? 0,
|
||||||
females: dogStats?.females ?? 0,
|
females: dogData?.stats?.females ?? 0,
|
||||||
totalLitters: littersRes.data.total,
|
totalLitters: littersRes.status === 'fulfilled' ? littersRes.value.data.total : 0,
|
||||||
activeHeatCycles: heatCyclesRes.data.length
|
activeHeatCycles: heatCyclesRes.status === 'fulfilled' ? heatCyclesRes.value.data.length : 0
|
||||||
})
|
})
|
||||||
|
setRecentDogs(dogData?.data ?? [])
|
||||||
|
|
||||||
setRecentDogs(recentDogsList)
|
const failures = [dogsRes, littersRes, heatCyclesRes].filter(r => r.status === 'rejected')
|
||||||
setLoading(false)
|
if (failures.length) {
|
||||||
} catch (error) {
|
console.error('Error fetching dashboard data:', failures.map(f => f.reason))
|
||||||
console.error('Error fetching dashboard data:', error)
|
setLoadError(true)
|
||||||
setLoading(false)
|
|
||||||
}
|
}
|
||||||
|
setLoading(false)
|
||||||
}
|
}
|
||||||
|
|
||||||
const calculateAge = (birthDate) => {
|
const calculateAge = (birthDate) => {
|
||||||
@@ -68,6 +72,17 @@ function Dashboard() {
|
|||||||
<div className="container" style={{ paddingTop: '2rem', paddingBottom: '3rem' }}>
|
<div className="container" style={{ paddingTop: '2rem', paddingBottom: '3rem' }}>
|
||||||
<h1 style={{ marginBottom: '2rem' }}>Dashboard</h1>
|
<h1 style={{ marginBottom: '2rem' }}>Dashboard</h1>
|
||||||
|
|
||||||
|
{loadError && (
|
||||||
|
<div className="card" style={{
|
||||||
|
borderColor: 'var(--danger)', padding: '0.75rem 1rem', marginBottom: '1.5rem',
|
||||||
|
display: 'flex', alignItems: 'center', gap: '0.75rem',
|
||||||
|
}}>
|
||||||
|
<AlertCircle size={18} color="var(--danger)" />
|
||||||
|
<span style={{ flex: 1, fontSize: '0.9rem' }}>Some dashboard data failed to load.</span>
|
||||||
|
<button className="btn btn-secondary" onClick={fetchDashboardData}>Retry</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Stats Grid */}
|
{/* Stats Grid */}
|
||||||
<div className="grid grid-4" style={{ marginBottom: '3rem' }}>
|
<div className="grid grid-4" style={{ marginBottom: '3rem' }}>
|
||||||
<div className="card stat-card">
|
<div className="card stat-card">
|
||||||
|
|||||||
@@ -8,6 +8,8 @@ import ClearanceSummaryCard from '../components/ClearanceSummaryCard'
|
|||||||
import HealthRecordForm from '../components/HealthRecordForm'
|
import HealthRecordForm from '../components/HealthRecordForm'
|
||||||
import GeneticPanelCard from '../components/GeneticPanelCard'
|
import GeneticPanelCard from '../components/GeneticPanelCard'
|
||||||
import { ShieldCheck } from 'lucide-react'
|
import { ShieldCheck } from 'lucide-react'
|
||||||
|
import { useToast } from '../components/Toast'
|
||||||
|
import { useConfirm } from '../components/ConfirmDialog'
|
||||||
|
|
||||||
function DogDetail() {
|
function DogDetail() {
|
||||||
const { id } = useParams()
|
const { id } = useParams()
|
||||||
@@ -18,6 +20,8 @@ function DogDetail() {
|
|||||||
const [uploading, setUploading] = useState(false)
|
const [uploading, setUploading] = useState(false)
|
||||||
const [selectedPhoto, setSelectedPhoto] = useState(0)
|
const [selectedPhoto, setSelectedPhoto] = useState(0)
|
||||||
const fileInputRef = useRef(null)
|
const fileInputRef = useRef(null)
|
||||||
|
const toast = useToast()
|
||||||
|
const confirm = useConfirm()
|
||||||
|
|
||||||
// Health records state
|
// Health records state
|
||||||
const [healthRecords, setHealthRecords] = useState([])
|
const [healthRecords, setHealthRecords] = useState([])
|
||||||
@@ -57,7 +61,7 @@ function DogDetail() {
|
|||||||
fetchDog()
|
fetchDog()
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error uploading photo:', error)
|
console.error('Error uploading photo:', error)
|
||||||
alert('Failed to upload photo')
|
toast.error(error.response?.data?.error || 'Failed to upload photo')
|
||||||
} finally {
|
} finally {
|
||||||
setUploading(false)
|
setUploading(false)
|
||||||
if (fileInputRef.current) fileInputRef.current.value = ''
|
if (fileInputRef.current) fileInputRef.current.value = ''
|
||||||
@@ -65,7 +69,7 @@ function DogDetail() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const handleDeletePhoto = async (photoIndex) => {
|
const handleDeletePhoto = async (photoIndex) => {
|
||||||
if (!confirm('Delete this photo?')) return
|
if (!(await confirm({ title: 'Delete photo?', message: 'This photo will be permanently removed.' }))) return
|
||||||
try {
|
try {
|
||||||
await axios.delete(`/api/dogs/${id}/photos/${photoIndex}`)
|
await axios.delete(`/api/dogs/${id}/photos/${photoIndex}`)
|
||||||
fetchDog()
|
fetchDog()
|
||||||
@@ -74,7 +78,7 @@ function DogDetail() {
|
|||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error deleting photo:', error)
|
console.error('Error deleting photo:', error)
|
||||||
alert('Failed to delete photo')
|
toast.error(error.response?.data?.error || 'Failed to delete photo')
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -97,6 +101,21 @@ 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 (!(await confirm({
|
||||||
|
title: 'Delete health record?',
|
||||||
|
message: `"${label}"${rec.test_date ? ` from ${rec.test_date}` : ''} will be permanently removed.`,
|
||||||
|
}))) return
|
||||||
|
try {
|
||||||
|
await axios.delete(`/api/health/${rec.id}`)
|
||||||
|
toast.success('Health record deleted')
|
||||||
|
fetchHealth()
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error deleting health record:', error)
|
||||||
|
toast.error(error.response?.data?.error || '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>
|
||||||
@@ -107,7 +126,7 @@ function DogDetail() {
|
|||||||
return (
|
return (
|
||||||
<div className="container" style={{ paddingTop: '2rem', paddingBottom: '3rem' }}>
|
<div className="container" style={{ paddingTop: '2rem', paddingBottom: '3rem' }}>
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<div style={{ display: 'flex', alignItems: 'center', gap: '1rem', marginBottom: '2rem' }}>
|
<div style={{ display: 'flex', alignItems: 'center', gap: '1rem', marginBottom: '2rem', flexWrap: 'wrap' }}>
|
||||||
<button className="btn-icon" onClick={() => navigate('/dogs')} style={{ marginRight: '0.5rem' }}>
|
<button className="btn-icon" onClick={() => navigate('/dogs')} style={{ marginRight: '0.5rem' }}>
|
||||||
<ArrowLeft size={20} />
|
<ArrowLeft size={20} />
|
||||||
</button>
|
</button>
|
||||||
@@ -141,7 +160,7 @@ function DogDetail() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 2fr', gap: '1.5rem', marginBottom: '1.5rem' }}>
|
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(min(320px, 100%), 1fr))', gap: '1.5rem', marginBottom: '1.5rem' }}>
|
||||||
{/* Photo Section */}
|
{/* Photo Section */}
|
||||||
<div className="card" style={{ padding: '1rem' }}>
|
<div className="card" style={{ padding: '1rem' }}>
|
||||||
<div style={{ marginBottom: '0.75rem', display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
<div style={{ marginBottom: '0.75rem', display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||||
@@ -356,9 +375,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 +387,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 +450,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}
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { Dog, Plus, Search, Calendar, Hash, ArrowRight, Trash2 } from 'lucide-re
|
|||||||
import axios from 'axios'
|
import axios from 'axios'
|
||||||
import DogForm from '../components/DogForm'
|
import DogForm from '../components/DogForm'
|
||||||
import { ChampionBadge, ChampionBloodlineBadge } from '../components/ChampionBadge'
|
import { ChampionBadge, ChampionBloodlineBadge } from '../components/ChampionBadge'
|
||||||
|
import { useToast } from '../components/Toast'
|
||||||
|
|
||||||
const LIMIT = 50
|
const LIMIT = 50
|
||||||
|
|
||||||
@@ -17,6 +18,7 @@ function DogList() {
|
|||||||
const [showAddModal, setShowAddModal] = useState(false)
|
const [showAddModal, setShowAddModal] = useState(false)
|
||||||
const [deleteTarget, setDeleteTarget] = useState(null) // { id, name }
|
const [deleteTarget, setDeleteTarget] = useState(null) // { id, name }
|
||||||
const [deleting, setDeleting] = useState(false)
|
const [deleting, setDeleting] = useState(false)
|
||||||
|
const toast = useToast()
|
||||||
const searchTimerRef = useRef(null)
|
const searchTimerRef = useRef(null)
|
||||||
|
|
||||||
useEffect(() => { fetchDogs(1, '', 'all') }, []) // eslint-disable-line
|
useEffect(() => { fetchDogs(1, '', 'all') }, []) // eslint-disable-line
|
||||||
@@ -62,11 +64,12 @@ function DogList() {
|
|||||||
setDeleting(true)
|
setDeleting(true)
|
||||||
try {
|
try {
|
||||||
await axios.delete(`/api/dogs/${deleteTarget.id}`)
|
await axios.delete(`/api/dogs/${deleteTarget.id}`)
|
||||||
|
toast.success(`${deleteTarget.name} deleted`)
|
||||||
setDeleteTarget(null)
|
setDeleteTarget(null)
|
||||||
fetchDogs(page, search, sexFilter)
|
fetchDogs(page, search, sexFilter)
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Delete failed:', err)
|
console.error('Delete failed:', err)
|
||||||
alert('Failed to delete dog. Please try again.')
|
toast.error(err.response?.data?.error || 'Failed to delete dog. Please try again.')
|
||||||
} finally {
|
} finally {
|
||||||
setDeleting(false)
|
setDeleting(false)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { Dog, Plus, Search, Calendar, Hash, ArrowRight, Trash2, ExternalLink } f
|
|||||||
import axios from 'axios'
|
import axios from 'axios'
|
||||||
import DogForm from '../components/DogForm'
|
import DogForm from '../components/DogForm'
|
||||||
import { ChampionBadge, ChampionBloodlineBadge } from '../components/ChampionBadge'
|
import { ChampionBadge, ChampionBloodlineBadge } from '../components/ChampionBadge'
|
||||||
|
import { useToast } from '../components/Toast'
|
||||||
|
|
||||||
function ExternalDogs() {
|
function ExternalDogs() {
|
||||||
const [dogs, setDogs] = useState([])
|
const [dogs, setDogs] = useState([])
|
||||||
@@ -14,6 +15,7 @@ function ExternalDogs() {
|
|||||||
const [showAddModal, setShowAddModal] = useState(false)
|
const [showAddModal, setShowAddModal] = useState(false)
|
||||||
const [deleteTarget, setDeleteTarget] = useState(null) // { id, name }
|
const [deleteTarget, setDeleteTarget] = useState(null) // { id, name }
|
||||||
const [deleting, setDeleting] = useState(false)
|
const [deleting, setDeleting] = useState(false)
|
||||||
|
const toast = useToast()
|
||||||
|
|
||||||
useEffect(() => { fetchDogs() }, [])
|
useEffect(() => { fetchDogs() }, [])
|
||||||
useEffect(() => { filterDogs() }, [dogs, search, sexFilter])
|
useEffect(() => { filterDogs() }, [dogs, search, sexFilter])
|
||||||
@@ -49,11 +51,12 @@ function ExternalDogs() {
|
|||||||
setDeleting(true)
|
setDeleting(true)
|
||||||
try {
|
try {
|
||||||
await axios.delete(`/api/dogs/${deleteTarget.id}`)
|
await axios.delete(`/api/dogs/${deleteTarget.id}`)
|
||||||
|
toast.success(`${deleteTarget.name} deleted`)
|
||||||
setDogs(prev => prev.filter(d => d.id !== deleteTarget.id))
|
setDogs(prev => prev.filter(d => d.id !== deleteTarget.id))
|
||||||
setDeleteTarget(null)
|
setDeleteTarget(null)
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Delete failed:', err)
|
console.error('Delete failed:', err)
|
||||||
alert('Failed to delete dog. Please try again.')
|
toast.error(err.response?.data?.error || 'Failed to delete dog. Please try again.')
|
||||||
} finally {
|
} finally {
|
||||||
setDeleting(false)
|
setDeleting(false)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,8 @@ import { useParams, useNavigate } from 'react-router-dom'
|
|||||||
import { ArrowLeft, Plus, X, ExternalLink, Dog, Weight, ChevronDown, ChevronUp, Trash2 } from 'lucide-react'
|
import { ArrowLeft, Plus, X, ExternalLink, Dog, Weight, ChevronDown, ChevronUp, Trash2 } from 'lucide-react'
|
||||||
import axios from 'axios'
|
import axios from 'axios'
|
||||||
import LitterForm from '../components/LitterForm'
|
import LitterForm from '../components/LitterForm'
|
||||||
|
import { useToast } from '../components/Toast'
|
||||||
|
import { useConfirm } from '../components/ConfirmDialog'
|
||||||
|
|
||||||
// ─── Puppy Log Panel ────────────────────────────────────────────────────────────
|
// ─── Puppy Log Panel ────────────────────────────────────────────────────────────
|
||||||
function PuppyLogPanel({ litterId, puppy, whelpingDate }) {
|
function PuppyLogPanel({ litterId, puppy, whelpingDate }) {
|
||||||
@@ -18,6 +20,8 @@ function PuppyLogPanel({ litterId, puppy, whelpingDate }) {
|
|||||||
record_type: 'weight_log'
|
record_type: 'weight_log'
|
||||||
})
|
})
|
||||||
const [saving, setSaving] = useState(false)
|
const [saving, setSaving] = useState(false)
|
||||||
|
const toast = useToast()
|
||||||
|
const confirm = useConfirm()
|
||||||
|
|
||||||
useEffect(() => { if (open) fetchLogs() }, [open])
|
useEffect(() => { if (open) fetchLogs() }, [open])
|
||||||
|
|
||||||
@@ -41,16 +45,22 @@ function PuppyLogPanel({ litterId, puppy, whelpingDate }) {
|
|||||||
setShowAdd(false)
|
setShowAdd(false)
|
||||||
setForm(f => ({ ...f, weight_oz: '', weight_lbs: '', notes: '' }))
|
setForm(f => ({ ...f, weight_oz: '', weight_lbs: '', notes: '' }))
|
||||||
fetchLogs()
|
fetchLogs()
|
||||||
} catch (e) { console.error(e) }
|
} catch (e) {
|
||||||
|
console.error(e)
|
||||||
|
toast.error(e.response?.data?.error || 'Failed to save log entry')
|
||||||
|
}
|
||||||
finally { setSaving(false) }
|
finally { setSaving(false) }
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleDelete = async (logId) => {
|
const handleDelete = async (logId) => {
|
||||||
if (!window.confirm('Delete this log entry?')) return
|
if (!(await confirm({ title: 'Delete log entry?', message: `This log entry for ${puppy.name} will be permanently removed.` }))) return
|
||||||
try {
|
try {
|
||||||
await axios.delete(`/api/litters/${litterId}/puppies/${puppy.id}/logs/${logId}`)
|
await axios.delete(`/api/litters/${litterId}/puppies/${puppy.id}/logs/${logId}`)
|
||||||
fetchLogs()
|
fetchLogs()
|
||||||
} catch (e) { console.error(e) }
|
} catch (e) {
|
||||||
|
console.error(e)
|
||||||
|
toast.error('Failed to delete log entry')
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const TYPES = [
|
const TYPES = [
|
||||||
@@ -262,6 +272,9 @@ function LitterDetail() {
|
|||||||
const [addMode, setAddMode] = useState('existing')
|
const [addMode, setAddMode] = useState('existing')
|
||||||
const [error, setError] = useState('')
|
const [error, setError] = useState('')
|
||||||
const [saving, setSaving] = useState(false)
|
const [saving, setSaving] = useState(false)
|
||||||
|
const [puppySort, setPuppySort] = useState('name')
|
||||||
|
const toast = useToast()
|
||||||
|
const confirm = useConfirm()
|
||||||
|
|
||||||
useEffect(() => { fetchLitter(); fetchAllDogs() }, [id])
|
useEffect(() => { fetchLitter(); fetchAllDogs() }, [id])
|
||||||
|
|
||||||
@@ -320,11 +333,18 @@ function LitterDetail() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const handleUnlinkPuppy = async (puppyId) => {
|
const handleUnlinkPuppy = async (puppyId) => {
|
||||||
if (!window.confirm('Remove this puppy from the litter? The dog record will not be deleted.')) return
|
if (!(await confirm({
|
||||||
|
title: 'Remove puppy from litter?',
|
||||||
|
message: 'The dog record itself will not be deleted.',
|
||||||
|
confirmLabel: 'Remove',
|
||||||
|
}))) return
|
||||||
try {
|
try {
|
||||||
await axios.delete(`/api/litters/${id}/puppies/${puppyId}`)
|
await axios.delete(`/api/litters/${id}/puppies/${puppyId}`)
|
||||||
fetchLitter()
|
fetchLitter()
|
||||||
} catch (err) { console.error('Error unlinking puppy:', err) }
|
} catch (err) {
|
||||||
|
console.error('Error unlinking puppy:', err)
|
||||||
|
toast.error(err.response?.data?.error || 'Failed to remove puppy from litter')
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (loading) return <div className="container loading">Loading litter...</div>
|
if (loading) return <div className="container loading">Loading litter...</div>
|
||||||
@@ -332,6 +352,12 @@ function LitterDetail() {
|
|||||||
|
|
||||||
const puppyCount = litter.puppies?.length ?? 0
|
const puppyCount = litter.puppies?.length ?? 0
|
||||||
|
|
||||||
|
const sortedPuppies = [...(litter.puppies || [])].sort((a, b) => {
|
||||||
|
if (puppySort === 'sex') return (a.sex || '').localeCompare(b.sex || '') || (a.name || '').localeCompare(b.name || '')
|
||||||
|
if (puppySort === 'dob') return (a.date_of_birth || '').localeCompare(b.date_of_birth || '') || (a.name || '').localeCompare(b.name || '')
|
||||||
|
return (a.name || '').localeCompare(b.name || '')
|
||||||
|
})
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="container">
|
<div className="container">
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
@@ -386,12 +412,27 @@ function LitterDetail() {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Puppies section */}
|
{/* Puppies section */}
|
||||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '1rem' }}>
|
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '1rem', flexWrap: 'wrap', gap: '0.75rem' }}>
|
||||||
<h2 style={{ margin: 0 }}>Puppies</h2>
|
<h2 style={{ margin: 0 }}>Puppies</h2>
|
||||||
<button className="btn btn-primary" onClick={() => { setShowAddPuppy(true); setError('') }}>
|
<div style={{ display: 'flex', alignItems: 'center', gap: '0.75rem' }}>
|
||||||
<Plus size={16} style={{ marginRight: '0.4rem' }} />
|
{puppyCount > 1 && (
|
||||||
Add Puppy
|
<select
|
||||||
</button>
|
value={puppySort}
|
||||||
|
onChange={e => setPuppySort(e.target.value)}
|
||||||
|
className="input"
|
||||||
|
style={{ width: 'auto', fontSize: '0.85rem', padding: '0.4rem 0.6rem' }}
|
||||||
|
aria-label="Sort puppies"
|
||||||
|
>
|
||||||
|
<option value="name">Sort: Name</option>
|
||||||
|
<option value="sex">Sort: Sex</option>
|
||||||
|
<option value="dob">Sort: Birth Date</option>
|
||||||
|
</select>
|
||||||
|
)}
|
||||||
|
<button className="btn btn-primary" onClick={() => { setShowAddPuppy(true); setError('') }}>
|
||||||
|
<Plus size={16} style={{ marginRight: '0.4rem' }} />
|
||||||
|
Add Puppy
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{puppyCount === 0 ? (
|
{puppyCount === 0 ? (
|
||||||
@@ -400,8 +441,8 @@ function LitterDetail() {
|
|||||||
<p style={{ color: 'var(--text-secondary)' }}>No puppies linked yet. Add puppies to this litter.</p>
|
<p style={{ color: 'var(--text-secondary)' }}>No puppies linked yet. Add puppies to this litter.</p>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(240px, 1fr))', gap: '1rem' }}>
|
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(min(220px, 100%), 1fr))', gap: '1rem' }}>
|
||||||
{litter.puppies.map(puppy => (
|
{sortedPuppies.map(puppy => (
|
||||||
<div key={puppy.id} className="card" style={{ position: 'relative' }}>
|
<div key={puppy.id} className="card" style={{ position: 'relative' }}>
|
||||||
<button
|
<button
|
||||||
className="btn-icon"
|
className="btn-icon"
|
||||||
|
|||||||
@@ -3,6 +3,8 @@ import { Activity, Plus, Edit2, Trash2, ChevronRight } from 'lucide-react'
|
|||||||
import { useNavigate } from 'react-router-dom'
|
import { useNavigate } from 'react-router-dom'
|
||||||
import axios from 'axios'
|
import axios from 'axios'
|
||||||
import LitterForm from '../components/LitterForm'
|
import LitterForm from '../components/LitterForm'
|
||||||
|
import { useToast } from '../components/Toast'
|
||||||
|
import { useConfirm } from '../components/ConfirmDialog'
|
||||||
|
|
||||||
const LIMIT = 50
|
const LIMIT = 50
|
||||||
|
|
||||||
@@ -15,6 +17,8 @@ function LitterList() {
|
|||||||
const [editingLitter, setEditingLitter] = useState(null)
|
const [editingLitter, setEditingLitter] = useState(null)
|
||||||
const [prefill, setPrefill] = useState(null)
|
const [prefill, setPrefill] = useState(null)
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
|
const toast = useToast()
|
||||||
|
const confirm = useConfirm()
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
fetchLitters(1)
|
fetchLitters(1)
|
||||||
@@ -61,12 +65,17 @@ function LitterList() {
|
|||||||
|
|
||||||
const handleDelete = async (e, id) => {
|
const handleDelete = async (e, id) => {
|
||||||
e.stopPropagation()
|
e.stopPropagation()
|
||||||
if (!window.confirm('Delete this litter record? Puppies will be unlinked but not deleted.')) return
|
if (!(await confirm({
|
||||||
|
title: 'Delete litter record?',
|
||||||
|
message: 'Puppies will be unlinked but their dog records are kept.',
|
||||||
|
}))) return
|
||||||
try {
|
try {
|
||||||
await axios.delete(`/api/litters/${id}`)
|
await axios.delete(`/api/litters/${id}`)
|
||||||
|
toast.success('Litter deleted')
|
||||||
fetchLitters(page)
|
fetchLitters(page)
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error deleting litter:', error)
|
console.error('Error deleting litter:', error)
|
||||||
|
toast.error(error.response?.data?.error || 'Failed to delete litter')
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -15,14 +15,19 @@ export default function PairingSimulator() {
|
|||||||
const [geneticChecking, setGeneticChecking] = useState(false)
|
const [geneticChecking, setGeneticChecking] = useState(false)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// include_external=1 ensures external sires/dams appear for pairing
|
// /all returns an unpaginated array of every active dog (kennel + external),
|
||||||
fetch('/api/dogs?include_external=1')
|
// so external sires/dams appear and nothing is cut off by pagination
|
||||||
.then(r => r.json())
|
fetch('/api/dogs/all')
|
||||||
|
.then(r => r.ok ? r.json() : Promise.reject(new Error(`HTTP ${r.status}`)))
|
||||||
.then(data => {
|
.then(data => {
|
||||||
setDogs(Array.isArray(data) ? data : (data.dogs || []))
|
setDogs(Array.isArray(data) ? data : (data.data || []))
|
||||||
|
setDogsLoading(false)
|
||||||
|
})
|
||||||
|
.catch((err) => {
|
||||||
|
console.error('Failed to load dogs for pairing:', err)
|
||||||
|
setError('Failed to load dogs — refresh to try again')
|
||||||
setDogsLoading(false)
|
setDogsLoading(false)
|
||||||
})
|
})
|
||||||
.catch(() => setDogsLoading(false))
|
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
// Check for direct relation whenever both sire and dam are selected
|
// Check for direct relation whenever both sire and dam are selected
|
||||||
|
|||||||
Generated
+2177
File diff suppressed because it is too large
Load Diff
+2
-6
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "breedr",
|
"name": "breedr",
|
||||||
"version": "1.0.0",
|
"version": "0.8.1",
|
||||||
"description": "Dog Breeding Genealogy Management System",
|
"description": "Dog Breeding Genealogy Management System",
|
||||||
"main": "server/index.js",
|
"main": "server/index.js",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
@@ -17,13 +17,9 @@
|
|||||||
"dependencies": {
|
"dependencies": {
|
||||||
"express": "^4.18.2",
|
"express": "^4.18.2",
|
||||||
"cors": "^2.8.5",
|
"cors": "^2.8.5",
|
||||||
"better-sqlite3": "^9.4.3",
|
"better-sqlite3": "^11.10.0",
|
||||||
"multer": "^1.4.5-lts.1",
|
"multer": "^1.4.5-lts.1",
|
||||||
"bcrypt": "^5.1.1",
|
|
||||||
"jsonwebtoken": "^9.0.2",
|
|
||||||
"express-validator": "^7.0.1",
|
|
||||||
"helmet": "^7.1.0",
|
"helmet": "^7.1.0",
|
||||||
"express-rate-limit": "^7.1.5",
|
|
||||||
"dotenv": "^16.4.5"
|
"dotenv": "^16.4.5"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
|||||||
+37
-2
@@ -2,8 +2,11 @@ const Database = require('better-sqlite3');
|
|||||||
const path = require('path');
|
const path = require('path');
|
||||||
const fs = require('fs');
|
const fs = require('fs');
|
||||||
|
|
||||||
const dbPath = path.join(__dirname, '../../data');
|
// Honor DB_PATH (used by Docker and the migration runner in index.js) so
|
||||||
const db = new Database(path.join(dbPath, 'breedr.db'));
|
// migrations and the app always operate on the same file.
|
||||||
|
const dbFile = process.env.DB_PATH || path.join(__dirname, '../../data/breedr.db');
|
||||||
|
fs.mkdirSync(path.dirname(dbFile), { recursive: true });
|
||||||
|
const db = new Database(dbFile);
|
||||||
|
|
||||||
function getDatabase() {
|
function getDatabase() {
|
||||||
return db;
|
return db;
|
||||||
@@ -91,6 +94,9 @@ function initDatabase() {
|
|||||||
male_count INTEGER DEFAULT 0,
|
male_count INTEGER DEFAULT 0,
|
||||||
female_count INTEGER DEFAULT 0,
|
female_count INTEGER DEFAULT 0,
|
||||||
stillborn_count INTEGER DEFAULT 0,
|
stillborn_count INTEGER DEFAULT 0,
|
||||||
|
breeding_date TEXT,
|
||||||
|
whelping_date TEXT,
|
||||||
|
puppy_count INTEGER DEFAULT 0,
|
||||||
notes TEXT,
|
notes TEXT,
|
||||||
created_at TEXT DEFAULT (datetime('now')),
|
created_at TEXT DEFAULT (datetime('now')),
|
||||||
updated_at TEXT DEFAULT (datetime('now')),
|
updated_at TEXT DEFAULT (datetime('now')),
|
||||||
@@ -100,6 +106,33 @@ function initDatabase() {
|
|||||||
)
|
)
|
||||||
`);
|
`);
|
||||||
|
|
||||||
|
// migrate: columns the litters routes use (routes/litters.js) that older
|
||||||
|
// schemas lack
|
||||||
|
const litterMigrations = [
|
||||||
|
['breeding_date', 'TEXT'],
|
||||||
|
['whelping_date', 'TEXT'],
|
||||||
|
['puppy_count', 'INTEGER DEFAULT 0'],
|
||||||
|
];
|
||||||
|
for (const [col, def] of litterMigrations) {
|
||||||
|
try { db.exec(`ALTER TABLE litters ADD COLUMN ${col} ${def}`); } catch (_) { /* already exists */ }
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Heat Cycles ───────────────────────────────────────────────────────────
|
||||||
|
db.exec(`
|
||||||
|
CREATE TABLE IF NOT EXISTS heat_cycles (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
dog_id INTEGER NOT NULL,
|
||||||
|
start_date TEXT NOT NULL,
|
||||||
|
end_date TEXT,
|
||||||
|
breeding_date TEXT,
|
||||||
|
breeding_successful INTEGER DEFAULT 0,
|
||||||
|
notes TEXT,
|
||||||
|
created_at TEXT DEFAULT (datetime('now')),
|
||||||
|
updated_at TEXT DEFAULT (datetime('now')),
|
||||||
|
FOREIGN KEY (dog_id) REFERENCES dogs(id)
|
||||||
|
)
|
||||||
|
`);
|
||||||
|
|
||||||
// ── Health Records (OFA-extended) ─────────────────────────────────────────
|
// ── Health Records (OFA-extended) ─────────────────────────────────────────
|
||||||
db.exec(`
|
db.exec(`
|
||||||
CREATE TABLE IF NOT EXISTS health_records (
|
CREATE TABLE IF NOT EXISTS health_records (
|
||||||
@@ -135,6 +168,8 @@ function initDatabase() {
|
|||||||
['result', 'TEXT'],
|
['result', 'TEXT'],
|
||||||
['vet_name', 'TEXT'],
|
['vet_name', 'TEXT'],
|
||||||
['next_due', 'TEXT'],
|
['next_due', 'TEXT'],
|
||||||
|
['record_date', 'TEXT'],
|
||||||
|
['description', 'TEXT'],
|
||||||
];
|
];
|
||||||
for (const [col, def] of healthMigrations) {
|
for (const [col, def] of healthMigrations) {
|
||||||
try { db.exec(`ALTER TABLE health_records ADD COLUMN ${col} ${def}`); } catch (_) { /* already exists */ }
|
try { db.exec(`ALTER TABLE health_records ADD COLUMN ${col} ${def}`); } catch (_) { /* already exists */ }
|
||||||
|
|||||||
+13
-1
@@ -52,6 +52,13 @@ class MigrationRunner {
|
|||||||
return columns.some(col => col.name === 'sire' || col.name === 'dam');
|
return columns.some(col => col.name === 'sire' || col.name === 'dam');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Check if a table exists (fresh DBs have none — migrations must no-op)
|
||||||
|
tableExists(name) {
|
||||||
|
return !!this.db.prepare(
|
||||||
|
"SELECT name FROM sqlite_master WHERE type='table' AND name = ?"
|
||||||
|
).get(name);
|
||||||
|
}
|
||||||
|
|
||||||
// Check if litter_id column exists
|
// Check if litter_id column exists
|
||||||
hasLitterIdColumn() {
|
hasLitterIdColumn() {
|
||||||
const columns = this.db.prepare("PRAGMA table_info(dogs)").all();
|
const columns = this.db.prepare("PRAGMA table_info(dogs)").all();
|
||||||
@@ -187,7 +194,12 @@ class MigrationRunner {
|
|||||||
// Migration 2: Add litter_id column if missing
|
// Migration 2: Add litter_id column if missing
|
||||||
migration002_addLitterIdColumn() {
|
migration002_addLitterIdColumn() {
|
||||||
console.log('[Migration 002] Checking for litter_id column...');
|
console.log('[Migration 002] Checking for litter_id column...');
|
||||||
|
|
||||||
|
if (!this.tableExists('dogs')) {
|
||||||
|
console.log('[Migration 002] Fresh database (no dogs table yet), skipping — init will create it');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (this.hasLitterIdColumn()) {
|
if (this.hasLitterIdColumn()) {
|
||||||
console.log('[Migration 002] litter_id column already exists, skipping');
|
console.log('[Migration 002] litter_id column already exists, skipping');
|
||||||
return;
|
return;
|
||||||
|
|||||||
+117
-69
@@ -31,6 +31,32 @@ const upload = multer({
|
|||||||
|
|
||||||
const emptyToNull = (v) => (v === '' || v === undefined) ? null : v;
|
const emptyToNull = (v) => (v === '' || v === undefined) ? null : v;
|
||||||
|
|
||||||
|
// photo_urls is stored as a JSON array string; never let one corrupted row
|
||||||
|
// crash every request that loads the dog.
|
||||||
|
function safeParsePhotos(raw) {
|
||||||
|
if (!raw) return [];
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(raw);
|
||||||
|
return Array.isArray(parsed) ? parsed : [];
|
||||||
|
} catch (_) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sire/dam references must exist and have the right sex — bad parent links
|
||||||
|
// corrupt the pedigree graph and COI math. Returns an error string or null.
|
||||||
|
function validateParents(db, sire_id, dam_id, dogId = null) {
|
||||||
|
const checks = [[sire_id, 'male', 'Sire'], [dam_id, 'female', 'Dam']];
|
||||||
|
for (const [pid, wantSex, label] of checks) {
|
||||||
|
if (!pid || pid === '') continue;
|
||||||
|
if (dogId !== null && String(pid) === String(dogId)) return `${label} cannot be the dog itself`;
|
||||||
|
const parent = db.prepare('SELECT sex FROM dogs WHERE id = ?').get(pid);
|
||||||
|
if (!parent) return `${label} does not exist (id ${pid})`;
|
||||||
|
if (parent.sex !== wantSex) return `${label} must be a ${wantSex} dog`;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
// ── Shared SELECT columns ────────────────────────────────────────────────
|
// ── Shared SELECT columns ────────────────────────────────────────────────
|
||||||
const DOG_COLS = `
|
const DOG_COLS = `
|
||||||
id, name, registration_number, breed, sex, birth_date,
|
id, name, registration_number, breed, sex, birth_date,
|
||||||
@@ -47,7 +73,7 @@ function attachParents(db, dogs) {
|
|||||||
WHERE p.dog_id = ?
|
WHERE p.dog_id = ?
|
||||||
`);
|
`);
|
||||||
dogs.forEach(dog => {
|
dogs.forEach(dog => {
|
||||||
dog.photo_urls = dog.photo_urls ? JSON.parse(dog.photo_urls) : [];
|
dog.photo_urls = safeParsePhotos(dog.photo_urls);
|
||||||
const parents = parentStmt.all(dog.id);
|
const parents = parentStmt.all(dog.id);
|
||||||
dog.sire = parents.find(p => p.parent_type === 'sire') || null;
|
dog.sire = parents.find(p => p.parent_type === 'sire') || null;
|
||||||
dog.dam = parents.find(p => p.parent_type === 'dam') || null;
|
dog.dam = parents.find(p => p.parent_type === 'dam') || null;
|
||||||
@@ -171,7 +197,7 @@ router.get('/:id', (req, res) => {
|
|||||||
|
|
||||||
if (!dog) return res.status(404).json({ error: 'Dog not found' });
|
if (!dog) return res.status(404).json({ error: 'Dog not found' });
|
||||||
|
|
||||||
dog.photo_urls = dog.photo_urls ? JSON.parse(dog.photo_urls) : [];
|
dog.photo_urls = safeParsePhotos(dog.photo_urls);
|
||||||
|
|
||||||
const parents = db.prepare(`
|
const parents = db.prepare(`
|
||||||
SELECT p.parent_type, d.id, d.name, d.is_champion, d.is_external
|
SELECT p.parent_type, d.id, d.name, d.is_champion, d.is_external
|
||||||
@@ -208,32 +234,40 @@ router.post('/', (req, res) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const db = getDatabase();
|
const db = getDatabase();
|
||||||
const result = db.prepare(`
|
|
||||||
INSERT INTO dogs (name, registration_number, breed, sex, birth_date, color,
|
|
||||||
microchip, notes, litter_id, photo_urls, is_champion, is_external)
|
|
||||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
||||||
`).run(
|
|
||||||
name,
|
|
||||||
emptyToNull(registration_number),
|
|
||||||
breed, sex,
|
|
||||||
emptyToNull(birth_date),
|
|
||||||
emptyToNull(color),
|
|
||||||
emptyToNull(microchip),
|
|
||||||
emptyToNull(notes),
|
|
||||||
emptyToNull(litter_id),
|
|
||||||
'[]',
|
|
||||||
is_champion ? 1 : 0,
|
|
||||||
is_external ? 1 : 0
|
|
||||||
);
|
|
||||||
|
|
||||||
const dogId = result.lastInsertRowid;
|
const parentError = validateParents(db, sire_id, dam_id);
|
||||||
|
if (parentError) return res.status(400).json({ error: parentError });
|
||||||
|
|
||||||
if (sire_id && sire_id !== '' && sire_id !== null) {
|
const createDog = db.transaction(() => {
|
||||||
db.prepare('INSERT INTO parents (dog_id, parent_id, parent_type) VALUES (?, ?, ?)').run(dogId, sire_id, 'sire');
|
const result = db.prepare(`
|
||||||
}
|
INSERT INTO dogs (name, registration_number, breed, sex, birth_date, color,
|
||||||
if (dam_id && dam_id !== '' && dam_id !== null) {
|
microchip, notes, litter_id, photo_urls, is_champion, is_external)
|
||||||
db.prepare('INSERT INTO parents (dog_id, parent_id, parent_type) VALUES (?, ?, ?)').run(dogId, dam_id, 'dam');
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
}
|
`).run(
|
||||||
|
name,
|
||||||
|
emptyToNull(registration_number),
|
||||||
|
breed, sex,
|
||||||
|
emptyToNull(birth_date),
|
||||||
|
emptyToNull(color),
|
||||||
|
emptyToNull(microchip),
|
||||||
|
emptyToNull(notes),
|
||||||
|
emptyToNull(litter_id),
|
||||||
|
'[]',
|
||||||
|
is_champion ? 1 : 0,
|
||||||
|
is_external ? 1 : 0
|
||||||
|
);
|
||||||
|
|
||||||
|
const id = result.lastInsertRowid;
|
||||||
|
if (sire_id && sire_id !== '' && sire_id !== null) {
|
||||||
|
db.prepare('INSERT INTO parents (dog_id, parent_id, parent_type) VALUES (?, ?, ?)').run(id, sire_id, 'sire');
|
||||||
|
}
|
||||||
|
if (dam_id && dam_id !== '' && dam_id !== null) {
|
||||||
|
db.prepare('INSERT INTO parents (dog_id, parent_id, parent_type) VALUES (?, ?, ?)').run(id, dam_id, 'dam');
|
||||||
|
}
|
||||||
|
return id;
|
||||||
|
});
|
||||||
|
|
||||||
|
const dogId = createDog();
|
||||||
|
|
||||||
const dog = db.prepare(`SELECT ${DOG_COLS} FROM dogs WHERE id = ?`).get(dogId);
|
const dog = db.prepare(`SELECT ${DOG_COLS} FROM dogs WHERE id = ?`).get(dogId);
|
||||||
dog.photo_urls = [];
|
dog.photo_urls = [];
|
||||||
@@ -253,36 +287,46 @@ router.put('/:id', (req, res) => {
|
|||||||
microchip, notes, sire_id, dam_id, litter_id, is_champion, is_external } = req.body;
|
microchip, notes, sire_id, dam_id, litter_id, is_champion, is_external } = req.body;
|
||||||
|
|
||||||
const db = getDatabase();
|
const db = getDatabase();
|
||||||
db.prepare(`
|
|
||||||
UPDATE dogs
|
|
||||||
SET name = ?, registration_number = ?, breed = ?, sex = ?,
|
|
||||||
birth_date = ?, color = ?, microchip = ?, notes = ?,
|
|
||||||
litter_id = ?, is_champion = ?, is_external = ?, updated_at = datetime('now')
|
|
||||||
WHERE id = ?
|
|
||||||
`).run(
|
|
||||||
name,
|
|
||||||
emptyToNull(registration_number),
|
|
||||||
breed, sex,
|
|
||||||
emptyToNull(birth_date),
|
|
||||||
emptyToNull(color),
|
|
||||||
emptyToNull(microchip),
|
|
||||||
emptyToNull(notes),
|
|
||||||
emptyToNull(litter_id),
|
|
||||||
is_champion ? 1 : 0,
|
|
||||||
is_external ? 1 : 0,
|
|
||||||
req.params.id
|
|
||||||
);
|
|
||||||
|
|
||||||
db.prepare('DELETE FROM parents WHERE dog_id = ?').run(req.params.id);
|
const existing = db.prepare('SELECT id FROM dogs WHERE id = ?').get(req.params.id);
|
||||||
if (sire_id && sire_id !== '' && sire_id !== null) {
|
if (!existing) return res.status(404).json({ error: 'Dog not found' });
|
||||||
db.prepare('INSERT INTO parents (dog_id, parent_id, parent_type) VALUES (?, ?, ?)').run(req.params.id, sire_id, 'sire');
|
|
||||||
}
|
const parentError = validateParents(db, sire_id, dam_id, req.params.id);
|
||||||
if (dam_id && dam_id !== '' && dam_id !== null) {
|
if (parentError) return res.status(400).json({ error: parentError });
|
||||||
db.prepare('INSERT INTO parents (dog_id, parent_id, parent_type) VALUES (?, ?, ?)').run(req.params.id, dam_id, 'dam');
|
|
||||||
}
|
const updateDog = db.transaction(() => {
|
||||||
|
db.prepare(`
|
||||||
|
UPDATE dogs
|
||||||
|
SET name = ?, registration_number = ?, breed = ?, sex = ?,
|
||||||
|
birth_date = ?, color = ?, microchip = ?, notes = ?,
|
||||||
|
litter_id = ?, is_champion = ?, is_external = ?, updated_at = datetime('now')
|
||||||
|
WHERE id = ?
|
||||||
|
`).run(
|
||||||
|
name,
|
||||||
|
emptyToNull(registration_number),
|
||||||
|
breed, sex,
|
||||||
|
emptyToNull(birth_date),
|
||||||
|
emptyToNull(color),
|
||||||
|
emptyToNull(microchip),
|
||||||
|
emptyToNull(notes),
|
||||||
|
emptyToNull(litter_id),
|
||||||
|
is_champion ? 1 : 0,
|
||||||
|
is_external ? 1 : 0,
|
||||||
|
req.params.id
|
||||||
|
);
|
||||||
|
|
||||||
|
db.prepare('DELETE FROM parents WHERE dog_id = ?').run(req.params.id);
|
||||||
|
if (sire_id && sire_id !== '' && sire_id !== null) {
|
||||||
|
db.prepare('INSERT INTO parents (dog_id, parent_id, parent_type) VALUES (?, ?, ?)').run(req.params.id, sire_id, 'sire');
|
||||||
|
}
|
||||||
|
if (dam_id && dam_id !== '' && dam_id !== null) {
|
||||||
|
db.prepare('INSERT INTO parents (dog_id, parent_id, parent_type) VALUES (?, ?, ?)').run(req.params.id, dam_id, 'dam');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
updateDog();
|
||||||
|
|
||||||
const dog = db.prepare(`SELECT ${DOG_COLS} FROM dogs WHERE id = ?`).get(req.params.id);
|
const dog = db.prepare(`SELECT ${DOG_COLS} FROM dogs WHERE id = ?`).get(req.params.id);
|
||||||
dog.photo_urls = dog.photo_urls ? JSON.parse(dog.photo_urls) : [];
|
dog.photo_urls = safeParsePhotos(dog.photo_urls);
|
||||||
|
|
||||||
console.log(`✔ Dog updated: ${dog.name} (ID: ${req.params.id})`);
|
console.log(`✔ Dog updated: ${dog.name} (ID: ${req.params.id})`);
|
||||||
res.json(dog);
|
res.json(dog);
|
||||||
@@ -300,11 +344,13 @@ router.delete('/:id', (req, res) => {
|
|||||||
if (!existing) return res.status(404).json({ error: 'Dog not found' });
|
if (!existing) return res.status(404).json({ error: 'Dog not found' });
|
||||||
|
|
||||||
const id = req.params.id;
|
const id = req.params.id;
|
||||||
db.prepare('DELETE FROM parents WHERE parent_id = ?').run(id);
|
db.transaction(() => {
|
||||||
db.prepare('DELETE FROM parents WHERE dog_id = ?').run(id);
|
db.prepare('DELETE FROM parents WHERE parent_id = ?').run(id);
|
||||||
db.prepare('DELETE FROM health_records WHERE dog_id = ?').run(id);
|
db.prepare('DELETE FROM parents WHERE dog_id = ?').run(id);
|
||||||
db.prepare('DELETE FROM heat_cycles WHERE dog_id = ?').run(id);
|
db.prepare('DELETE FROM health_records WHERE dog_id = ?').run(id);
|
||||||
db.prepare('DELETE FROM dogs WHERE id = ?').run(id);
|
db.prepare('DELETE FROM heat_cycles WHERE dog_id = ?').run(id);
|
||||||
|
db.prepare('DELETE FROM dogs WHERE id = ?').run(id);
|
||||||
|
})();
|
||||||
|
|
||||||
console.log(`✔ Dog #${id} (${existing.name}) permanently deleted`);
|
console.log(`✔ Dog #${id} (${existing.name}) permanently deleted`);
|
||||||
res.json({ success: true, message: `${existing.name} has been deleted` });
|
res.json({ success: true, message: `${existing.name} has been deleted` });
|
||||||
@@ -323,7 +369,7 @@ router.post('/:id/photos', upload.single('photo'), (req, res) => {
|
|||||||
const dog = db.prepare('SELECT photo_urls FROM dogs WHERE id = ?').get(req.params.id);
|
const dog = db.prepare('SELECT photo_urls FROM dogs WHERE id = ?').get(req.params.id);
|
||||||
if (!dog) return res.status(404).json({ error: 'Dog not found' });
|
if (!dog) return res.status(404).json({ error: 'Dog not found' });
|
||||||
|
|
||||||
const photoUrls = dog.photo_urls ? JSON.parse(dog.photo_urls) : [];
|
const photoUrls = safeParsePhotos(dog.photo_urls);
|
||||||
photoUrls.push(`/uploads/${req.file.filename}`);
|
photoUrls.push(`/uploads/${req.file.filename}`);
|
||||||
db.prepare('UPDATE dogs SET photo_urls = ? WHERE id = ?').run(JSON.stringify(photoUrls), req.params.id);
|
db.prepare('UPDATE dogs SET photo_urls = ? WHERE id = ?').run(JSON.stringify(photoUrls), req.params.id);
|
||||||
|
|
||||||
@@ -341,19 +387,21 @@ router.delete('/:id/photos/:photoIndex', (req, res) => {
|
|||||||
const dog = db.prepare('SELECT photo_urls FROM dogs WHERE id = ?').get(req.params.id);
|
const dog = db.prepare('SELECT photo_urls FROM dogs WHERE id = ?').get(req.params.id);
|
||||||
if (!dog) return res.status(404).json({ error: 'Dog not found' });
|
if (!dog) return res.status(404).json({ error: 'Dog not found' });
|
||||||
|
|
||||||
const photoUrls = dog.photo_urls ? JSON.parse(dog.photo_urls) : [];
|
const photoUrls = safeParsePhotos(dog.photo_urls);
|
||||||
const photoIndex = parseInt(req.params.photoIndex);
|
const photoIndex = parseInt(req.params.photoIndex, 10);
|
||||||
|
|
||||||
if (photoIndex >= 0 && photoIndex < photoUrls.length) {
|
if (!Number.isInteger(photoIndex) || photoIndex < 0 || photoIndex >= photoUrls.length) {
|
||||||
const photoPath = path.join(
|
return res.status(400).json({ error: 'Invalid photo index' });
|
||||||
process.env.UPLOAD_PATH || path.join(__dirname, '../../uploads'),
|
|
||||||
path.basename(photoUrls[photoIndex])
|
|
||||||
);
|
|
||||||
if (fs.existsSync(photoPath)) fs.unlinkSync(photoPath);
|
|
||||||
photoUrls.splice(photoIndex, 1);
|
|
||||||
db.prepare('UPDATE dogs SET photo_urls = ? WHERE id = ?').run(JSON.stringify(photoUrls), req.params.id);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const photoPath = path.join(
|
||||||
|
process.env.UPLOAD_PATH || path.join(__dirname, '../../uploads'),
|
||||||
|
path.basename(photoUrls[photoIndex])
|
||||||
|
);
|
||||||
|
if (fs.existsSync(photoPath)) fs.unlinkSync(photoPath);
|
||||||
|
photoUrls.splice(photoIndex, 1);
|
||||||
|
db.prepare('UPDATE dogs SET photo_urls = ? WHERE id = ?').run(JSON.stringify(photoUrls), req.params.id);
|
||||||
|
|
||||||
res.json({ photos: photoUrls });
|
res.json({ photos: photoUrls });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error deleting photo:', error);
|
console.error('Error deleting photo:', error);
|
||||||
|
|||||||
+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 {
|
||||||
|
|||||||
@@ -214,10 +214,11 @@ router.post('/:litterId/puppies/:puppyId/logs', (req, res) => {
|
|||||||
notes: notes || ''
|
notes: notes || ''
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// test_date mirrors record_date: the column is NOT NULL on fresh schemas
|
||||||
const result = db.prepare(`
|
const result = db.prepare(`
|
||||||
INSERT INTO health_records (dog_id, record_type, record_date, description, vet_name)
|
INSERT INTO health_records (dog_id, record_type, record_date, test_date, description, vet_name)
|
||||||
VALUES (?, ?, ?, ?, ?)
|
VALUES (?, ?, ?, ?, ?, ?)
|
||||||
`).run(puppyId, record_type || 'weight_log', record_date, description, null);
|
`).run(puppyId, record_type || 'weight_log', record_date, record_date, description, null);
|
||||||
|
|
||||||
const log = db.prepare('SELECT * FROM health_records WHERE id = ?').get(result.lastInsertRowid);
|
const log = db.prepare('SELECT * FROM health_records WHERE id = ?').get(result.lastInsertRowid);
|
||||||
res.status(201).json(log);
|
res.status(201).json(log);
|
||||||
|
|||||||
@@ -1,26 +0,0 @@
|
|||||||
const app = require('./index');
|
|
||||||
const http = require('http');
|
|
||||||
|
|
||||||
// Start temporary server
|
|
||||||
const server = http.createServer(app);
|
|
||||||
server.listen(3030, async () => {
|
|
||||||
console.log('Server started on 3030');
|
|
||||||
try {
|
|
||||||
const res = await fetch('http://localhost:3030/api/pedigree/relations/1/2');
|
|
||||||
const text = await res.text();
|
|
||||||
console.log('GET /api/pedigree/relations/1/2 RESPONSE:', res.status, text.substring(0, 150));
|
|
||||||
|
|
||||||
const postRes = await fetch('http://localhost:3030/api/pedigree/trial-pairing', {
|
|
||||||
method: 'POST',
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify({ sire_id: 1, dam_id: 2 })
|
|
||||||
});
|
|
||||||
const postText = await postRes.text();
|
|
||||||
console.log('POST /api/pedigree/trial-pairing RESPONSE:', postRes.status, postText.substring(0, 150));
|
|
||||||
} catch (err) {
|
|
||||||
console.error('Fetch error:', err);
|
|
||||||
} finally {
|
|
||||||
server.close();
|
|
||||||
process.exit(0);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
@@ -1,8 +0,0 @@
|
|||||||
const express = require('express');
|
|
||||||
const router = express.Router();
|
|
||||||
|
|
||||||
router.post(['/a', '/b'], (req, res) => {
|
|
||||||
res.send('ok');
|
|
||||||
});
|
|
||||||
|
|
||||||
console.log('Started successfully');
|
|
||||||
Reference in New Issue
Block a user