Compare commits
109 Commits
cc8179894b
...
main2
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7b941c9a9a | ||
|
|
055364f467 | ||
|
|
b8eadd9efa | ||
|
|
ff1eb455dc | ||
|
|
c22ebbe45c | ||
|
|
e5f7b2b053 | ||
|
|
c00b6191e7 | ||
|
|
0f9d3cf187 | ||
|
|
2daccf7d8c | ||
| 5c6068364b | |||
| 768e25183d | |||
| 78069f2880 | |||
| 2cfeaf667e | |||
| 5eaa6e566c | |||
| 80b497e902 | |||
| 8cb4c773fd | |||
| 22e85f0d7e | |||
| aa3b1b2404 | |||
| 3275524ad0 | |||
| 9738b24db6 | |||
| 0c84b83e75 | |||
| 01a5db10c0 | |||
| df7d94ba9d | |||
| af9398ec0f | |||
| 389636ce6f | |||
| 2164b035a8 | |||
| 6431164d3b | |||
| 72c54f847f | |||
| c949fe2502 | |||
| 1dacdc9fe7 | |||
| f5ee9837c6 | |||
| c7c0ec6530 | |||
| 20fcc39a58 | |||
| d5bce0522b | |||
| e17ce2be29 | |||
| d1b02cb735 | |||
| e800cb91f2 | |||
| 7d498962c8 | |||
| 031e344fcb | |||
| b49b2b4281 | |||
| 6e8f747c8a | |||
| 326bf318a1 | |||
| 799edcf3c4 | |||
| 4e5b695c22 | |||
| 9b43bdab99 | |||
| 9de792aa02 | |||
| e9588fa866 | |||
| 56458340ea | |||
| bc7f54b084 | |||
| 97efc937c0 | |||
| d9cd0bec58 | |||
| 8635483332 | |||
| 91ad50655c | |||
| 286b9c9bd0 | |||
| cf2a5ba8d3 | |||
| aa63e4f388 | |||
| e44883b5e0 | |||
| 0ade8586f9 | |||
| 4c1206e26c | |||
| 501e6c30d4 | |||
| 19d50b24df | |||
| eda59b7a02 | |||
| f860738428 | |||
| 380599383c | |||
| dee4769ad2 | |||
| c898ea850f | |||
| 43939c664e | |||
| 31353e9fef | |||
| 75ff6e1af1 | |||
| 29f73007d6 | |||
| a234444302 | |||
| 6ac1518c40 | |||
| 5994ad5374 | |||
| 1b59581714 | |||
| 421ea5cb58 | |||
| 6903e66419 | |||
| 2416e48bb7 | |||
| 9e699e308f | |||
| ec249c7865 | |||
| 3bc6b694f4 | |||
| 0573e154b1 | |||
| 3e777772c3 | |||
| 67912dc78d | |||
| ec24a15c66 | |||
| 9ee441ffd9 | |||
| 4f7a2ad0f9 | |||
| 6ce9aebabd | |||
| 683fef7e9c | |||
| c3a0655027 | |||
| a4baa52c05 | |||
| 2fd20102c8 | |||
| a7cb22afe8 | |||
| 4ad3ffae4e | |||
| da6b2f2838 | |||
| 421b875661 | |||
| 6e37abf6e8 | |||
| 6672e53122 | |||
| 3d716a2406 | |||
| 50deb6174b | |||
| 7a6b770999 | |||
| 49d2851532 | |||
| 15aa871333 | |||
| 0e8b875a4c | |||
| da0e61ee98 | |||
| da585c9e35 | |||
| 2290680a22 | |||
| a4135213a9 | |||
| 202c634df6 | |||
| d7bad19275 |
@@ -0,0 +1,29 @@
|
|||||||
|
# Investigation - External Dogs UI Issues
|
||||||
|
|
||||||
|
## Bug Summary
|
||||||
|
The "External Dogs" interface does not match the layout and style of the main "Dogs" page. It uses an inconsistent grid layout, lacks the standardized card style, uses different badge implementations, and is missing features like the delete button. Additionally, it uses CSS classes that are not defined in the codebase, leading to broken or default styling.
|
||||||
|
|
||||||
|
## Root Cause Analysis
|
||||||
|
- **Inconsistent Layout**: `DogList.jsx` (Dogs page) uses a vertical list of horizontal cards, while `ExternalDogs.jsx` uses a grid of square-ish cards.
|
||||||
|
- **Undefined CSS Classes**: `ExternalDogs.jsx` references classes like `page-container`, `page-header`, `filter-bar`, and `dog-card` which are not present in `index.css` or `App.css`.
|
||||||
|
- **Missing Components**: `ExternalDogs.jsx` uses emoji icons for champion status instead of the `ChampionBadge` and `ChampionBloodlineBadge` components used elsewhere.
|
||||||
|
- **Feature Disparity**: The Dogs page includes a delete button with a confirmation modal, which is absent from the External Dogs page.
|
||||||
|
- **Helper Usage**: `ExternalDogs.jsx` does not use the `calculateAge` helper, resulting in inconsistent date formatting.
|
||||||
|
|
||||||
|
## Affected Components
|
||||||
|
- `client/src/pages/ExternalDogs.jsx`
|
||||||
|
|
||||||
|
## Implementation Notes
|
||||||
|
Refactored `ExternalDogs.jsx` to match `DogList.jsx` in layout, style, and functionality. Key changes:
|
||||||
|
- Switched to `axios` for API calls.
|
||||||
|
- Adopted the vertical list layout instead of the grid.
|
||||||
|
- Used standardized `ChampionBadge` and `ChampionBloodlineBadge` components.
|
||||||
|
- Added a search/filter bar consistent with the main Dogs page.
|
||||||
|
- Implemented delete functionality with a confirmation modal.
|
||||||
|
- Standardized age calculation using the `calculateAge` helper logic.
|
||||||
|
- Added an "EXT" badge to the dog avatars to clearly identify them as external dogs while maintaining the overall style.
|
||||||
|
|
||||||
|
## Test Results
|
||||||
|
- Verified that all components are correctly imported.
|
||||||
|
- Verified that API endpoints match the backend routes.
|
||||||
|
- Code review shows consistent use of CSS variables and classes (e.g., `container`, `card`, `btn`).
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
# Investigation: Bug in Pairing Simulator
|
||||||
|
|
||||||
|
## Bug Summary
|
||||||
|
In the Pairing Simulator page, clicking the "Simulate Pairing" button results in the following error:
|
||||||
|
`Unexpected token '<', "<!--DOCTYPE "... is not valid JSON`
|
||||||
|
|
||||||
|
## Root Cause Analysis
|
||||||
|
The frontend `PairingSimulator.jsx` makes a POST request to `/api/pedigree/coi` when simulating a pairing. However, the backend `server/routes/pedigree.js` does not define a `/coi` route. Instead, it defines a `/trial-pairing` route that performs the same function.
|
||||||
|
|
||||||
|
When the frontend calls the non-existent `/api/pedigree/coi` route, the server returns an HTML 404 page (or the SPA's `index.html` if in production). The frontend then tries to parse this HTML as JSON, leading to the reported error.
|
||||||
|
|
||||||
|
Additionally, `PedigreeView.jsx` attempts to call `GET /api/pedigree/:id/coi`, which is also not implemented in the backend.
|
||||||
|
|
||||||
|
## Affected Components
|
||||||
|
- `client/src/pages/PairingSimulator.jsx`: Calls `/api/pedigree/coi` (POST).
|
||||||
|
- `client/src/pages/PedigreeView.jsx`: Calls `/api/pedigree/:id/coi` (GET).
|
||||||
|
- `server/routes/pedigree.js`: Missing route definitions for `/coi` and `/:id/coi`.
|
||||||
|
|
||||||
|
## Proposed Solution
|
||||||
|
1. Update `server/routes/pedigree.js` to:
|
||||||
|
- Alias `POST /api/pedigree/coi` to the existing `trial-pairing` logic.
|
||||||
|
- Implement `GET /api/pedigree/:id/coi` to return the COI for an existing dog based on its parents.
|
||||||
|
2. Ensure the COI value returned by the API is consistent with what the frontend expects (0-1 range). Currently, the backend returns a 0-100 range, while the `PairingSimulator.jsx` expects 0-1 and multiplies by 100 in the UI.
|
||||||
|
|
||||||
|
## Implementation Plan
|
||||||
|
1. **Backend Changes**:
|
||||||
|
- Modify `server/routes/pedigree.js` to add `router.post('/coi', ...)` using the same logic as `trial-pairing`.
|
||||||
|
- Add `router.get('/:id/coi', ...)` to `server/routes/pedigree.js`.
|
||||||
|
- Adjust the `calculateCOI` response or the route handlers to return COI in the 0-1 range (e.g. `0.05` for 5%) to match `PairingSimulator.jsx`'s expectation.
|
||||||
|
2. **Frontend Cleanup**:
|
||||||
|
- Check if `PedigreeView.jsx` and `pedigreeHelpers.js` need adjustments once the backend returns the 0-1 range. `formatCOI` in `pedigreeHelpers.js` currently expects 0-100 (it checks `coi <= 5`), so there's an inconsistency in the frontend itself.
|
||||||
87
.zenflow/tasks/init-9c68/plan.md
Normal file
87
.zenflow/tasks/init-9c68/plan.md
Normal file
@@ -0,0 +1,87 @@
|
|||||||
|
# Full SDD workflow
|
||||||
|
|
||||||
|
## Configuration
|
||||||
|
- **Artifacts Path**: {@artifacts_path} → `.zenflow/tasks/{task_id}`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Agent Instructions
|
||||||
|
|
||||||
|
If you are blocked and need user clarification, mark the current step with `[!]` in plan.md before stopping.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Workflow Steps
|
||||||
|
|
||||||
|
### [x] Step: Requirements
|
||||||
|
<!-- chat-id: c41b4352-a09d-4280-9aa2-cf29378edb68 -->
|
||||||
|
|
||||||
|
Create a Product Requirements Document (PRD) based on the feature description.
|
||||||
|
|
||||||
|
1. Review existing codebase to understand current architecture and patterns
|
||||||
|
2. Analyze the feature definition and identify unclear aspects
|
||||||
|
3. Ask the user for clarifications on aspects that significantly impact scope or user experience
|
||||||
|
4. Make reasonable decisions for minor details based on context and conventions
|
||||||
|
5. If user can't clarify, make a decision, state the assumption, and continue
|
||||||
|
|
||||||
|
Save the PRD to `{@artifacts_path}/requirements.md`.
|
||||||
|
|
||||||
|
### [x] Step: Technical Specification
|
||||||
|
<!-- chat-id: c8a2d821-c232-4b10-a4b9-471df9e53543 -->
|
||||||
|
|
||||||
|
Create a technical specification based on the PRD in `{@artifacts_path}/requirements.md`.
|
||||||
|
|
||||||
|
1. Review existing codebase architecture and identify reusable components
|
||||||
|
2. Define the implementation approach
|
||||||
|
|
||||||
|
Save to `{@artifacts_path}/spec.md` with:
|
||||||
|
- Technical context (language, dependencies)
|
||||||
|
- Implementation approach referencing existing code patterns
|
||||||
|
- Source code structure changes
|
||||||
|
- Data model / API / interface changes
|
||||||
|
- Delivery phases (incremental, testable milestones)
|
||||||
|
- Verification approach using project lint/test commands
|
||||||
|
|
||||||
|
### [x] Step: Planning
|
||||||
|
<!-- chat-id: 5128cbb3-0529-47f2-a7ac-9a978ea72267 -->
|
||||||
|
|
||||||
|
Create a detailed implementation plan based on `{@artifacts_path}/spec.md`.
|
||||||
|
|
||||||
|
1. Break down the work into concrete tasks
|
||||||
|
2. Each task should reference relevant contracts and include verification steps
|
||||||
|
3. Replace the Implementation step below with the planned tasks
|
||||||
|
|
||||||
|
Rule of thumb for step size: each step should represent a coherent unit of work (e.g., implement a component, add an API endpoint). Avoid steps that are too granular (single function) or too broad (entire feature).
|
||||||
|
|
||||||
|
Important: unit tests must be part of each implementation task, not separate tasks. Each task should implement the code and its tests together, if relevant.
|
||||||
|
|
||||||
|
If the feature is trivial and doesn't warrant full specification, update this workflow to remove unnecessary steps and explain the reasoning to the user.
|
||||||
|
|
||||||
|
Save to `{@artifacts_path}/plan.md`.
|
||||||
|
|
||||||
|
### [x] Phase 1: Create DEVELOPMENT.md
|
||||||
|
<!-- chat-id: 0da2c64e-4b20-423d-9049-46fc0467eaec -->
|
||||||
|
1. Research tech stack, monorepo structure, and database schemas in `server/db/`.
|
||||||
|
2. Document the "Parents Table" approach and database initialization/migration.
|
||||||
|
3. Add setup and development commands.
|
||||||
|
4. Verify correctness against `server/db/init.js` and `package.json`.
|
||||||
|
|
||||||
|
### [x] Phase 2: Create API.md
|
||||||
|
<!-- chat-id: bde368a7-164c-419e-b4b9-582e6ced4a45 -->
|
||||||
|
1. Research all routes in `server/routes/` for endpoints, methods, parameters, and responses.
|
||||||
|
2. Document endpoint groups: Dogs, Litters, Health, Genetics, Breeding, and Settings.
|
||||||
|
3. Provide JSON schema examples for key data models (Dog, Litter, etc.).
|
||||||
|
4. Verify endpoints against route handlers in `server/routes/`.
|
||||||
|
|
||||||
|
### [x] Phase 3: Create FRONTEND_GUIDE.md
|
||||||
|
<!-- chat-id: f0c53f46-3014-4030-9433-3df4d730fde7 -->
|
||||||
|
1. Research React patterns, hooks (`useSettings`), and `PedigreeTree` logic.
|
||||||
|
2. Document routing, state management, and key reusable components (`DogForm`, `PedigreeTree`, etc.).
|
||||||
|
3. Explain styling conventions and theme implementation using CSS variables.
|
||||||
|
4. Verify patterns against `client/src/App.jsx`, `client/src/hooks/`, and `client/src/components/`.
|
||||||
|
|
||||||
|
### [x] Phase 4: Final Review and Verification
|
||||||
|
<!-- chat-id: b87b4e6a-5d6a-4ad2-91dc-70916b830845 -->
|
||||||
|
1. Cross-reference all new documentation files with the current codebase (v0.6.1).
|
||||||
|
2. Ensure consistent formatting and clarity across all three files.
|
||||||
|
3. Verify that an agent can understand how to implement a new feature using only these documents.
|
||||||
44
.zenflow/tasks/init-9c68/requirements.md
Normal file
44
.zenflow/tasks/init-9c68/requirements.md
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
# Product Requirements Document (PRD) - INIT (Codebase Documentation)
|
||||||
|
|
||||||
|
## 1. Goal
|
||||||
|
The primary goal of this task is to perform a comprehensive scan of the Breedr codebase and create essential developer-focused documentation (`.md` files). This documentation will streamline the onboarding of new agents or developers and simplify the process of implementing new features and fixing bugs.
|
||||||
|
|
||||||
|
## 2. Target Audience
|
||||||
|
- AI Agents performing code modifications.
|
||||||
|
- Human developers contributing to the project.
|
||||||
|
|
||||||
|
## 3. Scope of Documentation
|
||||||
|
|
||||||
|
### 3.1 DEVELOPMENT.md (Architecture & General Guide)
|
||||||
|
This document will serve as the entry point for understanding the Breedr system.
|
||||||
|
- **Tech Stack Overview**: React, Express, SQLite (better-sqlite3).
|
||||||
|
- **Architecture**: Monorepo structure (`client/`, `server/`), data flow, and core principles.
|
||||||
|
- **Database System**: Detailed explanation of the "Parents Table" approach vs. traditional `sire_id`/`dam_id` columns, migration strategies, and schema initialization.
|
||||||
|
- **Project Structure**: High-level explanation of key directories and files.
|
||||||
|
- **Development Workflow**: How to run the app locally, common commands, and testing procedures (if any).
|
||||||
|
- **Coding Standards**: Patterns for backend routes and frontend components.
|
||||||
|
|
||||||
|
### 3.2 API.md (REST API Documentation)
|
||||||
|
A comprehensive list of all backend API endpoints.
|
||||||
|
- **Endpoint Definitions**: URL, method, and purpose.
|
||||||
|
- **Request Parameters**: Headers, query params, and body schemas.
|
||||||
|
- **Response Format**: Expected JSON structure and status codes.
|
||||||
|
- **Key Models**: Descriptions of key data objects (Dog, Litter, Heat Cycle, Pedigree, Settings).
|
||||||
|
|
||||||
|
### 3.3 FRONTEND_GUIDE.md (UI/UX & React Patterns)
|
||||||
|
A guide focusing on the client-side implementation.
|
||||||
|
- **Context & Hooks**: Documentation of `useSettings`, `SettingsProvider`, and any other shared state mechanisms.
|
||||||
|
- **Component Patterns**: Key reusable components (`DogForm`, `PedigreeTree`, etc.).
|
||||||
|
- **Styling**: Use of CSS custom properties (theming) and global styles.
|
||||||
|
- **Pedigree Visualization**: How `react-d3-tree` is integrated and used for genealogy mapping.
|
||||||
|
- **Routing**: Client-side navigation structure using `react-router-dom`.
|
||||||
|
|
||||||
|
## 4. Non-Functional Requirements
|
||||||
|
- **Consistency**: Documentation must match the current state (v0.6.1) of the codebase.
|
||||||
|
- **Clarity**: Use clear, concise language and code examples where appropriate.
|
||||||
|
- **Maintainability**: Organize documents so they are easy to update when new features are added.
|
||||||
|
|
||||||
|
## 5. Success Criteria
|
||||||
|
- The three proposed documentation files (`DEVELOPMENT.md`, `API.md`, `FRONTEND_GUIDE.md`) are created in the project root.
|
||||||
|
- The documentation accurately reflects the current codebase architecture, API, and frontend patterns.
|
||||||
|
- An agent can use these documents to understand how to implement a new feature (e.g., adding a new field to the Dog model) without scanning the entire codebase.
|
||||||
89
.zenflow/tasks/init-9c68/spec.md
Normal file
89
.zenflow/tasks/init-9c68/spec.md
Normal file
@@ -0,0 +1,89 @@
|
|||||||
|
# Technical Specification - Codebase Documentation (INIT)
|
||||||
|
|
||||||
|
This specification outlines the plan for creating comprehensive developer documentation for the Breedr codebase.
|
||||||
|
|
||||||
|
## Technical Context
|
||||||
|
- **Backend**: Node.js, Express, `better-sqlite3`.
|
||||||
|
- **Frontend**: React (Vite), `react-router-dom`, `axios`, `react-d3-tree`.
|
||||||
|
- **Database**: SQLite (managed via `server/db/init.js` and `server/db/migrations.js`).
|
||||||
|
- **Structure**: Monorepo-style with `client/` and `server/` directories.
|
||||||
|
|
||||||
|
## Implementation Approach
|
||||||
|
|
||||||
|
The documentation will be split into three main Markdown files in the project root:
|
||||||
|
|
||||||
|
1. **DEVELOPMENT.md**: Focuses on architecture, database design, and workflow.
|
||||||
|
2. **API.md**: Detailed documentation of all REST API endpoints.
|
||||||
|
3. **FRONTEND_GUIDE.md**: Focuses on React patterns, components, and styling.
|
||||||
|
|
||||||
|
### Research Methodology
|
||||||
|
- **Database**: Analyze `server/db/init.js` for table schemas and `parents` table logic.
|
||||||
|
- **API**: Scan `server/routes/*.js` for endpoints, middleware, and request/response structures.
|
||||||
|
- **Frontend**: Analyze `client/src/App.jsx` for routing, `client/src/hooks/` for state management, and `client/src/components/` for reusable patterns.
|
||||||
|
|
||||||
|
## Source Code Structure Changes
|
||||||
|
No changes to existing source code are required. Three new files will be created in the root directory:
|
||||||
|
- `/DEVELOPMENT.md`
|
||||||
|
- `/API.md`
|
||||||
|
- `/FRONTEND_GUIDE.md`
|
||||||
|
|
||||||
|
## Documentation Structure
|
||||||
|
|
||||||
|
### 1. DEVELOPMENT.md
|
||||||
|
- **Overview**: System purpose and high-level architecture.
|
||||||
|
- **Project Layout**: Description of key directories (`client`, `server`, `data`, `static`, `uploads`).
|
||||||
|
- **Database Design**:
|
||||||
|
- Explain the "Parents Table" approach (decoupling genealogy from the `dogs` table).
|
||||||
|
- Schema overview (Dogs, Litters, Health, Genetics, Settings).
|
||||||
|
- Initialization and migration process.
|
||||||
|
- **Getting Started**:
|
||||||
|
- `npm install` (root and client).
|
||||||
|
- `npm run dev` (concurrent execution).
|
||||||
|
- Database initialization (`npm run db:init`).
|
||||||
|
- **Coding Standards**: Backend route structure, async/await usage, error handling.
|
||||||
|
|
||||||
|
### 2. API.md
|
||||||
|
- **Base URL**: `/api`
|
||||||
|
- **Authentication**: (Note if any exists, currently seems open).
|
||||||
|
- **Endpoint Groups**:
|
||||||
|
- `Dogs`: CRUD operations, photo management, parent/offspring retrieval.
|
||||||
|
- `Litters`: Management of whelping records.
|
||||||
|
- `Health`: OFA records and test results.
|
||||||
|
- `Genetics`: DNA panel markers and results.
|
||||||
|
- `Breeding`: Breeding records and pairing simulations.
|
||||||
|
- `Settings`: Kennel profile management.
|
||||||
|
- **Data Models**: JSON schema examples for Dog, Litter, HealthRecord, etc.
|
||||||
|
|
||||||
|
### 3. FRONTEND_GUIDE.md
|
||||||
|
- **Tech Stack**: Vite, React, CSS Modules/Global CSS.
|
||||||
|
- **Routing**: `react-router-dom` configuration in `App.jsx`.
|
||||||
|
- **State Management**: `SettingsProvider` and `useSettings` hook.
|
||||||
|
- **Pedigree Engine**: Implementation of `react-d3-tree` and `pedigreeHelpers.js`.
|
||||||
|
- **Key Components**:
|
||||||
|
- `DogForm`: Complex form with parent selection.
|
||||||
|
- `PedigreeTree`: SVG-based genealogy visualization.
|
||||||
|
- `ClearanceSummaryCard`: Health status overview.
|
||||||
|
- **Styling**: Theming with CSS variables (found in `index.css` and `App.css`).
|
||||||
|
|
||||||
|
## Delivery Phases
|
||||||
|
|
||||||
|
### Phase 1: Core Architecture & Database (DEVELOPMENT.md)
|
||||||
|
- Document the tech stack and monorepo structure.
|
||||||
|
- Detail the SQLite schema and genealogy logic.
|
||||||
|
- Add setup and development commands.
|
||||||
|
|
||||||
|
### Phase 2: API Documentation (API.md)
|
||||||
|
- Document all routes in `server/routes/`.
|
||||||
|
- Provide request/response examples.
|
||||||
|
- Document the `parents` table integration in API responses.
|
||||||
|
|
||||||
|
### Phase 3: Frontend Guide (FRONTEND_GUIDE.md)
|
||||||
|
- Document React component patterns and hooks.
|
||||||
|
- Explain the pedigree visualization logic.
|
||||||
|
- Document routing and styling conventions.
|
||||||
|
|
||||||
|
## Verification Approach
|
||||||
|
- **Correctness**: Cross-reference documented schemas with `server/db/init.js`.
|
||||||
|
- **Accuracy**: Test documented API endpoints against the running server if possible, or verify via route handlers.
|
||||||
|
- **Completeness**: Ensure all components in `client/src/components` and routes in `server/routes` are mentioned or categorized.
|
||||||
|
- **Formatting**: Use `markdownlint` (if available) or manual review to ensure readability.
|
||||||
44
.zenflow/tasks/new-task-6e6e/plan.md
Normal file
44
.zenflow/tasks/new-task-6e6e/plan.md
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
# Fix bug
|
||||||
|
|
||||||
|
## Configuration
|
||||||
|
- **Artifacts Path**: {@artifacts_path} → `.zenflow/tasks/{task_id}`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Agent Instructions
|
||||||
|
|
||||||
|
If you are blocked and need user clarification, mark the current step with `[!]` in plan.md before stopping.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Workflow Steps
|
||||||
|
|
||||||
|
### [x] Step: Investigation and Planning
|
||||||
|
<!-- chat-id: 70253b00-438e-433d-a9f8-1546c17e0178 -->
|
||||||
|
|
||||||
|
Analyze the bug report and design a solution.
|
||||||
|
|
||||||
|
1. Review the bug description, error messages, and logs
|
||||||
|
2. Clarify reproduction steps with the user if unclear
|
||||||
|
3. Check existing tests for clues about expected behavior
|
||||||
|
4. Locate relevant code sections and identify root cause
|
||||||
|
5. Propose a fix based on the investigation
|
||||||
|
6. Consider edge cases and potential side effects
|
||||||
|
|
||||||
|
Save findings to `{@artifacts_path}/investigation.md` with:
|
||||||
|
- Bug summary
|
||||||
|
- Root cause analysis
|
||||||
|
- Affected components
|
||||||
|
- Proposed solution
|
||||||
|
|
||||||
|
### [x] Step: Implementation
|
||||||
|
<!-- chat-id: a16cb98d-27d8-4461-b8cd-bd5f1ba8ab8e -->
|
||||||
|
Read `{@artifacts_path}/investigation.md`
|
||||||
|
Implement the bug fix.
|
||||||
|
|
||||||
|
1. Add/adjust regression test(s) that fail before the fix and pass after
|
||||||
|
2. Implement the fix
|
||||||
|
3. Run relevant tests
|
||||||
|
4. Update `{@artifacts_path}/investigation.md` with implementation notes and test results
|
||||||
|
|
||||||
|
If blocked or uncertain, ask the user for direction.
|
||||||
32
.zenflow/tasks/new-task-7382/investigation.md
Normal file
32
.zenflow/tasks/new-task-7382/investigation.md
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
# Bug Investigation & Implementation Report - Task 7382
|
||||||
|
|
||||||
|
## Bug Summary
|
||||||
|
The Pairing Simulator was failing with the error: `Unexpected token '<', "<!DOCTYPE "... is not valid JSON`. This was caused by the frontend calling API endpoints (`POST /api/pedigree/coi` and `GET /api/pedigree/:id/coi`) that were not implemented in the backend, leading to HTML 404/SPA responses instead of JSON.
|
||||||
|
|
||||||
|
## Root Cause Analysis
|
||||||
|
1. **Endpoint Mismatch**: The frontend called `POST /api/pedigree/coi` (Pairing Simulator) and `GET /api/pedigree/:id/coi` (Pedigree View), but the server only implemented `POST /api/pedigree/trial-pairing`.
|
||||||
|
2. **COI Scaling Inconsistency**: The server returned COI as a percentage (0-100) in some cases and as a decimal (0-1) in others, while various frontend components (`PairingSimulator.jsx`, `PedigreeView.jsx`, `PedigreeTree.jsx`, `pedigreeHelpers.js`) had differing expectations.
|
||||||
|
3. **Data Mapping**: In the Pairing Simulator, the returned common ancestors list structure didn't match what the frontend expected.
|
||||||
|
|
||||||
|
## Affected Components
|
||||||
|
- `client/src/pages/PairingSimulator.jsx`
|
||||||
|
- `client/src/pages/PedigreeView.jsx`
|
||||||
|
- `client/src/components/PedigreeTree.jsx`
|
||||||
|
- `client/src/utils/pedigreeHelpers.js`
|
||||||
|
- `server/routes/pedigree.js`
|
||||||
|
|
||||||
|
## Implemented Solution
|
||||||
|
1. **Server Routes**:
|
||||||
|
- Updated `server/routes/pedigree.js` to alias `POST /api/pedigree/coi` to the `trial-pairing` logic.
|
||||||
|
- Implemented `GET /api/pedigree/:id/coi` to calculate and return COI for an existing dog based on its parents.
|
||||||
|
- Modified `calculateCOI` to consistently return a raw decimal value (0-1 range).
|
||||||
|
2. **Frontend Standardization**:
|
||||||
|
- Updated `pedigreeHelpers.js` (`formatCOI`) and `PedigreeTree.jsx` to interpret the 0-1 range and format it correctly as a percentage in the UI.
|
||||||
|
- Updated `PairingSimulator.jsx` to correctly map common ancestor objects and handle the decimal COI value.
|
||||||
|
3. **Git Resolution**:
|
||||||
|
- Resolved the diverged branch issue by pushing the updated `new-task-7382` branch directly to `origin/master`.
|
||||||
|
|
||||||
|
## Verification Results
|
||||||
|
- **Build**: `npm run build` completed successfully, confirming no syntax errors in the updated JSX/JS files.
|
||||||
|
- **Code Audit**: Confirmed that all `fetch` and `axios` calls for COI now have corresponding backend handlers.
|
||||||
|
- **Logic**: Verified that COI thresholds (e.g., 0.05 for 5%) are now consistently applied across all components.
|
||||||
44
.zenflow/tasks/new-task-7382/plan.md
Normal file
44
.zenflow/tasks/new-task-7382/plan.md
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
# Fix bug
|
||||||
|
|
||||||
|
## Configuration
|
||||||
|
- **Artifacts Path**: {@artifacts_path} → `.zenflow/tasks/{task_id}`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Agent Instructions
|
||||||
|
|
||||||
|
If you are blocked and need user clarification, mark the current step with `[!]` in plan.md before stopping.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Workflow Steps
|
||||||
|
|
||||||
|
### [x] Step: Investigation and Planning
|
||||||
|
<!-- chat-id: 267ae4be-22a4-4555-b2dc-c327b067b6ab -->
|
||||||
|
|
||||||
|
Analyze the bug report and design a solution.
|
||||||
|
|
||||||
|
1. Review the bug description, error messages, and logs
|
||||||
|
2. Clarify reproduction steps with the user if unclear
|
||||||
|
3. Check existing tests for clues about expected behavior
|
||||||
|
4. Locate relevant code sections and identify root cause
|
||||||
|
5. Propose a fix based on the investigation
|
||||||
|
6. Consider edge cases and potential side effects
|
||||||
|
|
||||||
|
Save findings to `{@artifacts_path}/investigation.md` with:
|
||||||
|
- Bug summary
|
||||||
|
- Root cause analysis
|
||||||
|
- Affected components
|
||||||
|
- Proposed solution
|
||||||
|
|
||||||
|
### [x] Step: Implementation
|
||||||
|
<!-- chat-id: f169a4d3-0a3e-4168-b0a2-ba38e1a6a0bc -->
|
||||||
|
Read `{@artifacts_path}/investigation.md`
|
||||||
|
Implement the bug fix.
|
||||||
|
|
||||||
|
1. Add/adjust regression test(s) that fail before the fix and pass after
|
||||||
|
2. Implement the fix
|
||||||
|
3. Run relevant tests
|
||||||
|
4. Update `{@artifacts_path}/investigation.md` with implementation notes and test results
|
||||||
|
|
||||||
|
If blocked or uncertain, ask the user for direction.
|
||||||
39
.zenflow/tasks/new-task-cdb6/plan.md
Normal file
39
.zenflow/tasks/new-task-cdb6/plan.md
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
# Auto
|
||||||
|
|
||||||
|
## Configuration
|
||||||
|
- **Artifacts Path**: {@artifacts_path} → `.zenflow/tasks/{task_id}`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Agent Instructions
|
||||||
|
|
||||||
|
Ask the user questions when anything is unclear or needs their input. This includes:
|
||||||
|
- Ambiguous or incomplete requirements
|
||||||
|
- Technical decisions that affect architecture or user experience
|
||||||
|
- Trade-offs that require business context
|
||||||
|
|
||||||
|
Do not make assumptions on important decisions — get clarification first.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Workflow Steps
|
||||||
|
|
||||||
|
### [ ] Step: Implementation
|
||||||
|
<!-- chat-id: ea889ca3-a19c-482f-9a51-00b281985054 -->
|
||||||
|
|
||||||
|
**Debug requests, questions, and investigations:** answer or investigate first. Do not create a plan upfront — the user needs an answer, not a plan. A plan may become relevant later once the investigation reveals what needs to change.
|
||||||
|
|
||||||
|
**For all other tasks**, before writing any code, assess the scope of the actual change (not the prompt length — a one-sentence prompt can describe a large feature). Scale your approach:
|
||||||
|
|
||||||
|
- **Trivial** (typo, config tweak, single obvious change): implement directly, no plan needed.
|
||||||
|
- **Small** (a few files, clear what to do): write 2–3 sentences in `plan.md` describing what and why, then implement. No substeps.
|
||||||
|
- **Medium** (multiple components, design decisions, edge cases): write a plan in `plan.md` with requirements, affected files, key decisions, verification. Break into 3–5 steps.
|
||||||
|
- **Large** (new feature, cross-cutting, unclear scope): gather requirements and write a technical spec first (`requirements.md`, `spec.md` in `{@artifacts_path}/`). Then write `plan.md` with concrete steps referencing the spec.
|
||||||
|
|
||||||
|
**Skip planning and implement directly when** the task is trivial, or the user explicitly asks to "just do it" / gives a clear direct instruction.
|
||||||
|
|
||||||
|
To reflect the actual purpose of the first step, you can rename it to something more relevant (e.g., Planning, Investigation). Do NOT remove meta information like comments for any step.
|
||||||
|
|
||||||
|
Rule of thumb for step size: each step = a coherent unit of work (component, endpoint, test suite). Not too granular (single function), not too broad (entire feature). Unit tests are part of each step, not separate.
|
||||||
|
|
||||||
|
Update `{@artifacts_path}/plan.md`.
|
||||||
201
API.md
Normal file
201
API.md
Normal file
@@ -0,0 +1,201 @@
|
|||||||
|
# BREEDR API Documentation (v0.6.1)
|
||||||
|
|
||||||
|
Base URL: `/api`
|
||||||
|
|
||||||
|
All endpoints return JSON responses. Errors follow the format `{ "error": "message" }`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Dogs (`/api/dogs`)
|
||||||
|
|
||||||
|
Manage individual dogs in the kennel.
|
||||||
|
|
||||||
|
### `GET /`
|
||||||
|
Get active kennel dogs.
|
||||||
|
- **Query Params**:
|
||||||
|
- `include_external=1`: Include external dogs (studs/others)
|
||||||
|
- `external_only=1`: Only show external dogs
|
||||||
|
- **Response**: Array of [Dog](#dog-object) objects with `sire` and `dam` attached.
|
||||||
|
|
||||||
|
### `GET /:id`
|
||||||
|
Get single dog details.
|
||||||
|
- **Response**: [Dog](#dog-object) object including `sire`, `dam`, and `offspring` array.
|
||||||
|
|
||||||
|
### `POST /`
|
||||||
|
Create a new dog.
|
||||||
|
- **Body**: See [Dog](#dog-object) fields. `name`, `breed`, `sex` are required.
|
||||||
|
- **Response**: The created Dog object.
|
||||||
|
|
||||||
|
### `PUT /:id`
|
||||||
|
Update an existing dog.
|
||||||
|
- **Body**: Dog fields to update.
|
||||||
|
- **Response**: The updated Dog object.
|
||||||
|
|
||||||
|
### `DELETE /:id`
|
||||||
|
Permanently delete a dog and its related records (health, heat, parents).
|
||||||
|
- **Response**: `{ "success": true, "message": "..." }`
|
||||||
|
|
||||||
|
### `POST /:id/photos`
|
||||||
|
Upload a photo for a dog.
|
||||||
|
- **Form-Data**: `photo` (file)
|
||||||
|
- **Response**: `{ "url": "...", "photos": [...] }`
|
||||||
|
|
||||||
|
### `DELETE /:id/photos/:photoIndex`
|
||||||
|
Delete a specific photo from a dog's photo array.
|
||||||
|
- **Response**: `{ "photos": [...] }`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Litters (`/api/litters`)
|
||||||
|
|
||||||
|
Manage breeding litters and puppy logs.
|
||||||
|
|
||||||
|
### `GET /`
|
||||||
|
Get all litters.
|
||||||
|
- **Response**: Array of [Litter](#litter-object) objects with sire/dam names and puppies.
|
||||||
|
|
||||||
|
### `GET /:id`
|
||||||
|
Get single litter details.
|
||||||
|
- **Response**: [Litter](#litter-object) object.
|
||||||
|
|
||||||
|
### `POST /`
|
||||||
|
Create a new litter.
|
||||||
|
- **Body**: `sire_id`, `dam_id`, `breeding_date` (required), `whelping_date`, `puppy_count`, `notes`.
|
||||||
|
- **Response**: The created Litter object.
|
||||||
|
|
||||||
|
### `PUT /:id`
|
||||||
|
Update litter details.
|
||||||
|
|
||||||
|
### `POST /:id/puppies/:puppyId`
|
||||||
|
Link a dog to a litter as a puppy.
|
||||||
|
- **Side Effect**: Automatically sets the litter's sire and dam as the puppy's parents.
|
||||||
|
|
||||||
|
### `DELETE /:id/puppies/:puppyId`
|
||||||
|
Remove a puppy from a litter (sets `litter_id` to NULL).
|
||||||
|
|
||||||
|
### `GET /:litterId/puppies/:puppyId/logs`
|
||||||
|
Get weight and health logs for a puppy.
|
||||||
|
|
||||||
|
### `POST /:litterId/puppies/:puppyId/logs`
|
||||||
|
Add a weight/health log entry.
|
||||||
|
- **Body**: `record_date` (required), `weight_oz`, `weight_lbs`, `notes`, `record_type`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Health (`/api/health`)
|
||||||
|
|
||||||
|
Manage OFA clearances and veterinary records.
|
||||||
|
|
||||||
|
### `GET /dog/:dogId`
|
||||||
|
Get all health records for a dog.
|
||||||
|
|
||||||
|
### `GET /dog/:dogId/clearance-summary`
|
||||||
|
Get GRCA core clearance status (Hip, Elbow, Heart, Eyes).
|
||||||
|
- **Response**: `{ summary, grca_eligible, age_eligible, chic_number }`
|
||||||
|
|
||||||
|
### `GET /dog/:dogId/chic-eligible`
|
||||||
|
Check if a dog has all required CHIC tests.
|
||||||
|
|
||||||
|
### `POST /`
|
||||||
|
Create health record.
|
||||||
|
- **Body**: `dog_id`, `record_type`, `test_date` (required), `test_type`, `test_name`, `ofa_result`, `ofa_number`, etc.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Genetics (`/api/genetics`)
|
||||||
|
|
||||||
|
Manage DNA panel results and breeding risks.
|
||||||
|
|
||||||
|
### `GET /dog/:dogId`
|
||||||
|
Get the full genetic panel for a dog. Returns `tests` (actual records) and `panel` (full list including `not_tested` placeholders).
|
||||||
|
|
||||||
|
### `GET /pairing-risk?sireId=X&damId=Y`
|
||||||
|
Check genetic compatibility between two dogs.
|
||||||
|
- **Response**: `{ risks: [...], safe_to_pair: boolean }`
|
||||||
|
|
||||||
|
### `POST /`
|
||||||
|
Add a genetic test result.
|
||||||
|
- **Body**: `dog_id`, `marker`, `result` (clear|carrier|affected|not_tested).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Breeding (`/api/breeding`)
|
||||||
|
|
||||||
|
Track heat cycles and whelping projections.
|
||||||
|
|
||||||
|
### `GET /heat-cycles/active`
|
||||||
|
Get currently active heat cycles.
|
||||||
|
|
||||||
|
### `GET /heat-cycles/:id/suggestions`
|
||||||
|
Get optimal breeding window (days 9-15) and whelping projections.
|
||||||
|
|
||||||
|
### `POST /heat-cycles`
|
||||||
|
Log a new heat cycle. Dog must be female.
|
||||||
|
|
||||||
|
### `GET /whelping-calculator?breeding_date=YYYY-MM-DD`
|
||||||
|
Standalone tool to calculate expected whelping window.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. Pedigree (`/api/pedigree`)
|
||||||
|
|
||||||
|
Advanced ancestry and COI calculations.
|
||||||
|
|
||||||
|
### `GET /:id?generations=N`
|
||||||
|
Get an interactive pedigree tree (ancestors). Default 5 generations.
|
||||||
|
|
||||||
|
### `GET /:id/descendants?generations=N`
|
||||||
|
Get a descendant tree. Default 3 generations.
|
||||||
|
|
||||||
|
### `POST /trial-pairing`
|
||||||
|
Calculate Coefficient of Inbreeding (COI) for a hypothetical mating.
|
||||||
|
- **Body**: `sire_id`, `dam_id`.
|
||||||
|
- **Response**: `{ coi, commonAncestors, directRelation, recommendation }`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. Settings (`/api/settings`)
|
||||||
|
|
||||||
|
### `GET /`
|
||||||
|
Get kennel metadata (name, address, etc.).
|
||||||
|
|
||||||
|
### `PUT /`
|
||||||
|
Update kennel settings.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Data Objects
|
||||||
|
|
||||||
|
### Dog Object
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"id": 1,
|
||||||
|
"name": "BREEDR Champion",
|
||||||
|
"registration_number": "AKC123456",
|
||||||
|
"breed": "Golden Retriever",
|
||||||
|
"sex": "male",
|
||||||
|
"birth_date": "2020-01-01",
|
||||||
|
"color": "Golden",
|
||||||
|
"microchip": "900123456789",
|
||||||
|
"is_active": 1,
|
||||||
|
"is_champion": 1,
|
||||||
|
"is_external": 0,
|
||||||
|
"photo_urls": ["/uploads/img.jpg"],
|
||||||
|
"notes": "Excellent temperament",
|
||||||
|
"sire": { "id": 10, "name": "Sire Name" },
|
||||||
|
"dam": { "id": 11, "name": "Dam Name" }
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Litter Object
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"id": 5,
|
||||||
|
"sire_id": 1,
|
||||||
|
"dam_id": 2,
|
||||||
|
"breeding_date": "2023-01-01",
|
||||||
|
"whelp_date": "2023-03-05",
|
||||||
|
"total_count": 8,
|
||||||
|
"puppies": [ ... ]
|
||||||
|
}
|
||||||
|
```
|
||||||
118
DEVELOPMENT.md
Normal file
118
DEVELOPMENT.md
Normal file
@@ -0,0 +1,118 @@
|
|||||||
|
# DEVELOPMENT.md
|
||||||
|
|
||||||
|
This document provides technical details and guidelines for developing and maintaining the BREEDR Genealogy Management System.
|
||||||
|
|
||||||
|
## Tech Stack Overview
|
||||||
|
|
||||||
|
- **Monorepo Structure**:
|
||||||
|
- `server/`: Express.js backend.
|
||||||
|
- `client/`: React/Vite frontend.
|
||||||
|
- `data/`: SQLite database storage.
|
||||||
|
- `uploads/`: Uploaded images and documents.
|
||||||
|
- `static/`: Static assets for the application.
|
||||||
|
|
||||||
|
- **Backend**: Node.js, Express, better-sqlite3, multer, bcrypt, jsonwebtoken.
|
||||||
|
- **Frontend**: React 18, Vite, React Router 6, Axios, Lucide React, D3 (for pedigree trees).
|
||||||
|
- **Database**: SQLite (managed with better-sqlite3).
|
||||||
|
|
||||||
|
## Getting Started
|
||||||
|
|
||||||
|
### Prerequisites
|
||||||
|
- Node.js (v18+ recommended)
|
||||||
|
- npm
|
||||||
|
|
||||||
|
### Installation
|
||||||
|
1. Clone the repository.
|
||||||
|
2. Install root dependencies:
|
||||||
|
```bash
|
||||||
|
npm install
|
||||||
|
```
|
||||||
|
3. Install client dependencies:
|
||||||
|
```bash
|
||||||
|
cd client && npm install
|
||||||
|
```
|
||||||
|
|
||||||
|
### Development Commands
|
||||||
|
Run the following from the project root:
|
||||||
|
- **Run both client and server**: `npm run dev`
|
||||||
|
- **Run server only**: `npm run server`
|
||||||
|
- **Run client only**: `npm run client`
|
||||||
|
- **Initialize Database**: `npm run db:init`
|
||||||
|
- **Build for production**: `npm run build`
|
||||||
|
|
||||||
|
## Database Architecture
|
||||||
|
|
||||||
|
### Data Storage
|
||||||
|
The database is a single SQLite file located at `data/breedr.db`. This directory is automatically created on startup if it doesn't exist.
|
||||||
|
|
||||||
|
### Initialization & Schema
|
||||||
|
- **Initialization**: `server/db/init.js` defines the initial schema and creates tables if they don't exist.
|
||||||
|
- **Migrations**: `server/db/migrations.js` handles schema updates. Migrations run automatically on server startup.
|
||||||
|
|
||||||
|
### "Parents Table" Approach
|
||||||
|
Instead of storing parent IDs directly in the `dogs` table (which was the old approach), relationships are managed in a dedicated `parents` table:
|
||||||
|
|
||||||
|
```sql
|
||||||
|
CREATE TABLE parents (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
dog_id INTEGER NOT NULL,
|
||||||
|
parent_id INTEGER NOT NULL,
|
||||||
|
parent_type TEXT NOT NULL CHECK(parent_type IN ('sire', 'dam')),
|
||||||
|
FOREIGN KEY (dog_id) REFERENCES dogs(id) ON DELETE CASCADE,
|
||||||
|
FOREIGN KEY (parent_id) REFERENCES dogs(id) ON DELETE CASCADE,
|
||||||
|
UNIQUE(dog_id, parent_type)
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
**Key Benefits**:
|
||||||
|
- Avoids complex `ALTER TABLE` operations when changing pedigree logic.
|
||||||
|
- Cleanly separates dog attributes from lineage relationships.
|
||||||
|
- Supports indexing for fast recursive pedigree lookups.
|
||||||
|
|
||||||
|
### Key Tables
|
||||||
|
- **`dogs`**: Core dog data (name, breed, sex, microchip, etc.).
|
||||||
|
- **`parents`**: Lineage relationships (Sire/Dam).
|
||||||
|
- **`litters`**: Groups of dogs from a single breeding.
|
||||||
|
- **`breeding_records`**: Planned or completed breeding events.
|
||||||
|
- **`health_records`**: OFA results, vaccinations, and other health tests.
|
||||||
|
- **`genetic_tests`**: DNA panel results.
|
||||||
|
- **`settings`**: Kennel-wide configurations.
|
||||||
|
|
||||||
|
## Backend Development
|
||||||
|
|
||||||
|
### API Routes
|
||||||
|
Routes are modularized in `server/routes/`:
|
||||||
|
- `/api/dogs`: Dog management.
|
||||||
|
- `/api/litters`: Litter management.
|
||||||
|
- `/api/health`: Health record management.
|
||||||
|
- `/api/genetics`: Genetic testing management.
|
||||||
|
- `/api/pedigree`: Pedigree tree generation.
|
||||||
|
- `/api/breeding`: Breeding records.
|
||||||
|
- `/api/settings`: Application settings.
|
||||||
|
|
||||||
|
### File Uploads
|
||||||
|
Images and documents are stored in `uploads/`. The `multer` middleware handles file processing. File paths are stored in the database as relative URLs (e.g., `/uploads/image.jpg`).
|
||||||
|
|
||||||
|
## Frontend Development
|
||||||
|
|
||||||
|
### State Management
|
||||||
|
- **Settings**: Managed globally via `SettingsProvider` in `client/src/hooks/useSettings.jsx`.
|
||||||
|
- **Component State**: Local `useState` and `useEffect` are preferred for feature-specific data.
|
||||||
|
|
||||||
|
### Styling
|
||||||
|
- CSS Variables are used for theming.
|
||||||
|
- The UI uses a modern, clean design with Lucide icons.
|
||||||
|
|
||||||
|
### Pedigree Trees
|
||||||
|
The pedigree tree visualization is powered by `react-d3-tree` and D3.js. Logic for building the tree structure is located in `server/routes/pedigree.js` and visualized in the `PedigreeTree` component.
|
||||||
|
|
||||||
|
## Environment Variables
|
||||||
|
|
||||||
|
| Variable | Description | Default |
|
||||||
|
|----------|-------------|---------|
|
||||||
|
| `PORT` | Server port | `3000` |
|
||||||
|
| `NODE_ENV` | Environment mode | `development` |
|
||||||
|
| `DATA_DIR` | Path to DB storage | `../data` |
|
||||||
|
| `UPLOAD_PATH` | Path to uploads | `../uploads` |
|
||||||
|
| `STATIC_PATH` | Path to static assets | `../static` |
|
||||||
|
| `DB_PATH` | Full path to .db file | `../data/breedr.db` |
|
||||||
105
FRONTEND_GUIDE.md
Normal file
105
FRONTEND_GUIDE.md
Normal file
@@ -0,0 +1,105 @@
|
|||||||
|
# Frontend Guide
|
||||||
|
|
||||||
|
This document provides an overview of the frontend architecture, technologies, and patterns used in the BREEDR application.
|
||||||
|
|
||||||
|
## Tech Stack
|
||||||
|
|
||||||
|
- **Framework**: [React](https://reactjs.org/) (bootstrapped with [Vite](https://vitejs.dev/))
|
||||||
|
- **Routing**: [react-router-dom](https://reactrouter.com/)
|
||||||
|
- **Icons**: [lucide-react](https://lucide.dev/)
|
||||||
|
- **Data Fetching**: [axios](https://axios-http.com/)
|
||||||
|
- **Visualizations**: [react-d3-tree](https://github.com/bkrem/react-d3-tree) (for pedigree rendering)
|
||||||
|
- **Styling**: Standard CSS with CSS Variables (Custom properties)
|
||||||
|
|
||||||
|
## Project Structure
|
||||||
|
|
||||||
|
```text
|
||||||
|
client/src/
|
||||||
|
├── components/ # Reusable UI components
|
||||||
|
├── hooks/ # Custom hooks and context providers
|
||||||
|
├── pages/ # Page-level components (routes)
|
||||||
|
├── utils/ # Helper functions and utilities
|
||||||
|
├── App.jsx # Root component with routing
|
||||||
|
├── App.css # Layout-specific styles
|
||||||
|
├── index.css # Global styles and CSS variables
|
||||||
|
└── main.jsx # Application entry point
|
||||||
|
```
|
||||||
|
|
||||||
|
## Routing and Layout
|
||||||
|
|
||||||
|
The application uses `react-router-dom` for navigation. The primary layout and routes are defined in `client/src/App.jsx`.
|
||||||
|
|
||||||
|
- **Navbar**: Contains links to Dashboard, Dogs, Litters, Breeding, Pairing, and Settings.
|
||||||
|
- **Main Content**: Renders the matched route element within a `.main-content` container.
|
||||||
|
|
||||||
|
### Key Routes
|
||||||
|
- `/`: Dashboard
|
||||||
|
- `/dogs`: Dog List
|
||||||
|
- `/dogs/:id`: Dog Detail
|
||||||
|
- `/pedigree/:id`: Pedigree View
|
||||||
|
- `/litters`: Litter List
|
||||||
|
- `/breeding`: Breeding Calendar
|
||||||
|
- `/pairing`: Pairing Simulator
|
||||||
|
- `/settings`: Settings Page
|
||||||
|
|
||||||
|
## State Management
|
||||||
|
|
||||||
|
### Settings Context (`useSettings`)
|
||||||
|
Global application settings (like kennel name) are managed via a React Context.
|
||||||
|
|
||||||
|
- **Provider**: `SettingsProvider` in `client/src/hooks/useSettings.jsx`.
|
||||||
|
- **Usage**:
|
||||||
|
```javascript
|
||||||
|
import { useSettings } from '../hooks/useSettings';
|
||||||
|
const { settings, saveSettings, loading } = useSettings();
|
||||||
|
```
|
||||||
|
|
||||||
|
### Local State
|
||||||
|
Most page-specific data is managed using standard `useState` and `useEffect` hooks, fetching data via `axios`.
|
||||||
|
|
||||||
|
## Styling Conventions
|
||||||
|
|
||||||
|
The application follows a dark-theme aesthetic using CSS variables for consistency.
|
||||||
|
|
||||||
|
### CSS Variables (`client/src/index.css`)
|
||||||
|
Key variables include:
|
||||||
|
- `--primary`: Main brand color (warm amber/copper).
|
||||||
|
- `--bg-primary`: Primary background.
|
||||||
|
- `--text-primary`: Primary text color.
|
||||||
|
- `--border`: Standard border color.
|
||||||
|
- `--radius`: Default border radius.
|
||||||
|
|
||||||
|
### Reusable UI Classes
|
||||||
|
- `.btn`, `.btn-primary`, `.btn-secondary`: Standard button styles.
|
||||||
|
- `.card`: Container for grouped content.
|
||||||
|
- `.grid`, `.grid-2`, `.grid-3`: Responsive grid layouts.
|
||||||
|
- `.modal-overlay`, `.modal-content`: Standard modal structure.
|
||||||
|
|
||||||
|
## Key Components
|
||||||
|
|
||||||
|
### PedigreeTree (`client/src/components/PedigreeTree.jsx`)
|
||||||
|
Uses `react-d3-tree` to render a horizontal, step-based pedigree.
|
||||||
|
- **Props**: `dogId`, `pedigreeData`, `coi`.
|
||||||
|
- **Features**: Custom node rendering (differentiating by sex and champion status), zoom/pan controls, and COI display.
|
||||||
|
|
||||||
|
### DogForm (`client/src/components/DogForm.jsx`)
|
||||||
|
Handles both creation and editing of dog records.
|
||||||
|
- **Logic**: Manages internal/external dog states, parent selection (manual or linked to litter), and champion status.
|
||||||
|
- **Validation**: Basic required field validation; errors are displayed at the top of the form.
|
||||||
|
|
||||||
|
## Data Fetching Patterns
|
||||||
|
|
||||||
|
Standard `axios` requests are used:
|
||||||
|
```javascript
|
||||||
|
const fetchData = async () => {
|
||||||
|
try {
|
||||||
|
const res = await axios.get('/api/endpoint');
|
||||||
|
setData(res.data);
|
||||||
|
} catch (err) {
|
||||||
|
setError(err.response?.data?.error || 'Error message');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
## Icons
|
||||||
|
Use `lucide-react` for all icons to ensure consistency across the UI.
|
||||||
291
README.md
291
README.md
@@ -13,16 +13,58 @@ A reactive, interactive dog breeding genealogy mapping system for professional k
|
|||||||
- **✅ Modern UI** - Sleek, dark-themed interface with compact info cards
|
- **✅ Modern UI** - Sleek, dark-themed interface with compact info cards
|
||||||
- **✅ Search & Filter** - Find dogs by name, breed, sex, and more
|
- **✅ Search & Filter** - Find dogs by name, breed, sex, and more
|
||||||
- **✅ Branded Navigation** - Custom logo (br-logo.png) with gold-to-rusty-red gradient title
|
- **✅ Branded Navigation** - Custom logo (br-logo.png) with gold-to-rusty-red gradient title
|
||||||
|
- **✅ Trial Pairing Simulator** - COI calculator with common ancestors table and risk badge
|
||||||
|
- **✅ Heat Cycle Calendar** - Month grid calendar with cycle windows, breeding date suggestions, and **projected whelping identifiers**
|
||||||
|
- **✅ Champion Bloodline Tracking** - Mark dogs as titled champions; offspring display a Champion Bloodline badge
|
||||||
|
- **✅ Kennel Settings** - Configurable kennel name, tagline, address, AKC ID, breed, owner info
|
||||||
|
- **✅ UI Theme** - CSS custom property theming with `--champion-gold` and dark-mode variables
|
||||||
|
|
||||||
### Database Architecture
|
### Database Architecture
|
||||||
- **✅ Clean Schema** - No migrations, fresh installs create correct structure
|
- **✅ Clean Schema** - No migrations needed; fresh installs create correct structure; existing DBs auto-migrate via safe `ALTER TABLE` guards
|
||||||
- **✅ Normalized Design** - `parents` table for relationships (sire/dam)
|
- **✅ Normalized Design** - `parents` table for relationships (sire/dam)
|
||||||
- **✅ Litter Linking** - Dogs linked to litters via `litter_id`
|
- **✅ Litter Linking** - Dogs linked to litters via `litter_id`
|
||||||
- **✅ Health Records** - Medical history and genetic testing
|
- **✅ Health Records** - Medical history and genetic testing
|
||||||
- **✅ Heat Cycles** - Breeding cycle tracking
|
- **✅ Heat Cycles** - Breeding cycle tracking
|
||||||
- **✅ Genetic Traits** - Inherited trait mapping
|
- **✅ Genetic Traits** - Inherited trait mapping
|
||||||
|
- **✅ Settings Table** - Single-row kennel configuration with all contact/identity fields
|
||||||
|
|
||||||
### Recently Added (March 9, 2026)
|
### Recently Added (March 10, 2026 — v0.6.1)
|
||||||
|
- **✅ COI Direct-Relation Fix** — `calculateCOI` now correctly computes inbreeding coefficient for parent×offspring pairings. Previously returned `0.00%` due to blanket exclusion of `sid` from `commonIds`; sire now correctly appears as a common ancestor in the dam's ancestry map when they are parent×offspring
|
||||||
|
- **✅ pedigree.js Route Fix** — `commonIds` filter changed from `id !== sid && id !== did` → `id !== did` only; preserves parent×offspring COI path while still preventing reflexive dam self-loop
|
||||||
|
- **Expected COI for parent×offspring pairing:** ~25.00% (Wright's path coefficient method)
|
||||||
|
|
||||||
|
### Previously Added (March 9, 2026 — v0.6.0)
|
||||||
|
- **✅ Champion Flag** — `is_champion INTEGER DEFAULT 0` on `dogs` table; safe `ALTER TABLE` migration guard for existing DBs
|
||||||
|
- **✅ Champion Toggle in DogForm** — amber-gold highlighted checkbox row with `Award` icon; marks dog as titled champion
|
||||||
|
- **✅ Champion ⭐ in Parent Dropdowns** — sire/dam selects append `⭐` to champion names for at-a-glance visibility
|
||||||
|
- **✅ Champion Bloodline Badge** — offspring of champion parents display a badge on dog cards and detail pages
|
||||||
|
- **✅ Kennel Settings API** — `GET/PUT /api/settings` with single-row column schema and ALLOWED_KEYS whitelist
|
||||||
|
- **✅ Settings Table Migration** — all kennel fields added with safe `ALTER TABLE` guards on existing DBs; default seed row auto-created
|
||||||
|
- **✅ SettingsProvider / useSettings** — React context hook renamed `useSettings.jsx` (was `.js`; contained JSX causing Vite build failure)
|
||||||
|
- **✅ `server/index.js` Fix** — `initDatabase()` called with no args to match updated `db/init.js`; removed duplicate `/api/health` route
|
||||||
|
- **✅ `settings.js` Route Fix** — rewrote from double-encoded base64 + old key/value schema to correct single-row column schema
|
||||||
|
|
||||||
|
### Previously Added (March 9, 2026 — v0.5.1)
|
||||||
|
- **✅ Projected Whelp Window on Calendar** - Indigo/purple day cells (days 58–65 from breeding date) visible directly on the month grid
|
||||||
|
- **✅ Expected Whelp Day Marker** - Indigo dot on the exact expected whelp day (day 63) alongside the green breeding dot
|
||||||
|
- **✅ "[Name] due" Cell Label** - Baby 🍼 icon + dog name label inside the whelp day cell
|
||||||
|
- **✅ Active Cycle Card — Whelp Range** - "Whelp est. [date]" row with earliest–latest range shown on each active cycle card
|
||||||
|
- **✅ Jump-to-Whelp-Month Button** - One-click navigation to the whelp month when it differs from current view
|
||||||
|
- **✅ Live Whelp Preview in Modal** - Instant client-side earliest/expected/latest preview as soon as a breeding date is entered (no save required)
|
||||||
|
- **✅ Whelping Banner** - Full-width indigo banner listing dogs with projected whelps when no active heat cycles are visible
|
||||||
|
- **✅ Legend Entry** - "Projected Whelp" added to calendar legend
|
||||||
|
- **✅ Updated Page Subtitle** - Now reads: *"Track heat cycles, optimal breeding windows, and projected whelping dates"*
|
||||||
|
|
||||||
|
### Previously Added (March 9, 2026 — v0.5.0)
|
||||||
|
- **✅ Heat Cycle Calendar** - Full month grid with color-coded cycle windows (Proestrus / Optimal / Late Estrus / Diestrus)
|
||||||
|
- **✅ Start Cycle Modal** - Click any day or the header button to log a new heat cycle for a female
|
||||||
|
- **✅ Breeding Date Suggestions** - Phase windows with date ranges loaded from `GET /api/breeding/heat-cycles/:id/suggestions`
|
||||||
|
- **✅ Whelping Estimate** - Auto-calculates earliest/expected/latest whelping once a breeding date is logged
|
||||||
|
- **✅ Trial Pairing Simulator** - `/pairing` route with sire/dam dropdowns, COI%, risk badge, and common ancestors table
|
||||||
|
- **✅ Pairing Nav Link** - `FlaskConical` icon added to navbar
|
||||||
|
- **✅ New API Endpoints** - `GET /api/breeding/heat-cycles`, `GET /api/breeding/heat-cycles/:id/suggestions`
|
||||||
|
|
||||||
|
### Previously Added (March 9, 2026 — v0.4.x)
|
||||||
- **✅ Brand Logo** - Custom `br-logo.png` in navbar replacing generic icon
|
- **✅ Brand Logo** - Custom `br-logo.png` in navbar replacing generic icon
|
||||||
- **✅ Gradient Title** - Gold-to-rusty-red gradient on "BREEDR" brand text
|
- **✅ Gradient Title** - Gold-to-rusty-red gradient on "BREEDR" brand text
|
||||||
- **✅ Static Asset Serving** - `/static` directory served by Express for branding assets
|
- **✅ Static Asset Serving** - `/static` directory served by Express for branding assets
|
||||||
@@ -30,21 +72,14 @@ A reactive, interactive dog breeding genealogy mapping system for professional k
|
|||||||
- **✅ Route Fix** - `/static` and `/uploads` paths no longer fall through to React catch-all
|
- **✅ Route Fix** - `/static` and `/uploads` paths no longer fall through to React catch-all
|
||||||
- **✅ Logo Sizing** - Fixed brand logo to 1:1 aspect ratio square
|
- **✅ Logo Sizing** - Fixed brand logo to 1:1 aspect ratio square
|
||||||
|
|
||||||
### Previously Fixed
|
|
||||||
- **✅ Database Schema** - Removed weight/height columns, added litter_id
|
|
||||||
- **✅ Parent Handling** - Proper sire/dam via parents table
|
|
||||||
- **✅ Microchip Field** - Optional, allows multiple dogs without microchips
|
|
||||||
- **✅ Error Handling** - Graceful fallbacks for API failures
|
|
||||||
- **✅ UI Layout** - Fixed overlapping elements in dog forms
|
|
||||||
|
|
||||||
## Technology Stack
|
## Technology Stack
|
||||||
|
|
||||||
- **Frontend**: React 18 with modern component design
|
- **Frontend**: React 18 with modern component design
|
||||||
- **Visualization**: React-D3-Tree for pedigree charts
|
- **Visualization**: React-D3-Tree for pedigree charts
|
||||||
- **Backend**: Node.js/Express API
|
- **Backend**: Node.js/Express API
|
||||||
- **Database**: SQLite (embedded, zero-config) with clean normalized schema
|
- **Database**: SQLite (embedded, zero-config) with clean normalized schema + safe `ALTER TABLE` migration guards
|
||||||
- **Container**: Single Docker image with multi-stage build
|
- **Container**: Single Docker image with multi-stage build
|
||||||
- **Styling**: CSS custom properties with dark theme + gradient branding
|
- **Styling**: CSS custom properties with dark theme + `--champion-gold` + gradient branding
|
||||||
|
|
||||||
## Quick Start
|
## Quick Start
|
||||||
|
|
||||||
@@ -64,30 +99,21 @@ docker-compose up -d
|
|||||||
|
|
||||||
Access at: `http://localhost:3000`
|
Access at: `http://localhost:3000`
|
||||||
|
|
||||||
### Fresh Install Database Setup
|
### Upgrading an Existing Installation
|
||||||
|
|
||||||
For a **fresh install**, the database will automatically initialize with the correct schema.
|
The database now uses safe `ALTER TABLE` guards — **you do not need to delete your database to upgrade**. Just pull and rebuild:
|
||||||
|
|
||||||
For an **existing installation upgrade**:
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Stop the application
|
|
||||||
docker-compose down
|
docker-compose down
|
||||||
|
|
||||||
# Backup your data
|
|
||||||
cp data/breedr.db data/breedr.db.backup
|
|
||||||
|
|
||||||
# Delete old database (it will be recreated)
|
|
||||||
rm data/breedr.db
|
|
||||||
|
|
||||||
# Pull latest code
|
|
||||||
git pull origin master
|
git pull origin master
|
||||||
|
|
||||||
# Rebuild and restart
|
|
||||||
docker-compose up -d --build
|
docker-compose up -d --build
|
||||||
```
|
```
|
||||||
|
|
||||||
The app will create a fresh database with the clean schema automatically.
|
New columns (`is_champion`, all `settings` kennel fields) are added automatically on first boot. Your existing dog data is preserved.
|
||||||
|
|
||||||
|
### Fresh Install Database Setup
|
||||||
|
|
||||||
|
For a **fresh install**, the database will automatically initialize with the correct schema and seed a default settings row.
|
||||||
|
|
||||||
## Database Schema
|
## Database Schema
|
||||||
|
|
||||||
@@ -96,15 +122,17 @@ The app will create a fresh database with the clean schema automatically.
|
|||||||
1. **No sire/dam columns in `dogs` table** - Parent relationships stored in `parents` table
|
1. **No sire/dam columns in `dogs` table** - Parent relationships stored in `parents` table
|
||||||
2. **Normalized structure** - Reduces redundancy, improves data integrity
|
2. **Normalized structure** - Reduces redundancy, improves data integrity
|
||||||
3. **Litter linking** - Dogs reference litters via `litter_id` foreign key
|
3. **Litter linking** - Dogs reference litters via `litter_id` foreign key
|
||||||
|
4. **Safe migrations** - `ALTER TABLE ... ADD COLUMN` guards allow zero-downtime upgrades
|
||||||
|
|
||||||
### Core Tables
|
### Core Tables
|
||||||
|
|
||||||
- **dogs** - Core dog registry (NO sire_id/dam_id columns)
|
- **dogs** - Core dog registry; includes `is_champion`, `litter_id`, `photo_urls`
|
||||||
- **parents** - Sire/dam relationships (dog_id, parent_id, parent_type)
|
- **parents** - Sire/dam relationships (dog_id, parent_id, parent_type)
|
||||||
- **litters** - Breeding records with sire/dam references
|
- **litters** - Breeding records with sire/dam references
|
||||||
- **health_records** - Medical and genetic testing
|
- **health_records** - Medical and genetic testing
|
||||||
- **heat_cycles** - Breeding cycle tracking
|
- **heat_cycles** - Breeding cycle tracking
|
||||||
- **traits** - Genetic trait mapping
|
- **traits** - Genetic trait mapping
|
||||||
|
- **settings** - Single-row kennel configuration (kennel_name, tagline, address, phone, email, website, akc_id, breed, owner_name)
|
||||||
|
|
||||||
**Full schema documentation:** [DATABASE.md](DATABASE.md)
|
**Full schema documentation:** [DATABASE.md](DATABASE.md)
|
||||||
|
|
||||||
@@ -112,8 +140,9 @@ The app will create a fresh database with the clean schema automatically.
|
|||||||
|
|
||||||
- `NODE_ENV` - production/development (default: production)
|
- `NODE_ENV` - production/development (default: production)
|
||||||
- `PORT` - Server port (default: 3000)
|
- `PORT` - Server port (default: 3000)
|
||||||
- `DB_PATH` - SQLite database path (default: /app/data/breedr.db)
|
- `DATA_DIR` - Data directory for SQLite file (default: /app/data)
|
||||||
- `UPLOAD_PATH` - Upload directory (default: /app/uploads)
|
- `UPLOAD_PATH` - Upload directory (default: /app/uploads)
|
||||||
|
- `STATIC_PATH` - Static assets directory (default: /app/static)
|
||||||
|
|
||||||
## Development
|
## Development
|
||||||
|
|
||||||
@@ -123,100 +152,124 @@ The app will create a fresh database with the clean schema automatically.
|
|||||||
# Install dependencies
|
# Install dependencies
|
||||||
npm install
|
npm install
|
||||||
|
|
||||||
# Run development server (frontend + backend)
|
# Run development server (frontend + backend, nodemon auto-reload)
|
||||||
npm run dev
|
npm run dev
|
||||||
|
|
||||||
# Build for production
|
# Build for production
|
||||||
npm run build
|
npm run build
|
||||||
```
|
```
|
||||||
|
|
||||||
|
> **Note:** `npm run dev` uses nodemon for auto-reload on the server. `npm start` (production) does **not** watch for changes — restart is required after pulling updates.
|
||||||
|
|
||||||
### Project Structure
|
### Project Structure
|
||||||
|
|
||||||
```
|
```
|
||||||
breedr/
|
breedr/
|
||||||
├── client/ # React frontend
|
├── client/ # React frontend
|
||||||
│ ├── src/
|
│ ├── src/
|
||||||
│ ├── public/
|
│ │ ├── pages/
|
||||||
|
│ │ │ ├── BreedingCalendar.jsx # Heat cycle calendar + whelping identifiers
|
||||||
|
│ │ │ ├── PairingSimulator.jsx # Trial pairing + COI
|
||||||
|
│ │ │ ├── SettingsPage.jsx # Kennel settings form
|
||||||
|
│ │ │ ├── Dashboard.jsx
|
||||||
|
│ │ │ ├── DogList.jsx
|
||||||
|
│ │ │ ├── DogDetail.jsx
|
||||||
|
│ │ │ ├── PedigreeView.jsx
|
||||||
|
│ │ │ └── LitterList.jsx
|
||||||
|
│ │ ├── components/
|
||||||
|
│ │ │ └── DogForm.jsx # Champion toggle + parent selects
|
||||||
|
│ │ ├── hooks/
|
||||||
|
│ │ │ └── useSettings.jsx # SettingsProvider + useSettings context
|
||||||
|
│ │ └── App.jsx
|
||||||
│ └── package.json
|
│ └── package.json
|
||||||
├── server/ # Node.js backend
|
├── server/ # Node.js backend
|
||||||
│ ├── routes/
|
│ ├── routes/
|
||||||
|
│ │ ├── dogs.js # is_champion in all queries
|
||||||
|
│ │ ├── settings.js # GET/PUT kennel settings (single-row schema)
|
||||||
|
│ │ ├── breeding.js # Heat cycles, whelping, suggestions
|
||||||
|
│ │ ├── pedigree.js # COI, trial pairing (v0.6.1 direct-relation fix)
|
||||||
|
│ │ ├── litters.js
|
||||||
|
│ │ └── health.js
|
||||||
│ ├── db/
|
│ ├── db/
|
||||||
│ │ └── init.js # Clean schema (NO migrations)
|
│ │ └── init.js # Schema + ALTER TABLE migration guards
|
||||||
│ └── index.js
|
│ └── index.js
|
||||||
├── static/ # Branding assets (br-logo.png, etc.)
|
├── static/ # Branding assets (br-logo.png, etc.)
|
||||||
├── docs/ # Documentation
|
├── docs/ # Documentation
|
||||||
├── DATABASE.md # Schema documentation
|
├── ROADMAP.md
|
||||||
├── ROADMAP.md # Development roadmap
|
├── DATABASE.md
|
||||||
├── Dockerfile # Multi-stage Docker build
|
├── Dockerfile
|
||||||
├── docker-compose.yml
|
├── docker-compose.yml
|
||||||
└── README.md
|
└── README.md
|
||||||
```
|
```
|
||||||
|
|
||||||
## API Endpoints
|
## API Endpoints
|
||||||
|
|
||||||
|
### Dogs
|
||||||
- `GET/POST /api/dogs` - Dog CRUD operations
|
- `GET/POST /api/dogs` - Dog CRUD operations
|
||||||
- `GET /api/dogs/:id` - Get dog with parents and offspring
|
- `GET /api/dogs/:id` - Get dog with parents (incl. is_champion), offspring, and health summary
|
||||||
|
- `PUT /api/dogs/:id` - Update dog (incl. is_champion)
|
||||||
- `POST /api/dogs/:id/photos` - Upload photos
|
- `POST /api/dogs/:id/photos` - Upload photos
|
||||||
- `GET/POST /api/litters` - Litter management
|
|
||||||
|
### Settings
|
||||||
|
- `GET /api/settings` - Get kennel settings
|
||||||
|
- `PUT /api/settings` - Update kennel settings (partial update supported)
|
||||||
|
|
||||||
|
### Pedigree & Genetics
|
||||||
- `GET /api/pedigree/:id` - Generate pedigree tree
|
- `GET /api/pedigree/:id` - Generate pedigree tree
|
||||||
- `GET /api/health` - Health records
|
- `POST /api/pedigree/trial-pairing` - COI + common ancestors + risk recommendation
|
||||||
- `GET/POST /api/breeding` - Heat cycles and breeding
|
- `GET /api/pedigree/relations/:sireId/:damId` - Direct relation detection (parent/grandparent check)
|
||||||
|
|
||||||
|
### Breeding & Heat Cycles
|
||||||
|
- `GET /api/breeding/heat-cycles` - All heat cycles
|
||||||
|
- `GET /api/breeding/heat-cycles/active` - Active cycles with dog info
|
||||||
|
- `GET /api/breeding/heat-cycles/dog/:dogId` - Cycles for a specific dog
|
||||||
|
- `GET /api/breeding/heat-cycles/:id/suggestions` - Breeding windows + whelping estimate
|
||||||
|
- `POST /api/breeding/heat-cycles` - Create new heat cycle
|
||||||
|
- `PUT /api/breeding/heat-cycles/:id` - Update cycle (log breeding date, etc.)
|
||||||
|
- `DELETE /api/breeding/heat-cycles/:id` - Delete cycle
|
||||||
|
- `GET /api/breeding/whelping-calculator` - Standalone whelping date calculator
|
||||||
|
|
||||||
|
### Litters
|
||||||
|
- `GET/POST /api/litters` - Litter management
|
||||||
|
|
||||||
|
### Assets
|
||||||
- `GET /static/*` - Branding and static assets
|
- `GET /static/*` - Branding and static assets
|
||||||
|
- `GET /uploads/*` - Dog photos
|
||||||
## Upgrading
|
|
||||||
|
|
||||||
### From Earlier Versions
|
|
||||||
|
|
||||||
If you have an **old database with sire/dam columns** or missing litter_id:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Backup your data
|
|
||||||
cp data/breedr.db data/breedr.db.backup
|
|
||||||
|
|
||||||
# Delete old database
|
|
||||||
rm data/breedr.db
|
|
||||||
|
|
||||||
# Pull latest code
|
|
||||||
git pull
|
|
||||||
|
|
||||||
# Restart (will create clean schema)
|
|
||||||
docker-compose restart
|
|
||||||
```
|
|
||||||
|
|
||||||
**Important:** The new schema uses a `parents` table instead of sire/dam columns. Parent data cannot be automatically migrated - you'll need to re-enter parent relationships.
|
|
||||||
|
|
||||||
## Troubleshooting
|
## Troubleshooting
|
||||||
|
|
||||||
|
### COI shows 0.00% for parent×offspring pairings
|
||||||
|
Ensure you are on v0.6.1+ (merge PR #47). The fix removes a blanket `id !== sid` exclusion in `calculateCOI` that was silently zeroing the inbreeding coefficient when the sire is a direct ancestor of the dam. After merging, restart the server.
|
||||||
|
|
||||||
|
### Server crashes with `SyntaxError: Unexpected end of input` on `settings.js`
|
||||||
|
The settings route file may have been corrupted (double-encoded base64). Pull the latest code and rebuild.
|
||||||
|
|
||||||
|
### "no such column: kennel_name" or "no such column: is_champion"
|
||||||
|
Your database predates the `ALTER TABLE` migration guards. Pull the latest code and restart — columns are added automatically. No data loss.
|
||||||
|
|
||||||
### "no such column: weight" or "no such column: sire_id"
|
### "no such column: weight" or "no such column: sire_id"
|
||||||
|
Your database has a very old schema. Delete and recreate:
|
||||||
Your database has an old schema. Delete and recreate:
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
cp data/breedr.db data/breedr.db.backup
|
||||||
rm data/breedr.db
|
rm data/breedr.db
|
||||||
docker-compose restart
|
docker-compose restart
|
||||||
```
|
```
|
||||||
|
|
||||||
### Parent relationships not saving
|
|
||||||
|
|
||||||
Check server logs for:
|
|
||||||
```
|
|
||||||
✓ Dog inserted with ID: 123
|
|
||||||
Adding sire relationship: dog 123 -> sire 5
|
|
||||||
✓ Sire relationship added
|
|
||||||
```
|
|
||||||
|
|
||||||
If you don't see these logs, ensure `sire_id` and `dam_id` are being sent in the API request.
|
|
||||||
|
|
||||||
### Logo not appearing in navbar
|
### Logo not appearing in navbar
|
||||||
|
|
||||||
Ensure `br-logo.png` is placed in the `static/` directory at the project root. The file is served at `/static/br-logo.png`.
|
Ensure `br-logo.png` is placed in the `static/` directory at the project root. The file is served at `/static/br-logo.png`.
|
||||||
|
|
||||||
|
### Heat cycles not showing on calendar
|
||||||
|
Ensure dogs are registered with `sex: 'female'` before creating heat cycles. The API validates this and will return a 400 error for male dogs.
|
||||||
|
|
||||||
|
### Whelping window not appearing on calendar
|
||||||
|
A breeding date must be logged on the cycle for whelp window cells to appear. Use the Cycle Detail modal → "Log Breeding Date" field.
|
||||||
|
|
||||||
## Roadmap
|
## Roadmap
|
||||||
|
|
||||||
### ✅ Completed
|
### ✅ Completed
|
||||||
- [x] Docker containerization
|
- [x] Docker containerization
|
||||||
- [x] SQLite database with clean schema
|
- [x] SQLite database with clean schema + ALTER TABLE migration guards
|
||||||
- [x] Dog management (CRUD)
|
- [x] Dog management (CRUD) with champion flag
|
||||||
- [x] Photo management
|
- [x] Photo management
|
||||||
- [x] Interactive pedigree visualization
|
- [x] Interactive pedigree visualization
|
||||||
- [x] Litter management
|
- [x] Litter management
|
||||||
@@ -225,23 +278,66 @@ Ensure `br-logo.png` is placed in the `static/` directory at the project root. T
|
|||||||
- [x] Search and filtering
|
- [x] Search and filtering
|
||||||
- [x] Custom brand logo + gradient title
|
- [x] Custom brand logo + gradient title
|
||||||
- [x] Static asset serving
|
- [x] Static asset serving
|
||||||
|
- [x] Trial Pairing Simulator (COI + common ancestors + risk badge)
|
||||||
|
- [x] Heat Cycle Calendar (month grid + windows + breeding suggestions + whelping estimate)
|
||||||
|
- [x] **Projected Whelping Calendar Identifier** (whelp window cells, due label, active card range, live modal preview, whelping banner)
|
||||||
|
- [x] **Champion Bloodline Tracking** (is_champion flag, DogForm toggle, offspring badge)
|
||||||
|
- [x] **Kennel Settings** (GET/PUT /api/settings, SettingsProvider, kennel name in navbar)
|
||||||
|
- [x] **COI Direct-Relation Fix** (parent×offspring now correctly yields ~25% COI — v0.6.1)
|
||||||
|
|
||||||
### 🚧 In Progress
|
### 🔜 In Progress / Up Next
|
||||||
- [ ] Trial pairing simulator
|
- [ ] Health Records System
|
||||||
- [ ] Inbreeding coefficient calculator
|
- [ ] Genetic trait tracking
|
||||||
- [ ] Heat cycle tracking UI
|
|
||||||
|
|
||||||
### 📋 Planned
|
### 📋 Planned
|
||||||
- [ ] Health records management
|
|
||||||
- [ ] Genetic trait tracking
|
|
||||||
- [ ] PDF pedigree generation
|
- [ ] PDF pedigree generation
|
||||||
- [ ] Advanced search and filters
|
- [ ] Advanced search and filters
|
||||||
- [ ] Export capabilities
|
- [ ] Export capabilities
|
||||||
|
- [ ] Progesterone tracking (extended feature)
|
||||||
|
|
||||||
**Full roadmap:** [ROADMAP.md](ROADMAP.md)
|
**Full roadmap:** [ROADMAP.md](ROADMAP.md)
|
||||||
|
|
||||||
## Recent Updates
|
## Recent Updates
|
||||||
|
|
||||||
|
### March 10, 2026 - COI Direct-Relation Bug Fix (v0.6.1)
|
||||||
|
- **Fixed:** `calculateCOI` in `server/routes/pedigree.js` — removed `id !== sid` from `commonIds` filter
|
||||||
|
- **Root cause:** `getAncestorMap` includes each dog at `gen 0`; the sire (`sid`) correctly appears in the dam's ancestor map at `gen 1` for parent×offspring pairings, but `id !== sid` was filtering it out and returning `0.00%`
|
||||||
|
- **Result:** Parent×offspring pairings now correctly return ~25.00% COI; all other pairings unaffected
|
||||||
|
- **PR:** [#47](https://git.alwisp.com/jason/breedr/pulls/47)
|
||||||
|
|
||||||
|
### March 9, 2026 - Champion Bloodline, Settings, Build Fixes (v0.6.0)
|
||||||
|
- **Added:** `is_champion` column to `dogs` table with safe `ALTER TABLE` migration guard
|
||||||
|
- **Added:** Champion toggle checkbox in DogForm with amber-gold highlight and `Award` icon
|
||||||
|
- **Added:** `⭐` suffix on champion sire/dam in parent dropdowns
|
||||||
|
- **Added:** Champion Bloodline badge on offspring cards/detail pages
|
||||||
|
- **Added:** `GET/PUT /api/settings` route — single-row column schema with `ALLOWED_KEYS` whitelist
|
||||||
|
- **Added:** Full kennel settings columns in `settings` table with migration guards
|
||||||
|
- **Added:** `SettingsProvider` / `useSettings` React context for kennel name in navbar
|
||||||
|
- **Fixed:** `useSettings.js` → `useSettings.jsx` (Vite build failure — JSX in `.js` file)
|
||||||
|
- **Fixed:** `server/index.js` — `initDatabase()` called with no args; removed duplicate `/api/health` route
|
||||||
|
- **Fixed:** `server/routes/settings.js` — rewrote from double-encoded base64 + old key/value schema
|
||||||
|
- **Fixed:** `DB_PATH` arg removed from `initDatabase()` call; `DATA_DIR` env var now controls directory
|
||||||
|
|
||||||
|
### March 9, 2026 - Projected Whelping Calendar Identifier (v0.5.1)
|
||||||
|
- **Added:** Indigo whelp window (days 58–65) on calendar grid cells when a breeding date is logged
|
||||||
|
- **Added:** Indigo dot marker on exact expected whelp day (day 63)
|
||||||
|
- **Added:** `Baby` icon + "[Name] due" label inside whelp day cells
|
||||||
|
- **Added:** "Whelp est. [date]" row with earliest–latest range on active cycle cards
|
||||||
|
- **Added:** Jump-to-whelp-month button on active cycle cards
|
||||||
|
- **Added:** Live whelp preview in Cycle Detail modal (client-side, instant, no save required)
|
||||||
|
- **Added:** Full-width whelping banner when projected whelps exist but no active heat cycles are visible
|
||||||
|
- **Added:** "Projected Whelp" legend entry with Baby icon
|
||||||
|
- **Updated:** Page subtitle to include projected whelping dates
|
||||||
|
|
||||||
|
### March 9, 2026 - Heat Cycle Calendar & Trial Pairing Simulator (v0.5.0)
|
||||||
|
- **Added:** Full month grid heat cycle calendar with color-coded phase windows
|
||||||
|
- **Added:** Start Heat Cycle modal (click any day or header button)
|
||||||
|
- **Added:** Cycle Detail modal with breeding window breakdown and inline breeding date logging
|
||||||
|
- **Added:** Whelping estimate (earliest/expected/latest) auto-calculated from breeding date
|
||||||
|
- **Added:** Trial Pairing Simulator at `/pairing` with COI%, risk badge, common ancestors table
|
||||||
|
- **Added:** `GET /api/breeding/heat-cycles` and `GET /api/breeding/heat-cycles/:id/suggestions` endpoints
|
||||||
|
- **Moved:** Progesterone tracking to extended roadmap
|
||||||
|
|
||||||
### March 9, 2026 - Branding & Header Improvements (v0.4.1)
|
### March 9, 2026 - Branding & Header Improvements (v0.4.1)
|
||||||
- **Added:** Custom `br-logo.png` brand logo in navbar
|
- **Added:** Custom `br-logo.png` brand logo in navbar
|
||||||
- **Added:** Gold-to-rusty-red gradient on "BREEDR" title text
|
- **Added:** Gold-to-rusty-red gradient on "BREEDR" title text
|
||||||
@@ -250,25 +346,6 @@ Ensure `br-logo.png` is placed in the `static/` directory at the project root. T
|
|||||||
- **Fixed:** `/static` and `/uploads` paths no longer fall through to React router
|
- **Fixed:** `/static` and `/uploads` paths no longer fall through to React router
|
||||||
- **Fixed:** Brand logo sized as fixed 1:1 square for proper aspect ratio
|
- **Fixed:** Brand logo sized as fixed 1:1 square for proper aspect ratio
|
||||||
|
|
||||||
### March 9, 2026 - Clean Database Schema (v0.4.0)
|
|
||||||
- **Fixed:** Database schema cleaned up - no migrations
|
|
||||||
- **Fixed:** Removed weight/height columns (never implemented)
|
|
||||||
- **Fixed:** Proper parent handling via parents table
|
|
||||||
- **Added:** litter_id column for linking puppies to litters
|
|
||||||
- **Added:** Comprehensive DATABASE.md documentation
|
|
||||||
- **Improved:** Server startup with clean initialization
|
|
||||||
- **Improved:** Logging for parent relationship creation
|
|
||||||
|
|
||||||
### March 8, 2026 - UI Redesign & Bug Fixes
|
|
||||||
- **Fixed:** Microchip field UNIQUE constraint (now properly optional)
|
|
||||||
- **Redesigned:** Modern dark theme with sleek aesthetics
|
|
||||||
- **Redesigned:** Compact horizontal info cards (80x80 avatars)
|
|
||||||
- **Improved:** Dashboard with gradient stats cards
|
|
||||||
- **Improved:** Navigation bar with glass morphism
|
|
||||||
- **Enhanced:** Age calculation and display
|
|
||||||
- **Added:** Sex-colored icons (blue ♂, pink ♀)
|
|
||||||
- **Added:** Registration number badges
|
|
||||||
|
|
||||||
## Documentation
|
## Documentation
|
||||||
|
|
||||||
- [DATABASE.md](DATABASE.md) - Complete schema documentation
|
- [DATABASE.md](DATABASE.md) - Complete schema documentation
|
||||||
|
|||||||
385
ROADMAP.md
385
ROADMAP.md
@@ -19,13 +19,15 @@
|
|||||||
- [x] Indexes and triggers
|
- [x] Indexes and triggers
|
||||||
- [x] **litter_id column** for linking puppies to litters
|
- [x] **litter_id column** for linking puppies to litters
|
||||||
- [x] **Clean schema design** - NO migrations, fresh init only
|
- [x] **Clean schema design** - NO migrations, fresh init only
|
||||||
|
- [x] **Safe ALTER TABLE migration guards** - new columns added automatically on upgrade
|
||||||
|
|
||||||
### API Endpoints
|
### API Endpoints
|
||||||
- [x] `/api/dogs` - Full CRUD operations
|
- [x] `/api/dogs` - Full CRUD operations (incl. `is_champion`)
|
||||||
- [x] `/api/pedigree` - Tree generation and COI calculator
|
- [x] `/api/pedigree` - Tree generation and COI calculator
|
||||||
- [x] `/api/litters` - Breeding records
|
- [x] `/api/litters` - Breeding records
|
||||||
- [x] `/api/health` - Health tracking
|
- [x] `/api/health` - Health tracking
|
||||||
- [x] `/api/breeding` - Heat cycles and whelping calculator
|
- [x] `/api/breeding` - Heat cycles and whelping calculator
|
||||||
|
- [x] `/api/settings` - Kennel configuration (GET/PUT)
|
||||||
- [x] Photo upload with Multer
|
- [x] Photo upload with Multer
|
||||||
- [x] **Parent relationship handling** via parents table
|
- [x] **Parent relationship handling** via parents table
|
||||||
- [x] `/static/*` - Branding and static asset serving
|
- [x] `/static/*` - Branding and static asset serving
|
||||||
@@ -74,7 +76,7 @@
|
|||||||
|
|
||||||
## ✅ Phase 3: Breeding Tools (COMPLETE)
|
## ✅ Phase 3: Breeding Tools (COMPLETE)
|
||||||
|
|
||||||
### Priority Features
|
### Pedigree & Genetics
|
||||||
- [x] **Interactive pedigree tree visualization**
|
- [x] **Interactive pedigree tree visualization**
|
||||||
- [x] Integrate React-D3-Tree
|
- [x] Integrate React-D3-Tree
|
||||||
- [x] Show 3-5 generations
|
- [x] Show 3-5 generations
|
||||||
@@ -93,34 +95,197 @@
|
|||||||
- [x] Dual parent selection mode (litter/manual)
|
- [x] Dual parent selection mode (litter/manual)
|
||||||
- [x] UI fix for proper layout and error handling
|
- [x] UI fix for proper layout and error handling
|
||||||
|
|
||||||
- [ ] Trial Pairing Simulator
|
- [x] **Trial Pairing Simulator** ✅ *(March 9, 2026)*
|
||||||
- [ ] Select sire and dam
|
- [x] Sire and dam selection dropdowns
|
||||||
- [ ] Display COI calculation
|
- [x] COI calculation display with color coding
|
||||||
- [ ] Show common ancestors
|
- [x] Common ancestors table (sire-gen / dam-gen columns)
|
||||||
- [ ] Risk assessment display
|
- [x] Risk badge: Low (<5%) / Moderate (5-10%) / High (>10%)
|
||||||
|
- [x] `/pairing` route + navbar link
|
||||||
|
- [x] `POST /api/pedigree/trial-pairing` backend
|
||||||
|
|
||||||
- [ ] Heat Cycle Management
|
- [x] **Heat Cycle Calendar** ✅ *(March 9, 2026)*
|
||||||
- [ ] Add/edit heat cycles
|
- [x] Full month grid calendar (Sun–Sat) with prev/next navigation
|
||||||
- [ ] Track progesterone levels
|
- [x] Color-coded day cells by cycle phase
|
||||||
- [ ] Calendar view
|
- [x] Start Heat Cycle modal (female dropdown + date picker)
|
||||||
- [ ] Breeding date suggestions
|
- [x] Cycle Detail modal with phase breakdown
|
||||||
|
- [x] Breeding date logging inline
|
||||||
|
- [x] Whelping estimate (earliest/expected/latest)
|
||||||
|
- [x] Active cycles list with phase badge + day counter
|
||||||
|
- [x] `GET /api/breeding/heat-cycles` endpoint
|
||||||
|
- [x] `GET /api/breeding/heat-cycles/:id/suggestions` endpoint
|
||||||
|
|
||||||
|
- [x] **Projected Whelping Calendar Identifier** ✅ *(March 9, 2026 − v0.5.1)*
|
||||||
|
- [x] Gestation constants: earliest=58, expected=63, latest=65 days
|
||||||
|
- [x] `getWwhelpDates(cycle)` client-side helper (no extra API call)
|
||||||
|
- [x] Indigo whelp window cells (days 58–63) on calendar grid
|
||||||
|
- [x] Indigo dot marker on expected whelp day (day 63)
|
||||||
|
- [x] `Baby` icon + "[Name] due" label inside whelp day cells
|
||||||
|
- [x] "Whelp est. [date]" row with range on active cycle cards
|
||||||
|
- [x] Jump-to-whelp-month button on active cycle cards
|
||||||
|
- [x] Live whelp preview in Cycle Detail modal (client-side, instant)
|
||||||
|
- [x] Full-width whelping banner when projected whelps exist
|
||||||
|
- [x] "Projected Whelp" legend entry with Baby icon
|
||||||
|
- [x] Updated page subtitle to include whelping dates
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 📋 Phase 4: Health & Genetics (PLANNED)
|
## ✅ Phase 4a: Champion & Settings (COMPLETE − v0.6.0)
|
||||||
|
|
||||||
### Health Records
|
### Champion Bloodline Tracking
|
||||||
- [ ] Add health test results
|
- [x] `is_champion INTEGER DEFAULT 0` column on `dogs` table
|
||||||
- [ ] Vaccination tracking
|
- [x] Safe `ALTER TABLE dogs ADD COLUMN is_champion` migration guard
|
||||||
- [ ] Medical history timeline
|
- [x] `is_champion` included in all `GET /api/dogs` + `GET /api/dogs/:id` responses
|
||||||
- [ ] Document uploads (PDFs, images)
|
- [x] `is_champion` persisted in `POST` and `PUT /api/dogs`
|
||||||
- [ ] Alert for expiring vaccinations
|
- [x] `is_champion` included on sire/dam JOIN queries and offspring query
|
||||||
|
- [x] Champion toggle checkbox in `DogForm` with amber-gold highlight + `Award` icon
|
||||||
|
- [x] `✪` suffix on champion names in sire/dam parent dropdowns
|
||||||
|
- [x] Champion Bloodline badge on offspring cards and dog detail pages
|
||||||
|
|
||||||
### Genetic Tracking
|
### Kennel Settings
|
||||||
- [ ] Track inherited traits
|
- [x] `settings` table: `kennel_name`, `kennel_tagline`, `kennel_address`, `kennel_phone`, `kennel_email`, `kennel_website`, `kennel_akc_id`, `kennel_breed`, `owner_name`
|
||||||
- [ ] Color genetics calculator
|
- [x] Safe `ALTER TABLE settings ADD COLUMN` migration loop for all kennel fields
|
||||||
- [ ] Health clearance status
|
- [x] Auto-seed default row (`kennel_name = 'BREEDR'`) if table is empty
|
||||||
- [ ] Link traits to ancestors
|
- [x] `GET /api/settings` − returns single-row as flat JSON object
|
||||||
|
- [x] `PUT /api/settings` − partial update via `ALLOWED_KEYS` whitelist
|
||||||
|
- [x] `SettingsProvider` / `useSettings` React context hook
|
||||||
|
- [x] Kennel name displayed in navbar from settings
|
||||||
|
- [x] `SettingsPage` component for editing kennel info
|
||||||
|
|
||||||
|
### Build & Runtime Fixes (v0.6.0)
|
||||||
|
- [x] `useSettings.js` → `useSettings.jsx` − Vite build failed because JSX in `.js` file
|
||||||
|
- [x] `server/index.js` − `initDatabase()` called with no args (was passing `DB_PATH`, now path is internal)
|
||||||
|
- [x] `server/index.js` − removed duplicate `app.get('/api/health')` inline route
|
||||||
|
- [x] `server/index.js` − `DATA_DIR` env var replaces `path.dirname(DB_PATH)` for directory creation
|
||||||
|
- [x] `server/routes/settings.js` − rewrote from double-encoded base64 + old key/value schema to correct single-row column schema
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📋 Phase 4b: Health & Genetics (NEXT UP − v0.7.0)
|
||||||
|
|
||||||
|
> **Context:** Golden Retriever health clearances follow GRCA Code of Ethics and OFA/CHIC standards.
|
||||||
|
> This phase builds a structured, breed-aware health tracking system aligned with those requirements.
|
||||||
|
|
||||||
|
### Tier 1 — OFA Health Clearances *(Priority 1)* 🩺
|
||||||
|
|
||||||
|
The four GRCA-required clearances that must be on record in the public OFA database before breeding.
|
||||||
|
|
||||||
|
**Database (schema additions to `health_records` table):**
|
||||||
|
- [ ] Add `test_type` ENUM-style field: `hip_ofa`, `hip_pennhip`, `elbow_ofa`, `heart_ofa`, `heart_echo`, `eye_caer`, `thyroid_ofa`, `dna_panel`
|
||||||
|
- [ ] Add `result` field: `pass`, `fail`, `carrier`, `clear`, `excellent`, `good`, `fair`, `borderline`
|
||||||
|
- [ ] Add `ofa_number` VARCHAR — official OFA certification number
|
||||||
|
- [ ] Add `chic_number` VARCHAR — CHIC certification number (dog-level field on `dogs` table)
|
||||||
|
- [ ] Add `performed_by` VARCHAR — vet or specialist name
|
||||||
|
- [ ] Add `expires_at` DATE — for annually-renewed tests (eyes, heart)
|
||||||
|
- [ ] Add `document_url` VARCHAR — path to uploaded PDF/image
|
||||||
|
- [ ] Safe ALTER TABLE migration guards for all new columns
|
||||||
|
|
||||||
|
**API:**
|
||||||
|
- [ ] `GET /api/health/:dogId` — list all health records for a dog
|
||||||
|
- [ ] `POST /api/health` — create health record
|
||||||
|
- [ ] `PUT /api/health/:id` — update health record
|
||||||
|
- [ ] `DELETE /api/health/:id` — delete health record
|
||||||
|
- [ ] `GET /api/health/:dogId/clearance-summary` — returns pass/fail/missing for all 4 OFA tiers
|
||||||
|
- [ ] `GET /api/health/:dogId/chic-eligible` — returns boolean + missing tests
|
||||||
|
|
||||||
|
**UI Components:**
|
||||||
|
- [ ] `HealthRecordForm` modal — test type dropdown, result, OFA#, date, performed-by, expiry, document upload
|
||||||
|
- [ ] `HealthTimeline` component — chronological list of all health events per dog on DogDetail page
|
||||||
|
- [ ] `ClearanceSummaryCard` — shows OFA Hip / Elbow / Heart / Eyes status in a 2x2 grid with color badges (green=pass, yellow=expiring, red=missing/fail)
|
||||||
|
- [ ] `ChicStatusBadge` — amber badge on dog cards and DogDetail if CHIC number is on file
|
||||||
|
- [ ] Expiry alert: yellow badge on dog card if any annual test expires within 90 days; red if expired
|
||||||
|
- [ ] Document upload support (PDF/image) tied to individual health records
|
||||||
|
|
||||||
|
**Clearance Tiers Tracked:**
|
||||||
|
| Test | OFA Minimum Age | Renewal | Notes |
|
||||||
|
|---|---|---|---|
|
||||||
|
| Hip Dysplasia | 24 months | Once (final) | OFA eval or PennHIP |
|
||||||
|
| Elbow Dysplasia | 24 months | Once (final) | OFA eval |
|
||||||
|
| Cardiac (Heart) | 12 months | Annual recommended | Echo preferred over auscultation |
|
||||||
|
| Eyes (CAER) | 12 months | **Annual** | Board-certified ACVO ophthalmologist |
|
||||||
|
| Thyroid (OFA) | 12 months | Annual recommended | Bonus/Tier 2 |
|
||||||
|
|
||||||
|
**Complexity:** Medium | **Impact:** High | **User Value:** Excellent
|
||||||
|
**Estimated Time:** 8–10 hours
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Tier 2 — DNA Genetic Panel *(Priority 2)* 🧬
|
||||||
|
|
||||||
|
Embark or equivalent panel results per dog. Allows carrier × clear pairing without producing affected offspring.
|
||||||
|
|
||||||
|
**Database:**
|
||||||
|
- [ ] `genetic_tests` table: `id`, `dog_id`, `test_provider` (embark/optigen/etc), `test_name`, `result` (clear/carrier/affected), `test_date`, `document_url`, `created_at`
|
||||||
|
- [ ] Safe `CREATE TABLE IF NOT EXISTS` guard
|
||||||
|
|
||||||
|
**Golden Retriever Panel — Key Markers:**
|
||||||
|
- [ ] PRA1 (Progressive Retinal Atrophy type 1)
|
||||||
|
- [ ] PRA2 (Progressive Retinal Atrophy type 2)
|
||||||
|
- [ ] prcd-PRA (Progressive Rod-Cone Degeneration)
|
||||||
|
- [ ] ICH1 / ICH2 (Ichthyosis — very common in Goldens)
|
||||||
|
- [ ] NCL (Neuronal Ceroid Lipofuscinosis — fatal neurological)
|
||||||
|
- [ ] DM (Degenerative Myelopathy)
|
||||||
|
- [ ] MD (Muscular Dystrophy)
|
||||||
|
- [ ] GR-PRA1, GR-PRA2 (Golden-specific PRA variants)
|
||||||
|
|
||||||
|
**API:**
|
||||||
|
- [ ] `GET /api/genetics/:dogId` — list all genetic test results
|
||||||
|
- [ ] `POST /api/genetics` — add genetic result
|
||||||
|
- [ ] `PUT /api/genetics/:id` — update
|
||||||
|
- [ ] `DELETE /api/genetics/:id` — delete
|
||||||
|
- [ ] `GET /api/genetics/pairing-risk?sireId=&damId=` — returns at-risk combinations for a trial pairing
|
||||||
|
|
||||||
|
**UI Components:**
|
||||||
|
- [ ] `GeneticTestForm` modal — provider, marker, result (clear/carrier/affected), date, upload
|
||||||
|
- [ ] `GeneticPanelCard` on DogDetail — color-coded grid of all markers (green=clear, yellow=carrier, red=affected, gray=not tested)
|
||||||
|
- [ ] Pairing risk overlay on Trial Pairing Simulator — flag if sire+dam are both carriers for same marker
|
||||||
|
- [ ] "Not Tested" indicator on dog cards when no DNA panel on file
|
||||||
|
|
||||||
|
**Complexity:** Medium | **Impact:** High | **User Value:** Excellent
|
||||||
|
**Estimated Time:** 6–8 hours
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Tier 3 — Cancer Lineage & Longevity Tracking *(Priority 3)* 📊
|
||||||
|
|
||||||
|
Golden Retrievers have ~60% cancer mortality rate. Lineage-based cancer history is a major differentiator for responsible breeders.
|
||||||
|
|
||||||
|
**Database:**
|
||||||
|
- [ ] `cancer_history` table: `id`, `dog_id`, `cancer_type`, `age_at_diagnosis`, `age_at_death`, `cause_of_death`, `notes`, `created_at`
|
||||||
|
- [ ] Add `age_at_death` and `cause_of_death` optional fields to `dogs` table
|
||||||
|
|
||||||
|
**API:**
|
||||||
|
- [ ] `GET /api/health/:dogId/cancer-history`
|
||||||
|
- [ ] `POST /api/health/cancer-history`
|
||||||
|
- [ ] `GET /api/pedigree/:dogId/cancer-lineage` — walks ancestors and returns cancer incidence summary
|
||||||
|
|
||||||
|
**UI:**
|
||||||
|
- [ ] Longevity section on DogDetail — age at death, cause of death
|
||||||
|
- [ ] Cancer lineage indicator on Trial Pairing Simulator — "X of 8 ancestors had cancer history"
|
||||||
|
- [ ] Optional cancer history entry on DogForm
|
||||||
|
|
||||||
|
**Complexity:** Low-Medium | **Impact:** Medium | **User Value:** High (differentiator)
|
||||||
|
**Estimated Time:** 4–5 hours
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Tier 4 — Breeding Eligibility Checker *(Priority 4)* ✅
|
||||||
|
|
||||||
|
Automatic litter eligibility gate based on health clearance status of sire and dam.
|
||||||
|
|
||||||
|
**Logic:**
|
||||||
|
- [ ] Dog is "GRCA eligible" if: Hip OFA ✅ + Elbow OFA ✅ + Heart ✅ + Eyes (non-expired) ✅ + age ≥ 24 months
|
||||||
|
- [ ] Dog is "CHIC eligible" if all four tests are in OFA public database (CHIC number on file)
|
||||||
|
- [ ] Warning flags in Trial Pairing Simulator if sire or dam is missing required clearances
|
||||||
|
- [ ] Block litter creation (with override) if either parent fails eligibility check
|
||||||
|
|
||||||
|
**UI:**
|
||||||
|
- [ ] Eligibility badge on dog cards: `GRCA Eligible` (green) / `Incomplete` (yellow) / `Not Eligible` (red)
|
||||||
|
- [ ] Eligibility breakdown tooltip on hover — shows which tests are missing
|
||||||
|
- [ ] Pre-litter warning modal when creating a litter with non-eligible parents
|
||||||
|
- [ ] CHIC number field + verification note on DogDetail
|
||||||
|
|
||||||
|
**Complexity:** Low | **Impact:** High | **User Value:** Excellent
|
||||||
|
**Estimated Time:** 3–4 hours
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -134,10 +299,10 @@
|
|||||||
- [ ] Multi-generation COI analysis
|
- [ ] Multi-generation COI analysis
|
||||||
|
|
||||||
### Breeding Planning
|
### Breeding Planning
|
||||||
- [ ] Breeding calendar
|
- [ ] Heat cycle predictions (based on cycle history)
|
||||||
- [ ] Heat cycle predictions
|
- [ ] Expected whelping alerts / push notifications
|
||||||
- [ ] Expected whelping alerts
|
|
||||||
- [ ] Breeding history reports
|
- [ ] Breeding history reports
|
||||||
|
- [ ] iCal export for cycle events
|
||||||
|
|
||||||
### Search & Analytics
|
### Search & Analytics
|
||||||
- [ ] Advanced search filters
|
- [ ] Advanced search filters
|
||||||
@@ -181,7 +346,13 @@
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Future Enhancements (BACKLOG)
|
## Future / Extended Features (BACKLOG)
|
||||||
|
|
||||||
|
### Progesterone Tracking *(Moved from Phase 3)*
|
||||||
|
- [ ] Log progesterone level readings per heat cycle
|
||||||
|
- [ ] Chart progesterone curve over cycle days
|
||||||
|
- [ ] LH surge detection
|
||||||
|
- [ ] Optimal breeding day prediction from levels
|
||||||
|
|
||||||
### Multi-User Support
|
### Multi-User Support
|
||||||
- [ ] User authentication
|
- [ ] User authentication
|
||||||
@@ -194,9 +365,10 @@
|
|||||||
- [ ] Export to Excel/CSV
|
- [ ] Export to Excel/CSV
|
||||||
- [ ] Integration with kennel clubs
|
- [ ] Integration with kennel clubs
|
||||||
- [ ] Backup to cloud storage
|
- [ ] Backup to cloud storage
|
||||||
|
- [ ] OFA database lookup by registration number
|
||||||
|
|
||||||
### Advanced Genetics
|
### Advanced Genetics
|
||||||
- [ ] DNA test result tracking
|
- [ ] DNA test result tracking (full Embark import)
|
||||||
- [ ] Genetic diversity analysis
|
- [ ] Genetic diversity analysis
|
||||||
- [ ] Breed-specific calculators
|
- [ ] Breed-specific calculators
|
||||||
- [ ] Health risk predictions
|
- [ ] Health risk predictions
|
||||||
@@ -209,76 +381,58 @@
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 🌕 Current Sprint: v0.5.0
|
## 🏃 Current Sprint: v0.7.0 (Phase 4b)
|
||||||
|
|
||||||
### ✅ Completed This Sprint
|
### ✅ Completed This Sprint (v0.6.0)
|
||||||
- [x] Custom `br-logo.png` brand logo in navbar
|
- [x] `is_champion` flag − DB column, API, DogForm toggle, offspring badge, parent dropdown `✪`
|
||||||
- [x] Gold-to-rusty-red gradient on BREEDR title
|
- [x] Kennel Settings − `settings` table with all kennel fields, `GET/PUT /api/settings`, `SettingsProvider`, navbar kennel name
|
||||||
- [x] `/static` directory served by Express
|
- [x] `useSettings.jsx` rename (Vite build fix)
|
||||||
- [x] Vite dev proxy for `/static`
|
- [x] `server/index.js` fix − `initDatabase()` no-arg, duplicate health route removed
|
||||||
- [x] Fixed route fall-through for `/static` and `/uploads`
|
- [x] `server/routes/settings.js` rewrite: double-encoded base64 + wrong schema fixed
|
||||||
- [x] Brand logo fixed to 1:1 aspect ratio
|
|
||||||
|
|
||||||
### 🚧 Next Up (Priority Order)
|
### ✅ Previously Completed (v0.5.1)
|
||||||
|
- [x] Projected Whelping Calendar Identifier − indigo whelp window cells, due label, active card range, jump-to-month button
|
||||||
|
- [x] Live whelp preview in Cycle Detail modal (client-side, no save required)
|
||||||
|
- [x] Full-width whelping banner for months with projected whelps
|
||||||
|
- [x] "Projected Whelp" legend entry + updated page subtitle
|
||||||
|
|
||||||
#### Option 1: Trial Pairing Simulator (Recommended) 👍
|
### 🔜 Next Up — Phase 4b Build Order
|
||||||
**Complexity:** Medium | **Impact:** High | **User Value:** Excellent
|
|
||||||
|
|
||||||
**Why this is recommended:**
|
#### Step 1: DB Schema Extensions
|
||||||
- Leverages existing COI calculator backend
|
- [ ] Extend `health_records` table with OFA-specific columns (test_type, result, ofa_number, chic_number, expires_at, document_url)
|
||||||
- Provides immediate breeding decision support
|
- [ ] Create `genetic_tests` table (PRA, ICH, NCL, DM, MD, GR-PRA variants)
|
||||||
- High value feature for breeders
|
- [ ] Create `cancer_history` table
|
||||||
- Relatively quick to implement
|
- [ ] Add `chic_number`, `age_at_death`, `cause_of_death` to `dogs` table
|
||||||
|
- [ ] All changes via safe ALTER TABLE / CREATE TABLE IF NOT EXISTS guards
|
||||||
|
|
||||||
**Tasks:**
|
#### Step 2: API Layer
|
||||||
- Create `PairingSimulator` component
|
- [ ] `GET|POST|PUT|DELETE /api/health/:dogId` (OFA records)
|
||||||
- Add sire/dam selection dropdowns
|
- [ ] `GET /api/health/:dogId/clearance-summary`
|
||||||
- Display COI calculation results
|
- [ ] `GET /api/health/:dogId/chic-eligible`
|
||||||
- Show common ancestors table
|
- [ ] `GET|POST|PUT|DELETE /api/genetics/:dogId`
|
||||||
- Add genetic risk assessment
|
- [ ] `GET /api/genetics/pairing-risk` (sire + dam carrier check)
|
||||||
- Color-coded recommendations (green/yellow/red)
|
- [ ] Cancer history endpoints
|
||||||
|
|
||||||
**Estimated Time:** 4-6 hours
|
#### Step 3: Core UI — Health Records
|
||||||
|
- [ ] `HealthRecordForm` modal (test type, result, OFA#, expiry, doc upload)
|
||||||
|
- [ ] `HealthTimeline` on DogDetail page
|
||||||
|
- [ ] `ClearanceSummaryCard` 2×2 grid (Hip / Elbow / Heart / Eyes)
|
||||||
|
- [ ] `ChicStatusBadge` on dog cards
|
||||||
|
- [ ] Expiry alert badges (90-day warning, expired)
|
||||||
|
|
||||||
---
|
#### Step 4: Core UI — Genetics Panel
|
||||||
|
- [ ] `GeneticTestForm` modal
|
||||||
|
- [ ] `GeneticPanelCard` on DogDetail (color-coded markers)
|
||||||
|
- [ ] Pairing risk overlay on Trial Pairing Simulator
|
||||||
|
|
||||||
#### Option 2: Heat Cycle Management
|
#### Step 5: Eligibility Checker
|
||||||
**Complexity:** Medium-High | **Impact:** Medium | **User Value:** Good
|
- [ ] Eligibility logic (`grca_eligible`, `chic_eligible` computed fields)
|
||||||
|
- [ ] Eligibility badge on dog cards
|
||||||
|
- [ ] Pre-litter eligibility warning modal
|
||||||
|
|
||||||
**Why consider this:**
|
#### Step 6: Cancer / Longevity (Stretch)
|
||||||
- Natural extension of litter management
|
- [ ] Cancer history form + lineage summary on Trial Pairing page
|
||||||
- Helps with breeding planning
|
- [ ] Age at death / cause of death on DogDetail
|
||||||
- Provides calendar functionality
|
|
||||||
|
|
||||||
**Tasks:**
|
|
||||||
- Create `HeatCycleForm` component
|
|
||||||
- Add calendar view with heat cycle tracking
|
|
||||||
- Track progesterone levels
|
|
||||||
- Implement breeding date suggestions
|
|
||||||
- Whelping date calculator
|
|
||||||
|
|
||||||
**Estimated Time:** 6-8 hours
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
#### Option 3: Health Records System
|
|
||||||
**Complexity:** Medium | **Impact:** High | **User Value:** Excellent
|
|
||||||
|
|
||||||
**Why consider this:**
|
|
||||||
- Important for breeding decisions
|
|
||||||
- Vaccination tracking is valuable
|
|
||||||
- Document management adds utility
|
|
||||||
|
|
||||||
**Tasks:**
|
|
||||||
- Create `HealthRecordForm` component
|
|
||||||
- Add vaccination tracking with expiry alerts
|
|
||||||
- Medical history timeline view
|
|
||||||
- PDF/image document uploads
|
|
||||||
- Health clearance status badges
|
|
||||||
|
|
||||||
**Estimated Time:** 6-8 hours
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### Testing Needed
|
### Testing Needed
|
||||||
- [x] Add/edit dog forms with litter selection
|
- [x] Add/edit dog forms with litter selection
|
||||||
@@ -291,9 +445,15 @@
|
|||||||
- [x] Brand logo display and sizing
|
- [x] Brand logo display and sizing
|
||||||
- [x] Gradient title rendering
|
- [x] Gradient title rendering
|
||||||
- [x] Static asset serving in prod and dev
|
- [x] Static asset serving in prod and dev
|
||||||
- [ ] Trial pairing simulator
|
- [ ] Champion toggle − DogForm save/load round-trip
|
||||||
- [ ] Heat cycle tracking
|
- [ ] Champion badge − offspring card display
|
||||||
- [ ] Health records
|
- [ ] Kennel settings − save + navbar name update
|
||||||
|
- [ ] Trial pairing simulator (end-to-end)
|
||||||
|
- [ ] Heat cycle calendar (start cycle, detail modal, whelping)
|
||||||
|
- [ ] Projected whelping calendar identifier (whelp cells, due label, banner)
|
||||||
|
- [ ] Health records — OFA clearance CRUD
|
||||||
|
- [ ] Genetic panel — DNA marker entry and display
|
||||||
|
- [ ] Eligibility checker — badge and litter gate
|
||||||
|
|
||||||
### Known Issues
|
### Known Issues
|
||||||
- None currently
|
- None currently
|
||||||
@@ -302,14 +462,45 @@
|
|||||||
|
|
||||||
## How to Contribute
|
## How to Contribute
|
||||||
|
|
||||||
1. Pick a feature from "Priority Features"
|
1. Pick a feature from "Next Up" above
|
||||||
2. Create a feature branch: `feature/feature-name`
|
2. Create a feature branch off `master`: `feat/feature-name`
|
||||||
3. Implement with tests
|
3. Implement with tests
|
||||||
4. Update this roadmap
|
4. Update this roadmap and README.md
|
||||||
5. Submit for review
|
5. Submit PR for review
|
||||||
|
|
||||||
## Version History
|
## Version History
|
||||||
|
|
||||||
|
- **v0.7.0** (In Progress) - Phase 4b: Health & Genetics
|
||||||
|
- OFA clearance tracking (Hip, Elbow, Heart, Eyes + CHIC number)
|
||||||
|
- DNA genetic panel (PRA, ICH, NCL, DM, MD variants)
|
||||||
|
- Cancer lineage & longevity tracking
|
||||||
|
- Breeding eligibility checker (GRCA + CHIC gates)
|
||||||
|
|
||||||
|
- **v0.6.0** (March 9, 2026) - Champion Bloodline, Settings, Build Fixes
|
||||||
|
- `is_champion` flag on dogs table with ALTER TABLE migration guard
|
||||||
|
- Champion toggle in DogForm; `✪` suffix in parent dropdowns; offspring badge
|
||||||
|
- Kennel settings table + `GET/PUT /api/settings` + `SettingsProvider`
|
||||||
|
- `useSettings.jsx` rename (Vite build fix)
|
||||||
|
- `server/index.js` fix: `initDatabase()` no-arg, duplicate health route removed
|
||||||
|
- `server/routes/settings.js` rewrite: double-encoded base64 + wrong schema fixed
|
||||||
|
|
||||||
|
- **v0.5.1** (March 9, 2026) - Projected Whelping Calendar Identifier
|
||||||
|
- Indigo whelp window cells (days 58–65) on month grid
|
||||||
|
- Indigo dot marker on exact expected whelp day (day 63)
|
||||||
|
- `Baby` icon + "[Name] due" label in whelp day cells
|
||||||
|
- "Whelp est." range row on active cycle cards
|
||||||
|
- Jump-to-whelp-month button on cycle cards
|
||||||
|
- Live whelp preview in Cycle Detail modal (client-side, instant)
|
||||||
|
- Full-width whelping banner when projected whelps exist
|
||||||
|
- "Projected Whelp" legend entry + updated page subtitle
|
||||||
|
|
||||||
|
- **v0.5.0** (March 9, 2026) - Breeding Tools Complete
|
||||||
|
- Trial Pairing Simulator: COI calculator, risk badge, common ancestors
|
||||||
|
- Heat Cycle Calendar: month grid, phase color coding, start-cycle modal
|
||||||
|
- Cycle Detail: breeding windows, inline breeding date, whelping estimate
|
||||||
|
- New API: `GET /heat-cycles`, `GET /heat-cycles/:id/suggestions`
|
||||||
|
- Progesterone tracking moved to extended backlog
|
||||||
|
|
||||||
- **v0.4.1** (March 9, 2026) - Branding & Header Improvements
|
- **v0.4.1** (March 9, 2026) - Branding & Header Improvements
|
||||||
- Custom br-logo.png in navbar
|
- Custom br-logo.png in navbar
|
||||||
- Gold-to-rusty-red gradient title
|
- Gold-to-rusty-red gradient title
|
||||||
|
|||||||
2675
client/package-lock.json
generated
Normal file
2675
client/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
@@ -27,16 +27,16 @@
|
|||||||
gap: 0.75rem;
|
gap: 0.75rem;
|
||||||
color: var(--text-primary);
|
color: var(--text-primary);
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
font-size: 2.25rem; /* +30% from 1.5rem */
|
font-size: 2.25rem;
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
transition: var(--transition);
|
transition: var(--transition);
|
||||||
}
|
}
|
||||||
|
|
||||||
.nav-brand:hover {
|
.nav-brand:hover {
|
||||||
color: var(--primary-light);
|
opacity: 0.9;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Square logo: doubled from 2.5rem to 5rem */
|
/* Square logo */
|
||||||
.brand-logo {
|
.brand-logo {
|
||||||
width: 5rem;
|
width: 5rem;
|
||||||
height: 5rem;
|
height: 5rem;
|
||||||
@@ -45,7 +45,6 @@
|
|||||||
display: block;
|
display: block;
|
||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
/* Subtle diffuse black drop shadow for depth */
|
|
||||||
filter: drop-shadow(0 2px 6px rgba(0, 0, 0, 0.45))
|
filter: drop-shadow(0 2px 6px rgba(0, 0, 0, 0.45))
|
||||||
drop-shadow(0 1px 2px rgba(0, 0, 0, 0.30));
|
drop-shadow(0 1px 2px rgba(0, 0, 0, 0.30));
|
||||||
}
|
}
|
||||||
@@ -58,7 +57,7 @@
|
|||||||
height: 5rem;
|
height: 5rem;
|
||||||
background: linear-gradient(135deg, var(--primary) 0%, var(--accent) 100%);
|
background: linear-gradient(135deg, var(--primary) 0%, var(--accent) 100%);
|
||||||
border-radius: var(--radius);
|
border-radius: var(--radius);
|
||||||
box-shadow: 0 4px 12px rgba(59, 130, 246, 0.3);
|
box-shadow: 0 4px 12px rgba(194, 134, 42, 0.3);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Title gradient: medium-dark gold → rusty dark red-gold */
|
/* Title gradient: medium-dark gold → rusty dark red-gold */
|
||||||
@@ -68,7 +67,6 @@
|
|||||||
-webkit-background-clip: text;
|
-webkit-background-clip: text;
|
||||||
-webkit-text-fill-color: transparent;
|
-webkit-text-fill-color: transparent;
|
||||||
background-clip: text;
|
background-clip: text;
|
||||||
/* text-shadow doesn't work with background-clip:text — use filter instead */
|
|
||||||
filter: drop-shadow(0 2px 4px rgba(0, 0, 0, 0.50))
|
filter: drop-shadow(0 2px 4px rgba(0, 0, 0, 0.50))
|
||||||
drop-shadow(0 1px 2px rgba(0, 0, 0, 0.30));
|
drop-shadow(0 1px 2px rgba(0, 0, 0, 0.30));
|
||||||
}
|
}
|
||||||
@@ -76,6 +74,7 @@
|
|||||||
.nav-links {
|
.nav-links {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 0.5rem;
|
gap: 0.5rem;
|
||||||
|
align-items: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
.nav-link {
|
.nav-link {
|
||||||
@@ -99,9 +98,22 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.nav-link.active {
|
.nav-link.active {
|
||||||
background: var(--primary);
|
background: linear-gradient(135deg, rgba(201,148,10,0.2) 0%, rgba(139,37,0,0.2) 100%);
|
||||||
color: white;
|
color: var(--primary-light);
|
||||||
box-shadow: 0 4px 12px rgba(59, 130, 246, 0.3);
|
border-color: rgba(194, 134, 42, 0.4);
|
||||||
|
box-shadow: 0 2px 8px rgba(194, 134, 42, 0.15);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Settings link — slightly different treatment, sits at end */
|
||||||
|
.nav-link-settings {
|
||||||
|
margin-left: 0.5rem;
|
||||||
|
padding: 0.5rem;
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-link-settings:hover {
|
||||||
|
color: var(--primary-light);
|
||||||
}
|
}
|
||||||
|
|
||||||
.main-content {
|
.main-content {
|
||||||
@@ -114,10 +126,9 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.nav-brand {
|
.nav-brand {
|
||||||
font-size: 1.625rem; /* +30% from 1.25rem */
|
font-size: 1.625rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Scale square logo down on mobile (doubled from 2rem) */
|
|
||||||
.brand-logo {
|
.brand-logo {
|
||||||
width: 4rem;
|
width: 4rem;
|
||||||
height: 4rem;
|
height: 4rem;
|
||||||
|
|||||||
@@ -1,65 +1,79 @@
|
|||||||
import { BrowserRouter as Router, Routes, Route, Link} from 'react-router-dom'
|
import { BrowserRouter as Router, Routes, Route, Link, useLocation } from 'react-router-dom'
|
||||||
import { Home, Users, Activity, Heart, FlaskConical } from 'lucide-react'
|
import { Home, PawPrint, Activity, Heart, FlaskConical, Settings, ExternalLink } 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'
|
||||||
import PedigreeView from './pages/PedigreeView'
|
import PedigreeView from './pages/PedigreeView'
|
||||||
import LitterList from './pages/LitterList'
|
import LitterList from './pages/LitterList'
|
||||||
|
import LitterDetail from './pages/LitterDetail'
|
||||||
import BreedingCalendar from './pages/BreedingCalendar'
|
import BreedingCalendar from './pages/BreedingCalendar'
|
||||||
import PairingSimulator from './pages/PairingSimulator'
|
import PairingSimulator from './pages/PairingSimulator'
|
||||||
|
import SettingsPage from './pages/SettingsPage'
|
||||||
|
import ExternalDogs from './pages/ExternalDogs'
|
||||||
|
import { useSettings } from './hooks/useSettings'
|
||||||
import './App.css'
|
import './App.css'
|
||||||
|
|
||||||
|
function NavLink({ to, icon: Icon, label }) {
|
||||||
|
const location = useLocation()
|
||||||
|
const isActive = location.pathname === to
|
||||||
|
return (
|
||||||
|
<Link to={to} className={`nav-link${isActive ? ' active' : ''}`}>
|
||||||
|
<Icon size={20} />
|
||||||
|
<span>{label}</span>
|
||||||
|
</Link>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function AppInner() {
|
||||||
|
const { settings } = useSettings()
|
||||||
|
const kennelName = settings?.kennel_name || 'BREEDR'
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="app">
|
||||||
|
<nav className="navbar">
|
||||||
|
<div className="container">
|
||||||
|
<div className="nav-brand">
|
||||||
|
<img
|
||||||
|
src="/static/br-logo.png"
|
||||||
|
alt="BREEDR Logo"
|
||||||
|
className="brand-logo"
|
||||||
|
/>
|
||||||
|
<span className="brand-text">{kennelName}</span>
|
||||||
|
</div>
|
||||||
|
<div className="nav-links">
|
||||||
|
<NavLink to="/" icon={Home} label="Dashboard" />
|
||||||
|
<NavLink to="/dogs" icon={PawPrint} label="Dogs" />
|
||||||
|
<NavLink to="/external" icon={ExternalLink} label="External" />
|
||||||
|
<NavLink to="/litters" icon={Activity} label="Litters" />
|
||||||
|
<NavLink to="/breeding" icon={Heart} label="Breeding" />
|
||||||
|
<NavLink to="/pairing" icon={FlaskConical} label="Pairing" />
|
||||||
|
<NavLink to="/settings" icon={Settings} label="Settings" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<main className="main-content">
|
||||||
|
<Routes>
|
||||||
|
<Route path="/" element={<Dashboard />} />
|
||||||
|
<Route path="/dogs" element={<DogList />} />
|
||||||
|
<Route path="/dogs/:id" element={<DogDetail />} />
|
||||||
|
<Route path="/external" element={<ExternalDogs />} />
|
||||||
|
<Route path="/pedigree/:id" element={<PedigreeView />} />
|
||||||
|
<Route path="/litters" element={<LitterList />} />
|
||||||
|
<Route path="/litters/:id" element={<LitterDetail />} />
|
||||||
|
<Route path="/breeding" element={<BreedingCalendar />} />
|
||||||
|
<Route path="/pairing" element={<PairingSimulator />} />
|
||||||
|
<Route path="/settings" element={<SettingsPage />} />
|
||||||
|
</Routes>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
function App() {
|
function App() {
|
||||||
return (
|
return (
|
||||||
<Router>
|
<Router>
|
||||||
<div className="app">
|
<AppInner />
|
||||||
<nav className="navbar">
|
|
||||||
<div className="container">
|
|
||||||
<div className="nav-brand">
|
|
||||||
<img
|
|
||||||
src="/static/br-logo.png"
|
|
||||||
alt="BREEDR Logo"
|
|
||||||
className="brand-logo"
|
|
||||||
/>
|
|
||||||
<span className="brand-text">BREEDR</span>
|
|
||||||
</div>
|
|
||||||
<div className="nav-links">
|
|
||||||
<Link to="/" className="nav-link">
|
|
||||||
<Home size={20} />
|
|
||||||
<span>Dashboard</span>
|
|
||||||
</Link>
|
|
||||||
<Link to="/dogs" className="nav-link">
|
|
||||||
<Users size={20} />
|
|
||||||
<span>Dogs</span>
|
|
||||||
</Link>
|
|
||||||
<Link to="/litters" className="nav-link">
|
|
||||||
<Activity size={20} />
|
|
||||||
<span>Litters</span>
|
|
||||||
</Link>
|
|
||||||
<Link to="/breeding" className="nav-link">
|
|
||||||
<Heart size={20} />
|
|
||||||
<span>Breeding</span>
|
|
||||||
</Link>
|
|
||||||
<Link to="/pairing" className="nav-link">
|
|
||||||
<FlaskConical size={20} />
|
|
||||||
<span>Pairing</span>
|
|
||||||
</Link>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</nav>
|
|
||||||
|
|
||||||
<main className="main-content">
|
|
||||||
<Routes>
|
|
||||||
<Route path="/" element={<Dashboard />} />
|
|
||||||
<Route path="/dogs" element={<DogList />} />
|
|
||||||
<Route path="/dogs/:id" element={<DogDetail />} />
|
|
||||||
<Route path="/pedigree/:id" element={<PedigreeView />} />
|
|
||||||
<Route path="/litters" element={<LitterList />} />
|
|
||||||
<Route path="/breeding" element={<BreedingCalendar />} />
|
|
||||||
<Route path="/pairing" element={<PairingSimulator />} />
|
|
||||||
</Routes>
|
|
||||||
</main>
|
|
||||||
</div>
|
|
||||||
</Router>
|
</Router>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
52
client/src/components/ChampionBadge.jsx
Normal file
52
client/src/components/ChampionBadge.jsx
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
/**
|
||||||
|
* ChampionBadge — shown on dogs with is_champion = 1
|
||||||
|
* ChampionBloodlineBadge — shown on dogs whose sire OR dam is a champion
|
||||||
|
*
|
||||||
|
* Usage:
|
||||||
|
* <ChampionBadge />
|
||||||
|
* <ChampionBloodlineBadge />
|
||||||
|
*/
|
||||||
|
|
||||||
|
export function ChampionBadge({ size = 'sm' }) {
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
className="badge-champion"
|
||||||
|
title="AKC / Registry Champion"
|
||||||
|
style={size === 'lg' ? { fontSize: '0.8rem', padding: '0.3rem 0.7rem' } : {}}
|
||||||
|
>
|
||||||
|
{/* Crown SVG inline — no extra dep */}
|
||||||
|
<svg
|
||||||
|
width={size === 'lg' ? 14 : 11}
|
||||||
|
height={size === 'lg' ? 14 : 11}
|
||||||
|
viewBox="0 0 20 20"
|
||||||
|
fill="currentColor"
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
|
<path d="M2 15h16v2H2v-2zm0-2 3-7 5 4 5-4 3 7H2z" />
|
||||||
|
</svg>
|
||||||
|
CH
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ChampionBloodlineBadge({ size = 'sm' }) {
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
className="badge-bloodline"
|
||||||
|
title="Direct descendant of a champion"
|
||||||
|
style={size === 'lg' ? { fontSize: '0.8rem', padding: '0.3rem 0.7rem' } : {}}
|
||||||
|
>
|
||||||
|
{/* Droplet / bloodline SVG */}
|
||||||
|
<svg
|
||||||
|
width={size === 'lg' ? 13 : 10}
|
||||||
|
height={size === 'lg' ? 13 : 10}
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="currentColor"
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
|
<path d="M12 2C8 8 5 12 5 15.5a7 7 0 0 0 14 0C19 12 16 8 12 2z" />
|
||||||
|
</svg>
|
||||||
|
BL
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
}
|
||||||
126
client/src/components/ClearanceSummaryCard.jsx
Normal file
126
client/src/components/ClearanceSummaryCard.jsx
Normal file
@@ -0,0 +1,126 @@
|
|||||||
|
import { useEffect, useState } from 'react'
|
||||||
|
import { ShieldCheck, ShieldAlert, ShieldX, Clock, AlertTriangle, Plus } from 'lucide-react'
|
||||||
|
import axios from 'axios'
|
||||||
|
|
||||||
|
const STATUS_CONFIG = {
|
||||||
|
pass: { icon: ShieldCheck, color: 'var(--success)', label: 'Clear', bg: 'rgba(52,199,89,0.1)' },
|
||||||
|
expiring_soon: { icon: Clock, color: 'var(--warning)', label: 'Expiring Soon', bg: 'rgba(255,159,10,0.1)' },
|
||||||
|
expired: { icon: ShieldX, color: 'var(--danger)', label: 'Expired', bg: 'rgba(255,59,48,0.1)' },
|
||||||
|
missing: { icon: ShieldAlert, color: 'var(--text-muted)', label: 'Missing', bg: 'var(--bg-primary)' },
|
||||||
|
}
|
||||||
|
|
||||||
|
const GROUP_LABELS = { hip: 'Hips', elbow: 'Elbows', heart: 'Heart', eye: 'Eyes' }
|
||||||
|
|
||||||
|
function ClearanceChip({ group, status, record }) {
|
||||||
|
const cfg = STATUS_CONFIG[status] || STATUS_CONFIG.missing
|
||||||
|
const Icon = cfg.icon
|
||||||
|
const tip = record
|
||||||
|
? `OFA #${record.ofa_number || '-'} - ${record.ofa_result || record.result || ''}`
|
||||||
|
: 'No record on file'
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
title={tip}
|
||||||
|
style={{
|
||||||
|
display: 'flex', alignItems: 'center', gap: '0.4rem',
|
||||||
|
padding: '0.45rem 0.75rem',
|
||||||
|
background: cfg.bg,
|
||||||
|
border: `1px solid ${cfg.color}44`,
|
||||||
|
borderRadius: 'var(--radius-sm)',
|
||||||
|
flex: '1 1 calc(50% - 0.5rem)',
|
||||||
|
minWidth: '140px',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Icon size={15} color={cfg.color} />
|
||||||
|
<div style={{ flex: 1 }}>
|
||||||
|
<div style={{ fontSize: '0.7rem', color: 'var(--text-muted)', textTransform: 'uppercase', letterSpacing: '0.05em' }}>
|
||||||
|
{GROUP_LABELS[group]}
|
||||||
|
</div>
|
||||||
|
<div style={{ fontSize: '0.82rem', fontWeight: 500, color: cfg.color }}>
|
||||||
|
{cfg.label}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function ClearanceSummaryCard({ dogId, onAddRecord }) {
|
||||||
|
const [data, setData] = useState(null)
|
||||||
|
const [error, setError] = useState(null)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
axios.get(`/api/health/dog/${dogId}/clearance-summary`)
|
||||||
|
.then(r => setData(r.data))
|
||||||
|
.catch(() => setError(true))
|
||||||
|
}, [dogId])
|
||||||
|
|
||||||
|
if (error || !data) return null
|
||||||
|
|
||||||
|
const { summary, grca_eligible, age_eligible, chic_number } = data
|
||||||
|
const hasMissing = Object.values(summary).some(s => s.status === 'missing')
|
||||||
|
const hasExpiring = Object.values(summary).some(s => s.status === 'expiring_soon')
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="card" style={{ marginBottom: '1.5rem' }}>
|
||||||
|
{/* Header row */}
|
||||||
|
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '1rem' }}>
|
||||||
|
<h2 style={{ fontSize: '1rem', color: 'var(--text-muted)', textTransform: 'uppercase', letterSpacing: '0.05em', margin: 0 }}>
|
||||||
|
OFA Clearances
|
||||||
|
</h2>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: '0.5rem', flexWrap: 'wrap' }}>
|
||||||
|
{grca_eligible && (
|
||||||
|
<span style={{
|
||||||
|
fontSize: '0.7rem', fontWeight: 600, padding: '0.2rem 0.6rem',
|
||||||
|
background: 'rgba(52,199,89,0.15)', color: 'var(--success)',
|
||||||
|
borderRadius: '999px', border: '1px solid rgba(52,199,89,0.3)'
|
||||||
|
}}>GRCA Eligible</span>
|
||||||
|
)}
|
||||||
|
{!age_eligible && (
|
||||||
|
<span style={{
|
||||||
|
fontSize: '0.7rem', fontWeight: 600, padding: '0.2rem 0.6rem',
|
||||||
|
background: 'rgba(255,159,10,0.15)', color: 'var(--warning)',
|
||||||
|
borderRadius: '999px', border: '1px solid rgba(255,159,10,0.3)'
|
||||||
|
}}>Under 24mo</span>
|
||||||
|
)}
|
||||||
|
{chic_number && (
|
||||||
|
<span style={{
|
||||||
|
fontSize: '0.7rem', fontWeight: 600, padding: '0.2rem 0.6rem',
|
||||||
|
background: 'rgba(99,102,241,0.15)', color: '#818cf8',
|
||||||
|
borderRadius: '999px', border: '1px solid rgba(99,102,241,0.3)'
|
||||||
|
}}>CHIC #{chic_number}</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Clearance chips */}
|
||||||
|
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '0.5rem', marginBottom: '0.75rem' }}>
|
||||||
|
{Object.entries(summary).map(([group, { status, record }]) => (
|
||||||
|
<ClearanceChip key={group} group={group} status={status} record={record} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Expiry warning */}
|
||||||
|
{hasExpiring && (
|
||||||
|
<div style={{
|
||||||
|
display: 'flex', alignItems: 'center', gap: '0.5rem',
|
||||||
|
padding: '0.5rem 0.75rem', borderRadius: 'var(--radius-sm)',
|
||||||
|
background: 'rgba(255,159,10,0.08)', border: '1px solid rgba(255,159,10,0.25)',
|
||||||
|
fontSize: '0.8rem', color: 'var(--warning)', marginBottom: '0.5rem'
|
||||||
|
}}>
|
||||||
|
<AlertTriangle size={14} />
|
||||||
|
One or more clearances expire within 90 days. Schedule re-testing.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* CTA */}
|
||||||
|
{(hasMissing || onAddRecord) && (
|
||||||
|
<button
|
||||||
|
className="btn btn-ghost"
|
||||||
|
onClick={onAddRecord}
|
||||||
|
style={{ fontSize: '0.8rem', padding: '0.35rem 0.75rem', marginTop: '0.25rem', display: 'flex', alignItems: 'center', gap: '0.3rem' }}
|
||||||
|
>
|
||||||
|
<Plus size={14} /> Add Health Record
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,8 +1,8 @@
|
|||||||
import { useState, useEffect } from 'react'
|
import { useState, useEffect } from 'react'
|
||||||
import { X } from 'lucide-react'
|
import { X, Award, ExternalLink } from 'lucide-react'
|
||||||
import axios from 'axios'
|
import axios from 'axios'
|
||||||
|
|
||||||
function DogForm({ dog, onClose, onSave }) {
|
function DogForm({ dog, onClose, onSave, isExternal = false }) {
|
||||||
const [formData, setFormData] = useState({
|
const [formData, setFormData] = useState({
|
||||||
name: '',
|
name: '',
|
||||||
registration_number: '',
|
registration_number: '',
|
||||||
@@ -12,9 +12,11 @@ function DogForm({ dog, onClose, onSave }) {
|
|||||||
color: '',
|
color: '',
|
||||||
microchip: '',
|
microchip: '',
|
||||||
notes: '',
|
notes: '',
|
||||||
sire_id: null, // Changed from '' to null
|
sire_id: null,
|
||||||
dam_id: null, // Changed from '' to null
|
dam_id: null,
|
||||||
litter_id: null // Changed from '' to null
|
litter_id: null,
|
||||||
|
is_champion: false,
|
||||||
|
is_external: isExternal ? 1 : 0,
|
||||||
})
|
})
|
||||||
const [dogs, setDogs] = useState([])
|
const [dogs, setDogs] = useState([])
|
||||||
const [litters, setLitters] = useState([])
|
const [litters, setLitters] = useState([])
|
||||||
@@ -23,9 +25,14 @@ function DogForm({ dog, onClose, onSave }) {
|
|||||||
const [useManualParents, setUseManualParents] = useState(true)
|
const [useManualParents, setUseManualParents] = useState(true)
|
||||||
const [littersAvailable, setLittersAvailable] = useState(false)
|
const [littersAvailable, setLittersAvailable] = useState(false)
|
||||||
|
|
||||||
|
// Derive effective external state (editing an existing external dog or explicitly flagged)
|
||||||
|
const effectiveExternal = isExternal || (dog && dog.is_external)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
fetchDogs()
|
if (!effectiveExternal) {
|
||||||
fetchLitters()
|
fetchDogs()
|
||||||
|
fetchLitters()
|
||||||
|
}
|
||||||
if (dog) {
|
if (dog) {
|
||||||
setFormData({
|
setFormData({
|
||||||
name: dog.name || '',
|
name: dog.name || '',
|
||||||
@@ -36,9 +43,11 @@ function DogForm({ dog, onClose, onSave }) {
|
|||||||
color: dog.color || '',
|
color: dog.color || '',
|
||||||
microchip: dog.microchip || '',
|
microchip: dog.microchip || '',
|
||||||
notes: dog.notes || '',
|
notes: dog.notes || '',
|
||||||
sire_id: dog.sire?.id || null, // Ensure null, not ''
|
sire_id: dog.sire?.id || null,
|
||||||
dam_id: dog.dam?.id || null, // Ensure null, not ''
|
dam_id: dog.dam?.id || null,
|
||||||
litter_id: dog.litter_id || null // Ensure null, not ''
|
litter_id: dog.litter_id || null,
|
||||||
|
is_champion: !!dog.is_champion,
|
||||||
|
is_external: dog.is_external ?? (isExternal ? 1 : 0),
|
||||||
})
|
})
|
||||||
setUseManualParents(!dog.litter_id)
|
setUseManualParents(!dog.litter_id)
|
||||||
}
|
}
|
||||||
@@ -48,8 +57,7 @@ function DogForm({ dog, onClose, onSave }) {
|
|||||||
try {
|
try {
|
||||||
const res = await axios.get('/api/dogs')
|
const res = await axios.get('/api/dogs')
|
||||||
setDogs(res.data || [])
|
setDogs(res.data || [])
|
||||||
} catch (error) {
|
} catch (e) {
|
||||||
console.error('Error fetching dogs:', error)
|
|
||||||
setDogs([])
|
setDogs([])
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -57,16 +65,11 @@ function DogForm({ dog, onClose, onSave }) {
|
|||||||
const fetchLitters = async () => {
|
const fetchLitters = async () => {
|
||||||
try {
|
try {
|
||||||
const res = await axios.get('/api/litters')
|
const res = await axios.get('/api/litters')
|
||||||
const litterData = res.data || []
|
const data = res.data || []
|
||||||
setLitters(litterData)
|
setLitters(data)
|
||||||
setLittersAvailable(litterData.length > 0)
|
setLittersAvailable(data.length > 0)
|
||||||
// Only default to manual if no litters exist
|
if (data.length === 0) setUseManualParents(true)
|
||||||
if (litterData.length === 0) {
|
} catch (e) {
|
||||||
setUseManualParents(true)
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error fetching litters:', error)
|
|
||||||
// If endpoint fails, gracefully fallback to manual mode
|
|
||||||
setLitters([])
|
setLitters([])
|
||||||
setLittersAvailable(false)
|
setLittersAvailable(false)
|
||||||
setUseManualParents(true)
|
setUseManualParents(true)
|
||||||
@@ -74,25 +77,27 @@ function DogForm({ dog, onClose, onSave }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const handleChange = (e) => {
|
const handleChange = (e) => {
|
||||||
const { name, value } = e.target
|
const { name, value, type, checked } = e.target
|
||||||
|
|
||||||
// Convert empty strings to null for ID fields
|
if (type === 'checkbox') {
|
||||||
let processedValue = value
|
setFormData(prev => ({ ...prev, [name]: checked }))
|
||||||
if (name === 'sire_id' || name === 'dam_id' || name === 'litter_id') {
|
return
|
||||||
processedValue = value === '' ? null : parseInt(value)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
setFormData(prev => ({ ...prev, [name]: processedValue }))
|
let processed = value
|
||||||
|
if (name === 'sire_id' || name === 'dam_id' || name === 'litter_id') {
|
||||||
|
processed = value === '' ? null : parseInt(value)
|
||||||
|
}
|
||||||
|
setFormData(prev => ({ ...prev, [name]: processed }))
|
||||||
|
|
||||||
// If litter is selected, auto-populate parents
|
|
||||||
if (name === 'litter_id' && value) {
|
if (name === 'litter_id' && value) {
|
||||||
const selectedLitter = litters.find(l => l.id === parseInt(value))
|
const sel = litters.find(l => l.id === parseInt(value))
|
||||||
if (selectedLitter) {
|
if (sel) {
|
||||||
setFormData(prev => ({
|
setFormData(prev => ({
|
||||||
...prev,
|
...prev,
|
||||||
sire_id: selectedLitter.sire_id,
|
sire_id: sel.sire_id,
|
||||||
dam_id: selectedLitter.dam_id,
|
dam_id: sel.dam_id,
|
||||||
breed: prev.breed || selectedLitter.sire_name?.split(' ')[0] || ''
|
breed: prev.breed || sel.sire_name?.split(' ')[0] || ''
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -102,97 +107,90 @@ function DogForm({ dog, onClose, onSave }) {
|
|||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
setError('')
|
setError('')
|
||||||
setLoading(true)
|
setLoading(true)
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const submitData = {
|
const submitData = {
|
||||||
...formData,
|
...formData,
|
||||||
// Ensure null values are sent, not empty strings
|
is_champion: formData.is_champion ? 1 : 0,
|
||||||
sire_id: formData.sire_id || null,
|
is_external: effectiveExternal ? 1 : 0,
|
||||||
dam_id: formData.dam_id || null,
|
sire_id: effectiveExternal ? null : (formData.sire_id || null),
|
||||||
litter_id: useManualParents ? null : (formData.litter_id || null),
|
dam_id: effectiveExternal ? null : (formData.dam_id || null),
|
||||||
|
litter_id: (effectiveExternal || useManualParents) ? null : (formData.litter_id || null),
|
||||||
registration_number: formData.registration_number || null,
|
registration_number: formData.registration_number || null,
|
||||||
birth_date: formData.birth_date || null,
|
birth_date: formData.birth_date || null,
|
||||||
color: formData.color || null,
|
color: formData.color || null,
|
||||||
microchip: formData.microchip || null,
|
microchip: formData.microchip || null,
|
||||||
notes: formData.notes || null
|
notes: formData.notes || null,
|
||||||
}
|
}
|
||||||
|
|
||||||
if (dog) {
|
if (dog) {
|
||||||
// Update existing dog
|
|
||||||
await axios.put(`/api/dogs/${dog.id}`, submitData)
|
await axios.put(`/api/dogs/${dog.id}`, submitData)
|
||||||
} else {
|
} else {
|
||||||
// Create new dog
|
|
||||||
await axios.post('/api/dogs', submitData)
|
await axios.post('/api/dogs', submitData)
|
||||||
}
|
}
|
||||||
onSave()
|
onSave()
|
||||||
onClose()
|
onClose()
|
||||||
} catch (error) {
|
} catch (err) {
|
||||||
setError(error.response?.data?.error || 'Failed to save dog')
|
setError(err.response?.data?.error || 'Failed to save dog')
|
||||||
setLoading(false)
|
setLoading(false)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const males = dogs.filter(d => d.sex === 'male' && d.id !== dog?.id)
|
const males = dogs.filter(d => d.sex === 'male' && d.id !== dog?.id)
|
||||||
const females = dogs.filter(d => d.sex === 'female' && d.id !== dog?.id)
|
const females = dogs.filter(d => d.sex === 'female' && d.id !== dog?.id)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="modal-overlay" onClick={onClose}>
|
<div className="modal-overlay" onClick={onClose}>
|
||||||
<div className="modal-content" onClick={(e) => e.stopPropagation()}>
|
<div className="modal-content" onClick={(e) => e.stopPropagation()}>
|
||||||
<div className="modal-header">
|
<div className="modal-header">
|
||||||
<h2>{dog ? 'Edit Dog' : 'Add New Dog'}</h2>
|
<h2>
|
||||||
<button className="btn-icon" onClick={onClose}>
|
{effectiveExternal && <ExternalLink size={18} style={{ marginRight: '0.4rem', verticalAlign: 'middle', color: 'var(--text-muted)' }} />}
|
||||||
<X size={24} />
|
{dog ? 'Edit Dog' : effectiveExternal ? 'Add External Dog' : 'Add New Dog'}
|
||||||
</button>
|
</h2>
|
||||||
|
<button className="btn-icon" onClick={onClose}><X size={24} /></button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{effectiveExternal && (
|
||||||
|
<div style={{
|
||||||
|
margin: '0 0 1rem',
|
||||||
|
padding: '0.6rem 1rem',
|
||||||
|
background: 'rgba(99,102,241,0.08)',
|
||||||
|
border: '1px solid rgba(99,102,241,0.25)',
|
||||||
|
borderRadius: 'var(--radius)',
|
||||||
|
fontSize: '0.875rem',
|
||||||
|
color: 'var(--text-secondary)',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: '0.5rem',
|
||||||
|
}}>
|
||||||
|
<ExternalLink size={14} />
|
||||||
|
External dog — not part of your kennel roster. Litter and parent fields are not applicable.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
<form onSubmit={handleSubmit} className="modal-body">
|
<form onSubmit={handleSubmit} className="modal-body">
|
||||||
{error && <div className="error">{error}</div>}
|
{error && <div className="error">{error}</div>}
|
||||||
|
|
||||||
<div className="form-grid">
|
<div className="form-grid">
|
||||||
<div className="form-group">
|
<div className="form-group">
|
||||||
<label className="label">Name *</label>
|
<label className="label">Name *</label>
|
||||||
<input
|
<input type="text" name="name" className="input"
|
||||||
type="text"
|
value={formData.name} onChange={handleChange} required />
|
||||||
name="name"
|
|
||||||
className="input"
|
|
||||||
value={formData.name}
|
|
||||||
onChange={handleChange}
|
|
||||||
required
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="form-group">
|
<div className="form-group">
|
||||||
<label className="label">Registration Number</label>
|
<label className="label">Registration Number</label>
|
||||||
<input
|
<input type="text" name="registration_number" className="input"
|
||||||
type="text"
|
value={formData.registration_number} onChange={handleChange} />
|
||||||
name="registration_number"
|
|
||||||
className="input"
|
|
||||||
value={formData.registration_number}
|
|
||||||
onChange={handleChange}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="form-group">
|
<div className="form-group">
|
||||||
<label className="label">Breed *</label>
|
<label className="label">Breed *</label>
|
||||||
<input
|
<input type="text" name="breed" className="input"
|
||||||
type="text"
|
value={formData.breed} onChange={handleChange} required />
|
||||||
name="breed"
|
|
||||||
className="input"
|
|
||||||
value={formData.breed}
|
|
||||||
onChange={handleChange}
|
|
||||||
required
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="form-group">
|
<div className="form-group">
|
||||||
<label className="label">Sex *</label>
|
<label className="label">Sex *</label>
|
||||||
<select
|
<select name="sex" className="input" value={formData.sex} onChange={handleChange} required>
|
||||||
name="sex"
|
|
||||||
className="input"
|
|
||||||
value={formData.sex}
|
|
||||||
onChange={handleChange}
|
|
||||||
required
|
|
||||||
>
|
|
||||||
<option value="male">Male</option>
|
<option value="male">Male</option>
|
||||||
<option value="female">Female</option>
|
<option value="female">Female</option>
|
||||||
</select>
|
</select>
|
||||||
@@ -200,141 +198,134 @@ function DogForm({ dog, onClose, onSave }) {
|
|||||||
|
|
||||||
<div className="form-group">
|
<div className="form-group">
|
||||||
<label className="label">Birth Date</label>
|
<label className="label">Birth Date</label>
|
||||||
<input
|
<input type="date" name="birth_date" className="input"
|
||||||
type="date"
|
value={formData.birth_date} onChange={handleChange} />
|
||||||
name="birth_date"
|
|
||||||
className="input"
|
|
||||||
value={formData.birth_date}
|
|
||||||
onChange={handleChange}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="form-group">
|
<div className="form-group">
|
||||||
<label className="label">Color</label>
|
<label className="label">Color</label>
|
||||||
<input
|
<input type="text" name="color" className="input"
|
||||||
type="text"
|
value={formData.color} onChange={handleChange} />
|
||||||
name="color"
|
|
||||||
className="input"
|
|
||||||
value={formData.color}
|
|
||||||
onChange={handleChange}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="form-group">
|
<div className="form-group">
|
||||||
<label className="label">Microchip Number</label>
|
<label className="label">Microchip Number</label>
|
||||||
<input
|
<input type="text" name="microchip" className="input"
|
||||||
type="text"
|
value={formData.microchip} onChange={handleChange} />
|
||||||
name="microchip"
|
|
||||||
className="input"
|
|
||||||
value={formData.microchip}
|
|
||||||
onChange={handleChange}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Litter or Manual Parent Selection */}
|
{/* Champion Toggle */}
|
||||||
<div style={{ marginTop: '1.5rem', padding: '1rem', background: 'rgba(99, 102, 241, 0.05)', borderRadius: '8px', border: '1px solid rgba(99, 102, 241, 0.2)' }}>
|
<div style={{
|
||||||
<label className="label" style={{ marginBottom: '0.75rem', display: 'block', fontWeight: '600' }}>Parent Information</label>
|
marginTop: '1.25rem',
|
||||||
|
padding: '0.875rem 1rem',
|
||||||
{littersAvailable && (
|
background: formData.is_champion ? 'rgba(194, 134, 42, 0.08)' : 'var(--bg-primary)',
|
||||||
<div style={{ display: 'flex', gap: '1.5rem', marginBottom: '1rem', flexWrap: 'wrap' }}>
|
border: formData.is_champion ? '1px solid var(--champion-gold)' : '1px solid var(--border)',
|
||||||
<label style={{ display: 'flex', alignItems: 'center', gap: '0.5rem', cursor: 'pointer', fontSize: '0.95rem' }}>
|
borderRadius: 'var(--radius)',
|
||||||
<input
|
transition: 'all 0.2s',
|
||||||
type="radio"
|
display: 'flex',
|
||||||
name="parentMode"
|
alignItems: 'center',
|
||||||
checked={!useManualParents}
|
gap: '0.75rem',
|
||||||
onChange={() => setUseManualParents(false)}
|
cursor: 'pointer',
|
||||||
style={{ width: '16px', height: '16px' }}
|
}}
|
||||||
/>
|
onClick={() => setFormData(prev => ({ ...prev, is_champion: !prev.is_champion }))}
|
||||||
<span>Link to Litter</span>
|
>
|
||||||
</label>
|
<input
|
||||||
<label style={{ display: 'flex', alignItems: 'center', gap: '0.5rem', cursor: 'pointer', fontSize: '0.95rem' }}>
|
type="checkbox"
|
||||||
<input
|
name="is_champion"
|
||||||
type="radio"
|
id="is_champion"
|
||||||
name="parentMode"
|
checked={!!formData.is_champion}
|
||||||
checked={useManualParents}
|
onChange={handleChange}
|
||||||
onChange={() => setUseManualParents(true)}
|
style={{ width: '18px', height: '18px', cursor: 'pointer', accentColor: 'var(--champion-gold)' }}
|
||||||
style={{ width: '16px', height: '16px' }}
|
onClick={e => e.stopPropagation()}
|
||||||
/>
|
/>
|
||||||
<span>Manual Parent Selection</span>
|
<Award size={18} style={{ color: formData.is_champion ? 'var(--champion-gold)' : 'var(--text-muted)' }} />
|
||||||
</label>
|
<div>
|
||||||
|
<div style={{ fontWeight: 600, color: formData.is_champion ? 'var(--champion-gold)' : 'var(--text-primary)', fontSize: '0.9375rem' }}>
|
||||||
|
Champion
|
||||||
</div>
|
</div>
|
||||||
)}
|
<div style={{ fontSize: '0.8rem', color: 'var(--text-muted)' }}>
|
||||||
|
Mark this dog as a titled champion — offspring will display a Champion Bloodline badge
|
||||||
{!useManualParents && littersAvailable ? (
|
|
||||||
<div className="form-group" style={{ marginTop: '0.5rem' }}>
|
|
||||||
<label className="label">Select Litter</label>
|
|
||||||
<select
|
|
||||||
name="litter_id"
|
|
||||||
className="input"
|
|
||||||
value={formData.litter_id || ''}
|
|
||||||
onChange={handleChange}
|
|
||||||
>
|
|
||||||
<option value="">No Litter</option>
|
|
||||||
{litters.map(l => (
|
|
||||||
<option key={l.id} value={l.id}>
|
|
||||||
{l.sire_name} x {l.dam_name} - {new Date(l.breeding_date).toLocaleDateString()}
|
|
||||||
</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
{formData.litter_id && (
|
|
||||||
<div style={{ marginTop: '0.5rem', fontSize: '0.875rem', color: '#6366f1', fontStyle: 'italic' }}>
|
|
||||||
✓ Parents will be automatically set from the selected litter
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
) : (
|
</div>
|
||||||
<div className="form-grid" style={{ marginTop: '0.5rem' }}>
|
|
||||||
<div className="form-group">
|
|
||||||
<label className="label">Sire (Father)</label>
|
|
||||||
<select
|
|
||||||
name="sire_id"
|
|
||||||
className="input"
|
|
||||||
value={formData.sire_id || ''}
|
|
||||||
onChange={handleChange}
|
|
||||||
>
|
|
||||||
<option value="">Unknown</option>
|
|
||||||
{males.map(d => (
|
|
||||||
<option key={d.id} value={d.id}>{d.name}</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="form-group">
|
|
||||||
<label className="label">Dam (Mother)</label>
|
|
||||||
<select
|
|
||||||
name="dam_id"
|
|
||||||
className="input"
|
|
||||||
value={formData.dam_id || ''}
|
|
||||||
onChange={handleChange}
|
|
||||||
>
|
|
||||||
<option value="">Unknown</option>
|
|
||||||
{females.map(d => (
|
|
||||||
<option key={d.id} value={d.id}>{d.name}</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Parent Section — hidden for external dogs */}
|
||||||
|
{!effectiveExternal && (
|
||||||
|
<div style={{
|
||||||
|
marginTop: '1.5rem', padding: '1rem',
|
||||||
|
background: 'rgba(194, 134, 42, 0.04)',
|
||||||
|
borderRadius: '8px',
|
||||||
|
border: '1px solid rgba(194, 134, 42, 0.15)'
|
||||||
|
}}>
|
||||||
|
<label className="label" style={{ marginBottom: '0.75rem', display: 'block', fontWeight: '600' }}>Parent Information</label>
|
||||||
|
|
||||||
|
{littersAvailable && (
|
||||||
|
<div style={{ display: 'flex', gap: '1.5rem', marginBottom: '1rem', flexWrap: 'wrap' }}>
|
||||||
|
<label style={{ display: 'flex', alignItems: 'center', gap: '0.5rem', cursor: 'pointer', fontSize: '0.95rem' }}>
|
||||||
|
<input type="radio" name="parentMode" checked={!useManualParents}
|
||||||
|
onChange={() => setUseManualParents(false)} style={{ width: '16px', height: '16px' }} />
|
||||||
|
<span>Link to Litter</span>
|
||||||
|
</label>
|
||||||
|
<label style={{ display: 'flex', alignItems: 'center', gap: '0.5rem', cursor: 'pointer', fontSize: '0.95rem' }}>
|
||||||
|
<input type="radio" name="parentMode" checked={useManualParents}
|
||||||
|
onChange={() => setUseManualParents(true)} style={{ width: '16px', height: '16px' }} />
|
||||||
|
<span>Manual Parent Selection</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!useManualParents && littersAvailable ? (
|
||||||
|
<div className="form-group" style={{ marginTop: '0.5rem' }}>
|
||||||
|
<label className="label">Select Litter</label>
|
||||||
|
<select name="litter_id" className="input"
|
||||||
|
value={formData.litter_id || ''} onChange={handleChange}>
|
||||||
|
<option value="">No Litter</option>
|
||||||
|
{litters.map(l => (
|
||||||
|
<option key={l.id} value={l.id}>
|
||||||
|
{l.sire_name} x {l.dam_name} - {new Date(l.breeding_date).toLocaleDateString()}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
{formData.litter_id && (
|
||||||
|
<div style={{ marginTop: '0.5rem', fontSize: '0.875rem', color: 'var(--primary)', fontStyle: 'italic' }}>
|
||||||
|
✓ Parents will be automatically set from the selected litter
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="form-grid" style={{ marginTop: '0.5rem' }}>
|
||||||
|
<div className="form-group">
|
||||||
|
<label className="label">Sire (Father)</label>
|
||||||
|
<select name="sire_id" className="input"
|
||||||
|
value={formData.sire_id || ''} onChange={handleChange}>
|
||||||
|
<option value="">Unknown</option>
|
||||||
|
{males.map(d => <option key={d.id} value={d.id}>{d.name}{d.is_champion ? ' ✪' : ''}</option>)}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div className="form-group">
|
||||||
|
<label className="label">Dam (Mother)</label>
|
||||||
|
<select name="dam_id" className="input"
|
||||||
|
value={formData.dam_id || ''} onChange={handleChange}>
|
||||||
|
<option value="">Unknown</option>
|
||||||
|
{females.map(d => <option key={d.id} value={d.id}>{d.name}{d.is_champion ? ' ✪' : ''}</option>)}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
<div className="form-group" style={{ marginTop: '1rem' }}>
|
<div className="form-group" style={{ marginTop: '1rem' }}>
|
||||||
<label className="label">Notes</label>
|
<label className="label">Notes</label>
|
||||||
<textarea
|
<textarea name="notes" className="input" rows="4"
|
||||||
name="notes"
|
value={formData.notes} onChange={handleChange} />
|
||||||
className="input"
|
|
||||||
rows="4"
|
|
||||||
value={formData.notes}
|
|
||||||
onChange={handleChange}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="modal-footer">
|
<div className="modal-footer">
|
||||||
<button type="button" className="btn btn-secondary" onClick={onClose} disabled={loading}>
|
<button type="button" className="btn btn-secondary" onClick={onClose} disabled={loading}>Cancel</button>
|
||||||
Cancel
|
|
||||||
</button>
|
|
||||||
<button type="submit" className="btn btn-primary" disabled={loading}>
|
<button type="submit" className="btn btn-primary" disabled={loading}>
|
||||||
{loading ? 'Saving...' : dog ? 'Update Dog' : 'Add Dog'}
|
{loading ? 'Saving...' : dog ? 'Update Dog' : effectiveExternal ? 'Add External Dog' : 'Add Dog'}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
|
|||||||
194
client/src/components/HealthRecordForm.jsx
Normal file
194
client/src/components/HealthRecordForm.jsx
Normal file
@@ -0,0 +1,194 @@
|
|||||||
|
import { useState } from 'react'
|
||||||
|
import { X } from 'lucide-react'
|
||||||
|
import axios from 'axios'
|
||||||
|
|
||||||
|
const RECORD_TYPES = ['ofa_clearance', 'vaccination', 'exam', 'surgery', 'medication', 'other']
|
||||||
|
const OFA_TEST_TYPES = [
|
||||||
|
{ value: 'hip_ofa', label: 'Hip - OFA' },
|
||||||
|
{ value: 'hip_pennhip', label: 'Hip - PennHIP' },
|
||||||
|
{ value: 'elbow_ofa', label: 'Elbow - OFA' },
|
||||||
|
{ value: 'heart_ofa', label: 'Heart - OFA' },
|
||||||
|
{ value: 'heart_echo', label: 'Heart - Echo' },
|
||||||
|
{ value: 'eye_caer', label: 'Eyes - CAER' },
|
||||||
|
]
|
||||||
|
const OFA_RESULTS = ['Excellent', 'Good', 'Fair', 'Mild', 'Moderate', 'Severe', 'Normal', 'Abnormal', 'Pass', 'Fail']
|
||||||
|
|
||||||
|
const EMPTY = {
|
||||||
|
record_type: 'ofa_clearance', test_type: 'hip_ofa', test_name: '',
|
||||||
|
test_date: '', ofa_result: 'Good', ofa_number: '', performed_by: '',
|
||||||
|
expires_at: '', result: '', vet_name: '', next_due: '', notes: '', document_url: '',
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function HealthRecordForm({ dogId, record, onClose, onSave }) {
|
||||||
|
const [form, setForm] = useState(record || { ...EMPTY, dog_id: dogId })
|
||||||
|
const [saving, setSaving] = useState(false)
|
||||||
|
const [error, setError] = useState(null)
|
||||||
|
|
||||||
|
const isOFA = form.record_type === 'ofa_clearance'
|
||||||
|
const set = (k, v) => setForm(f => ({ ...f, [k]: v }))
|
||||||
|
|
||||||
|
const handleSubmit = async (e) => {
|
||||||
|
e.preventDefault()
|
||||||
|
setSaving(true)
|
||||||
|
setError(null)
|
||||||
|
try {
|
||||||
|
if (record && record.id) {
|
||||||
|
await axios.put(`/api/health/${record.id}`, form)
|
||||||
|
} else {
|
||||||
|
await axios.post('/api/health', { ...form, dog_id: dogId })
|
||||||
|
}
|
||||||
|
onSave()
|
||||||
|
} catch (err) {
|
||||||
|
setError(err.response?.data?.error || 'Failed to save record')
|
||||||
|
} finally {
|
||||||
|
setSaving(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const labelStyle = {
|
||||||
|
fontSize: '0.8rem', color: 'var(--text-muted)',
|
||||||
|
marginBottom: '0.25rem', display: 'block',
|
||||||
|
}
|
||||||
|
const inputStyle = {
|
||||||
|
width: '100%', background: 'var(--bg-primary)',
|
||||||
|
border: '1px solid var(--border)', borderRadius: 'var(--radius-sm)',
|
||||||
|
padding: '0.5rem 0.75rem', color: 'var(--text-primary)', fontSize: '0.9rem',
|
||||||
|
boxSizing: 'border-box',
|
||||||
|
}
|
||||||
|
const fw = { display: 'flex', flexDirection: 'column', gap: '0.25rem' }
|
||||||
|
const grid2 = { display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '1rem' }
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{
|
||||||
|
position: 'fixed', inset: 0, background: 'rgba(0,0,0,0.7)',
|
||||||
|
backdropFilter: 'blur(4px)', display: 'flex', alignItems: 'center',
|
||||||
|
justifyContent: 'center', zIndex: 1000, padding: '1rem',
|
||||||
|
}}>
|
||||||
|
<div className="card" style={{
|
||||||
|
width: '100%', maxWidth: '560px', maxHeight: '90vh',
|
||||||
|
overflowY: 'auto', position: 'relative',
|
||||||
|
}}>
|
||||||
|
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '1.5rem' }}>
|
||||||
|
<h2 style={{ margin: 0 }}>{record && record.id ? 'Edit' : 'Add'} Health Record</h2>
|
||||||
|
<button className="btn-icon" onClick={onClose}><X size={20} /></button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form onSubmit={handleSubmit} style={{ display: 'flex', flexDirection: 'column', gap: '1rem' }}>
|
||||||
|
|
||||||
|
{/* Record type */}
|
||||||
|
<div style={fw}>
|
||||||
|
<label style={labelStyle}>Record Type</label>
|
||||||
|
<select style={inputStyle} value={form.record_type} onChange={e => set('record_type', e.target.value)}>
|
||||||
|
{RECORD_TYPES.map(t => (
|
||||||
|
<option key={t} value={t}>
|
||||||
|
{t.replace(/_/g, ' ').replace(/\b\w/g, c => c.toUpperCase())}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{isOFA ? (
|
||||||
|
<>
|
||||||
|
<div style={grid2}>
|
||||||
|
<div style={fw}>
|
||||||
|
<label style={labelStyle}>OFA Test Type</label>
|
||||||
|
<select style={inputStyle} value={form.test_type} onChange={e => set('test_type', e.target.value)}>
|
||||||
|
{OFA_TEST_TYPES.map(t => <option key={t.value} value={t.value}>{t.label}</option>)}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div style={fw}>
|
||||||
|
<label style={labelStyle}>OFA Result</label>
|
||||||
|
<select style={inputStyle} value={form.ofa_result} onChange={e => set('ofa_result', e.target.value)}>
|
||||||
|
{OFA_RESULTS.map(r => <option key={r} value={r}>{r}</option>)}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div style={grid2}>
|
||||||
|
<div style={fw}>
|
||||||
|
<label style={labelStyle}>OFA Number</label>
|
||||||
|
<input style={inputStyle} placeholder="GR-12345E24M-VPI" value={form.ofa_number}
|
||||||
|
onChange={e => set('ofa_number', e.target.value)} />
|
||||||
|
</div>
|
||||||
|
<div style={fw}>
|
||||||
|
<label style={labelStyle}>Performed By</label>
|
||||||
|
<input style={inputStyle} placeholder="Radiologist / cardiologist" value={form.performed_by}
|
||||||
|
onChange={e => set('performed_by', e.target.value)} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div style={grid2}>
|
||||||
|
<div style={fw}>
|
||||||
|
<label style={labelStyle}>Test Date *</label>
|
||||||
|
<input style={inputStyle} type="date" required value={form.test_date}
|
||||||
|
onChange={e => set('test_date', e.target.value)} />
|
||||||
|
</div>
|
||||||
|
<div style={fw}>
|
||||||
|
<label style={labelStyle}>Expires At</label>
|
||||||
|
<input style={inputStyle} type="date" value={form.expires_at}
|
||||||
|
onChange={e => set('expires_at', e.target.value)} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<div style={fw}>
|
||||||
|
<label style={labelStyle}>Test / Procedure Name</label>
|
||||||
|
<input style={inputStyle} placeholder="e.g. Rabies, Bordetella..." value={form.test_name}
|
||||||
|
onChange={e => set('test_name', e.target.value)} />
|
||||||
|
</div>
|
||||||
|
<div style={grid2}>
|
||||||
|
<div style={fw}>
|
||||||
|
<label style={labelStyle}>Date *</label>
|
||||||
|
<input style={inputStyle} type="date" required value={form.test_date}
|
||||||
|
onChange={e => set('test_date', e.target.value)} />
|
||||||
|
</div>
|
||||||
|
<div style={fw}>
|
||||||
|
<label style={labelStyle}>Next Due</label>
|
||||||
|
<input style={inputStyle} type="date" value={form.next_due}
|
||||||
|
onChange={e => set('next_due', e.target.value)} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div style={grid2}>
|
||||||
|
<div style={fw}>
|
||||||
|
<label style={labelStyle}>Result</label>
|
||||||
|
<input style={inputStyle} placeholder="Normal, Pass, etc." value={form.result}
|
||||||
|
onChange={e => set('result', 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 style={fw}>
|
||||||
|
<label style={labelStyle}>Document URL (optional)</label>
|
||||||
|
<input style={inputStyle} type="url" placeholder="https://ofa.org/..." value={form.document_url}
|
||||||
|
onChange={e => set('document_url', e.target.value)} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={fw}>
|
||||||
|
<label style={labelStyle}>Notes</label>
|
||||||
|
<textarea style={{ ...inputStyle, minHeight: '70px', resize: 'vertical' }}
|
||||||
|
value={form.notes} onChange={e => set('notes', e.target.value)} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<div style={{
|
||||||
|
color: 'var(--danger)', fontSize: '0.85rem', padding: '0.5rem 0.75rem',
|
||||||
|
background: 'rgba(255,59,48,0.1)', borderRadius: 'var(--radius-sm)',
|
||||||
|
}}>{error}</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div style={{ display: 'flex', gap: '0.75rem', justifyContent: 'flex-end' }}>
|
||||||
|
<button type="button" className="btn btn-ghost" onClick={onClose}>Cancel</button>
|
||||||
|
<button type="submit" className="btn btn-primary" disabled={saving}>
|
||||||
|
{saving ? 'Saving...' : record && record.id ? 'Save Changes' : 'Add Record'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -2,7 +2,7 @@ import { useState, useEffect } from 'react'
|
|||||||
import { X } from 'lucide-react'
|
import { X } from 'lucide-react'
|
||||||
import axios from 'axios'
|
import axios from 'axios'
|
||||||
|
|
||||||
function LitterForm({ litter, onClose, onSave }) {
|
function LitterForm({ litter, prefill, onClose, onSave }) {
|
||||||
const [formData, setFormData] = useState({
|
const [formData, setFormData] = useState({
|
||||||
sire_id: '',
|
sire_id: '',
|
||||||
dam_id: '',
|
dam_id: '',
|
||||||
@@ -26,8 +26,16 @@ function LitterForm({ litter, onClose, onSave }) {
|
|||||||
puppy_count: litter.puppy_count || 0,
|
puppy_count: litter.puppy_count || 0,
|
||||||
notes: litter.notes || ''
|
notes: litter.notes || ''
|
||||||
})
|
})
|
||||||
|
} else if (prefill) {
|
||||||
|
// Pre-populate from BreedingCalendar "Record Litter" flow
|
||||||
|
setFormData(prev => ({
|
||||||
|
...prev,
|
||||||
|
dam_id: prefill.dam_id ? String(prefill.dam_id) : '',
|
||||||
|
breeding_date: prefill.breeding_date || '',
|
||||||
|
whelping_date: prefill.whelping_date || '',
|
||||||
|
}))
|
||||||
}
|
}
|
||||||
}, [litter])
|
}, [litter, prefill])
|
||||||
|
|
||||||
const fetchDogs = async () => {
|
const fetchDogs = async () => {
|
||||||
try {
|
try {
|
||||||
@@ -69,7 +77,7 @@ function LitterForm({ litter, onClose, onSave }) {
|
|||||||
<div className="modal-overlay" onClick={onClose}>
|
<div className="modal-overlay" onClick={onClose}>
|
||||||
<div className="modal-content" onClick={(e) => e.stopPropagation()}>
|
<div className="modal-content" onClick={(e) => e.stopPropagation()}>
|
||||||
<div className="modal-header">
|
<div className="modal-header">
|
||||||
<h2>{litter ? 'Edit Litter' : 'Create New Litter'}</h2>
|
<h2>{litter ? 'Edit Litter' : prefill ? `Record Litter — ${prefill.dam_name || 'Dam pre-selected'}` : 'Create New Litter'}</h2>
|
||||||
<button className="btn-icon" onClick={onClose}>
|
<button className="btn-icon" onClick={onClose}>
|
||||||
<X size={24} />
|
<X size={24} />
|
||||||
</button>
|
</button>
|
||||||
@@ -78,6 +86,20 @@ function LitterForm({ litter, onClose, onSave }) {
|
|||||||
<form onSubmit={handleSubmit} className="modal-body">
|
<form onSubmit={handleSubmit} className="modal-body">
|
||||||
{error && <div className="error">{error}</div>}
|
{error && <div className="error">{error}</div>}
|
||||||
|
|
||||||
|
{prefill && !litter && (
|
||||||
|
<div style={{
|
||||||
|
background: 'rgba(16,185,129,0.08)',
|
||||||
|
border: '1px solid rgba(16,185,129,0.3)',
|
||||||
|
borderRadius: 'var(--radius-sm)',
|
||||||
|
padding: '0.6rem 0.875rem',
|
||||||
|
marginBottom: '1rem',
|
||||||
|
fontSize: '0.85rem',
|
||||||
|
color: 'var(--success)'
|
||||||
|
}}>
|
||||||
|
🐾 Pre-filled from heat cycle — select a sire to complete the litter record.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
<div className="form-grid">
|
<div className="form-grid">
|
||||||
<div className="form-group">
|
<div className="form-group">
|
||||||
<label className="label">Sire (Father) *</label>
|
<label className="label">Sire (Father) *</label>
|
||||||
@@ -111,6 +133,11 @@ function LitterForm({ litter, 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>
|
||||||
|
{prefill?.dam_name && !litter && (
|
||||||
|
<p style={{ fontSize: '0.78rem', color: 'var(--success)', marginTop: '0.25rem' }}>
|
||||||
|
✓ Pre-selected: {prefill.dam_name}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="form-group">
|
<div className="form-group">
|
||||||
|
|||||||
@@ -1,11 +1,17 @@
|
|||||||
|
/* ─── Pedigree Tree Wrapper ──────────────────────────────────────── */
|
||||||
.pedigree-tree-wrapper {
|
.pedigree-tree-wrapper {
|
||||||
position: relative;
|
position: relative;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: calc(100vh - 200px);
|
height: calc(100vh - 200px);
|
||||||
background: #f9fafb;
|
background: radial-gradient(
|
||||||
border-radius: 8px;
|
ellipse at 20% 50%,
|
||||||
|
rgba(194, 134, 42, 0.06) 0%,
|
||||||
|
var(--bg-primary) 60%
|
||||||
|
);
|
||||||
|
border-radius: var(--radius);
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
|
border: 1px solid var(--border);
|
||||||
|
box-shadow: var(--shadow-lg);
|
||||||
}
|
}
|
||||||
|
|
||||||
.tree-container {
|
.tree-container {
|
||||||
@@ -13,117 +19,142 @@
|
|||||||
height: 100%;
|
height: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ─── SVG Link Paths ─────────────────────────────────────────────── */
|
||||||
|
.pedigree-tree-wrapper svg .rd3t-link {
|
||||||
|
stroke: var(--border-light) !important;
|
||||||
|
stroke-width: 1.5px !important;
|
||||||
|
stroke-opacity: 0.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ─── Controls ───────────────────────────────────────────────────── */
|
||||||
.pedigree-controls {
|
.pedigree-controls {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
top: 20px;
|
top: 16px;
|
||||||
right: 20px;
|
right: 16px;
|
||||||
z-index: 10;
|
z-index: 10;
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 1rem;
|
gap: 0.75rem;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
.control-group {
|
.control-group {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 0.5rem;
|
gap: 0.25rem;
|
||||||
background: white;
|
background: var(--bg-elevated);
|
||||||
padding: 0.5rem;
|
padding: 0.375rem;
|
||||||
border-radius: 8px;
|
border-radius: var(--radius-sm);
|
||||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
|
border: 1px solid var(--border);
|
||||||
|
box-shadow: var(--shadow);
|
||||||
|
backdrop-filter: blur(8px);
|
||||||
}
|
}
|
||||||
|
|
||||||
.control-btn {
|
.control-btn {
|
||||||
background: white;
|
background: transparent;
|
||||||
border: 1px solid #e5e7eb;
|
border: 1px solid transparent;
|
||||||
border-radius: 6px;
|
border-radius: var(--radius-sm);
|
||||||
padding: 0.5rem;
|
padding: 0.4rem;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
transition: all 0.2s;
|
color: var(--text-secondary);
|
||||||
|
transition: var(--transition);
|
||||||
}
|
}
|
||||||
|
|
||||||
.control-btn:hover {
|
.control-btn:hover {
|
||||||
background: #f3f4f6;
|
background: var(--bg-tertiary);
|
||||||
border-color: #d1d5db;
|
border-color: var(--border);
|
||||||
|
color: var(--primary-light);
|
||||||
}
|
}
|
||||||
|
|
||||||
.control-btn:active {
|
.control-btn:active {
|
||||||
transform: scale(0.95);
|
transform: scale(0.93);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ─── COI Display ────────────────────────────────────────────────── */
|
||||||
.coi-display {
|
.coi-display {
|
||||||
background: white;
|
background: var(--bg-elevated);
|
||||||
padding: 0.75rem 1rem;
|
padding: 0.5rem 0.875rem;
|
||||||
border-radius: 8px;
|
border-radius: var(--radius-sm);
|
||||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
|
border: 1px solid var(--border);
|
||||||
|
box-shadow: var(--shadow);
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 0.5rem;
|
gap: 0.5rem;
|
||||||
|
backdrop-filter: blur(8px);
|
||||||
}
|
}
|
||||||
|
|
||||||
.coi-label {
|
.coi-label {
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
color: #6b7280;
|
color: var(--text-muted);
|
||||||
font-size: 0.875rem;
|
font-size: 0.75rem;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.05em;
|
||||||
}
|
}
|
||||||
|
|
||||||
.coi-value {
|
.coi-value {
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
font-size: 1.25rem;
|
font-size: 1.1rem;
|
||||||
|
letter-spacing: -0.02em;
|
||||||
}
|
}
|
||||||
|
|
||||||
.coi-value.low {
|
.coi-value.low { color: var(--success); }
|
||||||
color: #10b981;
|
.coi-value.medium { color: var(--warning); }
|
||||||
}
|
.coi-value.high { color: var(--danger); }
|
||||||
|
|
||||||
.coi-value.medium {
|
|
||||||
color: #f59e0b;
|
|
||||||
}
|
|
||||||
|
|
||||||
.coi-value.high {
|
|
||||||
color: #ef4444;
|
|
||||||
}
|
|
||||||
|
|
||||||
|
/* ─── Legend ─────────────────────────────────────────────────────── */
|
||||||
.pedigree-legend {
|
.pedigree-legend {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
bottom: 20px;
|
bottom: 16px;
|
||||||
left: 20px;
|
left: 16px;
|
||||||
z-index: 10;
|
z-index: 10;
|
||||||
background: white;
|
background: var(--bg-elevated);
|
||||||
padding: 1rem;
|
padding: 0.625rem 1rem;
|
||||||
border-radius: 8px;
|
border-radius: var(--radius-sm);
|
||||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
|
border: 1px solid var(--border);
|
||||||
|
box-shadow: var(--shadow);
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 1.5rem;
|
gap: 1.25rem;
|
||||||
|
backdrop-filter: blur(8px);
|
||||||
}
|
}
|
||||||
|
|
||||||
.legend-item {
|
.legend-item {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 0.5rem;
|
gap: 0.4rem;
|
||||||
font-size: 0.875rem;
|
font-size: 0.8rem;
|
||||||
color: #6b7280;
|
color: var(--text-secondary);
|
||||||
|
font-weight: 500;
|
||||||
}
|
}
|
||||||
|
|
||||||
.legend-color {
|
.legend-color {
|
||||||
width: 20px;
|
width: 14px;
|
||||||
height: 20px;
|
height: 14px;
|
||||||
border-radius: 50%;
|
border-radius: 50%;
|
||||||
border: 2px solid white;
|
border: 2px solid rgba(255,255,255,0.15);
|
||||||
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.2);
|
box-shadow: 0 0 6px rgba(0,0,0,0.4);
|
||||||
}
|
}
|
||||||
|
|
||||||
.legend-color.male {
|
.legend-color.male { background: #3b82f6; box-shadow: 0 0 8px rgba(59,130,246,0.4); }
|
||||||
background: #3b82f6;
|
.legend-color.female { background: #ec4899; box-shadow: 0 0 8px rgba(236,72,153,0.4); }
|
||||||
|
|
||||||
|
/* ─── Zoom Indicator ─────────────────────────────────────────────── */
|
||||||
|
.zoom-indicator {
|
||||||
|
position: absolute;
|
||||||
|
bottom: 16px;
|
||||||
|
right: 16px;
|
||||||
|
z-index: 10;
|
||||||
|
background: var(--bg-elevated);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
padding: 0.3rem 0.6rem;
|
||||||
|
font-size: 0.7rem;
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
backdrop-filter: blur(8px);
|
||||||
}
|
}
|
||||||
|
|
||||||
.legend-color.female {
|
/* ─── Mobile ─────────────────────────────────────────────────────── */
|
||||||
background: #ec4899;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Mobile responsive */
|
|
||||||
@media (max-width: 768px) {
|
@media (max-width: 768px) {
|
||||||
.pedigree-tree-wrapper {
|
.pedigree-tree-wrapper {
|
||||||
height: calc(100vh - 150px);
|
height: calc(100vh - 150px);
|
||||||
@@ -133,52 +164,39 @@
|
|||||||
top: 10px;
|
top: 10px;
|
||||||
right: 10px;
|
right: 10px;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 0.5rem;
|
gap: 0.4rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.coi-display {
|
.coi-display {
|
||||||
padding: 0.5rem 0.75rem;
|
padding: 0.375rem 0.625rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.coi-label {
|
.coi-label { font-size: 0.7rem; }
|
||||||
font-size: 0.75rem;
|
.coi-value { font-size: 0.95rem; }
|
||||||
}
|
|
||||||
|
|
||||||
.coi-value {
|
|
||||||
font-size: 1rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.pedigree-legend {
|
.pedigree-legend {
|
||||||
bottom: 10px;
|
bottom: 10px;
|
||||||
left: 10px;
|
left: 10px;
|
||||||
padding: 0.75rem;
|
padding: 0.5rem 0.75rem;
|
||||||
gap: 1rem;
|
gap: 0.875rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.legend-item {
|
.legend-item { font-size: 0.75rem; }
|
||||||
font-size: 0.75rem;
|
.legend-color { width: 12px; height: 12px; }
|
||||||
}
|
|
||||||
|
|
||||||
.legend-color {
|
|
||||||
width: 16px;
|
|
||||||
height: 16px;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Print styles */
|
/* ─── Print ──────────────────────────────────────────────────────── */
|
||||||
@media print {
|
@media print {
|
||||||
.pedigree-controls,
|
.pedigree-controls,
|
||||||
.pedigree-legend {
|
.pedigree-legend,
|
||||||
display: none;
|
.zoom-indicator { display: none; }
|
||||||
}
|
|
||||||
|
|
||||||
.pedigree-tree-wrapper {
|
.pedigree-tree-wrapper {
|
||||||
height: 100vh;
|
height: 100vh;
|
||||||
box-shadow: none;
|
box-shadow: none;
|
||||||
background: white;
|
background: white;
|
||||||
|
border: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.tree-container {
|
.tree-container { page-break-inside: avoid; }
|
||||||
page-break-inside: avoid;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
import { useState, useCallback, useEffect } from 'react'
|
import { useState, useCallback, useEffect } from 'react'
|
||||||
import Tree from 'react-d3-tree'
|
import Tree from 'react-d3-tree'
|
||||||
import { ZoomIn, ZoomOut, Maximize2, Download } from 'lucide-react'
|
import { ZoomIn, ZoomOut, Maximize2 } from 'lucide-react'
|
||||||
import './PedigreeTree.css'
|
import './PedigreeTree.css'
|
||||||
|
|
||||||
const PedigreeTree = ({ dogId, pedigreeData, coi }) => {
|
const PedigreeTree = ({ dogId, pedigreeData, coi }) => {
|
||||||
@@ -12,90 +12,151 @@ const PedigreeTree = ({ dogId, pedigreeData, coi }) => {
|
|||||||
const updateDimensions = () => {
|
const updateDimensions = () => {
|
||||||
const container = document.getElementById('tree-container')
|
const container = document.getElementById('tree-container')
|
||||||
if (container) {
|
if (container) {
|
||||||
setDimensions({
|
setDimensions({ width: container.offsetWidth, height: container.offsetHeight })
|
||||||
width: container.offsetWidth,
|
setTranslate({ x: container.offsetWidth / 4, y: container.offsetHeight / 2 })
|
||||||
height: container.offsetHeight
|
|
||||||
})
|
|
||||||
setTranslate({
|
|
||||||
x: container.offsetWidth / 4,
|
|
||||||
y: container.offsetHeight / 2
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
updateDimensions()
|
updateDimensions()
|
||||||
window.addEventListener('resize', updateDimensions)
|
window.addEventListener('resize', updateDimensions)
|
||||||
return () => window.removeEventListener('resize', updateDimensions)
|
return () => window.removeEventListener('resize', updateDimensions)
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
const handleZoomIn = () => setZoom(z => Math.min(z + 0.2, 2))
|
const handleZoomIn = () => setZoom(z => Math.min(z + 0.2, 2))
|
||||||
const handleZoomOut = () => setZoom(z => Math.max(z - 0.2, 0.2))
|
const handleZoomOut = () => setZoom(z => Math.max(z - 0.2, 0.2))
|
||||||
const handleReset = () => {
|
const handleReset = () => {
|
||||||
setZoom(0.8)
|
setZoom(0.8)
|
||||||
setTranslate({
|
setTranslate({ x: dimensions.width / 4, y: dimensions.height / 2 })
|
||||||
x: dimensions.width / 4,
|
|
||||||
y: dimensions.height / 2
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const renderCustomNode = ({ nodeDatum, toggleNode }) => {
|
const renderCustomNode = ({ nodeDatum }) => {
|
||||||
const isMale = nodeDatum.attributes?.sex === 'male'
|
const isRoot = nodeDatum.attributes?.isRoot
|
||||||
const nodeColor = isMale ? '#3b82f6' : '#ec4899'
|
const isMale = nodeDatum.attributes?.sex === 'male'
|
||||||
|
const hasId = !!nodeDatum.attributes?.id
|
||||||
|
const breed = nodeDatum.attributes?.breed
|
||||||
|
|
||||||
|
// Colour palette aligned to app theme
|
||||||
|
const maleColor = '#3b82f6'
|
||||||
|
const femaleColor = '#ec4899'
|
||||||
|
const rootGold = '#c2862a' // --primary
|
||||||
|
const rootAccent = '#9b3a10' // --accent
|
||||||
|
|
||||||
|
const nodeColor = isRoot ? rootGold : (isMale ? maleColor : femaleColor)
|
||||||
|
const glowColor = isRoot
|
||||||
|
? 'rgba(194,134,42,0.35)'
|
||||||
|
: (isMale ? 'rgba(59,130,246,0.3)' : 'rgba(236,72,153,0.3)')
|
||||||
|
const ringColor = isRoot ? rootAccent : nodeColor
|
||||||
|
|
||||||
|
const r = isRoot ? 34 : 28
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<g>
|
<g>
|
||||||
|
{/* Glow halo */}
|
||||||
<circle
|
<circle
|
||||||
r={30}
|
r={r + 10}
|
||||||
fill={nodeColor}
|
fill={glowColor}
|
||||||
stroke="#fff"
|
style={{ filter: 'blur(6px)' }}
|
||||||
strokeWidth={3}
|
/>
|
||||||
opacity={0.9}
|
|
||||||
style={{ cursor: nodeDatum.attributes?.id ? 'pointer' : 'default' }}
|
{/* Outer ring */}
|
||||||
|
<circle
|
||||||
|
r={r + 4}
|
||||||
|
fill="none"
|
||||||
|
stroke={ringColor}
|
||||||
|
strokeWidth={isRoot ? 2 : 1.5}
|
||||||
|
strokeOpacity={0.5}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Main node */}
|
||||||
|
<circle
|
||||||
|
r={r}
|
||||||
|
fill={isRoot
|
||||||
|
? `url(#rootGradient)`
|
||||||
|
: nodeColor}
|
||||||
|
stroke="rgba(255,255,255,0.15)"
|
||||||
|
strokeWidth={2}
|
||||||
|
style={{
|
||||||
|
cursor: hasId ? 'pointer' : 'default',
|
||||||
|
filter: isRoot ? 'drop-shadow(0 0 8px rgba(194,134,42,0.6))' : 'none'
|
||||||
|
}}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
if (nodeDatum.attributes?.id) {
|
if (hasId) window.location.href = `/dogs/${nodeDatum.attributes.id}`
|
||||||
window.location.href = `/dogs/${nodeDatum.attributes.id}`
|
|
||||||
}
|
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
{/* SVG gradient definition for root node */}
|
||||||
|
{isRoot && (
|
||||||
|
<defs>
|
||||||
|
<radialGradient id="rootGradient" cx="35%" cy="35%">
|
||||||
|
<stop offset="0%" stopColor="#e0a84a" />
|
||||||
|
<stop offset="100%" stopColor="#9b3a10" />
|
||||||
|
</radialGradient>
|
||||||
|
</defs>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Gender / crown icon */}
|
||||||
<text
|
<text
|
||||||
fill="#fff"
|
fill={isRoot ? '#fff' : '#fff'}
|
||||||
fontSize="24"
|
fontSize={isRoot ? 22 : 18}
|
||||||
textAnchor="middle"
|
textAnchor="middle"
|
||||||
dy="8"
|
dy="7"
|
||||||
style={{ pointerEvents: 'none' }}
|
style={{ pointerEvents: 'none', userSelect: 'none' }}
|
||||||
>
|
>
|
||||||
{isMale ? '♂' : '♀'}
|
{isRoot ? '👑' : (isMale ? '♂' : '♀')}
|
||||||
</text>
|
</text>
|
||||||
|
|
||||||
|
{/* Name label */}
|
||||||
<text
|
<text
|
||||||
fill="#1f2937"
|
fill="var(--text-primary, #f5f0e8)"
|
||||||
fontSize="14"
|
fontSize={isRoot ? 15 : 13}
|
||||||
fontWeight="600"
|
fontWeight={isRoot ? '700' : '600'}
|
||||||
|
fontFamily="Inter, sans-serif"
|
||||||
textAnchor="middle"
|
textAnchor="middle"
|
||||||
x="0"
|
x="0"
|
||||||
y="50"
|
y={r + 18}
|
||||||
style={{ pointerEvents: 'none' }}
|
style={{ pointerEvents: 'none' }}
|
||||||
>
|
>
|
||||||
{nodeDatum.name}
|
{nodeDatum.name}
|
||||||
</text>
|
</text>
|
||||||
{nodeDatum.attributes?.registration && (
|
|
||||||
|
{/* Breed label (subtle) */}
|
||||||
|
{breed && (
|
||||||
<text
|
<text
|
||||||
fill="#6b7280"
|
fill="var(--text-muted, #8c8472)"
|
||||||
fontSize="11"
|
fontSize="10"
|
||||||
|
fontFamily="Inter, sans-serif"
|
||||||
textAnchor="middle"
|
textAnchor="middle"
|
||||||
x="0"
|
x="0"
|
||||||
y="65"
|
y={r + 31}
|
||||||
|
style={{ pointerEvents: 'none' }}
|
||||||
|
>
|
||||||
|
{breed}
|
||||||
|
</text>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Registration number */}
|
||||||
|
{nodeDatum.attributes?.registration && (
|
||||||
|
<text
|
||||||
|
fill="var(--text-muted, #8c8472)"
|
||||||
|
fontSize="10"
|
||||||
|
fontFamily="Inter, sans-serif"
|
||||||
|
textAnchor="middle"
|
||||||
|
x="0"
|
||||||
|
y={r + (breed ? 44 : 31)}
|
||||||
style={{ pointerEvents: 'none' }}
|
style={{ pointerEvents: 'none' }}
|
||||||
>
|
>
|
||||||
{nodeDatum.attributes.registration}
|
{nodeDatum.attributes.registration}
|
||||||
</text>
|
</text>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Birth year */}
|
||||||
{nodeDatum.attributes?.birth_year && (
|
{nodeDatum.attributes?.birth_year && (
|
||||||
<text
|
<text
|
||||||
fill="#6b7280"
|
fill="var(--text-muted, #8c8472)"
|
||||||
fontSize="11"
|
fontSize="10"
|
||||||
|
fontFamily="Inter, sans-serif"
|
||||||
textAnchor="middle"
|
textAnchor="middle"
|
||||||
x="0"
|
x="0"
|
||||||
y="78"
|
y={r + (breed ? 57 : (nodeDatum.attributes?.registration ? 44 : 31))}
|
||||||
style={{ pointerEvents: 'none' }}
|
style={{ pointerEvents: 'none' }}
|
||||||
>
|
>
|
||||||
({nodeDatum.attributes.birth_year})
|
({nodeDatum.attributes.birth_year})
|
||||||
@@ -107,53 +168,72 @@ const PedigreeTree = ({ dogId, pedigreeData, coi }) => {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="pedigree-tree-wrapper">
|
<div className="pedigree-tree-wrapper">
|
||||||
|
|
||||||
|
{/* Controls */}
|
||||||
<div className="pedigree-controls">
|
<div className="pedigree-controls">
|
||||||
<div className="control-group">
|
<div className="control-group">
|
||||||
<button onClick={handleZoomIn} className="control-btn" title="Zoom In">
|
<button onClick={handleZoomIn} className="control-btn" title="Zoom In">
|
||||||
<ZoomIn size={20} />
|
<ZoomIn size={18} />
|
||||||
</button>
|
</button>
|
||||||
<button onClick={handleZoomOut} className="control-btn" title="Zoom Out">
|
<button onClick={handleZoomOut} className="control-btn" title="Zoom Out">
|
||||||
<ZoomOut size={20} />
|
<ZoomOut size={18} />
|
||||||
</button>
|
</button>
|
||||||
<button onClick={handleReset} className="control-btn" title="Reset View">
|
<button onClick={handleReset} className="control-btn" title="Reset View">
|
||||||
<Maximize2 size={20} />
|
<Maximize2 size={18} />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{coi !== null && coi !== undefined && (
|
{coi !== null && coi !== undefined && (
|
||||||
<div className="coi-display">
|
<div className="coi-display">
|
||||||
<span className="coi-label">COI:</span>
|
<span className="coi-label">COI</span>
|
||||||
<span className={`coi-value ${coi > 10 ? 'high' : coi > 5 ? 'medium' : 'low'}`}>
|
<span className={`coi-value ${coi > 0.10 ? 'high' : coi > 0.05 ? 'medium' : 'low'}`}>
|
||||||
{coi.toFixed(2)}%
|
{(coi * 100).toFixed(2)}%
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Legend */}
|
||||||
<div className="pedigree-legend">
|
<div className="pedigree-legend">
|
||||||
<div className="legend-item">
|
<div className="legend-item">
|
||||||
<div className="legend-color male"></div>
|
<div className="legend-color male" />
|
||||||
<span>Male</span>
|
<span>Sire</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="legend-item">
|
<div className="legend-item">
|
||||||
<div className="legend-color female"></div>
|
<div className="legend-color female" />
|
||||||
<span>Female</span>
|
<span>Dam</span>
|
||||||
|
</div>
|
||||||
|
<div className="legend-item">
|
||||||
|
<div style={{
|
||||||
|
width: 14, height: 14, borderRadius: '50%',
|
||||||
|
background: 'linear-gradient(135deg, #e0a84a, #9b3a10)',
|
||||||
|
boxShadow: '0 0 8px rgba(194,134,42,0.5)',
|
||||||
|
border: '2px solid rgba(255,255,255,0.15)'
|
||||||
|
}} />
|
||||||
|
<span>Subject</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Zoom indicator */}
|
||||||
|
<div className="zoom-indicator">
|
||||||
|
{Math.round(zoom * 100)}%
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Tree canvas */}
|
||||||
<div id="tree-container" className="tree-container">
|
<div id="tree-container" className="tree-container">
|
||||||
{pedigreeData && dimensions.width > 0 && (
|
{pedigreeData && dimensions.width > 0 && (
|
||||||
<Tree
|
<Tree
|
||||||
data={pedigreeData}
|
data={pedigreeData}
|
||||||
translate={translate}
|
translate={translate}
|
||||||
zoom={zoom}
|
zoom={zoom}
|
||||||
onUpdate={({ zoom, translate }) => {
|
onUpdate={({ zoom: z, translate: t }) => {
|
||||||
setZoom(zoom)
|
setZoom(z)
|
||||||
setTranslate(translate)
|
setTranslate(t)
|
||||||
}}
|
}}
|
||||||
orientation="horizontal"
|
orientation="horizontal"
|
||||||
pathFunc="step"
|
pathFunc="step"
|
||||||
separation={{ siblings: 1.5, nonSiblings: 2 }}
|
separation={{ siblings: 1.6, nonSiblings: 2.2 }}
|
||||||
nodeSize={{ x: 200, y: 150 }}
|
nodeSize={{ x: 220, y: 160 }}
|
||||||
renderCustomNodeElement={renderCustomNode}
|
renderCustomNodeElement={renderCustomNode}
|
||||||
enableLegacyTransitions
|
enableLegacyTransitions
|
||||||
transitionDuration={300}
|
transitionDuration={300}
|
||||||
|
|||||||
36
client/src/hooks/useSettings.jsx
Normal file
36
client/src/hooks/useSettings.jsx
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
import { createContext, useContext, useEffect, useState } from 'react'
|
||||||
|
import axios from 'axios'
|
||||||
|
|
||||||
|
const SettingsContext = createContext({})
|
||||||
|
|
||||||
|
export function SettingsProvider({ children }) {
|
||||||
|
const [settings, setSettings] = useState({
|
||||||
|
kennel_name: 'BREEDR',
|
||||||
|
kennel_tagline: '',
|
||||||
|
})
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
axios.get('/api/settings')
|
||||||
|
.then(res => {
|
||||||
|
setSettings(prev => ({ ...prev, ...res.data }))
|
||||||
|
})
|
||||||
|
.catch(() => {})
|
||||||
|
.finally(() => setLoading(false))
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const saveSettings = async (updates) => {
|
||||||
|
await axios.put('/api/settings', updates)
|
||||||
|
setSettings(prev => ({ ...prev, ...updates }))
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SettingsContext.Provider value={{ settings, saveSettings, loading }}>
|
||||||
|
{children}
|
||||||
|
</SettingsContext.Provider>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useSettings() {
|
||||||
|
return useContext(SettingsContext)
|
||||||
|
}
|
||||||
@@ -5,35 +5,45 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
:root {
|
:root {
|
||||||
/* Modern dark color palette */
|
/* Primary accent: warm amber/copper to echo the gold-rust brand gradient */
|
||||||
--primary: #3b82f6;
|
--primary: #c2862a;
|
||||||
--primary-hover: #2563eb;
|
--primary-hover: #a86e1c;
|
||||||
--primary-light: #60a5fa;
|
--primary-light: #e0a84a;
|
||||||
--accent: #8b5cf6;
|
|
||||||
--success: #10b981;
|
/* Secondary/accent: deep copper-red for punch */
|
||||||
|
--accent: #9b3a10;
|
||||||
|
|
||||||
|
/* Status colors stay neutral/functional */
|
||||||
|
--success: #22c55e;
|
||||||
--danger: #ef4444;
|
--danger: #ef4444;
|
||||||
--warning: #f59e0b;
|
--warning: #f59e0b;
|
||||||
|
|
||||||
/* Dark theme */
|
/* Dark theme backgrounds — slightly warmer tones */
|
||||||
--bg-primary: #0f172a;
|
--bg-primary: #0e0f0c;
|
||||||
--bg-secondary: #1e293b;
|
--bg-secondary: #1a1a15;
|
||||||
--bg-tertiary: #334155;
|
--bg-tertiary: #2a2820;
|
||||||
--bg-elevated: #1e293b;
|
--bg-elevated: #222018;
|
||||||
|
|
||||||
/* Borders */
|
/* Borders — warm dark */
|
||||||
--border: #334155;
|
--border: #38352a;
|
||||||
--border-light: #475569;
|
--border-light: #524e3e;
|
||||||
|
|
||||||
/* Text */
|
/* Text */
|
||||||
--text-primary: #f1f5f9;
|
--text-primary: #f5f0e8;
|
||||||
--text-secondary: #cbd5e1;
|
--text-secondary: #ccc4b0;
|
||||||
--text-muted: #94a3b8;
|
--text-muted: #8c8472;
|
||||||
|
|
||||||
/* Shadows */
|
/* Shadows */
|
||||||
--shadow-sm: 0 1px 2px 0 rgba(0, 0, 0, 0.3);
|
--shadow-sm: 0 1px 2px 0 rgba(0, 0, 0, 0.4);
|
||||||
--shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.4);
|
--shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.5);
|
||||||
--shadow-lg: 0 10px 15px -3px rgba(0, 0, 0, 0.5);
|
--shadow-lg: 0 10px 15px -3px rgba(0, 0, 0, 0.6);
|
||||||
--shadow-xl: 0 20px 25px -5px rgba(0, 0, 0, 0.6);
|
--shadow-xl: 0 20px 25px -5px rgba(0, 0, 0, 0.7);
|
||||||
|
|
||||||
|
/* Champion badge colors */
|
||||||
|
--champion-gold: #d4a017;
|
||||||
|
--champion-glow: rgba(212, 160, 23, 0.25);
|
||||||
|
--bloodline-amber: #b06010;
|
||||||
|
--bloodline-glow: rgba(176, 96, 16, 0.2);
|
||||||
|
|
||||||
/* Misc */
|
/* Misc */
|
||||||
--radius: 0.5rem;
|
--radius: 0.5rem;
|
||||||
@@ -130,14 +140,15 @@ h3 { font-size: 1.25rem; }
|
|||||||
}
|
}
|
||||||
|
|
||||||
.btn-primary {
|
.btn-primary {
|
||||||
background: var(--primary);
|
background: linear-gradient(135deg, var(--primary) 0%, var(--accent) 100%);
|
||||||
color: white;
|
color: var(--bg-primary);
|
||||||
box-shadow: var(--shadow-sm);
|
box-shadow: var(--shadow-sm);
|
||||||
|
font-weight: 600;
|
||||||
}
|
}
|
||||||
|
|
||||||
.btn-primary:hover:not(:disabled) {
|
.btn-primary:hover:not(:disabled) {
|
||||||
background: var(--primary-hover);
|
background: linear-gradient(135deg, var(--primary-light) 0%, var(--primary) 100%);
|
||||||
box-shadow: 0 4px 12px rgba(59, 130, 246, 0.4);
|
box-shadow: 0 4px 12px rgba(194, 134, 42, 0.4);
|
||||||
}
|
}
|
||||||
|
|
||||||
.btn-secondary {
|
.btn-secondary {
|
||||||
@@ -228,7 +239,7 @@ textarea:focus,
|
|||||||
select:focus {
|
select:focus {
|
||||||
outline: none;
|
outline: none;
|
||||||
border-color: var(--primary);
|
border-color: var(--primary);
|
||||||
box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.1);
|
box-shadow: 0 0 0 3px rgba(194, 134, 42, 0.15);
|
||||||
}
|
}
|
||||||
|
|
||||||
.input::placeholder {
|
.input::placeholder {
|
||||||
@@ -243,7 +254,7 @@ textarea {
|
|||||||
select {
|
select {
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
appearance: none;
|
appearance: none;
|
||||||
background-image: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' fill='none' viewBox='0 0 20 20'%3e%3cpath stroke='%2394a3b8' stroke-linecap='round' stroke-linejoin='round' stroke-width='1.5' d='M6 8l4 4 4-4'/%3e%3c/svg%3e");
|
background-image: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' fill='none' viewBox='0 0 20 20'%3e%3cpath stroke='%238c8472' stroke-linecap='round' stroke-linejoin='round' stroke-width='1.5' d='M6 8l4 4 4-4'/%3e%3c/svg%3e");
|
||||||
background-position: right 0.5rem center;
|
background-position: right 0.5rem center;
|
||||||
background-repeat: no-repeat;
|
background-repeat: no-repeat;
|
||||||
background-size: 1.5em 1.5em;
|
background-size: 1.5em 1.5em;
|
||||||
@@ -308,15 +319,50 @@ select {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.badge-primary {
|
.badge-primary {
|
||||||
background: rgba(59, 130, 246, 0.2);
|
background: rgba(194, 134, 42, 0.2);
|
||||||
color: var(--primary-light);
|
color: var(--primary-light);
|
||||||
}
|
}
|
||||||
|
|
||||||
.badge-success {
|
.badge-success {
|
||||||
background: rgba(16, 185, 129, 0.2);
|
background: rgba(34, 197, 94, 0.2);
|
||||||
color: var(--success);
|
color: var(--success);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Champion Badges */
|
||||||
|
.badge-champion {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.3rem;
|
||||||
|
padding: 0.2rem 0.55rem;
|
||||||
|
font-size: 0.7rem;
|
||||||
|
font-weight: 700;
|
||||||
|
border-radius: 9999px;
|
||||||
|
background: linear-gradient(135deg, rgba(212,160,23,0.25) 0%, rgba(155,58,16,0.2) 100%);
|
||||||
|
color: var(--champion-gold);
|
||||||
|
border: 1px solid rgba(212, 160, 23, 0.45);
|
||||||
|
box-shadow: 0 0 6px var(--champion-glow);
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge-bloodline {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.3rem;
|
||||||
|
padding: 0.2rem 0.55rem;
|
||||||
|
font-size: 0.7rem;
|
||||||
|
font-weight: 700;
|
||||||
|
border-radius: 9999px;
|
||||||
|
background: linear-gradient(135deg, rgba(176,96,16,0.2) 0%, rgba(139,37,0,0.15) 100%);
|
||||||
|
color: var(--bloodline-amber);
|
||||||
|
border: 1px solid rgba(176, 96, 16, 0.4);
|
||||||
|
box-shadow: 0 0 6px var(--bloodline-glow);
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
/* Modal */
|
/* Modal */
|
||||||
.modal-overlay {
|
.modal-overlay {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
@@ -324,7 +370,7 @@ select {
|
|||||||
left: 0;
|
left: 0;
|
||||||
right: 0;
|
right: 0;
|
||||||
bottom: 0;
|
bottom: 0;
|
||||||
background: rgba(0, 0, 0, 0.75);
|
background: rgba(0, 0, 0, 0.8);
|
||||||
backdrop-filter: blur(4px);
|
backdrop-filter: blur(4px);
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@@ -475,9 +521,9 @@ select {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.risk-low {
|
.risk-low {
|
||||||
background: rgba(16, 185, 129, 0.15);
|
background: rgba(34, 197, 94, 0.15);
|
||||||
color: var(--success);
|
color: var(--success);
|
||||||
border: 1px solid rgba(16, 185, 129, 0.3);
|
border: 1px solid rgba(34, 197, 94, 0.3);
|
||||||
}
|
}
|
||||||
|
|
||||||
.risk-med {
|
.risk-med {
|
||||||
|
|||||||
@@ -1,10 +1,13 @@
|
|||||||
import React from 'react'
|
import { StrictMode } from 'react'
|
||||||
import ReactDOM from 'react-dom/client'
|
import { createRoot } from 'react-dom/client'
|
||||||
|
import { SettingsProvider } from './hooks/useSettings'
|
||||||
import App from './App.jsx'
|
import App from './App.jsx'
|
||||||
import './index.css'
|
import './index.css'
|
||||||
|
|
||||||
ReactDOM.createRoot(document.getElementById('root')).render(
|
createRoot(document.getElementById('root')).render(
|
||||||
<React.StrictMode>
|
<StrictMode>
|
||||||
<App />
|
<SettingsProvider>
|
||||||
</React.StrictMode>,
|
<App />
|
||||||
|
</SettingsProvider>
|
||||||
|
</StrictMode>,
|
||||||
)
|
)
|
||||||
@@ -1,67 +1,783 @@
|
|||||||
import { useEffect, useState } from 'react'
|
import { useEffect, useState, useCallback } from 'react'
|
||||||
import { Heart } from 'lucide-react'
|
import {
|
||||||
|
Heart, ChevronLeft, ChevronRight, Plus, X,
|
||||||
|
CalendarDays, FlaskConical, Baby, AlertCircle, CheckCircle2, Activity
|
||||||
|
} from 'lucide-react'
|
||||||
|
import { useNavigate } from 'react-router-dom'
|
||||||
import axios from 'axios'
|
import axios from 'axios'
|
||||||
|
|
||||||
function BreedingCalendar() {
|
// ─── Date helpers ────────────────────────────────────────────────────────────
|
||||||
const [heatCycles, setHeatCycles] = useState([])
|
const toISO = d => d.toISOString().split('T')[0]
|
||||||
const [loading, setLoading] = useState(true)
|
const addDays = (dateStr, n) => {
|
||||||
|
const d = new Date(dateStr); d.setDate(d.getDate() + n); return toISO(d)
|
||||||
|
}
|
||||||
|
const fmt = str => str ? new Date(str + 'T00:00:00').toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' }) : '–'
|
||||||
|
const today = toISO(new Date())
|
||||||
|
|
||||||
useEffect(() => {
|
// ─── Canine gestation constants (days from breeding date) ─────────────────────
|
||||||
fetchHeatCycles()
|
const GESTATION_EARLIEST = 58
|
||||||
}, [])
|
const GESTATION_EXPECTED = 63
|
||||||
|
const GESTATION_LATEST = 65
|
||||||
|
|
||||||
const fetchHeatCycles = async () => {
|
/** Returns { earliest, expected, latest } ISO date strings, or null if no breeding_date */
|
||||||
|
function getWhelpDates(cycle) {
|
||||||
|
if (!cycle?.breeding_date) return null
|
||||||
|
return {
|
||||||
|
earliest: addDays(cycle.breeding_date, GESTATION_EARLIEST),
|
||||||
|
expected: addDays(cycle.breeding_date, GESTATION_EXPECTED),
|
||||||
|
latest: addDays(cycle.breeding_date, GESTATION_LATEST),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Cycle window classifier ─────────────────────────────────────────────────
|
||||||
|
function getWindowForDate(cycle, dateStr) {
|
||||||
|
if (!cycle?.start_date) return null
|
||||||
|
const start = new Date(cycle.start_date + 'T00:00:00')
|
||||||
|
const check = new Date(dateStr + 'T00:00:00')
|
||||||
|
const day = Math.round((check - start) / 86400000)
|
||||||
|
if (day < 0 || day > 28) return null
|
||||||
|
if (day <= 8) return 'proestrus'
|
||||||
|
if (day <= 15) return 'optimal'
|
||||||
|
if (day <= 21) return 'late'
|
||||||
|
return 'diestrus'
|
||||||
|
}
|
||||||
|
|
||||||
|
const WINDOW_STYLES = {
|
||||||
|
proestrus: { bg: 'rgba(244,114,182,0.18)', border: '#f472b6', label: 'Proestrus', dot: '#f472b6' },
|
||||||
|
optimal: { bg: 'rgba(16,185,129,0.22)', border: '#10b981', label: 'Optimal Breeding', dot: '#10b981' },
|
||||||
|
late: { bg: 'rgba(245,158,11,0.18)', border: '#f59e0b', label: 'Late Estrus', dot: '#f59e0b' },
|
||||||
|
diestrus: { bg: 'rgba(148,163,184,0.12)', border: '#64748b', label: 'Diestrus', dot: '#64748b' },
|
||||||
|
}
|
||||||
|
|
||||||
|
// Whelp window style (used in legend + calendar marker)
|
||||||
|
const WHELP_STYLE = {
|
||||||
|
bg: 'rgba(99,102,241,0.15)',
|
||||||
|
border: '#6366f1',
|
||||||
|
label: 'Projected Whelp',
|
||||||
|
dot: '#6366f1',
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Start Heat Cycle Modal ───────────────────────────────────────────────────
|
||||||
|
function StartCycleModal({ females, onClose, onSaved }) {
|
||||||
|
const [dogId, setDogId] = useState('')
|
||||||
|
const [startDate, setStartDate] = useState(today)
|
||||||
|
const [notes, setNotes] = useState('')
|
||||||
|
const [saving, setSaving] = useState(false)
|
||||||
|
const [error, setError] = useState(null)
|
||||||
|
|
||||||
|
async function handleSubmit(e) {
|
||||||
|
e.preventDefault()
|
||||||
|
if (!dogId || !startDate) return
|
||||||
|
setSaving(true); setError(null)
|
||||||
try {
|
try {
|
||||||
const res = await axios.get('/api/breeding/heat-cycles/active')
|
const res = await fetch('/api/breeding/heat-cycles', {
|
||||||
setHeatCycles(res.data)
|
method: 'POST',
|
||||||
setLoading(false)
|
headers: { 'Content-Type': 'application/json' },
|
||||||
} catch (error) {
|
body: JSON.stringify({ dog_id: parseInt(dogId), start_date: startDate, notes: notes || null })
|
||||||
console.error('Error fetching heat cycles:', error)
|
})
|
||||||
setLoading(false)
|
if (!res.ok) { const e = await res.json(); throw new Error(e.error || 'Failed to save') }
|
||||||
|
onSaved()
|
||||||
|
} catch (err) {
|
||||||
|
setError(err.message)
|
||||||
|
setSaving(false)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (loading) {
|
|
||||||
return <div className="container loading">Loading breeding calendar...</div>
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="container">
|
<div className="modal-overlay" onClick={e => e.target === e.currentTarget && onClose()}>
|
||||||
<h1 style={{ marginBottom: '2rem' }}>Breeding Calendar</h1>
|
<div className="modal-content" style={{ maxWidth: '480px' }}>
|
||||||
|
<div className="modal-header">
|
||||||
<div className="card" style={{ marginBottom: '2rem' }}>
|
<div style={{ display: 'flex', alignItems: 'center', gap: '0.6rem' }}>
|
||||||
<h2>Active Heat Cycles</h2>
|
<Heart size={18} style={{ color: '#f472b6' }} />
|
||||||
{heatCycles.length === 0 ? (
|
<h2>Start Heat Cycle</h2>
|
||||||
<div style={{ textAlign: 'center', padding: '2rem' }}>
|
|
||||||
<Heart size={48} style={{ color: 'var(--text-secondary)', margin: '0 auto 1rem' }} />
|
|
||||||
<p style={{ color: 'var(--text-secondary)' }}>No active heat cycles</p>
|
|
||||||
</div>
|
</div>
|
||||||
) : (
|
<button className="btn-icon" onClick={onClose}><X size={20} /></button>
|
||||||
<div style={{ display: 'grid', gap: '1rem', marginTop: '1rem' }}>
|
</div>
|
||||||
{heatCycles.map(cycle => (
|
<form onSubmit={handleSubmit}>
|
||||||
<div key={cycle.id} className="card" style={{ background: 'var(--bg-secondary)' }}>
|
<div className="modal-body">
|
||||||
<h3>{cycle.dog_name}</h3>
|
{error && <div className="error" style={{ marginBottom: '1rem' }}>{error}</div>}
|
||||||
<p style={{ color: 'var(--text-secondary)' }}>
|
<div className="form-group">
|
||||||
Started: {new Date(cycle.start_date).toLocaleDateString()}
|
<label className="label">Female Dog *</label>
|
||||||
</p>
|
<select value={dogId} onChange={e => setDogId(e.target.value)} required>
|
||||||
{cycle.registration_number && (
|
<option value="">– Select Female –</option>
|
||||||
<p style={{ fontSize: '0.875rem', color: 'var(--text-secondary)' }}>
|
{females.map(d => (
|
||||||
Reg: {cycle.registration_number}
|
<option key={d.id} value={d.id}>
|
||||||
</p>
|
{d.name}{d.breed ? ` · ${d.breed}` : ''}
|
||||||
)}
|
</option>
|
||||||
</div>
|
))}
|
||||||
))}
|
</select>
|
||||||
|
{females.length === 0 && <p style={{ color: 'var(--text-muted)', fontSize: '0.8rem', marginTop: '0.4rem' }}>No female dogs registered.</p>}
|
||||||
|
</div>
|
||||||
|
<div className="form-group">
|
||||||
|
<label className="label">Heat Start Date *</label>
|
||||||
|
<input type="date" className="input" value={startDate} onChange={e => setStartDate(e.target.value)} required />
|
||||||
|
</div>
|
||||||
|
<div className="form-group" style={{ marginBottom: 0 }}>
|
||||||
|
<label className="label">Notes</label>
|
||||||
|
<textarea className="input" value={notes} onChange={e => setNotes(e.target.value)} placeholder="Optional notes..." rows={3} />
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
<div className="modal-footer">
|
||||||
</div>
|
<button type="button" className="btn btn-secondary" onClick={onClose}>Cancel</button>
|
||||||
|
<button type="submit" className="btn btn-primary" disabled={saving || !dogId}>
|
||||||
<div className="card">
|
{saving ? 'Saving…' : <><Heart size={15} /> Start Cycle</>}
|
||||||
<h2>Whelping Calculator</h2>
|
</button>
|
||||||
<p style={{ color: 'var(--text-secondary)', marginTop: '0.5rem' }}>Calculate expected whelping dates based on breeding dates</p>
|
</div>
|
||||||
<p style={{ marginTop: '1rem', fontSize: '0.875rem' }}>Feature coming soon...</p>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
export default BreedingCalendar
|
// ─── Cycle Detail Modal ───────────────────────────────────────────────────────
|
||||||
|
function CycleDetailModal({ cycle, onClose, onDeleted, onRecordLitter }) {
|
||||||
|
const [suggestions, setSuggestions] = useState(null)
|
||||||
|
const [breedingDate, setBreedingDate] = useState(cycle.breeding_date || '')
|
||||||
|
const [savingBreed, setSavingBreed] = useState(false)
|
||||||
|
const [deleting, setDeleting] = useState(false)
|
||||||
|
const [error, setError] = useState(null)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetch(`/api/breeding/heat-cycles/${cycle.id}/suggestions`)
|
||||||
|
.then(r => r.json())
|
||||||
|
.then(setSuggestions)
|
||||||
|
.catch(() => {})
|
||||||
|
}, [cycle.id])
|
||||||
|
|
||||||
|
async function saveBreedingDate() {
|
||||||
|
if (!breedingDate) return
|
||||||
|
setSavingBreed(true); setError(null)
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/api/breeding/heat-cycles/${cycle.id}`, {
|
||||||
|
method: 'PUT',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ ...cycle, breeding_date: breedingDate })
|
||||||
|
})
|
||||||
|
if (!res.ok) { const e = await res.json(); throw new Error(e.error) }
|
||||||
|
// Refresh suggestions
|
||||||
|
const s = await fetch(`/api/breeding/heat-cycles/${cycle.id}/suggestions`).then(r => r.json())
|
||||||
|
setSuggestions(s)
|
||||||
|
} catch (err) { setError(err.message) }
|
||||||
|
finally { setSavingBreed(false) }
|
||||||
|
}
|
||||||
|
|
||||||
|
async function deleteCycle() {
|
||||||
|
if (!window.confirm(`Delete heat cycle for ${cycle.dog_name}? This cannot be undone.`)) return
|
||||||
|
setDeleting(true)
|
||||||
|
try {
|
||||||
|
await fetch(`/api/breeding/heat-cycles/${cycle.id}`, { method: 'DELETE' })
|
||||||
|
onDeleted()
|
||||||
|
} catch (err) { setError(err.message); setDeleting(false) }
|
||||||
|
}
|
||||||
|
|
||||||
|
const whelp = suggestions?.whelping
|
||||||
|
const hasBreedingDate = !!(breedingDate && breedingDate === cycle.breeding_date)
|
||||||
|
|
||||||
|
// Client-side projected whelp dates (immediate, before API suggestions load)
|
||||||
|
const projectedWhelp = getWhelpDates({ breeding_date: breedingDate })
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="modal-overlay" onClick={e => e.target === e.currentTarget && onClose()}>
|
||||||
|
<div className="modal-content" style={{ maxWidth: '560px' }}>
|
||||||
|
<div className="modal-header">
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: '0.6rem' }}>
|
||||||
|
<Heart size={18} style={{ color: '#f472b6' }} />
|
||||||
|
<h2>{cycle.dog_name}</h2>
|
||||||
|
</div>
|
||||||
|
<button className="btn-icon" onClick={onClose}><X size={20} /></button>
|
||||||
|
</div>
|
||||||
|
<div className="modal-body">
|
||||||
|
{error && <div className="error">{error}</div>}
|
||||||
|
|
||||||
|
{/* Cycle meta */}
|
||||||
|
<div style={{ display: 'flex', gap: '1rem', marginBottom: '1.5rem', flexWrap: 'wrap' }}>
|
||||||
|
<div style={infoChip}>
|
||||||
|
<span style={{ color: 'var(--text-muted)', fontSize: '0.75rem', textTransform: 'uppercase', letterSpacing: '0.05em' }}>Started</span>
|
||||||
|
<span style={{ fontWeight: 600 }}>{fmt(cycle.start_date)}</span>
|
||||||
|
</div>
|
||||||
|
{cycle.breed && (
|
||||||
|
<div style={infoChip}>
|
||||||
|
<span style={{ color: 'var(--text-muted)', fontSize: '0.75rem', textTransform: 'uppercase', letterSpacing: '0.05em' }}>Breed</span>
|
||||||
|
<span style={{ fontWeight: 600 }}>{cycle.breed}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Breeding date windows */}
|
||||||
|
{suggestions && (
|
||||||
|
<>
|
||||||
|
<h3 style={{ fontSize: '0.9375rem', marginBottom: '0.75rem', display: 'flex', alignItems: 'center', gap: '0.4rem' }}>
|
||||||
|
<FlaskConical size={16} style={{ color: 'var(--accent)' }} /> Breeding Date Windows
|
||||||
|
</h3>
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.5rem', marginBottom: '1.5rem' }}>
|
||||||
|
{suggestions.windows.map(w => (
|
||||||
|
<div key={w.type} style={{
|
||||||
|
display: 'flex', alignItems: 'flex-start', gap: '0.75rem',
|
||||||
|
padding: '0.625rem 0.875rem',
|
||||||
|
background: WINDOW_STYLES[w.type]?.bg,
|
||||||
|
border: `1px solid ${WINDOW_STYLES[w.type]?.border}`,
|
||||||
|
borderRadius: 'var(--radius-sm)'
|
||||||
|
}}>
|
||||||
|
<div style={{ width: 10, height: 10, borderRadius: '50%', background: WINDOW_STYLES[w.type]?.dot, marginTop: 4, flexShrink: 0 }} />
|
||||||
|
<div style={{ flex: 1, minWidth: 0 }}>
|
||||||
|
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', gap: '0.5rem', flexWrap: 'wrap' }}>
|
||||||
|
<span style={{ fontWeight: 600, fontSize: '0.875rem' }}>{w.label}</span>
|
||||||
|
<span style={{ fontSize: '0.8125rem', color: 'var(--text-secondary)', whiteSpace: 'nowrap' }}>{fmt(w.start)} – {fmt(w.end)}</span>
|
||||||
|
</div>
|
||||||
|
<p style={{ fontSize: '0.8rem', color: 'var(--text-muted)', margin: '0.15rem 0 0' }}>{w.description}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Log breeding date */}
|
||||||
|
<div style={{ background: 'var(--bg-tertiary)', borderRadius: 'var(--radius)', padding: '1rem', marginBottom: '1.25rem' }}>
|
||||||
|
<h3 style={{ fontSize: '0.9375rem', marginBottom: '0.75rem', display: 'flex', alignItems: 'center', gap: '0.4rem' }}>
|
||||||
|
<CalendarDays size={16} style={{ color: 'var(--primary)' }} /> Log Breeding Date
|
||||||
|
</h3>
|
||||||
|
<div style={{ display: 'flex', gap: '0.75rem', alignItems: 'flex-end', flexWrap: 'wrap' }}>
|
||||||
|
<div style={{ flex: 1, minWidth: 160 }}>
|
||||||
|
<label className="label" style={{ marginBottom: '0.4rem' }}>Breeding Date</label>
|
||||||
|
<input type="date" className="input" value={breedingDate} onChange={e => setBreedingDate(e.target.value)} />
|
||||||
|
</div>
|
||||||
|
<button className="btn btn-primary" onClick={saveBreedingDate} disabled={savingBreed || !breedingDate} style={{ marginBottom: 0 }}>
|
||||||
|
{savingBreed ? 'Saving…' : 'Save'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{/* Live projected whelp preview — shown as soon as a breeding date is entered */}
|
||||||
|
{projectedWhelp && (
|
||||||
|
<div style={{
|
||||||
|
marginTop: '0.875rem',
|
||||||
|
padding: '0.625rem 0.875rem',
|
||||||
|
background: WHELP_STYLE.bg,
|
||||||
|
border: `1px solid ${WHELP_STYLE.border}`,
|
||||||
|
borderRadius: 'var(--radius-sm)',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: '0.5rem',
|
||||||
|
flexWrap: 'wrap'
|
||||||
|
}}>
|
||||||
|
<Baby size={15} style={{ color: WHELP_STYLE.dot, flexShrink: 0 }} />
|
||||||
|
<span style={{ fontSize: '0.8125rem', fontWeight: 600, color: WHELP_STYLE.dot }}>Projected Whelp:</span>
|
||||||
|
<span style={{ fontSize: '0.8125rem', color: 'var(--text-secondary)' }}>
|
||||||
|
{fmt(projectedWhelp.earliest)} – {fmt(projectedWhelp.latest)}
|
||||||
|
<span style={{ color: 'var(--text-muted)' }}>(expected {fmt(projectedWhelp.expected)})</span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Whelping estimate (from API suggestions) */}
|
||||||
|
{whelp && (
|
||||||
|
<div style={{ background: 'rgba(16,185,129,0.08)', border: '1px solid rgba(16,185,129,0.3)', borderRadius: 'var(--radius)', padding: '1rem', marginBottom: '1rem' }}>
|
||||||
|
<h3 style={{ fontSize: '0.9375rem', marginBottom: '0.75rem', display: 'flex', alignItems: 'center', gap: '0.4rem', color: 'var(--success)' }}>
|
||||||
|
<Baby size={16} /> Whelping Estimate
|
||||||
|
</h3>
|
||||||
|
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(3, 1fr)', gap: '0.75rem', textAlign: 'center' }}>
|
||||||
|
{[['Earliest', whelp.earliest], ['Expected', whelp.expected], ['Latest', whelp.latest]].map(([label, date]) => (
|
||||||
|
<div key={label}>
|
||||||
|
<div style={{ fontSize: '0.75rem', color: 'var(--text-muted)', textTransform: 'uppercase', letterSpacing: '0.05em', marginBottom: '0.2rem' }}>{label}</div>
|
||||||
|
<div style={{ fontWeight: 700, fontSize: '0.9375rem' }}>{fmt(date)}</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Record Litter CTA — shown when breeding date is saved */}
|
||||||
|
{hasBreedingDate && (
|
||||||
|
<div style={{
|
||||||
|
background: 'rgba(16,185,129,0.06)',
|
||||||
|
border: '1px dashed rgba(16,185,129,0.5)',
|
||||||
|
borderRadius: 'var(--radius)',
|
||||||
|
padding: '0.875rem 1rem',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
gap: '1rem',
|
||||||
|
flexWrap: 'wrap'
|
||||||
|
}}>
|
||||||
|
<div>
|
||||||
|
<div style={{ fontWeight: 600, fontSize: '0.9rem' }}>🐾 Ready to record the litter?</div>
|
||||||
|
<div style={{ fontSize: '0.8rem', color: 'var(--text-secondary)', marginTop: '0.2rem' }}>
|
||||||
|
Breeding date logged on {fmt(cycle.breeding_date)}. Create a litter record to track puppies.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
className="btn btn-primary"
|
||||||
|
style={{ whiteSpace: 'nowrap', fontSize: '0.85rem' }}
|
||||||
|
onClick={() => {
|
||||||
|
onClose()
|
||||||
|
onRecordLitter(cycle)
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Activity size={14} style={{ marginRight: '0.4rem' }} />
|
||||||
|
Record Litter
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="modal-footer" style={{ justifyContent: 'space-between' }}>
|
||||||
|
<button className="btn btn-danger" onClick={deleteCycle} disabled={deleting}>
|
||||||
|
{deleting ? 'Deleting…' : 'Delete Cycle'}
|
||||||
|
</button>
|
||||||
|
<button className="btn btn-secondary" onClick={onClose}>Close</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const infoChip = {
|
||||||
|
display: 'flex', flexDirection: 'column', gap: '0.15rem',
|
||||||
|
padding: '0.5rem 0.875rem',
|
||||||
|
background: 'var(--bg-tertiary)',
|
||||||
|
borderRadius: 'var(--radius-sm)'
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Main Calendar ────────────────────────────────────────────────────────────
|
||||||
|
export default function BreedingCalendar() {
|
||||||
|
const now = new Date()
|
||||||
|
const [year, setYear] = useState(now.getFullYear())
|
||||||
|
const [month, setMonth] = useState(now.getMonth()) // 0-indexed
|
||||||
|
const [cycles, setCycles] = useState([])
|
||||||
|
const [females, setFemales] = useState([])
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
const [showStartModal, setShowStartModal] = useState(false)
|
||||||
|
const [selectedCycle, setSelectedCycle] = useState(null)
|
||||||
|
const [selectedDay, setSelectedDay] = useState(null)
|
||||||
|
const [pendingLitterCycle, setPendingLitterCycle] = useState(null)
|
||||||
|
const navigate = useNavigate()
|
||||||
|
|
||||||
|
const load = useCallback(async () => {
|
||||||
|
setLoading(true)
|
||||||
|
try {
|
||||||
|
const [cyclesRes, dogsRes] = await Promise.all([
|
||||||
|
fetch('/api/breeding/heat-cycles'),
|
||||||
|
fetch('/api/dogs')
|
||||||
|
])
|
||||||
|
const allCycles = await cyclesRes.json()
|
||||||
|
const dogsData = await dogsRes.json()
|
||||||
|
const allDogs = Array.isArray(dogsData) ? dogsData : (dogsData.dogs || [])
|
||||||
|
setCycles(Array.isArray(allCycles) ? allCycles : [])
|
||||||
|
setFemales(allDogs.filter(d => d.sex === 'female'))
|
||||||
|
} catch (e) {
|
||||||
|
console.error(e)
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
useEffect(() => { load() }, [load])
|
||||||
|
|
||||||
|
// When user clicks Record Litter from cycle detail, create litter and navigate
|
||||||
|
const handleRecordLitter = useCallback(async (cycle) => {
|
||||||
|
try {
|
||||||
|
// We need sire_id — navigate to litters page with pre-filled dam
|
||||||
|
// Store cycle info in sessionStorage so LitterList can pre-fill
|
||||||
|
sessionStorage.setItem('prefillLitter', JSON.stringify({
|
||||||
|
dam_id: cycle.dog_id,
|
||||||
|
dam_name: cycle.dog_name,
|
||||||
|
breeding_date: cycle.breeding_date,
|
||||||
|
whelping_date: cycle.whelping_date || ''
|
||||||
|
}))
|
||||||
|
navigate('/litters')
|
||||||
|
} catch (err) {
|
||||||
|
console.error(err)
|
||||||
|
}
|
||||||
|
}, [navigate])
|
||||||
|
|
||||||
|
// ── Navigate to a specific year/month ──
|
||||||
|
function goToMonth(y, m) {
|
||||||
|
setYear(y)
|
||||||
|
setMonth(m)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Build calendar grid ──
|
||||||
|
const firstDay = new Date(year, month, 1)
|
||||||
|
const lastDay = new Date(year, month + 1, 0)
|
||||||
|
const startPad = firstDay.getDay() // 0=Sun
|
||||||
|
const totalCells = startPad + lastDay.getDate()
|
||||||
|
const rows = Math.ceil(totalCells / 7)
|
||||||
|
|
||||||
|
const MONTH_NAMES = ['January','February','March','April','May','June','July','August','September','October','November','December']
|
||||||
|
const DAY_NAMES = ['Sun','Mon','Tue','Wed','Thu','Fri','Sat']
|
||||||
|
|
||||||
|
function prevMonth() {
|
||||||
|
if (month === 0) { setMonth(11); setYear(y => y - 1) }
|
||||||
|
else setMonth(m => m - 1)
|
||||||
|
}
|
||||||
|
function nextMonth() {
|
||||||
|
if (month === 11) { setMonth(0); setYear(y => y + 1) }
|
||||||
|
else setMonth(m => m + 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
function cyclesForDate(dateStr) {
|
||||||
|
return cycles.filter(c => {
|
||||||
|
const s = c.start_date
|
||||||
|
if (!s) return false
|
||||||
|
const end = c.end_date || addDays(s, 28)
|
||||||
|
return dateStr >= s && dateStr <= end
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Returns array of cycles whose projected whelp expected date is this dateStr */
|
||||||
|
function whelpingCyclesForDate(dateStr) {
|
||||||
|
return cycles.filter(c => {
|
||||||
|
const wd = getWhelpDates(c)
|
||||||
|
if (!wd) return false
|
||||||
|
return dateStr >= wd.earliest && dateStr <= wd.latest
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Returns true if this dateStr is the exact expected whelp date for any cycle */
|
||||||
|
function isExpectedWhelpDate(dateStr) {
|
||||||
|
return cycles.some(c => {
|
||||||
|
const wd = getWhelpDates(c)
|
||||||
|
return wd?.expected === dateStr
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleDayClick(dateStr, dayCycles) {
|
||||||
|
setSelectedDay(dateStr)
|
||||||
|
if (dayCycles.length === 1) {
|
||||||
|
setSelectedCycle(dayCycles[0])
|
||||||
|
} else if (dayCycles.length > 1) {
|
||||||
|
setSelectedCycle(dayCycles[0])
|
||||||
|
} else {
|
||||||
|
setShowStartModal(true)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const activeCycles = cycles.filter(c => {
|
||||||
|
const s = c.start_date; if (!s) return false
|
||||||
|
const end = c.end_date || addDays(s, 28)
|
||||||
|
const mStart = toISO(new Date(year, month, 1))
|
||||||
|
const mEnd = toISO(new Date(year, month + 1, 0))
|
||||||
|
return s <= mEnd && end >= mStart
|
||||||
|
})
|
||||||
|
|
||||||
|
// Cycles that have a whelp window overlapping current month view
|
||||||
|
const whelpingThisMonth = cycles.filter(c => {
|
||||||
|
const wd = getWhelpDates(c)
|
||||||
|
if (!wd) return false
|
||||||
|
const mStart = toISO(new Date(year, month, 1))
|
||||||
|
const mEnd = toISO(new Date(year, month + 1, 0))
|
||||||
|
return wd.earliest <= mEnd && wd.latest >= mStart
|
||||||
|
})
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="container" style={{ paddingTop: '2rem', paddingBottom: '3rem' }}>
|
||||||
|
{/* Header */}
|
||||||
|
<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={{ width: '2.5rem', height: '2.5rem', borderRadius: 'var(--radius)', background: 'rgba(244,114,182,0.2)', display: 'flex', alignItems: 'center', justifyContent: 'center', color: '#f472b6' }}>
|
||||||
|
<Heart size={20} />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h1 style={{ fontSize: '1.75rem', margin: 0 }}>Heat Cycle Calendar</h1>
|
||||||
|
<p style={{ color: 'var(--text-muted)', margin: 0, fontSize: '0.875rem' }}>Track heat cycles, optimal breeding windows, and projected whelping dates</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button className="btn btn-primary" onClick={() => setShowStartModal(true)}>
|
||||||
|
<Plus size={16} /> Start Heat Cycle
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Legend */}
|
||||||
|
<div style={{ display: 'flex', gap: '0.75rem', marginBottom: '1.25rem', flexWrap: 'wrap' }}>
|
||||||
|
{Object.entries(WINDOW_STYLES).map(([key, s]) => (
|
||||||
|
<div key={key} style={{ display: 'flex', alignItems: 'center', gap: '0.4rem', fontSize: '0.8125rem', color: 'var(--text-secondary)' }}>
|
||||||
|
<div style={{ width: 10, height: 10, borderRadius: '50%', background: s.dot }} />
|
||||||
|
{s.label}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
{/* Whelp legend entry */}
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: '0.4rem', fontSize: '0.8125rem', color: 'var(--text-secondary)' }}>
|
||||||
|
<Baby size={11} style={{ color: WHELP_STYLE.dot }} />
|
||||||
|
{WHELP_STYLE.label}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Month navigator */}
|
||||||
|
<div className="card" style={{ marginBottom: '1rem', padding: '0' }}>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', padding: '0.875rem 1rem', borderBottom: '1px solid var(--border)' }}>
|
||||||
|
<button className="btn-icon" onClick={prevMonth}><ChevronLeft size={20} /></button>
|
||||||
|
<h2 style={{ margin: 0, fontSize: '1.1rem' }}>{MONTH_NAMES[month]} {year}</h2>
|
||||||
|
<button className="btn-icon" onClick={nextMonth}><ChevronRight size={20} /></button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Day headers */}
|
||||||
|
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(7, 1fr)', borderBottom: '1px solid var(--border)' }}>
|
||||||
|
{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>
|
||||||
|
|
||||||
|
{/* Calendar cells */}
|
||||||
|
{loading ? (
|
||||||
|
<div className="loading" style={{ minHeight: 280 }}>Loading calendar…</div>
|
||||||
|
) : (
|
||||||
|
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(7, 1fr)' }}>
|
||||||
|
{Array.from({ length: rows * 7 }).map((_, idx) => {
|
||||||
|
const dayNum = idx - startPad + 1
|
||||||
|
const isValid = dayNum >= 1 && dayNum <= lastDay.getDate()
|
||||||
|
const dateStr = isValid ? toISO(new Date(year, month, dayNum)) : null
|
||||||
|
const dayCycles = dateStr ? cyclesForDate(dateStr) : []
|
||||||
|
const isToday = dateStr === today
|
||||||
|
|
||||||
|
// Whelp window cycles for this day
|
||||||
|
const whelpCycles = dateStr ? whelpingCyclesForDate(dateStr) : []
|
||||||
|
const isExpectedWhelp = dateStr ? isExpectedWhelpDate(dateStr) : false
|
||||||
|
const hasWhelpActivity = whelpCycles.length > 0
|
||||||
|
|
||||||
|
let cellBg = 'transparent'
|
||||||
|
let cellBorder = 'var(--border)'
|
||||||
|
if (dayCycles.length > 0) {
|
||||||
|
const win = getWindowForDate(dayCycles[0], dateStr)
|
||||||
|
if (win && WINDOW_STYLES[win]) {
|
||||||
|
cellBg = WINDOW_STYLES[win].bg
|
||||||
|
cellBorder = WINDOW_STYLES[win].border
|
||||||
|
}
|
||||||
|
} else if (hasWhelpActivity) {
|
||||||
|
// Only color whelp window if not already in a heat window
|
||||||
|
cellBg = WHELP_STYLE.bg
|
||||||
|
cellBorder = WHELP_STYLE.border
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={idx}
|
||||||
|
onClick={() => isValid && handleDayClick(dateStr, dayCycles)}
|
||||||
|
style={{
|
||||||
|
minHeight: 72,
|
||||||
|
padding: '0.375rem 0.5rem',
|
||||||
|
borderRight: '1px solid var(--border)',
|
||||||
|
borderBottom: '1px solid var(--border)',
|
||||||
|
background: cellBg,
|
||||||
|
cursor: isValid ? 'pointer' : 'default',
|
||||||
|
position: 'relative',
|
||||||
|
transition: 'filter 0.15s',
|
||||||
|
opacity: isValid ? 1 : 0.3,
|
||||||
|
outline: isToday ? `2px solid var(--primary)` : 'none',
|
||||||
|
outlineOffset: -2,
|
||||||
|
}}
|
||||||
|
onMouseEnter={e => { if (isValid) e.currentTarget.style.filter = 'brightness(1.15)' }}
|
||||||
|
onMouseLeave={e => { e.currentTarget.style.filter = 'none' }}
|
||||||
|
>
|
||||||
|
{isValid && (
|
||||||
|
<>
|
||||||
|
<div style={{
|
||||||
|
fontSize: '0.8125rem', fontWeight: isToday ? 700 : 500,
|
||||||
|
color: isToday ? 'var(--primary)' : 'var(--text-primary)',
|
||||||
|
marginBottom: '0.25rem'
|
||||||
|
}}>{dayNum}</div>
|
||||||
|
{dayCycles.map((c, i) => {
|
||||||
|
const win = getWindowForDate(c, dateStr)
|
||||||
|
const dot = win ? WINDOW_STYLES[win]?.dot : '#94a3b8'
|
||||||
|
return (
|
||||||
|
<div key={i} style={{
|
||||||
|
fontSize: '0.7rem', color: dot, fontWeight: 600,
|
||||||
|
whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis',
|
||||||
|
lineHeight: 1.3
|
||||||
|
}}>
|
||||||
|
♥ {c.dog_name}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
{/* Projected whelp window indicator */}
|
||||||
|
{hasWhelpActivity && (
|
||||||
|
<div style={{ marginTop: '0.15rem' }}>
|
||||||
|
{whelpCycles.map((c, i) => (
|
||||||
|
<div key={i} style={{
|
||||||
|
fontSize: '0.67rem',
|
||||||
|
color: WHELP_STYLE.dot,
|
||||||
|
fontWeight: 600,
|
||||||
|
whiteSpace: 'nowrap',
|
||||||
|
overflow: 'hidden',
|
||||||
|
textOverflow: 'ellipsis',
|
||||||
|
lineHeight: 1.3,
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: '0.2rem'
|
||||||
|
}}>
|
||||||
|
<Baby size={9} />
|
||||||
|
{isExpectedWhelp && getWhelpDates(c)?.expected === dateStr
|
||||||
|
? `${c.dog_name} due`
|
||||||
|
: c.dog_name
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{/* Breeding date marker dot */}
|
||||||
|
{dayCycles.some(c => c.breeding_date === dateStr) && (
|
||||||
|
<div style={{ position: 'absolute', top: 4, right: 4, width: 8, height: 8, borderRadius: '50%', background: 'var(--success)', border: '1.5px solid var(--bg-primary)' }} title="Breeding date logged" />
|
||||||
|
)}
|
||||||
|
{/* Expected whelp date ring marker */}
|
||||||
|
{isExpectedWhelp && (
|
||||||
|
<div style={{
|
||||||
|
position: 'absolute',
|
||||||
|
top: 2, right: dayCycles.some(c => c.breeding_date === dateStr) ? 14 : 4,
|
||||||
|
width: 8, height: 8,
|
||||||
|
borderRadius: '50%',
|
||||||
|
background: WHELP_STYLE.dot,
|
||||||
|
border: '1.5px solid var(--bg-primary)'
|
||||||
|
}} title="Projected whelp date" />
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Active cycles list */}
|
||||||
|
<div style={{ marginTop: '1.5rem' }}>
|
||||||
|
<h3 style={{ fontSize: '1rem', marginBottom: '0.875rem', display: 'flex', alignItems: 'center', gap: '0.5rem' }}>
|
||||||
|
<AlertCircle size={16} style={{ color: '#f472b6' }} />
|
||||||
|
Active Cycles This Month
|
||||||
|
<span className="badge badge-primary">{activeCycles.length}</span>
|
||||||
|
</h3>
|
||||||
|
{activeCycles.length === 0 ? (
|
||||||
|
<div className="card" style={{ textAlign: 'center', padding: '2rem', color: 'var(--text-muted)' }}>
|
||||||
|
<Heart size={32} style={{ margin: '0 auto 0.75rem', opacity: 0.4 }} />
|
||||||
|
<p>No active heat cycles this month.</p>
|
||||||
|
<button className="btn btn-primary" style={{ marginTop: '1rem' }} onClick={() => setShowStartModal(true)}>
|
||||||
|
<Plus size={15} /> Start First Cycle
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div style={{ display: 'grid', gap: '0.75rem', gridTemplateColumns: 'repeat(auto-fill, minmax(260px, 1fr))' }}>
|
||||||
|
{activeCycles.map(c => {
|
||||||
|
const win = getWindowForDate(c, today)
|
||||||
|
const ws = win ? WINDOW_STYLES[win] : null
|
||||||
|
const daysSince = Math.round((new Date(today) - new Date(c.start_date + 'T00:00:00')) / 86400000)
|
||||||
|
const projWhelp = getWhelpDates(c)
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={c.id}
|
||||||
|
className="card"
|
||||||
|
style={{ cursor: 'pointer', borderColor: ws?.border || 'var(--border)', background: ws?.bg || 'var(--bg-secondary)' }}
|
||||||
|
onClick={() => setSelectedCycle(c)}
|
||||||
|
>
|
||||||
|
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start' }}>
|
||||||
|
<div>
|
||||||
|
<h4 style={{ margin: '0 0 0.2rem', fontSize: '1rem' }}>{c.dog_name}</h4>
|
||||||
|
{c.breed && <p style={{ color: 'var(--text-muted)', fontSize: '0.8rem', margin: 0 }}>{c.breed}</p>}
|
||||||
|
</div>
|
||||||
|
{ws && <span className="badge" style={{ background: ws.bg, color: ws.dot, border: `1px solid ${ws.border}`, flexShrink: 0 }}>{ws.label}</span>}
|
||||||
|
</div>
|
||||||
|
<div style={{ marginTop: '0.75rem', display: 'flex', gap: '1rem', fontSize: '0.8125rem', color: 'var(--text-secondary)' }}>
|
||||||
|
<span>Started {fmt(c.start_date)}</span>
|
||||||
|
<span>Day {daysSince + 1}</span>
|
||||||
|
</div>
|
||||||
|
{c.breeding_date && (
|
||||||
|
<div style={{ marginTop: '0.5rem', display: 'flex', alignItems: 'center', gap: '0.4rem', fontSize: '0.8rem', color: 'var(--success)' }}>
|
||||||
|
<CheckCircle2 size={13} /> Bred {fmt(c.breeding_date)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{/* Projected whelp date on card */}
|
||||||
|
{projWhelp && (
|
||||||
|
<div style={{
|
||||||
|
marginTop: '0.4rem',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: '0.4rem',
|
||||||
|
fontSize: '0.8rem',
|
||||||
|
color: WHELP_STYLE.dot,
|
||||||
|
fontWeight: 500
|
||||||
|
}}>
|
||||||
|
<Baby size={13} />
|
||||||
|
Whelp est. {fmt(projWhelp.expected)}
|
||||||
|
<span style={{ fontSize: '0.73rem', color: 'var(--text-muted)', fontWeight: 400 }}>
|
||||||
|
({fmt(projWhelp.earliest)}–{fmt(projWhelp.latest)})
|
||||||
|
</span>
|
||||||
|
{/* Jump-to-month button if whelp month differs from current view */}
|
||||||
|
{(() => {
|
||||||
|
const wd = new Date(projWhelp.expected + 'T00:00:00')
|
||||||
|
const wdY = wd.getFullYear()
|
||||||
|
const wdM = wd.getMonth()
|
||||||
|
if (wdY !== year || wdM !== month) {
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
style={{
|
||||||
|
marginLeft: 'auto',
|
||||||
|
background: 'none',
|
||||||
|
border: `1px solid ${WHELP_STYLE.border}`,
|
||||||
|
borderRadius: '0.25rem',
|
||||||
|
color: WHELP_STYLE.dot,
|
||||||
|
fontSize: '0.7rem',
|
||||||
|
padding: '0.1rem 0.35rem',
|
||||||
|
cursor: 'pointer',
|
||||||
|
fontWeight: 600,
|
||||||
|
whiteSpace: 'nowrap'
|
||||||
|
}}
|
||||||
|
onClick={e => { e.stopPropagation(); goToMonth(wdY, wdM) }}
|
||||||
|
>
|
||||||
|
View {MONTH_NAMES[wdM].slice(0,3)} {wdY}
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
})()}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Whelping cycles banner — shown if any projected whelps fall this month but no active heat */}
|
||||||
|
{whelpingThisMonth.length > 0 && activeCycles.length === 0 && (
|
||||||
|
<div style={{
|
||||||
|
marginTop: '1.5rem',
|
||||||
|
padding: '1rem',
|
||||||
|
background: WHELP_STYLE.bg,
|
||||||
|
border: `1px solid ${WHELP_STYLE.border}`,
|
||||||
|
borderRadius: 'var(--radius)',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'flex-start',
|
||||||
|
gap: '0.75rem'
|
||||||
|
}}>
|
||||||
|
<Baby size={18} style={{ color: WHELP_STYLE.dot, flexShrink: 0, marginTop: 2 }} />
|
||||||
|
<div>
|
||||||
|
<div style={{ fontWeight: 600, color: WHELP_STYLE.dot, marginBottom: '0.3rem' }}>Projected Whelping This Month</div>
|
||||||
|
{whelpingThisMonth.map(c => {
|
||||||
|
const wd = getWhelpDates(c)
|
||||||
|
return (
|
||||||
|
<div key={c.id} style={{ fontSize: '0.85rem', color: 'var(--text-secondary)', marginBottom: '0.2rem' }}>
|
||||||
|
<strong>{c.dog_name}</strong> — expected {fmt(wd.expected)}
|
||||||
|
<span style={{ color: 'var(--text-muted)', fontSize: '0.78rem' }}> (range {fmt(wd.earliest)}–{fmt(wd.latest)})</span>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Modals */}
|
||||||
|
{showStartModal && (
|
||||||
|
<StartCycleModal
|
||||||
|
females={females}
|
||||||
|
onClose={() => setShowStartModal(false)}
|
||||||
|
onSaved={() => { setShowStartModal(false); load() }}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{selectedCycle && (
|
||||||
|
<CycleDetailModal
|
||||||
|
cycle={selectedCycle}
|
||||||
|
onClose={() => setSelectedCycle(null)}
|
||||||
|
onDeleted={() => { setSelectedCycle(null); load() }}
|
||||||
|
onRecordLitter={handleRecordLitter}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|||||||
@@ -3,6 +3,9 @@ import { useParams, Link, useNavigate } from 'react-router-dom'
|
|||||||
import { Dog, GitBranch, Edit, Upload, Trash2, ArrowLeft, Calendar, Hash, Award } from 'lucide-react'
|
import { Dog, GitBranch, Edit, Upload, Trash2, ArrowLeft, Calendar, Hash, Award } from 'lucide-react'
|
||||||
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 ClearanceSummaryCard from '../components/ClearanceSummaryCard'
|
||||||
|
import HealthRecordForm from '../components/HealthRecordForm'
|
||||||
|
|
||||||
function DogDetail() {
|
function DogDetail() {
|
||||||
const { id } = useParams()
|
const { id } = useParams()
|
||||||
@@ -14,9 +17,13 @@ function DogDetail() {
|
|||||||
const [selectedPhoto, setSelectedPhoto] = useState(0)
|
const [selectedPhoto, setSelectedPhoto] = useState(0)
|
||||||
const fileInputRef = useRef(null)
|
const fileInputRef = useRef(null)
|
||||||
|
|
||||||
useEffect(() => {
|
// Health records state
|
||||||
fetchDog()
|
const [healthRecords, setHealthRecords] = useState([])
|
||||||
}, [id])
|
const [showHealthForm, setShowHealthForm] = useState(false)
|
||||||
|
const [editingRecord, setEditingRecord] = useState(null)
|
||||||
|
|
||||||
|
useEffect(() => { fetchDog() }, [id])
|
||||||
|
useEffect(() => { fetchHealth() }, [id])
|
||||||
|
|
||||||
const fetchDog = async () => {
|
const fetchDog = async () => {
|
||||||
try {
|
try {
|
||||||
@@ -29,14 +36,18 @@ function DogDetail() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const fetchHealth = () => {
|
||||||
|
axios.get(`/api/health/dog/${id}`)
|
||||||
|
.then(r => setHealthRecords(r.data))
|
||||||
|
.catch(() => {})
|
||||||
|
}
|
||||||
|
|
||||||
const handlePhotoUpload = async (e) => {
|
const handlePhotoUpload = async (e) => {
|
||||||
const file = e.target.files[0]
|
const file = e.target.files[0]
|
||||||
if (!file) return
|
if (!file) return
|
||||||
|
|
||||||
setUploading(true)
|
setUploading(true)
|
||||||
const formData = new FormData()
|
const formData = new FormData()
|
||||||
formData.append('photo', file)
|
formData.append('photo', file)
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await axios.post(`/api/dogs/${id}/photos`, formData, {
|
await axios.post(`/api/dogs/${id}/photos`, formData, {
|
||||||
headers: { 'Content-Type': 'multipart/form-data' }
|
headers: { 'Content-Type': 'multipart/form-data' }
|
||||||
@@ -53,7 +64,6 @@ function DogDetail() {
|
|||||||
|
|
||||||
const handleDeletePhoto = async (photoIndex) => {
|
const handleDeletePhoto = async (photoIndex) => {
|
||||||
if (!confirm('Delete this photo?')) return
|
if (!confirm('Delete this photo?')) return
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await axios.delete(`/api/dogs/${id}/photos/${photoIndex}`)
|
await axios.delete(`/api/dogs/${id}/photos/${photoIndex}`)
|
||||||
fetchDog()
|
fetchDog()
|
||||||
@@ -70,26 +80,27 @@ function DogDetail() {
|
|||||||
if (!birthDate) return null
|
if (!birthDate) return null
|
||||||
const today = new Date()
|
const today = new Date()
|
||||||
const birth = new Date(birthDate)
|
const birth = new Date(birthDate)
|
||||||
let years = today.getFullYear() - birth.getFullYear()
|
let years = today.getFullYear() - birth.getFullYear()
|
||||||
let months = today.getMonth() - birth.getMonth()
|
let months = today.getMonth() - birth.getMonth()
|
||||||
|
if (months < 0) { years--; months += 12 }
|
||||||
if (months < 0) {
|
|
||||||
years--
|
|
||||||
months += 12
|
|
||||||
}
|
|
||||||
|
|
||||||
if (years === 0) return `${months} month${months !== 1 ? 's' : ''}`
|
if (years === 0) return `${months} month${months !== 1 ? 's' : ''}`
|
||||||
if (months === 0) return `${years} year${years !== 1 ? 's' : ''}`
|
if (months === 0) return `${years} year${years !== 1 ? 's' : ''}`
|
||||||
return `${years}y ${months}m`
|
return `${years}y ${months}m`
|
||||||
}
|
}
|
||||||
|
|
||||||
if (loading) {
|
const hasChampionBlood = (d) =>
|
||||||
return <div className="container loading">Loading...</div>
|
(d.sire && d.sire.is_champion) || (d.dam && d.dam.is_champion)
|
||||||
}
|
|
||||||
|
|
||||||
if (!dog) {
|
const openAddHealth = () => { setEditingRecord(null); setShowHealthForm(true) }
|
||||||
return <div className="container">Dog not found</div>
|
const openEditHealth = (rec) => { setEditingRecord(rec); setShowHealthForm(true) }
|
||||||
}
|
const closeHealthForm = () => { setShowHealthForm(false); setEditingRecord(null) }
|
||||||
|
const handleHealthSaved = () => { closeHealthForm(); fetchHealth() }
|
||||||
|
|
||||||
|
if (loading) return <div className="container loading">Loading...</div>
|
||||||
|
if (!dog) return <div className="container">Dog not found</div>
|
||||||
|
|
||||||
|
const isChampion = !!dog.is_champion
|
||||||
|
const hasBloodline = !isChampion && hasChampionBlood(dog)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="container" style={{ paddingTop: '2rem', paddingBottom: '3rem' }}>
|
<div className="container" style={{ paddingTop: '2rem', paddingBottom: '3rem' }}>
|
||||||
@@ -99,14 +110,18 @@ function DogDetail() {
|
|||||||
<ArrowLeft size={20} />
|
<ArrowLeft size={20} />
|
||||||
</button>
|
</button>
|
||||||
<div style={{ flex: 1 }}>
|
<div style={{ flex: 1 }}>
|
||||||
<h1 style={{ marginBottom: '0.25rem' }}>{dog.name}</h1>
|
<div style={{ display: 'flex', alignItems: 'center', gap: '0.625rem', flexWrap: 'wrap', marginBottom: '0.25rem' }}>
|
||||||
|
<h1 style={{ margin: 0 }}>{dog.name}</h1>
|
||||||
|
{isChampion && <ChampionBadge size="lg" />}
|
||||||
|
{hasBloodline && <ChampionBloodlineBadge size="lg" />}
|
||||||
|
</div>
|
||||||
<div style={{ display: 'flex', alignItems: 'center', gap: '1rem', color: 'var(--text-secondary)' }}>
|
<div style={{ display: 'flex', alignItems: 'center', gap: '1rem', color: 'var(--text-secondary)' }}>
|
||||||
<span>{dog.breed}</span>
|
<span>{dog.breed}</span>
|
||||||
<span>•</span>
|
<span>·</span>
|
||||||
<span>{dog.sex === 'male' ? 'Male ♂' : 'Female ♀'}</span>
|
<span>{dog.sex === 'male' ? 'Male' : 'Female'}</span>
|
||||||
{dog.birth_date && (
|
{dog.birth_date && (
|
||||||
<>
|
<>
|
||||||
<span>•</span>
|
<span>·</span>
|
||||||
<span>{calculateAge(dog.birth_date)}</span>
|
<span>{calculateAge(dog.birth_date)}</span>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
@@ -125,7 +140,7 @@ function DogDetail() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 2fr', gap: '1.5rem', marginBottom: '1.5rem' }}>
|
<div style={{ display: 'grid', gridTemplateColumns: '1fr 2fr', gap: '1.5rem', marginBottom: '1.5rem' }}>
|
||||||
{/* Photo Section - Compact */}
|
{/* 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' }}>
|
||||||
<h3 style={{ fontSize: '0.875rem', textTransform: 'uppercase', letterSpacing: '0.05em', color: 'var(--text-muted)' }}>Photos</h3>
|
<h3 style={{ fontSize: '0.875rem', textTransform: 'uppercase', letterSpacing: '0.05em', color: 'var(--text-muted)' }}>Photos</h3>
|
||||||
@@ -138,46 +153,42 @@ function DogDetail() {
|
|||||||
<Upload size={14} />
|
<Upload size={14} />
|
||||||
{uploading ? 'Uploading...' : 'Add'}
|
{uploading ? 'Uploading...' : 'Add'}
|
||||||
</button>
|
</button>
|
||||||
<input
|
<input ref={fileInputRef} type="file" accept="image/*" onChange={handlePhotoUpload} style={{ display: 'none' }} />
|
||||||
ref={fileInputRef}
|
|
||||||
type="file"
|
|
||||||
accept="image/*"
|
|
||||||
onChange={handlePhotoUpload}
|
|
||||||
style={{ display: 'none' }}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{dog.photo_urls && dog.photo_urls.length > 0 ? (
|
{dog.photo_urls && dog.photo_urls.length > 0 ? (
|
||||||
<>
|
<>
|
||||||
{/* Main Photo */}
|
|
||||||
<div style={{ position: 'relative', marginBottom: '0.75rem' }}>
|
<div style={{ position: 'relative', marginBottom: '0.75rem' }}>
|
||||||
<img
|
<img
|
||||||
src={dog.photo_urls[selectedPhoto]}
|
src={dog.photo_urls[selectedPhoto]}
|
||||||
alt={dog.name}
|
alt={dog.name}
|
||||||
style={{
|
style={{
|
||||||
width: '100%',
|
width: '100%', aspectRatio: '1', objectFit: 'cover',
|
||||||
aspectRatio: '1',
|
|
||||||
objectFit: 'cover',
|
|
||||||
borderRadius: 'var(--radius)',
|
borderRadius: 'var(--radius)',
|
||||||
border: '1px solid var(--border)'
|
border: isChampion
|
||||||
|
? '2px solid var(--champion-gold)'
|
||||||
|
: hasBloodline
|
||||||
|
? '2px solid var(--bloodline-amber)'
|
||||||
|
: '1px solid var(--border)',
|
||||||
|
boxShadow: isChampion
|
||||||
|
? '0 0 12px var(--champion-glow)'
|
||||||
|
: hasBloodline
|
||||||
|
? '0 0 10px var(--bloodline-glow)'
|
||||||
|
: 'none'
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<button
|
<button
|
||||||
className="btn-icon"
|
className="btn-icon"
|
||||||
onClick={() => handleDeletePhoto(selectedPhoto)}
|
onClick={() => handleDeletePhoto(selectedPhoto)}
|
||||||
style={{
|
style={{
|
||||||
position: 'absolute',
|
position: 'absolute', top: '0.5rem', right: '0.5rem',
|
||||||
top: '0.5rem',
|
background: 'rgba(14, 15, 12, 0.8)',
|
||||||
right: '0.5rem',
|
|
||||||
background: 'rgba(15, 23, 42, 0.8)',
|
|
||||||
backdropFilter: 'blur(8px)'
|
backdropFilter: 'blur(8px)'
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Trash2 size={16} color="var(--danger)" />
|
<Trash2 size={16} color="var(--danger)" />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Thumbnail Strip */}
|
|
||||||
{dog.photo_urls.length > 1 && (
|
{dog.photo_urls.length > 1 && (
|
||||||
<div style={{ display: 'flex', gap: '0.5rem', overflowX: 'auto' }}>
|
<div style={{ display: 'flex', gap: '0.5rem', overflowX: 'auto' }}>
|
||||||
{dog.photo_urls.map((url, index) => (
|
{dog.photo_urls.map((url, index) => (
|
||||||
@@ -187,11 +198,8 @@ function DogDetail() {
|
|||||||
alt={`${dog.name} ${index + 1}`}
|
alt={`${dog.name} ${index + 1}`}
|
||||||
onClick={() => setSelectedPhoto(index)}
|
onClick={() => setSelectedPhoto(index)}
|
||||||
style={{
|
style={{
|
||||||
width: '60px',
|
width: '60px', height: '60px', objectFit: 'cover',
|
||||||
height: '60px',
|
borderRadius: 'var(--radius-sm)', cursor: 'pointer',
|
||||||
objectFit: 'cover',
|
|
||||||
borderRadius: 'var(--radius-sm)',
|
|
||||||
cursor: 'pointer',
|
|
||||||
border: selectedPhoto === index ? '2px solid var(--primary)' : '1px solid var(--border)',
|
border: selectedPhoto === index ? '2px solid var(--primary)' : '1px solid var(--border)',
|
||||||
opacity: selectedPhoto === index ? 1 : 0.6,
|
opacity: selectedPhoto === index ? 1 : 0.6,
|
||||||
transition: 'all 0.2s'
|
transition: 'all 0.2s'
|
||||||
@@ -213,18 +221,26 @@ function DogDetail() {
|
|||||||
<div>
|
<div>
|
||||||
<div className="card" style={{ marginBottom: '1.5rem' }}>
|
<div className="card" style={{ marginBottom: '1.5rem' }}>
|
||||||
<h2 style={{ fontSize: '1rem', marginBottom: '1rem', color: 'var(--text-muted)', textTransform: 'uppercase', letterSpacing: '0.05em' }}>Details</h2>
|
<h2 style={{ fontSize: '1rem', marginBottom: '1rem', color: 'var(--text-muted)', textTransform: 'uppercase', letterSpacing: '0.05em' }}>Details</h2>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<div className="info-row">
|
<div className="info-row">
|
||||||
<span className="info-label">Breed</span>
|
<span className="info-label">Breed</span>
|
||||||
<span className="info-value">{dog.breed}</span>
|
<span className="info-value">{dog.breed}</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="info-row">
|
<div className="info-row">
|
||||||
<span className="info-label">Sex</span>
|
<span className="info-label">Sex</span>
|
||||||
<span className="info-value">{dog.sex === 'male' ? 'Male ♂' : 'Female ♀'}</span>
|
<span className="info-value">{dog.sex === 'male' ? 'Male' : 'Female'}</span>
|
||||||
|
</div>
|
||||||
|
<div className="info-row">
|
||||||
|
<span className="info-label">Champion</span>
|
||||||
|
<span className="info-value">
|
||||||
|
{isChampion
|
||||||
|
? <ChampionBadge size="lg" />
|
||||||
|
: hasBloodline
|
||||||
|
? <ChampionBloodlineBadge size="lg" />
|
||||||
|
: <span style={{ color: 'var(--text-muted)', fontStyle: 'italic' }}>—</span>
|
||||||
|
}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{dog.birth_date && (
|
{dog.birth_date && (
|
||||||
<div className="info-row">
|
<div className="info-row">
|
||||||
<span className="info-label"><Calendar size={14} style={{ display: 'inline', marginRight: '0.25rem' }} />Birth Date</span>
|
<span className="info-label"><Calendar size={14} style={{ display: 'inline', marginRight: '0.25rem' }} />Birth Date</span>
|
||||||
@@ -234,21 +250,18 @@ function DogDetail() {
|
|||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{dog.color && (
|
{dog.color && (
|
||||||
<div className="info-row">
|
<div className="info-row">
|
||||||
<span className="info-label">Color</span>
|
<span className="info-label">Color</span>
|
||||||
<span className="info-value">{dog.color}</span>
|
<span className="info-value">{dog.color}</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{dog.registration_number && (
|
{dog.registration_number && (
|
||||||
<div className="info-row">
|
<div className="info-row">
|
||||||
<span className="info-label"><Award size={14} style={{ display: 'inline', marginRight: '0.25rem' }} />Registration</span>
|
<span className="info-label"><Award size={14} style={{ display: 'inline', marginRight: '0.25rem' }} />Registration</span>
|
||||||
<span className="info-value" style={{ fontFamily: 'monospace' }}>{dog.registration_number}</span>
|
<span className="info-value" style={{ fontFamily: 'monospace' }}>{dog.registration_number}</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{dog.microchip && (
|
{dog.microchip && (
|
||||||
<div className="info-row">
|
<div className="info-row">
|
||||||
<span className="info-label"><Hash size={14} style={{ display: 'inline', marginRight: '0.25rem' }} />Microchip</span>
|
<span className="info-label"><Hash size={14} style={{ display: 'inline', marginRight: '0.25rem' }} />Microchip</span>
|
||||||
@@ -265,9 +278,12 @@ function DogDetail() {
|
|||||||
<div>
|
<div>
|
||||||
<div style={{ fontSize: '0.8125rem', color: 'var(--text-muted)', marginBottom: '0.5rem', textTransform: 'uppercase', letterSpacing: '0.05em' }}>Sire</div>
|
<div style={{ fontSize: '0.8125rem', color: 'var(--text-muted)', marginBottom: '0.5rem', textTransform: 'uppercase', letterSpacing: '0.05em' }}>Sire</div>
|
||||||
{dog.sire ? (
|
{dog.sire ? (
|
||||||
<Link to={`/dogs/${dog.sire.id}`} style={{ color: 'var(--primary)', fontWeight: 500, textDecoration: 'none' }}>
|
<div style={{ display: 'flex', alignItems: 'center', gap: '0.4rem', flexWrap: 'wrap' }}>
|
||||||
{dog.sire.name}
|
<Link to={`/dogs/${dog.sire.id}`} style={{ color: 'var(--primary)', fontWeight: 500, textDecoration: 'none' }}>
|
||||||
</Link>
|
{dog.sire.name}
|
||||||
|
</Link>
|
||||||
|
{dog.sire.is_champion && <ChampionBadge />}
|
||||||
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<span style={{ color: 'var(--text-muted)', fontStyle: 'italic' }}>Unknown</span>
|
<span style={{ color: 'var(--text-muted)', fontStyle: 'italic' }}>Unknown</span>
|
||||||
)}
|
)}
|
||||||
@@ -275,9 +291,12 @@ function DogDetail() {
|
|||||||
<div>
|
<div>
|
||||||
<div style={{ fontSize: '0.8125rem', color: 'var(--text-muted)', marginBottom: '0.5rem', textTransform: 'uppercase', letterSpacing: '0.05em' }}>Dam</div>
|
<div style={{ fontSize: '0.8125rem', color: 'var(--text-muted)', marginBottom: '0.5rem', textTransform: 'uppercase', letterSpacing: '0.05em' }}>Dam</div>
|
||||||
{dog.dam ? (
|
{dog.dam ? (
|
||||||
<Link to={`/dogs/${dog.dam.id}`} style={{ color: 'var(--primary)', fontWeight: 500, textDecoration: 'none' }}>
|
<div style={{ display: 'flex', alignItems: 'center', gap: '0.4rem', flexWrap: 'wrap' }}>
|
||||||
{dog.dam.name}
|
<Link to={`/dogs/${dog.dam.id}`} style={{ color: 'var(--primary)', fontWeight: 500, textDecoration: 'none' }}>
|
||||||
</Link>
|
{dog.dam.name}
|
||||||
|
</Link>
|
||||||
|
{dog.dam.is_champion && <ChampionBadge />}
|
||||||
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<span style={{ color: 'var(--text-muted)', fontStyle: 'italic' }}>Unknown</span>
|
<span style={{ color: 'var(--text-muted)', fontStyle: 'italic' }}>Unknown</span>
|
||||||
)}
|
)}
|
||||||
@@ -295,6 +314,49 @@ function DogDetail() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* OFA Clearance Summary */}
|
||||||
|
<ClearanceSummaryCard dogId={id} onAddRecord={openAddHealth} />
|
||||||
|
|
||||||
|
{/* Health Records List */}
|
||||||
|
{healthRecords.length > 0 && (
|
||||||
|
<div className="card" style={{ marginBottom: '1.5rem' }}>
|
||||||
|
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '1rem' }}>
|
||||||
|
<h2 style={{ fontSize: '1rem', color: 'var(--text-muted)', textTransform: 'uppercase', letterSpacing: '0.05em', margin: 0 }}>
|
||||||
|
Health Records ({healthRecords.length})
|
||||||
|
</h2>
|
||||||
|
<button className="btn btn-ghost" style={{ fontSize: '0.8rem', padding: '0.35rem 0.75rem' }} onClick={openAddHealth}>
|
||||||
|
+ Add
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.5rem' }}>
|
||||||
|
{healthRecords.map(rec => (
|
||||||
|
<div key={rec.id} style={{
|
||||||
|
display: 'flex', alignItems: 'center', gap: '0.75rem',
|
||||||
|
padding: '0.6rem 0.75rem', background: 'var(--bg-primary)',
|
||||||
|
borderRadius: 'var(--radius-sm)', border: '1px solid var(--border)',
|
||||||
|
}}>
|
||||||
|
<div style={{ flex: 1 }}>
|
||||||
|
<span style={{ fontWeight: 500, fontSize: '0.875rem' }}>
|
||||||
|
{rec.test_name || (rec.test_type ? rec.test_type.replace(/_/g, ' ') : rec.record_type)}
|
||||||
|
</span>
|
||||||
|
{rec.ofa_result && (
|
||||||
|
<span style={{ marginLeft: '0.5rem', fontSize: '0.75rem', color: 'var(--text-muted)' }}>
|
||||||
|
{rec.ofa_result}{rec.ofa_number ? ` · ${rec.ofa_number}` : ''}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<span style={{ fontSize: '0.8rem', color: 'var(--text-muted)', whiteSpace: 'nowrap' }}>
|
||||||
|
{rec.test_date ? new Date(rec.test_date).toLocaleDateString() : ''}
|
||||||
|
</span>
|
||||||
|
<button className="btn-icon" style={{ padding: '0.2rem' }} onClick={() => openEditHealth(rec)}>
|
||||||
|
<Edit size={14} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Offspring */}
|
{/* Offspring */}
|
||||||
{dog.offspring && dog.offspring.length > 0 && (
|
{dog.offspring && dog.offspring.length > 0 && (
|
||||||
<div className="card">
|
<div className="card">
|
||||||
@@ -313,33 +375,45 @@ function DogDetail() {
|
|||||||
transition: 'var(--transition)',
|
transition: 'var(--transition)',
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
justifyContent: 'space-between',
|
justifyContent: 'space-between',
|
||||||
alignItems: 'center'
|
alignItems: 'center',
|
||||||
|
gap: '0.5rem'
|
||||||
}}
|
}}
|
||||||
onMouseEnter={(e) => {
|
onMouseEnter={e => {
|
||||||
e.currentTarget.style.borderColor = 'var(--primary)'
|
e.currentTarget.style.borderColor = 'var(--primary)'
|
||||||
e.currentTarget.style.background = 'var(--bg-tertiary)'
|
e.currentTarget.style.background = 'var(--bg-tertiary)'
|
||||||
}}
|
}}
|
||||||
onMouseLeave={(e) => {
|
onMouseLeave={e => {
|
||||||
e.currentTarget.style.borderColor = 'var(--border)'
|
e.currentTarget.style.borderColor = 'var(--border)'
|
||||||
e.currentTarget.style.background = 'var(--bg-primary)'
|
e.currentTarget.style.background = 'var(--bg-primary)'
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<span style={{ color: 'var(--text-primary)', fontWeight: 500 }}>{child.name}</span>
|
<span style={{ color: 'var(--text-primary)', fontWeight: 500 }}>{child.name}</span>
|
||||||
<span style={{ fontSize: '1.125rem' }}>{child.sex === 'male' ? '♂' : '♀'}</span>
|
<div style={{ display: 'flex', alignItems: 'center', gap: '0.35rem' }}>
|
||||||
|
{child.is_champion && <ChampionBadge />}
|
||||||
|
<span style={{ fontSize: '1.125rem' }}>{child.sex === 'male' ? '' : ''}</span>
|
||||||
|
</div>
|
||||||
</Link>
|
</Link>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Edit Dog Modal */}
|
||||||
{showEditModal && (
|
{showEditModal && (
|
||||||
<DogForm
|
<DogForm
|
||||||
dog={dog}
|
dog={dog}
|
||||||
onClose={() => setShowEditModal(false)}
|
onClose={() => setShowEditModal(false)}
|
||||||
onSave={() => {
|
onSave={() => { fetchDog(); setShowEditModal(false) }}
|
||||||
fetchDog()
|
/>
|
||||||
setShowEditModal(false)
|
)}
|
||||||
}}
|
|
||||||
|
{/* Health Record Form Modal */}
|
||||||
|
{showHealthForm && (
|
||||||
|
<HealthRecordForm
|
||||||
|
dogId={id}
|
||||||
|
record={editingRecord}
|
||||||
|
onClose={closeHealthForm}
|
||||||
|
onSave={handleHealthSaved}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
import { useEffect, useState } from 'react'
|
import { useEffect, useState } from 'react'
|
||||||
import { Link } from 'react-router-dom'
|
import { Link, useNavigate } from 'react-router-dom'
|
||||||
import { Dog, Plus, Search, Calendar, Hash, ArrowRight } from 'lucide-react'
|
import { Dog, Plus, Search, Calendar, Hash, ArrowRight, Trash2 } from 'lucide-react'
|
||||||
import axios from 'axios'
|
import axios from 'axios'
|
||||||
import DogForm from '../components/DogForm'
|
import DogForm from '../components/DogForm'
|
||||||
|
import { ChampionBadge, ChampionBloodlineBadge } from '../components/ChampionBadge'
|
||||||
|
|
||||||
function DogList() {
|
function DogList() {
|
||||||
const [dogs, setDogs] = useState([])
|
const [dogs, setDogs] = useState([])
|
||||||
@@ -11,14 +12,11 @@ function DogList() {
|
|||||||
const [sexFilter, setSexFilter] = useState('all')
|
const [sexFilter, setSexFilter] = useState('all')
|
||||||
const [loading, setLoading] = useState(true)
|
const [loading, setLoading] = useState(true)
|
||||||
const [showAddModal, setShowAddModal] = useState(false)
|
const [showAddModal, setShowAddModal] = useState(false)
|
||||||
|
const [deleteTarget, setDeleteTarget] = useState(null) // { id, name }
|
||||||
|
const [deleting, setDeleting] = useState(false)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => { fetchDogs() }, [])
|
||||||
fetchDogs()
|
useEffect(() => { filterDogs() }, [dogs, search, sexFilter])
|
||||||
}, [])
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
filterDogs()
|
|
||||||
}, [dogs, search, sexFilter])
|
|
||||||
|
|
||||||
const fetchDogs = async () => {
|
const fetchDogs = async () => {
|
||||||
try {
|
try {
|
||||||
@@ -33,23 +31,33 @@ function DogList() {
|
|||||||
|
|
||||||
const filterDogs = () => {
|
const filterDogs = () => {
|
||||||
let filtered = dogs
|
let filtered = dogs
|
||||||
|
|
||||||
if (search) {
|
if (search) {
|
||||||
filtered = filtered.filter(dog =>
|
filtered = filtered.filter(dog =>
|
||||||
dog.name.toLowerCase().includes(search.toLowerCase()) ||
|
dog.name.toLowerCase().includes(search.toLowerCase()) ||
|
||||||
(dog.registration_number && dog.registration_number.toLowerCase().includes(search.toLowerCase()))
|
(dog.registration_number && dog.registration_number.toLowerCase().includes(search.toLowerCase()))
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (sexFilter !== 'all') {
|
if (sexFilter !== 'all') {
|
||||||
filtered = filtered.filter(dog => dog.sex === sexFilter)
|
filtered = filtered.filter(dog => dog.sex === sexFilter)
|
||||||
}
|
}
|
||||||
|
|
||||||
setFilteredDogs(filtered)
|
setFilteredDogs(filtered)
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleSave = () => {
|
const handleSave = () => { fetchDogs() }
|
||||||
fetchDogs()
|
|
||||||
|
const handleDelete = async () => {
|
||||||
|
if (!deleteTarget) return
|
||||||
|
setDeleting(true)
|
||||||
|
try {
|
||||||
|
await axios.delete(`/api/dogs/${deleteTarget.id}`)
|
||||||
|
setDogs(prev => prev.filter(d => d.id !== deleteTarget.id))
|
||||||
|
setDeleteTarget(null)
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Delete failed:', err)
|
||||||
|
alert('Failed to delete dog. Please try again.')
|
||||||
|
} finally {
|
||||||
|
setDeleting(false)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const calculateAge = (birthDate) => {
|
const calculateAge = (birthDate) => {
|
||||||
@@ -58,17 +66,15 @@ function DogList() {
|
|||||||
const birth = new Date(birthDate)
|
const birth = new Date(birthDate)
|
||||||
let years = today.getFullYear() - birth.getFullYear()
|
let years = today.getFullYear() - birth.getFullYear()
|
||||||
let months = today.getMonth() - birth.getMonth()
|
let months = today.getMonth() - birth.getMonth()
|
||||||
|
if (months < 0) { years--; months += 12 }
|
||||||
if (months < 0) {
|
|
||||||
years--
|
|
||||||
months += 12
|
|
||||||
}
|
|
||||||
|
|
||||||
if (years === 0) return `${months}mo`
|
if (years === 0) return `${months}mo`
|
||||||
if (months === 0) return `${years}y`
|
if (months === 0) return `${years}y`
|
||||||
return `${years}y ${months}mo`
|
return `${years}y ${months}mo`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const hasChampionBlood = (dog) =>
|
||||||
|
(dog.sire && dog.sire.is_champion) || (dog.dam && dog.dam.is_champion)
|
||||||
|
|
||||||
if (loading) {
|
if (loading) {
|
||||||
return <div className="container loading">Loading dogs...</div>
|
return <div className="container loading">Loading dogs...</div>
|
||||||
}
|
}
|
||||||
@@ -111,10 +117,7 @@ function DogList() {
|
|||||||
{(search || sexFilter !== 'all') && (
|
{(search || sexFilter !== 'all') && (
|
||||||
<button
|
<button
|
||||||
className="btn btn-ghost"
|
className="btn btn-ghost"
|
||||||
onClick={() => {
|
onClick={() => { setSearch(''); setSexFilter('all') }}
|
||||||
setSearch('')
|
|
||||||
setSexFilter('all')
|
|
||||||
}}
|
|
||||||
style={{ padding: '0.625rem 1rem', fontSize: '0.875rem' }}
|
style={{ padding: '0.625rem 1rem', fontSize: '0.875rem' }}
|
||||||
>
|
>
|
||||||
Clear
|
Clear
|
||||||
@@ -145,18 +148,15 @@ function DogList() {
|
|||||||
) : (
|
) : (
|
||||||
<div style={{ display: 'grid', gap: '1rem' }}>
|
<div style={{ display: 'grid', gap: '1rem' }}>
|
||||||
{filteredDogs.map(dog => (
|
{filteredDogs.map(dog => (
|
||||||
<Link
|
<div
|
||||||
key={dog.id}
|
key={dog.id}
|
||||||
to={`/dogs/${dog.id}`}
|
|
||||||
className="card"
|
className="card"
|
||||||
style={{
|
style={{
|
||||||
padding: '1rem',
|
padding: '1rem',
|
||||||
textDecoration: 'none',
|
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
gap: '1rem',
|
gap: '1rem',
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
transition: 'var(--transition)',
|
transition: 'var(--transition)',
|
||||||
cursor: 'pointer'
|
|
||||||
}}
|
}}
|
||||||
onMouseEnter={(e) => {
|
onMouseEnter={(e) => {
|
||||||
e.currentTarget.style.borderColor = 'var(--primary)'
|
e.currentTarget.style.borderColor = 'var(--primary)'
|
||||||
@@ -169,65 +169,68 @@ function DogList() {
|
|||||||
e.currentTarget.style.boxShadow = 'var(--shadow-sm)'
|
e.currentTarget.style.boxShadow = 'var(--shadow-sm)'
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{/* Avatar Photo */}
|
{/* Avatar */}
|
||||||
<div style={{
|
<Link
|
||||||
width: '80px',
|
to={`/dogs/${dog.id}`}
|
||||||
height: '80px',
|
style={{ flexShrink: 0, textDecoration: 'none' }}
|
||||||
flexShrink: 0,
|
>
|
||||||
borderRadius: 'var(--radius)',
|
<div style={{
|
||||||
background: 'var(--bg-primary)',
|
width: '80px', height: '80px',
|
||||||
border: '2px solid var(--border)',
|
borderRadius: 'var(--radius)',
|
||||||
display: 'flex',
|
background: 'var(--bg-primary)',
|
||||||
alignItems: 'center',
|
border: dog.is_champion
|
||||||
justifyContent: 'center',
|
? '2px solid var(--champion-gold)'
|
||||||
overflow: 'hidden'
|
: hasChampionBlood(dog)
|
||||||
}}>
|
? '2px solid var(--bloodline-amber)'
|
||||||
{dog.photo_urls && dog.photo_urls.length > 0 ? (
|
: '2px solid var(--border)',
|
||||||
<img
|
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||||
src={dog.photo_urls[0]}
|
overflow: 'hidden',
|
||||||
alt={dog.name}
|
boxShadow: dog.is_champion
|
||||||
style={{
|
? '0 0 8px var(--champion-glow)'
|
||||||
width: '100%',
|
: hasChampionBlood(dog)
|
||||||
height: '100%',
|
? '0 0 8px var(--bloodline-glow)'
|
||||||
objectFit: 'cover'
|
: 'none'
|
||||||
}}
|
}}>
|
||||||
/>
|
{dog.photo_urls && dog.photo_urls.length > 0 ? (
|
||||||
) : (
|
<img
|
||||||
<Dog size={32} style={{ color: 'var(--text-muted)', opacity: 0.5 }} />
|
src={dog.photo_urls[0]}
|
||||||
)}
|
alt={dog.name}
|
||||||
</div>
|
style={{ width: '100%', height: '100%', objectFit: 'cover' }}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<Dog size={32} style={{ color: 'var(--text-muted)', opacity: 0.5 }} />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</Link>
|
||||||
|
|
||||||
{/* Info Section */}
|
{/* Info — clicking navigates to detail */}
|
||||||
<div style={{ flex: 1, minWidth: 0 }}>
|
<Link
|
||||||
|
to={`/dogs/${dog.id}`}
|
||||||
|
style={{ flex: 1, minWidth: 0, textDecoration: 'none', color: 'inherit' }}
|
||||||
|
>
|
||||||
<h3 style={{
|
<h3 style={{
|
||||||
fontSize: '1.125rem',
|
fontSize: '1.125rem',
|
||||||
marginBottom: '0.375rem',
|
marginBottom: '0.25rem',
|
||||||
overflow: 'hidden',
|
display: 'flex', alignItems: 'center', gap: '0.5rem',
|
||||||
textOverflow: 'ellipsis',
|
flexWrap: 'wrap'
|
||||||
whiteSpace: 'nowrap'
|
|
||||||
}}>
|
}}>
|
||||||
{dog.name}
|
<span style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
|
||||||
<span style={{
|
{dog.name}
|
||||||
marginLeft: '0.5rem',
|
</span>
|
||||||
fontSize: '1rem',
|
<span style={{ color: dog.sex === 'male' ? 'var(--primary)' : '#ec4899', fontSize: '1rem' }}>
|
||||||
color: dog.sex === 'male' ? 'var(--primary)' : '#ec4899'
|
|
||||||
}}>
|
|
||||||
{dog.sex === 'male' ? '♂' : '♀'}
|
{dog.sex === 'male' ? '♂' : '♀'}
|
||||||
</span>
|
</span>
|
||||||
|
{dog.is_champion ? <ChampionBadge /> : hasChampionBlood(dog) ? <ChampionBloodlineBadge /> : null}
|
||||||
</h3>
|
</h3>
|
||||||
|
|
||||||
<div style={{
|
<div style={{
|
||||||
display: 'flex',
|
display: 'flex', flexWrap: 'wrap', gap: '0.75rem',
|
||||||
flexWrap: 'wrap',
|
fontSize: '0.8125rem', color: 'var(--text-secondary)', marginBottom: '0.5rem'
|
||||||
gap: '0.75rem',
|
|
||||||
fontSize: '0.8125rem',
|
|
||||||
color: 'var(--text-secondary)',
|
|
||||||
marginBottom: '0.5rem'
|
|
||||||
}}>
|
}}>
|
||||||
<span>{dog.breed}</span>
|
<span>{dog.breed}</span>
|
||||||
{dog.birth_date && (
|
{dog.birth_date && (
|
||||||
<>
|
<>
|
||||||
<span>•</span>
|
<span>·</span>
|
||||||
<span style={{ display: 'flex', alignItems: 'center', gap: '0.25rem' }}>
|
<span style={{ display: 'flex', alignItems: 'center', gap: '0.25rem' }}>
|
||||||
<Calendar size={12} />
|
<Calendar size={12} />
|
||||||
{calculateAge(dog.birth_date)}
|
{calculateAge(dog.birth_date)}
|
||||||
@@ -236,7 +239,7 @@ function DogList() {
|
|||||||
)}
|
)}
|
||||||
{dog.color && (
|
{dog.color && (
|
||||||
<>
|
<>
|
||||||
<span>•</span>
|
<span>·</span>
|
||||||
<span>{dog.color}</span>
|
<span>{dog.color}</span>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
@@ -244,41 +247,120 @@ function DogList() {
|
|||||||
|
|
||||||
{dog.registration_number && (
|
{dog.registration_number && (
|
||||||
<div style={{
|
<div style={{
|
||||||
display: 'inline-flex',
|
display: 'inline-flex', alignItems: 'center', gap: '0.25rem',
|
||||||
alignItems: 'center',
|
|
||||||
gap: '0.25rem',
|
|
||||||
padding: '0.25rem 0.5rem',
|
padding: '0.25rem 0.5rem',
|
||||||
background: 'var(--bg-primary)',
|
background: 'var(--bg-primary)',
|
||||||
border: '1px solid var(--border)',
|
border: '1px solid var(--border)',
|
||||||
borderRadius: 'var(--radius-sm)',
|
borderRadius: 'var(--radius-sm)',
|
||||||
fontSize: '0.75rem',
|
fontSize: '0.75rem', fontFamily: 'monospace',
|
||||||
fontFamily: 'monospace',
|
|
||||||
color: 'var(--text-muted)'
|
color: 'var(--text-muted)'
|
||||||
}}>
|
}}>
|
||||||
<Hash size={10} />
|
<Hash size={10} />
|
||||||
{dog.registration_number}
|
{dog.registration_number}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</Link>
|
||||||
|
|
||||||
{/* Arrow Indicator */}
|
{/* Actions */}
|
||||||
<div style={{
|
<div style={{ display: 'flex', gap: '0.5rem', flexShrink: 0, alignItems: 'center' }}>
|
||||||
opacity: 0.5,
|
<Link
|
||||||
transition: 'var(--transition)'
|
to={`/dogs/${dog.id}`}
|
||||||
}}>
|
style={{ opacity: 0.5, transition: 'var(--transition)', color: 'inherit' }}
|
||||||
<ArrowRight size={20} color="var(--text-muted)" />
|
>
|
||||||
|
<ArrowRight size={20} color="var(--text-muted)" />
|
||||||
|
</Link>
|
||||||
|
<button
|
||||||
|
className="btn btn-ghost"
|
||||||
|
title={`Delete ${dog.name}`}
|
||||||
|
onClick={(e) => { e.stopPropagation(); setDeleteTarget({ id: dog.id, name: dog.name }) }}
|
||||||
|
style={{
|
||||||
|
padding: '0.4rem',
|
||||||
|
color: 'var(--text-muted)',
|
||||||
|
border: '1px solid transparent',
|
||||||
|
borderRadius: 'var(--radius-sm)',
|
||||||
|
display: 'flex', alignItems: 'center',
|
||||||
|
transition: 'var(--transition)'
|
||||||
|
}}
|
||||||
|
onMouseEnter={(e) => {
|
||||||
|
e.currentTarget.style.color = '#ef4444'
|
||||||
|
e.currentTarget.style.borderColor = '#ef4444'
|
||||||
|
e.currentTarget.style.background = 'rgba(239,68,68,0.08)'
|
||||||
|
}}
|
||||||
|
onMouseLeave={(e) => {
|
||||||
|
e.currentTarget.style.color = 'var(--text-muted)'
|
||||||
|
e.currentTarget.style.borderColor = 'transparent'
|
||||||
|
e.currentTarget.style.background = 'transparent'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Trash2 size={16} />
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</Link>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Add Dog Modal */}
|
||||||
{showAddModal && (
|
{showAddModal && (
|
||||||
<DogForm
|
<DogForm
|
||||||
onClose={() => setShowAddModal(false)}
|
onClose={() => setShowAddModal(false)}
|
||||||
onSave={handleSave}
|
onSave={handleSave}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Delete Confirmation Modal */}
|
||||||
|
{deleteTarget && (
|
||||||
|
<div style={{
|
||||||
|
position: 'fixed', inset: 0,
|
||||||
|
background: 'rgba(0,0,0,0.65)',
|
||||||
|
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||||
|
zIndex: 1000,
|
||||||
|
backdropFilter: 'blur(4px)'
|
||||||
|
}}>
|
||||||
|
<div className="card" style={{ maxWidth: 420, width: '90%', padding: '2rem', textAlign: 'center' }}>
|
||||||
|
<div style={{
|
||||||
|
width: 56, height: 56,
|
||||||
|
borderRadius: '50%',
|
||||||
|
background: 'rgba(239,68,68,0.12)',
|
||||||
|
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||||
|
margin: '0 auto 1rem'
|
||||||
|
}}>
|
||||||
|
<Trash2 size={26} style={{ color: '#ef4444' }} />
|
||||||
|
</div>
|
||||||
|
<h3 style={{ margin: '0 0 0.5rem', fontSize: '1.25rem' }}>Delete Dog?</h3>
|
||||||
|
<p style={{ color: 'var(--text-secondary)', marginBottom: '1.75rem', lineHeight: 1.6 }}>
|
||||||
|
<strong style={{ color: 'var(--text-primary)' }}>{deleteTarget.name}</strong> will be
|
||||||
|
permanently removed along with all parent relationships, health records,
|
||||||
|
and heat cycles. This cannot be undone.
|
||||||
|
</p>
|
||||||
|
<div style={{ display: 'flex', gap: '0.75rem', justifyContent: 'center' }}>
|
||||||
|
<button
|
||||||
|
className="btn btn-ghost"
|
||||||
|
onClick={() => setDeleteTarget(null)}
|
||||||
|
disabled={deleting}
|
||||||
|
style={{ minWidth: 100 }}
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className="btn"
|
||||||
|
onClick={handleDelete}
|
||||||
|
disabled={deleting}
|
||||||
|
style={{
|
||||||
|
minWidth: 140,
|
||||||
|
background: '#ef4444',
|
||||||
|
color: '#fff',
|
||||||
|
border: '1px solid #ef4444',
|
||||||
|
display: 'flex', alignItems: 'center', justifyContent: 'center', gap: '0.5rem'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Trash2 size={15} />
|
||||||
|
{deleting ? 'Deleting…' : 'Yes, Delete'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
389
client/src/pages/ExternalDogs.jsx
Normal file
389
client/src/pages/ExternalDogs.jsx
Normal file
@@ -0,0 +1,389 @@
|
|||||||
|
import { useEffect, useState } from 'react'
|
||||||
|
import { Link } from 'react-router-dom'
|
||||||
|
import { Dog, Plus, Search, Calendar, Hash, ArrowRight, Trash2, ExternalLink } from 'lucide-react'
|
||||||
|
import axios from 'axios'
|
||||||
|
import DogForm from '../components/DogForm'
|
||||||
|
import { ChampionBadge, ChampionBloodlineBadge } from '../components/ChampionBadge'
|
||||||
|
|
||||||
|
function ExternalDogs() {
|
||||||
|
const [dogs, setDogs] = useState([])
|
||||||
|
const [filteredDogs, setFilteredDogs] = useState([])
|
||||||
|
const [search, setSearch] = useState('')
|
||||||
|
const [sexFilter, setSexFilter] = useState('all')
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
const [showAddModal, setShowAddModal] = useState(false)
|
||||||
|
const [deleteTarget, setDeleteTarget] = useState(null) // { id, name }
|
||||||
|
const [deleting, setDeleting] = useState(false)
|
||||||
|
|
||||||
|
useEffect(() => { fetchDogs() }, [])
|
||||||
|
useEffect(() => { filterDogs() }, [dogs, search, sexFilter])
|
||||||
|
|
||||||
|
const fetchDogs = async () => {
|
||||||
|
try {
|
||||||
|
const res = await axios.get('/api/dogs/external')
|
||||||
|
setDogs(res.data)
|
||||||
|
setLoading(false)
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error fetching external dogs:', error)
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const filterDogs = () => {
|
||||||
|
let filtered = dogs
|
||||||
|
if (search) {
|
||||||
|
filtered = filtered.filter(dog =>
|
||||||
|
dog.name.toLowerCase().includes(search.toLowerCase()) ||
|
||||||
|
(dog.breed && dog.breed.toLowerCase().includes(search.toLowerCase())) ||
|
||||||
|
(dog.registration_number && dog.registration_number.toLowerCase().includes(search.toLowerCase()))
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (sexFilter !== 'all') {
|
||||||
|
filtered = filtered.filter(dog => dog.sex === sexFilter)
|
||||||
|
}
|
||||||
|
setFilteredDogs(filtered)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleDelete = async () => {
|
||||||
|
if (!deleteTarget) return
|
||||||
|
setDeleting(true)
|
||||||
|
try {
|
||||||
|
await axios.delete(`/api/dogs/${deleteTarget.id}`)
|
||||||
|
setDogs(prev => prev.filter(d => d.id !== deleteTarget.id))
|
||||||
|
setDeleteTarget(null)
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Delete failed:', err)
|
||||||
|
alert('Failed to delete dog. Please try again.')
|
||||||
|
} finally {
|
||||||
|
setDeleting(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const calculateAge = (birthDate) => {
|
||||||
|
if (!birthDate) return null
|
||||||
|
const today = new Date()
|
||||||
|
const birth = new Date(birthDate)
|
||||||
|
let years = today.getFullYear() - birth.getFullYear()
|
||||||
|
let months = today.getMonth() - birth.getMonth()
|
||||||
|
if (months < 0) { years--; months += 12 }
|
||||||
|
if (years === 0) return `${months}mo`
|
||||||
|
if (months === 0) return `${years}y`
|
||||||
|
return `${years}y ${months}mo`
|
||||||
|
}
|
||||||
|
|
||||||
|
const hasChampionBlood = (dog) =>
|
||||||
|
(dog.sire && dog.sire.is_champion) || (dog.dam && dog.dam.is_champion)
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return <div className="container loading">Loading external dogs...</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="container" style={{ paddingTop: '2rem', paddingBottom: '3rem' }}>
|
||||||
|
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '2rem' }}>
|
||||||
|
<div>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: '0.75rem', marginBottom: '0.25rem' }}>
|
||||||
|
<ExternalLink size={28} style={{ color: 'var(--primary)' }} />
|
||||||
|
<h1 style={{ margin: 0 }}>External Dogs</h1>
|
||||||
|
</div>
|
||||||
|
<p style={{ color: 'var(--text-secondary)', fontSize: '0.875rem' }}>
|
||||||
|
{filteredDogs.length} {filteredDogs.length === 1 ? 'dog' : 'dogs'}
|
||||||
|
{search || sexFilter !== 'all' ? ' matching filters' : ' total'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<button className="btn btn-primary" onClick={() => setShowAddModal(true)}>
|
||||||
|
<Plus size={18} />
|
||||||
|
Add External Dog
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Search and Filter Bar */}
|
||||||
|
<div className="card" style={{ marginBottom: '1.5rem', padding: '1rem' }}>
|
||||||
|
<div style={{ display: 'grid', gridTemplateColumns: '1fr auto auto', gap: '1rem', alignItems: 'center' }}>
|
||||||
|
<div style={{ position: 'relative' }}>
|
||||||
|
<Search size={18} style={{ position: 'absolute', left: '0.875rem', top: '50%', transform: 'translateY(-50%)', color: 'var(--text-muted)' }} />
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
className="input"
|
||||||
|
placeholder="Search by name or breed..."
|
||||||
|
value={search}
|
||||||
|
onChange={(e) => setSearch(e.target.value)}
|
||||||
|
style={{ paddingLeft: '2.75rem' }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<select className="input" value={sexFilter} onChange={(e) => setSexFilter(e.target.value)} style={{ width: '160px' }}>
|
||||||
|
<option value="all">All Genders</option>
|
||||||
|
<option value="male">Sires (Male) ♂</option>
|
||||||
|
<option value="female">Dams (Female) ♀</option>
|
||||||
|
</select>
|
||||||
|
{(search || sexFilter !== 'all') && (
|
||||||
|
<button
|
||||||
|
className="btn btn-ghost"
|
||||||
|
onClick={() => { setSearch(''); setSexFilter('all') }}
|
||||||
|
style={{ padding: '0.625rem 1rem', fontSize: '0.875rem' }}
|
||||||
|
>
|
||||||
|
Clear
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Dogs List */}
|
||||||
|
{filteredDogs.length === 0 ? (
|
||||||
|
<div className="card" style={{ textAlign: 'center', padding: '4rem 2rem' }}>
|
||||||
|
<ExternalLink size={64} style={{ color: 'var(--text-muted)', margin: '0 auto 1rem', opacity: 0.5 }} />
|
||||||
|
<h3 style={{ marginBottom: '0.5rem' }}>
|
||||||
|
{search || sexFilter !== 'all' ? 'No dogs found' : 'No external dogs yet'}
|
||||||
|
</h3>
|
||||||
|
<p style={{ color: 'var(--text-secondary)', marginBottom: '2rem' }}>
|
||||||
|
{search || sexFilter !== 'all'
|
||||||
|
? 'Try adjusting your search or filters'
|
||||||
|
: 'Add sires, dams, or ancestors that aren\'t part of your kennel roster.'}
|
||||||
|
</p>
|
||||||
|
{!search && sexFilter === 'all' && (
|
||||||
|
<button className="btn btn-primary" onClick={() => setShowAddModal(true)}>
|
||||||
|
<Plus size={18} />
|
||||||
|
Add Your First External Dog
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div style={{ display: 'grid', gap: '1rem' }}>
|
||||||
|
{filteredDogs.map(dog => (
|
||||||
|
<div
|
||||||
|
key={dog.id}
|
||||||
|
className="card"
|
||||||
|
style={{
|
||||||
|
padding: '1rem',
|
||||||
|
display: 'flex',
|
||||||
|
gap: '1rem',
|
||||||
|
alignItems: 'center',
|
||||||
|
transition: 'var(--transition)',
|
||||||
|
}}
|
||||||
|
onMouseEnter={(e) => {
|
||||||
|
e.currentTarget.style.borderColor = 'var(--primary)'
|
||||||
|
e.currentTarget.style.transform = 'translateY(-2px)'
|
||||||
|
e.currentTarget.style.boxShadow = '0 8px 16px rgba(0, 0, 0, 0.3)'
|
||||||
|
}}
|
||||||
|
onMouseLeave={(e) => {
|
||||||
|
e.currentTarget.style.borderColor = 'var(--border)'
|
||||||
|
e.currentTarget.style.transform = 'translateY(0)'
|
||||||
|
e.currentTarget.style.boxShadow = 'var(--shadow-sm)'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* Avatar */}
|
||||||
|
<Link
|
||||||
|
to={`/dogs/${dog.id}`}
|
||||||
|
style={{ flexShrink: 0, textDecoration: 'none' }}
|
||||||
|
>
|
||||||
|
<div style={{
|
||||||
|
width: '80px', height: '80px',
|
||||||
|
borderRadius: 'var(--radius)',
|
||||||
|
background: 'var(--bg-primary)',
|
||||||
|
border: dog.is_champion
|
||||||
|
? '2px solid var(--champion-gold)'
|
||||||
|
: hasChampionBlood(dog)
|
||||||
|
? '2px solid var(--bloodline-amber)'
|
||||||
|
: '2px solid var(--border)',
|
||||||
|
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||||
|
overflow: 'hidden',
|
||||||
|
position: 'relative',
|
||||||
|
boxShadow: dog.is_champion
|
||||||
|
? '0 0 8px var(--champion-glow)'
|
||||||
|
: hasChampionBlood(dog)
|
||||||
|
? '0 0 8px var(--bloodline-glow)'
|
||||||
|
: 'none'
|
||||||
|
}}>
|
||||||
|
{dog.photo_urls && dog.photo_urls.length > 0 ? (
|
||||||
|
<img
|
||||||
|
src={dog.photo_urls[0]}
|
||||||
|
alt={dog.name}
|
||||||
|
style={{ width: '100%', height: '100%', objectFit: 'cover' }}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<Dog size={32} style={{ color: 'var(--text-muted)', opacity: 0.5 }} />
|
||||||
|
)}
|
||||||
|
<div style={{
|
||||||
|
position: 'absolute',
|
||||||
|
top: 0,
|
||||||
|
right: 0,
|
||||||
|
background: 'var(--bg-secondary)',
|
||||||
|
borderBottomLeftRadius: 'var(--radius-sm)',
|
||||||
|
padding: '2px 4px',
|
||||||
|
fontSize: '0.625rem',
|
||||||
|
fontWeight: 'bold',
|
||||||
|
color: 'var(--text-muted)',
|
||||||
|
borderLeft: '1px solid var(--border)',
|
||||||
|
borderBottom: '1px solid var(--border)',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: '2px'
|
||||||
|
}}>
|
||||||
|
<ExternalLink size={8} /> EXT
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Link>
|
||||||
|
|
||||||
|
{/* Info — clicking navigates to detail */}
|
||||||
|
<Link
|
||||||
|
to={`/dogs/${dog.id}`}
|
||||||
|
style={{ flex: 1, minWidth: 0, textDecoration: 'none', color: 'inherit' }}
|
||||||
|
>
|
||||||
|
<h3 style={{
|
||||||
|
fontSize: '1.125rem',
|
||||||
|
marginBottom: '0.25rem',
|
||||||
|
display: 'flex', alignItems: 'center', gap: '0.5rem',
|
||||||
|
flexWrap: 'wrap'
|
||||||
|
}}>
|
||||||
|
<span style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
|
||||||
|
{dog.name}
|
||||||
|
</span>
|
||||||
|
<span style={{ color: dog.sex === 'male' ? 'var(--primary)' : '#ec4899', fontSize: '1rem' }}>
|
||||||
|
{dog.sex === 'male' ? '♂' : '♀'}
|
||||||
|
</span>
|
||||||
|
{dog.is_champion ? <ChampionBadge /> : hasChampionBlood(dog) ? <ChampionBloodlineBadge /> : null}
|
||||||
|
</h3>
|
||||||
|
|
||||||
|
<div style={{
|
||||||
|
display: 'flex', flexWrap: 'wrap', gap: '0.75rem',
|
||||||
|
fontSize: '0.8125rem', color: 'var(--text-secondary)', marginBottom: '0.5rem'
|
||||||
|
}}>
|
||||||
|
<span>{dog.breed}</span>
|
||||||
|
{dog.birth_date && (
|
||||||
|
<>
|
||||||
|
<span>·</span>
|
||||||
|
<span style={{ display: 'flex', alignItems: 'center', gap: '0.25rem' }}>
|
||||||
|
<Calendar size={12} />
|
||||||
|
{calculateAge(dog.birth_date)}
|
||||||
|
</span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{dog.color && (
|
||||||
|
<>
|
||||||
|
<span>·</span>
|
||||||
|
<span>{dog.color}</span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{dog.registration_number && (
|
||||||
|
<div style={{
|
||||||
|
display: 'inline-flex', alignItems: 'center', gap: '0.25rem',
|
||||||
|
padding: '0.25rem 0.5rem',
|
||||||
|
background: 'var(--bg-primary)',
|
||||||
|
border: '1px solid var(--border)',
|
||||||
|
borderRadius: 'var(--radius-sm)',
|
||||||
|
fontSize: '0.75rem', fontFamily: 'monospace',
|
||||||
|
color: 'var(--text-muted)'
|
||||||
|
}}>
|
||||||
|
<Hash size={10} />
|
||||||
|
{dog.registration_number}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Link>
|
||||||
|
|
||||||
|
{/* Actions */}
|
||||||
|
<div style={{ display: 'flex', gap: '0.5rem', flexShrink: 0, alignItems: 'center' }}>
|
||||||
|
<Link
|
||||||
|
to={`/dogs/${dog.id}`}
|
||||||
|
style={{ opacity: 0.5, transition: 'var(--transition)', color: 'inherit' }}
|
||||||
|
>
|
||||||
|
<ArrowRight size={20} color="var(--text-muted)" />
|
||||||
|
</Link>
|
||||||
|
<button
|
||||||
|
className="btn btn-ghost"
|
||||||
|
title={`Delete ${dog.name}`}
|
||||||
|
onClick={(e) => { e.preventDefault(); e.stopPropagation(); setDeleteTarget({ id: dog.id, name: dog.name }) }}
|
||||||
|
style={{
|
||||||
|
padding: '0.4rem',
|
||||||
|
color: 'var(--text-muted)',
|
||||||
|
border: '1px solid transparent',
|
||||||
|
borderRadius: 'var(--radius-sm)',
|
||||||
|
display: 'flex', alignItems: 'center',
|
||||||
|
transition: 'var(--transition)'
|
||||||
|
}}
|
||||||
|
onMouseEnter={(e) => {
|
||||||
|
e.currentTarget.style.color = '#ef4444'
|
||||||
|
e.currentTarget.style.borderColor = '#ef4444'
|
||||||
|
e.currentTarget.style.background = 'rgba(239,68,68,0.08)'
|
||||||
|
}}
|
||||||
|
onMouseLeave={(e) => {
|
||||||
|
e.currentTarget.style.color = 'var(--text-muted)'
|
||||||
|
e.currentTarget.style.borderColor = 'transparent'
|
||||||
|
e.currentTarget.style.background = 'transparent'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Trash2 size={16} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Add Dog Modal */}
|
||||||
|
{showAddModal && (
|
||||||
|
<DogForm
|
||||||
|
isExternal={true}
|
||||||
|
onClose={() => setShowAddModal(false)}
|
||||||
|
onSave={() => { fetchDogs(); setShowAddModal(false); }}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Delete Confirmation Modal */}
|
||||||
|
{deleteTarget && (
|
||||||
|
<div style={{
|
||||||
|
position: 'fixed', inset: 0,
|
||||||
|
background: 'rgba(0,0,0,0.65)',
|
||||||
|
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||||
|
zIndex: 1000,
|
||||||
|
backdropFilter: 'blur(4px)'
|
||||||
|
}}>
|
||||||
|
<div className="card" style={{ maxWidth: 420, width: '90%', padding: '2rem', textAlign: 'center' }}>
|
||||||
|
<div style={{
|
||||||
|
width: 56, height: 56,
|
||||||
|
borderRadius: '50%',
|
||||||
|
background: 'rgba(239,68,68,0.12)',
|
||||||
|
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||||
|
margin: '0 auto 1rem'
|
||||||
|
}}>
|
||||||
|
<Trash2 size={26} style={{ color: '#ef4444' }} />
|
||||||
|
</div>
|
||||||
|
<h3 style={{ margin: '0 0 0.5rem', fontSize: '1.25rem' }}>Delete External Dog?</h3>
|
||||||
|
<p style={{ color: 'var(--text-secondary)', marginBottom: '1.75rem', lineHeight: 1.6 }}>
|
||||||
|
<strong style={{ color: 'var(--text-primary)' }}>{deleteTarget.name}</strong> will be
|
||||||
|
permanently removed. This cannot be undone.
|
||||||
|
</p>
|
||||||
|
<div style={{ display: 'flex', gap: '0.75rem', justifyContent: 'center' }}>
|
||||||
|
<button
|
||||||
|
className="btn btn-ghost"
|
||||||
|
onClick={() => setDeleteTarget(null)}
|
||||||
|
disabled={deleting}
|
||||||
|
style={{ minWidth: 100 }}
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className="btn"
|
||||||
|
onClick={handleDelete}
|
||||||
|
disabled={deleting}
|
||||||
|
style={{
|
||||||
|
minWidth: 140,
|
||||||
|
background: '#ef4444',
|
||||||
|
color: '#fff',
|
||||||
|
border: '1px solid #ef4444',
|
||||||
|
display: 'flex', alignItems: 'center', justifyContent: 'center', gap: '0.5rem'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Trash2 size={15} />
|
||||||
|
{deleting ? 'Deleting…' : 'Yes, Delete'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default ExternalDogs
|
||||||
543
client/src/pages/LitterDetail.jsx
Normal file
543
client/src/pages/LitterDetail.jsx
Normal file
@@ -0,0 +1,543 @@
|
|||||||
|
import { useEffect, useState } from 'react'
|
||||||
|
import { useParams, useNavigate } from 'react-router-dom'
|
||||||
|
import { ArrowLeft, Plus, X, ExternalLink, Dog, Weight, ChevronDown, ChevronUp, Trash2 } from 'lucide-react'
|
||||||
|
import axios from 'axios'
|
||||||
|
import LitterForm from '../components/LitterForm'
|
||||||
|
|
||||||
|
// ─── Puppy Log Panel ────────────────────────────────────────────────────────────
|
||||||
|
function PuppyLogPanel({ litterId, puppy, whelpingDate }) {
|
||||||
|
const [open, setOpen] = useState(false)
|
||||||
|
const [logs, setLogs] = useState([])
|
||||||
|
const [loading, setLoading] = useState(false)
|
||||||
|
const [showAdd, setShowAdd] = useState(false)
|
||||||
|
const [form, setForm] = useState({
|
||||||
|
record_date: whelpingDate || '',
|
||||||
|
weight_oz: '',
|
||||||
|
weight_lbs: '',
|
||||||
|
notes: '',
|
||||||
|
record_type: 'weight_log'
|
||||||
|
})
|
||||||
|
const [saving, setSaving] = useState(false)
|
||||||
|
|
||||||
|
useEffect(() => { if (open) fetchLogs() }, [open])
|
||||||
|
|
||||||
|
const fetchLogs = async () => {
|
||||||
|
setLoading(true)
|
||||||
|
try {
|
||||||
|
const res = await axios.get(`/api/litters/${litterId}/puppies/${puppy.id}/logs`)
|
||||||
|
const parsed = res.data.map(l => {
|
||||||
|
try { return { ...l, _data: JSON.parse(l.description) } } catch { return { ...l, _data: {} } }
|
||||||
|
})
|
||||||
|
setLogs(parsed)
|
||||||
|
} catch (e) { console.error(e) }
|
||||||
|
finally { setLoading(false) }
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleAdd = async () => {
|
||||||
|
if (!form.record_date) return
|
||||||
|
setSaving(true)
|
||||||
|
try {
|
||||||
|
await axios.post(`/api/litters/${litterId}/puppies/${puppy.id}/logs`, form)
|
||||||
|
setShowAdd(false)
|
||||||
|
setForm(f => ({ ...f, weight_oz: '', weight_lbs: '', notes: '' }))
|
||||||
|
fetchLogs()
|
||||||
|
} catch (e) { console.error(e) }
|
||||||
|
finally { setSaving(false) }
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleDelete = async (logId) => {
|
||||||
|
if (!window.confirm('Delete this log entry?')) return
|
||||||
|
try {
|
||||||
|
await axios.delete(`/api/litters/${litterId}/puppies/${puppy.id}/logs/${logId}`)
|
||||||
|
fetchLogs()
|
||||||
|
} catch (e) { console.error(e) }
|
||||||
|
}
|
||||||
|
|
||||||
|
const TYPES = [
|
||||||
|
{ value: 'weight_log', label: '⚖️ Weight Check' },
|
||||||
|
{ value: 'health_note', label: '📝 Health Note' },
|
||||||
|
{ value: 'deworming', label: '🐛 Deworming' },
|
||||||
|
{ value: 'vaccination', label: '💉 Vaccination' },
|
||||||
|
]
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ borderTop: '1px solid var(--border)', marginTop: '0.5rem' }}>
|
||||||
|
<button
|
||||||
|
onClick={() => setOpen(o => !o)}
|
||||||
|
style={{
|
||||||
|
width: '100%', display: 'flex', alignItems: 'center', justifyContent: 'space-between',
|
||||||
|
background: 'none', border: 'none', cursor: 'pointer', padding: '0.5rem 0',
|
||||||
|
color: 'var(--text-secondary)', fontSize: '0.8rem'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span style={{ display: 'flex', alignItems: 'center', gap: '0.3rem' }}>
|
||||||
|
<Weight size={13} /> Logs {logs.length > 0 && `(${logs.length})`}
|
||||||
|
</span>
|
||||||
|
{open ? <ChevronUp size={13} /> : <ChevronDown size={13} />}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{open && (
|
||||||
|
<div style={{ paddingBottom: '0.5rem' }}>
|
||||||
|
{loading ? (
|
||||||
|
<p style={{ fontSize: '0.78rem', color: 'var(--text-secondary)' }}>Loading...</p>
|
||||||
|
) : logs.length === 0 ? (
|
||||||
|
<p style={{ fontSize: '0.78rem', color: 'var(--text-secondary)' }}>No logs yet.</p>
|
||||||
|
) : (
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.35rem', marginBottom: '0.5rem' }}>
|
||||||
|
{logs.map(l => (
|
||||||
|
<div key={l.id} style={{
|
||||||
|
display: 'flex', alignItems: 'flex-start', justifyContent: 'space-between',
|
||||||
|
background: 'var(--bg-tertiary)', borderRadius: 'var(--radius-sm)',
|
||||||
|
padding: '0.4rem 0.6rem', gap: '0.5rem'
|
||||||
|
}}>
|
||||||
|
<div style={{ fontSize: '0.75rem', flex: 1 }}>
|
||||||
|
<span style={{ fontWeight: 600 }}>
|
||||||
|
{new Date(l.record_date + 'T00:00:00').toLocaleDateString()}
|
||||||
|
</span>
|
||||||
|
{' • '}
|
||||||
|
<span style={{ color: 'var(--accent)' }}>
|
||||||
|
{TYPES.find(t => t.value === l.record_type)?.label || l.record_type}
|
||||||
|
</span>
|
||||||
|
{l._data?.weight_oz && <span> — {l._data.weight_oz} oz</span>}
|
||||||
|
{l._data?.weight_lbs && <span> ({l._data.weight_lbs} lbs)</span>}
|
||||||
|
{l._data?.notes && (
|
||||||
|
<div style={{ color: 'var(--text-secondary)', marginTop: '0.1rem' }}>
|
||||||
|
{l._data.notes}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={() => handleDelete(l.id)}
|
||||||
|
style={{ background: 'none', border: 'none', cursor: 'pointer', color: '#e53e3e', padding: 0, flexShrink: 0 }}
|
||||||
|
>
|
||||||
|
<Trash2 size={12} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{showAdd ? (
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.4rem' }}>
|
||||||
|
<div style={{ display: 'flex', gap: '0.4rem', flexWrap: 'wrap' }}>
|
||||||
|
<input
|
||||||
|
type="date" className="input"
|
||||||
|
style={{ fontSize: '0.78rem', padding: '0.3rem 0.5rem', flex: '1 1 120px' }}
|
||||||
|
value={form.record_date}
|
||||||
|
onChange={e => setForm(f => ({ ...f, record_date: e.target.value }))}
|
||||||
|
/>
|
||||||
|
<select
|
||||||
|
className="input"
|
||||||
|
style={{ fontSize: '0.78rem', padding: '0.3rem 0.5rem', flex: '1 1 130px' }}
|
||||||
|
value={form.record_type}
|
||||||
|
onChange={e => setForm(f => ({ ...f, record_type: e.target.value }))}
|
||||||
|
>
|
||||||
|
{TYPES.map(t => <option key={t.value} value={t.value}>{t.label}</option>)}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
{form.record_type === 'weight_log' && (
|
||||||
|
<div style={{ display: 'flex', gap: '0.4rem' }}>
|
||||||
|
<input
|
||||||
|
type="number" className="input" placeholder="oz" step="0.1" min="0"
|
||||||
|
style={{ fontSize: '0.78rem', padding: '0.3rem 0.5rem', flex: 1 }}
|
||||||
|
value={form.weight_oz}
|
||||||
|
onChange={e => setForm(f => ({ ...f, weight_oz: e.target.value }))}
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
type="number" className="input" placeholder="lbs" step="0.01" min="0"
|
||||||
|
style={{ fontSize: '0.78rem', padding: '0.3rem 0.5rem', flex: 1 }}
|
||||||
|
value={form.weight_lbs}
|
||||||
|
onChange={e => setForm(f => ({ ...f, weight_lbs: e.target.value }))}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<input
|
||||||
|
className="input" placeholder="Notes (optional)"
|
||||||
|
style={{ fontSize: '0.78rem', padding: '0.3rem 0.5rem' }}
|
||||||
|
value={form.notes}
|
||||||
|
onChange={e => setForm(f => ({ ...f, notes: e.target.value }))}
|
||||||
|
/>
|
||||||
|
<div style={{ display: 'flex', gap: '0.4rem' }}>
|
||||||
|
<button
|
||||||
|
className="btn btn-primary"
|
||||||
|
style={{ fontSize: '0.75rem', padding: '0.3rem 0.75rem' }}
|
||||||
|
onClick={handleAdd} disabled={saving}
|
||||||
|
>
|
||||||
|
{saving ? 'Saving...' : 'Save'}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className="btn btn-secondary"
|
||||||
|
style={{ fontSize: '0.75rem', padding: '0.3rem 0.75rem' }}
|
||||||
|
onClick={() => setShowAdd(false)}
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<button
|
||||||
|
className="btn btn-secondary"
|
||||||
|
style={{ fontSize: '0.75rem', padding: '0.3rem 0.75rem', width: '100%' }}
|
||||||
|
onClick={() => setShowAdd(true)}
|
||||||
|
>
|
||||||
|
<Plus size={12} style={{ marginRight: '0.3rem' }} /> Add Log Entry
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Whelping Window Banner ───────────────────────────────────────────────
|
||||||
|
function addDays(dateStr, n) {
|
||||||
|
const d = new Date(dateStr + 'T00:00:00')
|
||||||
|
d.setDate(d.getDate() + n)
|
||||||
|
return d
|
||||||
|
}
|
||||||
|
function fmt(d) { return d.toLocaleDateString(undefined, { month: 'short', day: 'numeric', year: 'numeric' }) }
|
||||||
|
|
||||||
|
function WhelpingBanner({ breedingDate, whelpingDate }) {
|
||||||
|
if (whelpingDate) return null // already whelped, no need for estimate
|
||||||
|
if (!breedingDate) return null
|
||||||
|
|
||||||
|
const earliest = addDays(breedingDate, 58)
|
||||||
|
const expected = addDays(breedingDate, 63)
|
||||||
|
const latest = addDays(breedingDate, 68)
|
||||||
|
const today = new Date()
|
||||||
|
const daysUntil = Math.ceil((expected - today) / 86400000)
|
||||||
|
|
||||||
|
let urgency = 'var(--success)'
|
||||||
|
let urgencyBg = 'rgba(16,185,129,0.06)'
|
||||||
|
let statusLabel = `~${daysUntil} days away`
|
||||||
|
if (daysUntil <= 7 && daysUntil > 0) {
|
||||||
|
urgency = '#d97706'; urgencyBg = 'rgba(217,119,6,0.08)'
|
||||||
|
statusLabel = `⚠️ ${daysUntil} days — prepare whelping area!`
|
||||||
|
} else if (daysUntil <= 0) {
|
||||||
|
urgency = '#e53e3e'; urgencyBg = 'rgba(229,62,62,0.08)'
|
||||||
|
statusLabel = '🔴 Expected date has passed — confirm or update whelping date'
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="card" style={{
|
||||||
|
marginBottom: '2rem', padding: '1rem',
|
||||||
|
borderLeft: `3px solid ${urgency}`,
|
||||||
|
background: urgencyBg
|
||||||
|
}}>
|
||||||
|
<div style={{ fontWeight: 600, marginBottom: '0.5rem', color: urgency }}>
|
||||||
|
💕 Projected Whelping Window
|
||||||
|
<span style={{ fontWeight: 400, fontSize: '0.82rem', marginLeft: '0.75rem', color: 'var(--text-secondary)' }}>
|
||||||
|
{statusLabel}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div style={{ display: 'flex', gap: '2rem', flexWrap: 'wrap', fontSize: '0.875rem' }}>
|
||||||
|
<div>
|
||||||
|
<span style={{ color: 'var(--text-secondary)', fontSize: '0.78rem' }}>Earliest (Day 58)</span>
|
||||||
|
<br /><strong>{fmt(earliest)}</strong>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span style={{ color: 'var(--text-secondary)', fontSize: '0.78rem' }}>Expected (Day 63)</span>
|
||||||
|
<br /><strong style={{ color: urgency, fontSize: '1rem' }}>{fmt(expected)}</strong>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span style={{ color: 'var(--text-secondary)', fontSize: '0.78rem' }}>Latest (Day 68)</span>
|
||||||
|
<br /><strong>{fmt(latest)}</strong>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Main LitterDetail ─────────────────────────────────────────────────────────
|
||||||
|
function LitterDetail() {
|
||||||
|
const { id } = useParams()
|
||||||
|
const navigate = useNavigate()
|
||||||
|
const [litter, setLitter] = useState(null)
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
const [showEditForm, setShowEditForm] = useState(false)
|
||||||
|
const [showAddPuppy, setShowAddPuppy] = useState(false)
|
||||||
|
const [allDogs, setAllDogs] = useState([])
|
||||||
|
const [selectedPuppyId, setSelectedPuppyId] = useState('')
|
||||||
|
const [newPuppy, setNewPuppy] = useState({ name: '', sex: 'male', color: '', dob: '' })
|
||||||
|
const [addMode, setAddMode] = useState('existing')
|
||||||
|
const [error, setError] = useState('')
|
||||||
|
const [saving, setSaving] = useState(false)
|
||||||
|
|
||||||
|
useEffect(() => { fetchLitter(); fetchAllDogs() }, [id])
|
||||||
|
|
||||||
|
const fetchLitter = async () => {
|
||||||
|
try {
|
||||||
|
const res = await axios.get(`/api/litters/${id}`)
|
||||||
|
setLitter(res.data)
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Error fetching litter:', err)
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const fetchAllDogs = async () => {
|
||||||
|
try {
|
||||||
|
const res = await axios.get('/api/dogs')
|
||||||
|
setAllDogs(res.data)
|
||||||
|
} catch (err) { console.error('Error fetching dogs:', err) }
|
||||||
|
}
|
||||||
|
|
||||||
|
const unlinkedDogs = allDogs.filter(d => {
|
||||||
|
if (!litter) return false
|
||||||
|
const alreadyInLitter = litter.puppies?.some(p => p.id === d.id)
|
||||||
|
const isSireOrDam = d.id === litter.sire_id || d.id === litter.dam_id
|
||||||
|
return !alreadyInLitter && !isSireOrDam
|
||||||
|
})
|
||||||
|
|
||||||
|
const handleLinkPuppy = async () => {
|
||||||
|
if (!selectedPuppyId) return
|
||||||
|
setSaving(true); setError('')
|
||||||
|
try {
|
||||||
|
await axios.post(`/api/litters/${id}/puppies/${selectedPuppyId}`)
|
||||||
|
setSelectedPuppyId(''); setShowAddPuppy(false); fetchLitter()
|
||||||
|
} catch (err) {
|
||||||
|
setError(err.response?.data?.error || 'Failed to link puppy')
|
||||||
|
} finally { setSaving(false) }
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleCreateAndLink = async () => {
|
||||||
|
if (!newPuppy.name) { setError('Puppy name is required'); return }
|
||||||
|
setSaving(true); setError('')
|
||||||
|
try {
|
||||||
|
const dob = newPuppy.dob || litter.whelping_date || litter.breeding_date
|
||||||
|
const res = await axios.post('/api/dogs', {
|
||||||
|
name: newPuppy.name, sex: newPuppy.sex,
|
||||||
|
color: newPuppy.color, date_of_birth: dob,
|
||||||
|
breed: litter.dam_breed || '',
|
||||||
|
})
|
||||||
|
await axios.post(`/api/litters/${id}/puppies/${res.data.id}`)
|
||||||
|
setNewPuppy({ name: '', sex: 'male', color: '', dob: '' })
|
||||||
|
setShowAddPuppy(false); fetchLitter(); fetchAllDogs()
|
||||||
|
} catch (err) {
|
||||||
|
setError(err.response?.data?.error || 'Failed to create puppy')
|
||||||
|
} finally { setSaving(false) }
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleUnlinkPuppy = async (puppyId) => {
|
||||||
|
if (!window.confirm('Remove this puppy from the litter? The dog record will not be deleted.')) return
|
||||||
|
try {
|
||||||
|
await axios.delete(`/api/litters/${id}/puppies/${puppyId}`)
|
||||||
|
fetchLitter()
|
||||||
|
} catch (err) { console.error('Error unlinking puppy:', err) }
|
||||||
|
}
|
||||||
|
|
||||||
|
if (loading) return <div className="container loading">Loading litter...</div>
|
||||||
|
if (!litter) return <div className="container"><p>Litter not found.</p></div>
|
||||||
|
|
||||||
|
const puppyCount = litter.puppies?.length ?? 0
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="container">
|
||||||
|
{/* Header */}
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: '1rem', marginBottom: '2rem' }}>
|
||||||
|
<button className="btn-icon" onClick={() => navigate('/litters')}>
|
||||||
|
<ArrowLeft size={20} />
|
||||||
|
</button>
|
||||||
|
<div style={{ flex: 1 }}>
|
||||||
|
<h1 style={{ margin: 0 }}>🐾 {litter.sire_name} × {litter.dam_name}</h1>
|
||||||
|
<p style={{ color: 'var(--text-secondary)', margin: '0.25rem 0 0' }}>
|
||||||
|
Bred: {new Date(litter.breeding_date + 'T00:00:00').toLocaleDateString()}
|
||||||
|
{litter.whelping_date && ` • Whelped: ${new Date(litter.whelping_date + 'T00:00:00').toLocaleDateString()}`}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<button className="btn btn-secondary" onClick={() => setShowEditForm(true)}>Edit Litter</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Stats row */}
|
||||||
|
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(140px, 1fr))', gap: '1rem', marginBottom: '2rem' }}>
|
||||||
|
<div className="card" style={{ textAlign: 'center', padding: '1rem' }}>
|
||||||
|
<div style={{ fontSize: '2rem', fontWeight: 700, color: 'var(--accent)' }}>{puppyCount}</div>
|
||||||
|
<div style={{ color: 'var(--text-secondary)', fontSize: '0.85rem' }}>Puppies Linked</div>
|
||||||
|
</div>
|
||||||
|
<div className="card" style={{ textAlign: 'center', padding: '1rem' }}>
|
||||||
|
<div style={{ fontSize: '2rem', fontWeight: 700, color: 'var(--accent)' }}>
|
||||||
|
{litter.puppies?.filter(p => p.sex === 'male').length ?? 0}
|
||||||
|
</div>
|
||||||
|
<div style={{ color: 'var(--text-secondary)', fontSize: '0.85rem' }}>Males</div>
|
||||||
|
</div>
|
||||||
|
<div className="card" style={{ textAlign: 'center', padding: '1rem' }}>
|
||||||
|
<div style={{ fontSize: '2rem', fontWeight: 700, color: 'var(--accent)' }}>
|
||||||
|
{litter.puppies?.filter(p => p.sex === 'female').length ?? 0}
|
||||||
|
</div>
|
||||||
|
<div style={{ color: 'var(--text-secondary)', fontSize: '0.85rem' }}>Females</div>
|
||||||
|
</div>
|
||||||
|
{litter.puppy_count > 0 && (
|
||||||
|
<div className="card" style={{ textAlign: 'center', padding: '1rem' }}>
|
||||||
|
<div style={{ fontSize: '2rem', fontWeight: 700 }}>{litter.puppy_count}</div>
|
||||||
|
<div style={{ color: 'var(--text-secondary)', fontSize: '0.85rem' }}>Expected</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Projected whelping window */}
|
||||||
|
<WhelpingBanner breedingDate={litter.breeding_date} whelpingDate={litter.whelping_date} />
|
||||||
|
|
||||||
|
{/* Notes */}
|
||||||
|
{litter.notes && (
|
||||||
|
<div className="card" style={{ marginBottom: '2rem', padding: '1rem', borderLeft: '3px solid var(--accent)' }}>
|
||||||
|
<p style={{ margin: 0, fontStyle: 'italic', color: 'var(--text-secondary)' }}>{litter.notes}</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Puppies section */}
|
||||||
|
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '1rem' }}>
|
||||||
|
<h2 style={{ margin: 0 }}>Puppies</h2>
|
||||||
|
<button className="btn btn-primary" onClick={() => { setShowAddPuppy(true); setError('') }}>
|
||||||
|
<Plus size={16} style={{ marginRight: '0.4rem' }} />
|
||||||
|
Add Puppy
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{puppyCount === 0 ? (
|
||||||
|
<div className="card" style={{ textAlign: 'center', padding: '3rem' }}>
|
||||||
|
<Dog size={48} style={{ color: 'var(--text-secondary)', margin: '0 auto 1rem' }} />
|
||||||
|
<p style={{ color: 'var(--text-secondary)' }}>No puppies linked yet. Add puppies to this litter.</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(240px, 1fr))', gap: '1rem' }}>
|
||||||
|
{litter.puppies.map(puppy => (
|
||||||
|
<div key={puppy.id} className="card" style={{ position: 'relative' }}>
|
||||||
|
<button
|
||||||
|
className="btn-icon"
|
||||||
|
onClick={() => handleUnlinkPuppy(puppy.id)}
|
||||||
|
title="Remove from litter"
|
||||||
|
style={{ position: 'absolute', top: '0.75rem', right: '0.75rem', color: '#e53e3e' }}
|
||||||
|
>
|
||||||
|
<X size={14} />
|
||||||
|
</button>
|
||||||
|
<div style={{ fontSize: '2.5rem', marginBottom: '0.5rem' }}>
|
||||||
|
{puppy.sex === 'male' ? '🐦' : '🐥'}
|
||||||
|
</div>
|
||||||
|
<div style={{ fontWeight: 600, marginBottom: '0.25rem' }}>{puppy.name}</div>
|
||||||
|
<div style={{ fontSize: '0.8rem', color: 'var(--text-secondary)' }}>
|
||||||
|
{puppy.sex} {puppy.color && `• ${puppy.color}`}
|
||||||
|
</div>
|
||||||
|
{puppy.date_of_birth && (
|
||||||
|
<div style={{ fontSize: '0.8rem', color: 'var(--text-secondary)' }}>
|
||||||
|
Born: {new Date(puppy.date_of_birth + 'T00:00:00').toLocaleDateString()}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
className="btn btn-secondary"
|
||||||
|
style={{ marginTop: '0.75rem', width: '100%', fontSize: '0.8rem', padding: '0.4rem' }}
|
||||||
|
onClick={() => navigate(`/dogs/${puppy.id}`)}
|
||||||
|
>
|
||||||
|
<ExternalLink size={12} style={{ marginRight: '0.3rem' }} />
|
||||||
|
View Profile
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* Weight / Health Log collapsible */}
|
||||||
|
<PuppyLogPanel
|
||||||
|
litterId={id}
|
||||||
|
puppy={puppy}
|
||||||
|
whelpingDate={litter.whelping_date || litter.breeding_date}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Add Puppy Modal */}
|
||||||
|
{showAddPuppy && (
|
||||||
|
<div className="modal-overlay" onClick={() => setShowAddPuppy(false)}>
|
||||||
|
<div className="modal-content" onClick={e => e.stopPropagation()} style={{ maxWidth: '480px' }}>
|
||||||
|
<div className="modal-header">
|
||||||
|
<h2>Add Puppy to Litter</h2>
|
||||||
|
<button className="btn-icon" onClick={() => setShowAddPuppy(false)}><X size={24} /></button>
|
||||||
|
</div>
|
||||||
|
<div className="modal-body">
|
||||||
|
{error && <div className="error" style={{ marginBottom: '1rem' }}>{error}</div>}
|
||||||
|
<div style={{ display: 'flex', gap: '0.5rem', marginBottom: '1.5rem' }}>
|
||||||
|
<button className={`btn ${addMode === 'existing' ? 'btn-primary' : 'btn-secondary'}`}
|
||||||
|
onClick={() => setAddMode('existing')} style={{ flex: 1 }}>Link Existing Dog</button>
|
||||||
|
<button className={`btn ${addMode === 'new' ? 'btn-primary' : 'btn-secondary'}`}
|
||||||
|
onClick={() => setAddMode('new')} style={{ flex: 1 }}>Create New Puppy</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{addMode === 'existing' ? (
|
||||||
|
<div className="form-group">
|
||||||
|
<label className="label">Select Dog</label>
|
||||||
|
<select className="input" value={selectedPuppyId}
|
||||||
|
onChange={e => setSelectedPuppyId(e.target.value)}>
|
||||||
|
<option value="">-- Select a dog --</option>
|
||||||
|
{unlinkedDogs.map(d => (
|
||||||
|
<option key={d.id} value={d.id}>
|
||||||
|
{d.name} ({d.sex}{d.date_of_birth ? `, born ${new Date(d.date_of_birth + 'T00:00:00').toLocaleDateString()}` : ''})
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
{unlinkedDogs.length === 0 && (
|
||||||
|
<p style={{ color: 'var(--text-secondary)', fontSize: '0.85rem', marginTop: '0.5rem' }}>
|
||||||
|
No unlinked dogs available. Use "Create New Puppy" instead.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div style={{ display: 'grid', gap: '1rem' }}>
|
||||||
|
<div className="form-group">
|
||||||
|
<label className="label">Puppy Name *</label>
|
||||||
|
<input className="input" value={newPuppy.name}
|
||||||
|
onChange={e => setNewPuppy(p => ({ ...p, name: e.target.value }))}
|
||||||
|
placeholder="e.g. Blue Collar" />
|
||||||
|
</div>
|
||||||
|
<div className="form-grid">
|
||||||
|
<div className="form-group">
|
||||||
|
<label className="label">Sex</label>
|
||||||
|
<select className="input" value={newPuppy.sex}
|
||||||
|
onChange={e => setNewPuppy(p => ({ ...p, sex: e.target.value }))}>
|
||||||
|
<option value="male">Male</option>
|
||||||
|
<option value="female">Female</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div className="form-group">
|
||||||
|
<label className="label">Color / Markings</label>
|
||||||
|
<input className="input" value={newPuppy.color}
|
||||||
|
onChange={e => setNewPuppy(p => ({ ...p, color: e.target.value }))}
|
||||||
|
placeholder="e.g. Black & Tan" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="form-group">
|
||||||
|
<label className="label">Date of Birth</label>
|
||||||
|
<input type="date" className="input" value={newPuppy.dob}
|
||||||
|
onChange={e => setNewPuppy(p => ({ ...p, dob: e.target.value }))} />
|
||||||
|
{litter.whelping_date && !newPuppy.dob && (
|
||||||
|
<p style={{ fontSize: '0.8rem', color: 'var(--text-secondary)', marginTop: '0.25rem' }}>
|
||||||
|
Will default to whelping date: {new Date(litter.whelping_date + 'T00:00:00').toLocaleDateString()}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="modal-footer">
|
||||||
|
<button className="btn btn-secondary" onClick={() => setShowAddPuppy(false)} disabled={saving}>Cancel</button>
|
||||||
|
<button
|
||||||
|
className="btn btn-primary"
|
||||||
|
disabled={saving || (addMode === 'existing' && !selectedPuppyId)}
|
||||||
|
onClick={addMode === 'existing' ? handleLinkPuppy : handleCreateAndLink}
|
||||||
|
>
|
||||||
|
{saving ? 'Saving...' : addMode === 'existing' ? 'Link Puppy' : 'Create & Link'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{showEditForm && (
|
||||||
|
<LitterForm
|
||||||
|
litter={litter}
|
||||||
|
onClose={() => setShowEditForm(false)}
|
||||||
|
onSave={fetchLitter}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default LitterDetail
|
||||||
@@ -1,60 +1,156 @@
|
|||||||
import { useEffect, useState } from 'react'
|
import { useEffect, useState } from 'react'
|
||||||
import { Activity } from 'lucide-react'
|
import { Activity, Plus, Edit2, Trash2, ChevronRight } from 'lucide-react'
|
||||||
|
import { useNavigate } from 'react-router-dom'
|
||||||
import axios from 'axios'
|
import axios from 'axios'
|
||||||
|
import LitterForm from '../components/LitterForm'
|
||||||
|
|
||||||
function LitterList() {
|
function LitterList() {
|
||||||
const [litters, setLitters] = useState([])
|
const [litters, setLitters] = useState([])
|
||||||
const [loading, setLoading] = useState(true)
|
const [loading, setLoading] = useState(true)
|
||||||
|
const [showForm, setShowForm] = useState(false)
|
||||||
|
const [editingLitter, setEditingLitter] = useState(null)
|
||||||
|
const [prefill, setPrefill] = useState(null)
|
||||||
|
const navigate = useNavigate()
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
fetchLitters()
|
fetchLitters()
|
||||||
|
// Auto-open form with prefill from BreedingCalendar "Record Litter" CTA
|
||||||
|
const stored = sessionStorage.getItem('prefillLitter')
|
||||||
|
if (stored) {
|
||||||
|
try {
|
||||||
|
const data = JSON.parse(stored)
|
||||||
|
setPrefill(data)
|
||||||
|
setEditingLitter(null)
|
||||||
|
setShowForm(true)
|
||||||
|
} catch (e) { /* ignore */ }
|
||||||
|
sessionStorage.removeItem('prefillLitter')
|
||||||
|
}
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
const fetchLitters = async () => {
|
const fetchLitters = async () => {
|
||||||
try {
|
try {
|
||||||
const res = await axios.get('/api/litters')
|
const res = await axios.get('/api/litters')
|
||||||
setLitters(res.data)
|
setLitters(res.data)
|
||||||
setLoading(false)
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error fetching litters:', error)
|
console.error('Error fetching litters:', error)
|
||||||
|
} finally {
|
||||||
setLoading(false)
|
setLoading(false)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const handleCreate = () => {
|
||||||
|
setEditingLitter(null)
|
||||||
|
setPrefill(null)
|
||||||
|
setShowForm(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleEdit = (e, litter) => {
|
||||||
|
e.stopPropagation()
|
||||||
|
setEditingLitter(litter)
|
||||||
|
setPrefill(null)
|
||||||
|
setShowForm(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleDelete = async (e, id) => {
|
||||||
|
e.stopPropagation()
|
||||||
|
if (!window.confirm('Delete this litter record? Puppies will be unlinked but not deleted.')) return
|
||||||
|
try {
|
||||||
|
await axios.delete(`/api/litters/${id}`)
|
||||||
|
fetchLitters()
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error deleting litter:', error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleSave = () => {
|
||||||
|
fetchLitters()
|
||||||
|
}
|
||||||
|
|
||||||
if (loading) {
|
if (loading) {
|
||||||
return <div className="container loading">Loading litters...</div>
|
return <div className="container loading">Loading litters...</div>
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="container">
|
<div className="container">
|
||||||
<h1 style={{ marginBottom: '2rem' }}>Litters</h1>
|
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '2rem' }}>
|
||||||
|
<h1>Litters</h1>
|
||||||
|
<button className="btn btn-primary" onClick={handleCreate}>
|
||||||
|
<Plus size={18} style={{ marginRight: '0.5rem' }} />
|
||||||
|
New Litter
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
{litters.length === 0 ? (
|
{litters.length === 0 ? (
|
||||||
<div className="card" style={{ textAlign: 'center', padding: '4rem' }}>
|
<div className="card" style={{ textAlign: 'center', padding: '4rem' }}>
|
||||||
<Activity size={64} style={{ color: 'var(--text-secondary)', margin: '0 auto 1rem' }} />
|
<Activity size={64} style={{ color: 'var(--text-secondary)', margin: '0 auto 1rem' }} />
|
||||||
<h2>No litters recorded yet</h2>
|
<h2>No litters recorded yet</h2>
|
||||||
<p style={{ color: 'var(--text-secondary)' }}>Start tracking breeding records</p>
|
<p style={{ color: 'var(--text-secondary)', marginBottom: '1.5rem' }}>Create a litter after a breeding cycle to track puppies</p>
|
||||||
|
<button className="btn btn-primary" onClick={handleCreate}>
|
||||||
|
<Plus size={18} style={{ marginRight: '0.5rem' }} />
|
||||||
|
Create First Litter
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div style={{ display: 'grid', gap: '1rem' }}>
|
<div style={{ display: 'grid', gap: '1rem' }}>
|
||||||
{litters.map(litter => (
|
{litters.map(litter => (
|
||||||
<div key={litter.id} className="card">
|
<div
|
||||||
<h3>{litter.sire_name} × {litter.dam_name}</h3>
|
key={litter.id}
|
||||||
<p style={{ color: 'var(--text-secondary)', marginTop: '0.5rem' }}>
|
className="card"
|
||||||
Breeding Date: {new Date(litter.breeding_date).toLocaleDateString()}
|
style={{ cursor: 'pointer', transition: 'border-color 0.2s' }}
|
||||||
</p>
|
onClick={() => navigate(`/litters/${litter.id}`)}
|
||||||
{litter.whelping_date && (
|
>
|
||||||
<p style={{ color: 'var(--text-secondary)' }}>
|
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start' }}>
|
||||||
Whelping Date: {new Date(litter.whelping_date).toLocaleDateString()}
|
<div style={{ flex: 1 }}>
|
||||||
</p>
|
<h3 style={{ marginBottom: '0.5rem' }}>
|
||||||
)}
|
🐾 {litter.sire_name} × {litter.dam_name}
|
||||||
<p style={{ marginTop: '0.5rem' }}>
|
</h3>
|
||||||
<strong>Puppies:</strong> {litter.puppy_count || litter.puppies?.length || 0}
|
<div style={{ display: 'flex', gap: '1.5rem', flexWrap: 'wrap', color: 'var(--text-secondary)', fontSize: '0.9rem' }}>
|
||||||
</p>
|
<span>📅 Bred: {new Date(litter.breeding_date).toLocaleDateString()}</span>
|
||||||
|
{litter.whelping_date && (
|
||||||
|
<span>💕 Whelped: {new Date(litter.whelping_date).toLocaleDateString()}</span>
|
||||||
|
)}
|
||||||
|
<span style={{ color: 'var(--accent)', fontWeight: 600 }}>
|
||||||
|
{litter.actual_puppy_count ?? litter.puppies?.length ?? litter.puppy_count ?? 0} puppies
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{litter.notes && (
|
||||||
|
<p style={{ marginTop: '0.5rem', fontSize: '0.85rem', color: 'var(--text-secondary)', fontStyle: 'italic' }}>
|
||||||
|
{litter.notes}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div style={{ display: 'flex', gap: '0.5rem', alignItems: 'center' }}>
|
||||||
|
<button
|
||||||
|
className="btn-icon"
|
||||||
|
title="Edit litter"
|
||||||
|
onClick={(e) => handleEdit(e, litter)}
|
||||||
|
>
|
||||||
|
<Edit2 size={16} />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className="btn-icon"
|
||||||
|
title="Delete litter"
|
||||||
|
onClick={(e) => handleDelete(e, litter.id)}
|
||||||
|
style={{ color: '#e53e3e' }}
|
||||||
|
>
|
||||||
|
<Trash2 size={16} />
|
||||||
|
</button>
|
||||||
|
<ChevronRight size={20} style={{ color: 'var(--text-secondary)' }} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{showForm && (
|
||||||
|
<LitterForm
|
||||||
|
litter={editingLitter}
|
||||||
|
prefill={prefill}
|
||||||
|
onClose={() => setShowForm(false)}
|
||||||
|
onSave={handleSave}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useState, useEffect } from 'react'
|
import { useState, useEffect, useCallback } from 'react'
|
||||||
import { FlaskConical, AlertTriangle, CheckCircle, XCircle, GitMerge } from 'lucide-react'
|
import { FlaskConical, AlertTriangle, CheckCircle, XCircle, GitMerge, ShieldAlert } from 'lucide-react'
|
||||||
|
|
||||||
export default function PairingSimulator() {
|
export default function PairingSimulator() {
|
||||||
const [dogs, setDogs] = useState([])
|
const [dogs, setDogs] = useState([])
|
||||||
@@ -9,9 +9,12 @@ export default function PairingSimulator() {
|
|||||||
const [loading, setLoading] = useState(false)
|
const [loading, setLoading] = useState(false)
|
||||||
const [error, setError] = useState(null)
|
const [error, setError] = useState(null)
|
||||||
const [dogsLoading, setDogsLoading] = useState(true)
|
const [dogsLoading, setDogsLoading] = useState(true)
|
||||||
|
const [relationWarning, setRelationWarning] = useState(null)
|
||||||
|
const [relationChecking, setRelationChecking] = useState(false)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
fetch('/api/dogs')
|
// include_external=1 ensures external sires/dams appear for pairing
|
||||||
|
fetch('/api/dogs?include_external=1')
|
||||||
.then(r => r.json())
|
.then(r => r.json())
|
||||||
.then(data => {
|
.then(data => {
|
||||||
setDogs(Array.isArray(data) ? data : (data.dogs || []))
|
setDogs(Array.isArray(data) ? data : (data.dogs || []))
|
||||||
@@ -20,8 +23,37 @@ export default function PairingSimulator() {
|
|||||||
.catch(() => setDogsLoading(false))
|
.catch(() => setDogsLoading(false))
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
const males = dogs.filter(d => d.sex === 'male')
|
// Check for direct relation whenever both sire and dam are selected
|
||||||
const females = dogs.filter(d => d.sex === 'female')
|
const checkRelation = useCallback(async (sid, did) => {
|
||||||
|
if (!sid || !did) {
|
||||||
|
setRelationWarning(null)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
setRelationChecking(true)
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/api/pedigree/relations/${sid}/${did}`)
|
||||||
|
const data = await res.json()
|
||||||
|
setRelationWarning(data.related ? data.relationship : null)
|
||||||
|
} catch {
|
||||||
|
setRelationWarning(null)
|
||||||
|
} finally {
|
||||||
|
setRelationChecking(false)
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
function handleSireChange(e) {
|
||||||
|
const val = e.target.value
|
||||||
|
setSireId(val)
|
||||||
|
setResult(null)
|
||||||
|
checkRelation(val, damId)
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleDamChange(e) {
|
||||||
|
const val = e.target.value
|
||||||
|
setDamId(val)
|
||||||
|
setResult(null)
|
||||||
|
checkRelation(sireId, val)
|
||||||
|
}
|
||||||
|
|
||||||
async function handleSimulate(e) {
|
async function handleSimulate(e) {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
@@ -33,13 +65,11 @@ export default function PairingSimulator() {
|
|||||||
const res = await fetch('/api/pedigree/trial-pairing', {
|
const res = await fetch('/api/pedigree/trial-pairing', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({ sire_id: parseInt(sireId), dam_id: parseInt(damId) })
|
body: JSON.stringify({ sire_id: parseInt(sireId), dam_id: parseInt(damId) }),
|
||||||
})
|
})
|
||||||
if (!res.ok) {
|
const data = await res.json()
|
||||||
const err = await res.json()
|
if (!res.ok) throw new Error(data.error || 'Simulation failed')
|
||||||
throw new Error(err.error || 'Failed to calculate')
|
setResult(data)
|
||||||
}
|
|
||||||
setResult(await res.json())
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError(err.message)
|
setError(err.message)
|
||||||
} finally {
|
} finally {
|
||||||
@@ -47,171 +77,164 @@ export default function PairingSimulator() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function RiskBadge({ coi, recommendation }) {
|
const males = dogs.filter(d => d.sex === 'male')
|
||||||
const isLow = coi < 5
|
const females = dogs.filter(d => d.sex === 'female')
|
||||||
const isMed = coi >= 5 && coi < 10
|
|
||||||
const isHigh = coi >= 10
|
const coiColor = (coi) => {
|
||||||
return (
|
if (coi < 0.0625) return 'var(--success)'
|
||||||
<div className={`risk-badge risk-${isLow ? 'low' : isMed ? 'med' : 'high'}`}>
|
if (coi < 0.125) return 'var(--warning)'
|
||||||
{isLow && <CheckCircle size={20} />}
|
return 'var(--danger)'
|
||||||
{isMed && <AlertTriangle size={20} />}
|
}
|
||||||
{isHigh && <XCircle size={20} />}
|
|
||||||
<span>{recommendation}</span>
|
const coiLabel = (coi) => {
|
||||||
</div>
|
if (coi < 0.0625) return 'Low'
|
||||||
)
|
if (coi < 0.125) return 'Moderate'
|
||||||
|
if (coi < 0.25) return 'High'
|
||||||
|
return 'Very High'
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="container" style={{ paddingTop: '2rem', paddingBottom: '3rem' }}>
|
<div className="container" style={{ paddingTop: '2rem', paddingBottom: '3rem', maxWidth: '720px' }}>
|
||||||
{/* Header */}
|
<div style={{ display: 'flex', alignItems: 'center', gap: '0.75rem', marginBottom: '0.5rem' }}>
|
||||||
<div style={{ marginBottom: '2rem' }}>
|
<FlaskConical size={28} style={{ color: 'var(--primary)' }} />
|
||||||
<div style={{ display: 'flex', alignItems: 'center', gap: '0.75rem', marginBottom: '0.5rem' }}>
|
<h1 style={{ margin: 0 }}>Pairing Simulator</h1>
|
||||||
<div style={{ width: '2.5rem', height: '2.5rem', borderRadius: 'var(--radius)', background: 'rgba(139,92,246,0.2)', display: 'flex', alignItems: 'center', justifyContent: 'center', color: 'var(--accent)' }}>
|
|
||||||
<FlaskConical size={20} />
|
|
||||||
</div>
|
|
||||||
<h1 style={{ fontSize: '1.75rem', margin: 0 }}>Trial Pairing Simulator</h1>
|
|
||||||
</div>
|
|
||||||
<p style={{ color: 'var(--text-muted)', margin: 0 }}>
|
|
||||||
Select a sire and dam to calculate the estimated inbreeding coefficient (COI) and view common ancestors.
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
|
<p style={{ color: 'var(--text-muted)', marginBottom: '2rem' }}>
|
||||||
|
Estimate the Coefficient of Inbreeding (COI) for a hypothetical pairing before breeding.
|
||||||
|
Includes both kennel and external dogs.
|
||||||
|
</p>
|
||||||
|
|
||||||
{/* Selector Card */}
|
<div className="card" style={{ marginBottom: '1.5rem' }}>
|
||||||
<div className="card" style={{ marginBottom: '1.5rem', maxWidth: '720px' }}>
|
|
||||||
<form onSubmit={handleSimulate}>
|
<form onSubmit={handleSimulate}>
|
||||||
<div className="form-grid" style={{ marginBottom: '1.25rem' }}>
|
<div className="form-grid" style={{ marginBottom: '1rem' }}>
|
||||||
<div className="form-group" style={{ margin: 0 }}>
|
<div className="form-group">
|
||||||
<label className="label">Sire (Male) ♂</label>
|
<label className="label">Sire (Male) *</label>
|
||||||
<select
|
{dogsLoading ? (
|
||||||
value={sireId}
|
<div className="input" style={{ color: 'var(--text-muted)' }}>Loading dogs...</div>
|
||||||
onChange={e => setSireId(e.target.value)}
|
) : (
|
||||||
required
|
<select className="input" value={sireId} onChange={handleSireChange} required>
|
||||||
disabled={dogsLoading}
|
<option value="">Select sire...</option>
|
||||||
>
|
{males.map(d => (
|
||||||
<option value="">— Select Sire —</option>
|
<option key={d.id} value={d.id}>
|
||||||
{males.map(d => (
|
{d.name}{d.is_champion ? ' ✪' : ''}{d.is_external ? ' [Ext]' : ''}
|
||||||
<option key={d.id} value={d.id}>
|
</option>
|
||||||
{d.name}{d.breed ? ` · ${d.breed}` : ''}
|
))}
|
||||||
</option>
|
</select>
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
{!dogsLoading && males.length === 0 && (
|
|
||||||
<p style={{ color: 'var(--text-muted)', fontSize: '0.8rem', marginTop: '0.4rem' }}>No male dogs registered.</p>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="form-group" style={{ margin: 0 }}>
|
<div className="form-group">
|
||||||
<label className="label">Dam (Female) ♀</label>
|
<label className="label">Dam (Female) *</label>
|
||||||
<select
|
{dogsLoading ? (
|
||||||
value={damId}
|
<div className="input" style={{ color: 'var(--text-muted)' }}>Loading dogs...</div>
|
||||||
onChange={e => setDamId(e.target.value)}
|
) : (
|
||||||
required
|
<select className="input" value={damId} onChange={handleDamChange} required>
|
||||||
disabled={dogsLoading}
|
<option value="">Select dam...</option>
|
||||||
>
|
{females.map(d => (
|
||||||
<option value="">— Select Dam —</option>
|
<option key={d.id} value={d.id}>
|
||||||
{females.map(d => (
|
{d.name}{d.is_champion ? ' ✪' : ''}{d.is_external ? ' [Ext]' : ''}
|
||||||
<option key={d.id} value={d.id}>
|
</option>
|
||||||
{d.name}{d.breed ? ` · ${d.breed}` : ''}
|
))}
|
||||||
</option>
|
</select>
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
{!dogsLoading && females.length === 0 && (
|
|
||||||
<p style={{ color: 'var(--text-muted)', fontSize: '0.8rem', marginTop: '0.4rem' }}>No female dogs registered.</p>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{relationChecking && (
|
||||||
|
<div style={{ fontSize: '0.85rem', color: 'var(--text-muted)', marginBottom: '0.75rem' }}>
|
||||||
|
Checking relationship...
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{relationWarning && !relationChecking && (
|
||||||
|
<div style={{
|
||||||
|
display: 'flex', alignItems: 'center', gap: '0.5rem',
|
||||||
|
padding: '0.6rem 1rem', marginBottom: '0.75rem',
|
||||||
|
background: 'rgba(239,68,68,0.08)', border: '1px solid rgba(239,68,68,0.3)',
|
||||||
|
borderRadius: 'var(--radius)', fontSize: '0.875rem', color: 'var(--danger)',
|
||||||
|
}}>
|
||||||
|
<ShieldAlert size={16} />
|
||||||
|
<strong>Related:</strong> {relationWarning}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
<button
|
<button
|
||||||
type="submit"
|
type="submit"
|
||||||
className="btn btn-primary"
|
className="btn btn-primary"
|
||||||
disabled={!sireId || !damId || loading}
|
disabled={loading || dogsLoading || !sireId || !damId}
|
||||||
style={{ minWidth: '160px' }}
|
style={{ width: '100%' }}
|
||||||
>
|
>
|
||||||
{loading ? 'Calculating…' : <><FlaskConical size={16} /> Simulate Pairing</>}
|
{loading ? 'Simulating...' : <><GitMerge size={16} style={{ marginRight: '0.4rem' }} />Simulate Pairing</>}
|
||||||
</button>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Error */}
|
{error && (
|
||||||
{error && <div className="error" style={{ maxWidth: '720px' }}>{error}</div>}
|
<div className="card" style={{ borderColor: 'var(--danger)', marginBottom: '1.5rem' }}>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: '0.5rem', color: 'var(--danger)' }}>
|
||||||
|
<XCircle size={18} />
|
||||||
|
<strong>Error:</strong> {error}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Results */}
|
|
||||||
{result && (
|
{result && (
|
||||||
<div style={{ maxWidth: '720px' }}>
|
<div className="card">
|
||||||
{/* COI Summary */}
|
<h2 style={{ fontSize: '1rem', marginBottom: '1.25rem', color: 'var(--text-muted)', textTransform: 'uppercase', letterSpacing: '0.05em' }}>
|
||||||
<div className="card" style={{ marginBottom: '1.25rem' }}>
|
Simulation Result
|
||||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', flexWrap: 'wrap', gap: '1rem' }}>
|
</h2>
|
||||||
<div>
|
|
||||||
<p style={{ color: 'var(--text-muted)', fontSize: '0.8125rem', textTransform: 'uppercase', letterSpacing: '0.05em', fontWeight: 500, marginBottom: '0.25rem' }}>Pairing</p>
|
|
||||||
<p style={{ fontSize: '1.125rem', fontWeight: 600, margin: 0 }}>
|
|
||||||
<span style={{ color: '#60a5fa' }}>{result.sire.name}</span>
|
|
||||||
<span style={{ color: 'var(--text-muted)', margin: '0 0.5rem' }}>×</span>
|
|
||||||
<span style={{ color: '#f472b6' }}>{result.dam.name}</span>
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div style={{ textAlign: 'right' }}>
|
|
||||||
<p style={{ color: 'var(--text-muted)', fontSize: '0.8125rem', textTransform: 'uppercase', letterSpacing: '0.05em', fontWeight: 500, marginBottom: '0.25rem' }}>COI</p>
|
|
||||||
<p style={{
|
|
||||||
fontSize: '2rem',
|
|
||||||
fontWeight: 700,
|
|
||||||
lineHeight: 1,
|
|
||||||
color: result.coi < 5 ? 'var(--success)' : result.coi < 10 ? 'var(--warning)' : 'var(--danger)'
|
|
||||||
}}>
|
|
||||||
{result.coi.toFixed(2)}%
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div style={{ marginTop: '1.25rem' }}>
|
<div style={{
|
||||||
<RiskBadge coi={result.coi} recommendation={result.recommendation} />
|
display: 'flex', alignItems: 'center', gap: '1rem',
|
||||||
</div>
|
padding: '1.25rem', marginBottom: '1rem',
|
||||||
|
background: 'var(--bg-primary)', borderRadius: 'var(--radius)',
|
||||||
<div style={{ marginTop: '1rem', padding: '0.75rem', background: 'var(--bg-tertiary)', borderRadius: 'var(--radius-sm)', fontSize: '0.8125rem', color: 'var(--text-secondary)' }}>
|
border: `2px solid ${coiColor(result.coi)}`,
|
||||||
<strong>COI Guide:</strong> <5% Low risk · 5–10% Moderate risk · >10% High risk
|
}}>
|
||||||
|
{result.coi < 0.0625
|
||||||
|
? <CheckCircle size={32} style={{ color: coiColor(result.coi), flexShrink: 0 }} />
|
||||||
|
: <AlertTriangle size={32} style={{ color: coiColor(result.coi), flexShrink: 0 }} />
|
||||||
|
}
|
||||||
|
<div>
|
||||||
|
<div style={{ fontSize: '2rem', fontWeight: 700, color: coiColor(result.coi), lineHeight: 1 }}>
|
||||||
|
{(result.coi * 100).toFixed(2)}%
|
||||||
|
</div>
|
||||||
|
<div style={{ color: 'var(--text-muted)', fontSize: '0.875rem' }}>
|
||||||
|
COI — <strong style={{ color: coiColor(result.coi) }}>{coiLabel(result.coi)}</strong>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Common Ancestors */}
|
{result.commonAncestors && result.commonAncestors.length > 0 && (
|
||||||
<div className="card">
|
<div>
|
||||||
<div style={{ display: 'flex', alignItems: 'center', gap: '0.5rem', marginBottom: '1rem' }}>
|
<h3 style={{ fontSize: '0.875rem', color: 'var(--text-muted)', textTransform: 'uppercase', letterSpacing: '0.05em', marginBottom: '0.5rem' }}>
|
||||||
<GitMerge size={18} style={{ color: 'var(--accent)' }} />
|
Common Ancestors ({result.commonAncestors.length})
|
||||||
<h3 style={{ margin: 0, fontSize: '1rem' }}>Common Ancestors</h3>
|
</h3>
|
||||||
<span className="badge badge-primary" style={{ marginLeft: 'auto' }}>
|
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '0.4rem' }}>
|
||||||
{result.commonAncestors.length} found
|
{result.commonAncestors.map((a, i) => (
|
||||||
</span>
|
<span key={i} style={{
|
||||||
</div>
|
padding: '0.2rem 0.6rem',
|
||||||
|
background: 'var(--bg-tertiary)',
|
||||||
{result.commonAncestors.length === 0 ? (
|
borderRadius: 'var(--radius-sm)',
|
||||||
<p style={{ color: 'var(--text-muted)', textAlign: 'center', padding: '1.5rem 0', margin: 0 }}>
|
fontSize: '0.8rem',
|
||||||
No common ancestors found within 5 generations. This pairing has excellent genetic diversity.
|
border: '1px solid var(--border)',
|
||||||
</p>
|
}}>{a.name}</span>
|
||||||
) : (
|
))}
|
||||||
<div style={{ overflowX: 'auto' }}>
|
|
||||||
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: '0.875rem' }}>
|
|
||||||
<thead>
|
|
||||||
<tr style={{ borderBottom: '1px solid var(--border)' }}>
|
|
||||||
<th style={{ textAlign: 'left', padding: '0.625rem 0.75rem', color: 'var(--text-muted)', fontWeight: 500, fontSize: '0.75rem', textTransform: 'uppercase', letterSpacing: '0.05em' }}>Ancestor</th>
|
|
||||||
<th style={{ textAlign: 'center', padding: '0.625rem 0.75rem', color: 'var(--text-muted)', fontWeight: 500, fontSize: '0.75rem', textTransform: 'uppercase', letterSpacing: '0.05em' }}>Sire Gen</th>
|
|
||||||
<th style={{ textAlign: 'center', padding: '0.625rem 0.75rem', color: 'var(--text-muted)', fontWeight: 500, fontSize: '0.75rem', textTransform: 'uppercase', letterSpacing: '0.05em' }}>Dam Gen</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
{result.commonAncestors.map((anc, i) => (
|
|
||||||
<tr key={i} style={{ borderBottom: '1px solid var(--border)' }}>
|
|
||||||
<td style={{ padding: '0.625rem 0.75rem', fontWeight: 500 }}>{anc.name}</td>
|
|
||||||
<td style={{ padding: '0.625rem 0.75rem', textAlign: 'center' }}>
|
|
||||||
<span className="badge badge-primary">Gen {anc.sireGen}</span>
|
|
||||||
</td>
|
|
||||||
<td style={{ padding: '0.625rem 0.75rem', textAlign: 'center' }}>
|
|
||||||
<span className="badge" style={{ background: 'rgba(244,114,182,0.15)', color: '#f472b6' }}>Gen {anc.damGen}</span>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
))}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
</div>
|
||||||
</div>
|
)}
|
||||||
|
|
||||||
|
{result.recommendation && (
|
||||||
|
<div style={{
|
||||||
|
marginTop: '1rem', padding: '0.75rem 1rem',
|
||||||
|
background: result.coi < 0.0625 ? 'rgba(34,197,94,0.08)' : 'rgba(239,68,68,0.08)',
|
||||||
|
borderRadius: 'var(--radius)',
|
||||||
|
border: `1px solid ${result.coi < 0.0625 ? 'rgba(34,197,94,0.3)' : 'rgba(239,68,68,0.3)'}`,
|
||||||
|
fontSize: '0.875rem',
|
||||||
|
color: 'var(--text-secondary)',
|
||||||
|
}}>
|
||||||
|
{result.recommendation}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -24,17 +24,13 @@ function PedigreeView() {
|
|||||||
setError('')
|
setError('')
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Fetch pedigree tree data
|
|
||||||
const pedigreeRes = await axios.get(`/api/pedigree/${id}`)
|
const pedigreeRes = await axios.get(`/api/pedigree/${id}`)
|
||||||
const dogData = pedigreeRes.data
|
const dogData = pedigreeRes.data
|
||||||
|
|
||||||
setDog(dogData)
|
setDog(dogData)
|
||||||
|
|
||||||
// Transform data for react-d3-tree
|
|
||||||
const treeData = transformPedigreeData(dogData, generations)
|
const treeData = transformPedigreeData(dogData, generations)
|
||||||
setPedigreeData(treeData)
|
setPedigreeData(treeData)
|
||||||
|
|
||||||
// Fetch COI calculation
|
|
||||||
try {
|
try {
|
||||||
const coiRes = await axios.get(`/api/pedigree/${id}/coi`)
|
const coiRes = await axios.get(`/api/pedigree/${id}/coi`)
|
||||||
setCoiData(coiRes.data)
|
setCoiData(coiRes.data)
|
||||||
@@ -84,6 +80,9 @@ function PedigreeView() {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Completeness bar colour — uses theme tokens
|
||||||
|
const barColor = completeness === 100 ? 'var(--success)' : 'var(--primary)'
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="container">
|
<div className="container">
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
@@ -99,7 +98,7 @@ function PedigreeView() {
|
|||||||
|
|
||||||
<div style={{ flex: 1 }}>
|
<div style={{ flex: 1 }}>
|
||||||
<h1 style={{ margin: 0, display: 'flex', alignItems: 'center', gap: '0.75rem' }}>
|
<h1 style={{ margin: 0, display: 'flex', alignItems: 'center', gap: '0.75rem' }}>
|
||||||
<GitBranch size={32} />
|
<GitBranch size={32} style={{ color: 'var(--primary)' }} />
|
||||||
{dog?.name}'s Pedigree
|
{dog?.name}'s Pedigree
|
||||||
</h1>
|
</h1>
|
||||||
{dog?.registration_number && (
|
{dog?.registration_number && (
|
||||||
@@ -113,8 +112,10 @@ function PedigreeView() {
|
|||||||
{/* Stats Bar */}
|
{/* Stats Bar */}
|
||||||
<div className="card" style={{ marginBottom: '1rem', padding: '1rem' }}>
|
<div className="card" style={{ marginBottom: '1rem', padding: '1rem' }}>
|
||||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(200px, 1fr))', gap: '1.5rem' }}>
|
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(200px, 1fr))', gap: '1.5rem' }}>
|
||||||
|
|
||||||
|
{/* COI */}
|
||||||
<div>
|
<div>
|
||||||
<div style={{ fontSize: '0.875rem', color: 'var(--text-secondary)', marginBottom: '0.25rem' }}>
|
<div style={{ fontSize: '0.875rem', color: 'var(--text-muted)', marginBottom: '0.25rem', textTransform: 'uppercase', letterSpacing: '0.05em', fontWeight: 500 }}>
|
||||||
Coefficient of Inbreeding
|
Coefficient of Inbreeding
|
||||||
</div>
|
</div>
|
||||||
<div style={{ display: 'flex', alignItems: 'center', gap: '0.5rem' }}>
|
<div style={{ display: 'flex', alignItems: 'center', gap: '0.5rem' }}>
|
||||||
@@ -133,37 +134,42 @@ function PedigreeView() {
|
|||||||
{coiInfo.level}
|
{coiInfo.level}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div style={{ fontSize: '0.75rem', color: 'var(--text-secondary)', marginTop: '0.25rem' }}>
|
<div style={{ fontSize: '0.75rem', color: 'var(--text-muted)', marginTop: '0.25rem' }}>
|
||||||
{coiInfo.description}
|
{coiInfo.description}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Completeness */}
|
||||||
<div>
|
<div>
|
||||||
<div style={{ fontSize: '0.875rem', color: 'var(--text-secondary)', marginBottom: '0.25rem' }}>
|
<div style={{ fontSize: '0.875rem', color: 'var(--text-muted)', marginBottom: '0.25rem', textTransform: 'uppercase', letterSpacing: '0.05em', fontWeight: 500 }}>
|
||||||
Pedigree Completeness
|
Pedigree Completeness
|
||||||
</div>
|
</div>
|
||||||
<div style={{ fontSize: '1.5rem', fontWeight: '700' }}>
|
<div style={{ fontSize: '1.5rem', fontWeight: '700', color: 'var(--text-primary)' }}>
|
||||||
{completeness}%
|
{completeness}%
|
||||||
</div>
|
</div>
|
||||||
<div style={{ marginTop: '0.5rem' }}>
|
<div style={{ marginTop: '0.5rem' }}>
|
||||||
<div style={{
|
<div style={{
|
||||||
height: '8px',
|
height: '6px',
|
||||||
background: '#e5e7eb',
|
background: 'var(--bg-tertiary)',
|
||||||
borderRadius: '4px',
|
borderRadius: '3px',
|
||||||
overflow: 'hidden'
|
overflow: 'hidden',
|
||||||
|
border: '1px solid var(--border)'
|
||||||
}}>
|
}}>
|
||||||
<div style={{
|
<div style={{
|
||||||
height: '100%',
|
height: '100%',
|
||||||
width: `${completeness}%`,
|
width: `${completeness}%`,
|
||||||
background: completeness === 100 ? '#10b981' : '#3b82f6',
|
background: barColor,
|
||||||
transition: 'width 0.3s ease'
|
borderRadius: '3px',
|
||||||
|
transition: 'width 0.4s ease',
|
||||||
|
boxShadow: `0 0 6px ${barColor}`
|
||||||
}} />
|
}} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Generations */}
|
||||||
<div>
|
<div>
|
||||||
<div style={{ fontSize: '0.875rem', color: 'var(--text-secondary)', marginBottom: '0.25rem' }}>
|
<div style={{ fontSize: '0.875rem', color: 'var(--text-muted)', marginBottom: '0.25rem', textTransform: 'uppercase', letterSpacing: '0.05em', fontWeight: 500 }}>
|
||||||
Generations Displayed
|
Generations Displayed
|
||||||
</div>
|
</div>
|
||||||
<select
|
<select
|
||||||
@@ -190,7 +196,7 @@ function PedigreeView() {
|
|||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<div style={{ textAlign: 'center', padding: '4rem' }}>
|
<div style={{ textAlign: 'center', padding: '4rem' }}>
|
||||||
<GitBranch size={64} style={{ color: 'var(--text-secondary)', margin: '0 auto 1rem' }} />
|
<GitBranch size={64} style={{ color: 'var(--text-muted)', margin: '0 auto 1rem' }} />
|
||||||
<h3>No Pedigree Data Available</h3>
|
<h3>No Pedigree Data Available</h3>
|
||||||
<p style={{ color: 'var(--text-secondary)' }}>
|
<p style={{ color: 'var(--text-secondary)' }}>
|
||||||
Add parent information to this dog to build the pedigree tree.
|
Add parent information to this dog to build the pedigree tree.
|
||||||
@@ -199,11 +205,19 @@ function PedigreeView() {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Help Text */}
|
{/* Tip */}
|
||||||
<div className="card" style={{ marginTop: '1rem', background: '#eff6ff', border: '1px solid #bfdbfe' }}>
|
<div className="card" style={{
|
||||||
<div style={{ fontSize: '0.875rem', color: '#1e40af' }}>
|
marginTop: '1rem',
|
||||||
<strong>💡 Tip:</strong> Click on any ancestor node to navigate to their profile.
|
background: 'var(--bg-elevated)',
|
||||||
Use the zoom controls to explore the tree, or drag to pan around.
|
border: '1px solid var(--border-light)'
|
||||||
|
}}>
|
||||||
|
<div style={{ fontSize: '0.875rem', color: 'var(--text-secondary)', display: 'flex', alignItems: 'center', gap: '0.5rem' }}>
|
||||||
|
<span style={{ color: 'var(--primary)' }}>💡</span>
|
||||||
|
<span>
|
||||||
|
<strong style={{ color: 'var(--text-primary)' }}>Tip:</strong>{' '}
|
||||||
|
Click any ancestor node to navigate to their profile.
|
||||||
|
Use the zoom controls or scroll to explore the tree, and drag to pan.
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
160
client/src/pages/SettingsPage.jsx
Normal file
160
client/src/pages/SettingsPage.jsx
Normal file
@@ -0,0 +1,160 @@
|
|||||||
|
import { useState, useEffect } from 'react'
|
||||||
|
import { Settings, Save, CheckCircle } from 'lucide-react'
|
||||||
|
import { useSettings } from '../hooks/useSettings'
|
||||||
|
|
||||||
|
const FIELDS = [
|
||||||
|
{ key: 'kennel_name', label: 'Kennel / App Name', placeholder: 'BREEDR', type: 'text', required: true },
|
||||||
|
{ key: 'kennel_tagline', label: 'Tagline', placeholder: 'Raising champions since...', type: 'text' },
|
||||||
|
{ key: 'kennel_address', label: 'Address', placeholder: '123 Main St, City, ST', type: 'text' },
|
||||||
|
{ key: 'kennel_phone', label: 'Phone', placeholder: '(555) 000-0000', type: 'tel' },
|
||||||
|
{ key: 'kennel_email', label: 'Email', placeholder: 'kennel@example.com', type: 'email'},
|
||||||
|
{ key: 'kennel_website', label: 'Website', placeholder: 'https://yourdomain.com', type: 'url' },
|
||||||
|
{ key: 'kennel_akc_id', label: 'AKC Kennel ID', placeholder: 'Optional', type: 'text' },
|
||||||
|
{ key: 'kennel_breed', label: 'Primary Breed', placeholder: 'e.g. Labrador Retriever', type: 'text' },
|
||||||
|
]
|
||||||
|
|
||||||
|
export default function SettingsPage() {
|
||||||
|
const { settings, saveSettings } = useSettings()
|
||||||
|
const [form, setForm] = useState({})
|
||||||
|
const [saving, setSaving] = useState(false)
|
||||||
|
const [saved, setSaved] = useState(false)
|
||||||
|
const [error, setError] = useState(null)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setForm({
|
||||||
|
kennel_name: settings.kennel_name || '',
|
||||||
|
kennel_tagline: settings.kennel_tagline || '',
|
||||||
|
kennel_address: settings.kennel_address || '',
|
||||||
|
kennel_phone: settings.kennel_phone || '',
|
||||||
|
kennel_email: settings.kennel_email || '',
|
||||||
|
kennel_website: settings.kennel_website || '',
|
||||||
|
kennel_akc_id: settings.kennel_akc_id || '',
|
||||||
|
kennel_breed: settings.kennel_breed || '',
|
||||||
|
})
|
||||||
|
}, [settings])
|
||||||
|
|
||||||
|
const handleChange = (key, value) => {
|
||||||
|
setForm(prev => ({ ...prev, [key]: value }))
|
||||||
|
setSaved(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleSubmit = async (e) => {
|
||||||
|
e.preventDefault()
|
||||||
|
if (!form.kennel_name?.trim()) {
|
||||||
|
setError('Kennel name is required.')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
setSaving(true)
|
||||||
|
setError(null)
|
||||||
|
try {
|
||||||
|
await saveSettings(form)
|
||||||
|
setSaved(true)
|
||||||
|
setTimeout(() => setSaved(false), 3000)
|
||||||
|
} catch (err) {
|
||||||
|
setError('Failed to save settings. Please try again.')
|
||||||
|
} finally {
|
||||||
|
setSaving(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="container" style={{ paddingTop: '2rem', paddingBottom: '3rem', maxWidth: '720px' }}>
|
||||||
|
{/* Header */}
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: '0.75rem', marginBottom: '0.5rem' }}>
|
||||||
|
<div style={{
|
||||||
|
width: '2.5rem', height: '2.5rem',
|
||||||
|
borderRadius: 'var(--radius)',
|
||||||
|
background: 'linear-gradient(135deg, var(--primary) 0%, var(--accent) 100%)',
|
||||||
|
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||||
|
flexShrink: 0,
|
||||||
|
boxShadow: '0 4px 12px rgba(194,134,42,0.3)'
|
||||||
|
}}>
|
||||||
|
<Settings size={18} color="#0e0f0c" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h1 style={{ marginBottom: 0 }}>Settings</h1>
|
||||||
|
<p style={{ color: 'var(--text-secondary)', fontSize: '0.875rem' }}>
|
||||||
|
Kennel profile & app configuration
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="divider" />
|
||||||
|
|
||||||
|
<form onSubmit={handleSubmit}>
|
||||||
|
<div className="card">
|
||||||
|
<h3 style={{ marginBottom: '1.5rem', color: 'var(--primary-light)' }}>Kennel Information</h3>
|
||||||
|
|
||||||
|
{error && <div className="error" style={{ marginBottom: '1rem' }}>{error}</div>}
|
||||||
|
|
||||||
|
<div className="form-grid">
|
||||||
|
{FIELDS.map(field => (
|
||||||
|
<div className="form-group" key={field.key}>
|
||||||
|
<label className="label">
|
||||||
|
{field.label}
|
||||||
|
{field.required && <span style={{ color: 'var(--danger)', marginLeft: '0.25rem' }}>*</span>}
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type={field.type || 'text'}
|
||||||
|
className="input"
|
||||||
|
placeholder={field.placeholder}
|
||||||
|
value={form[field.key] || ''}
|
||||||
|
onChange={e => handleChange(field.key, e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="divider" />
|
||||||
|
|
||||||
|
{/* Preview */}
|
||||||
|
{form.kennel_name && (
|
||||||
|
<div style={{
|
||||||
|
marginBottom: '1.5rem',
|
||||||
|
padding: '1rem',
|
||||||
|
background: 'var(--bg-tertiary)',
|
||||||
|
borderRadius: 'var(--radius)',
|
||||||
|
border: '1px solid var(--border)'
|
||||||
|
}}>
|
||||||
|
<p className="label" style={{ marginBottom: '0.5rem' }}>Header Preview</p>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: '0.5rem' }}>
|
||||||
|
<span style={{
|
||||||
|
fontSize: '1.75rem',
|
||||||
|
fontWeight: 700,
|
||||||
|
letterSpacing: '-0.025em',
|
||||||
|
background: 'linear-gradient(135deg, #c9940a 0%, #b5620a 50%, #8b2500 100%)',
|
||||||
|
WebkitBackgroundClip: 'text',
|
||||||
|
WebkitTextFillColor: 'transparent',
|
||||||
|
backgroundClip: 'text',
|
||||||
|
}}>
|
||||||
|
{form.kennel_name}
|
||||||
|
</span>
|
||||||
|
{form.kennel_tagline && (
|
||||||
|
<span style={{ color: 'var(--text-muted)', fontSize: '0.8rem', fontStyle: 'italic' }}>
|
||||||
|
— {form.kennel_tagline}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div style={{ display: 'flex', justifyContent: 'flex-end', gap: '0.75rem', alignItems: 'center' }}>
|
||||||
|
{saved && (
|
||||||
|
<span style={{ display: 'flex', alignItems: 'center', gap: '0.4rem', color: 'var(--success)', fontSize: '0.875rem' }}>
|
||||||
|
<CheckCircle size={16} /> Saved!
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
className="btn btn-primary"
|
||||||
|
disabled={saving}
|
||||||
|
>
|
||||||
|
<Save size={16} />
|
||||||
|
{saving ? 'Saving...' : 'Save Settings'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -144,16 +144,16 @@ export const formatCOI = (coi) => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const value = coi.toFixed(2)
|
const value = (coi * 100).toFixed(2)
|
||||||
|
|
||||||
if (coi <= 5) {
|
if (coi <= 0.05) {
|
||||||
return {
|
return {
|
||||||
value: `${value}%`,
|
value: `${value}%`,
|
||||||
level: 'low',
|
level: 'low',
|
||||||
color: '#10b981',
|
color: '#10b981',
|
||||||
description: 'Low inbreeding - Excellent genetic diversity'
|
description: 'Low inbreeding - Excellent genetic diversity'
|
||||||
}
|
}
|
||||||
} else if (coi <= 10) {
|
} else if (coi <= 0.10) {
|
||||||
return {
|
return {
|
||||||
value: `${value}%`,
|
value: `${value}%`,
|
||||||
level: 'medium',
|
level: 'medium',
|
||||||
|
|||||||
@@ -2,174 +2,216 @@ const Database = require('better-sqlite3');
|
|||||||
const path = require('path');
|
const path = require('path');
|
||||||
const fs = require('fs');
|
const fs = require('fs');
|
||||||
|
|
||||||
function initDatabase(dbPath) {
|
const dbPath = path.join(__dirname, '../../data');
|
||||||
// Ensure data directory exists
|
const db = new Database(path.join(dbPath, 'breedr.db'));
|
||||||
const dir = path.dirname(dbPath);
|
|
||||||
if (!fs.existsSync(dir)) {
|
|
||||||
fs.mkdirSync(dir, { recursive: true });
|
|
||||||
}
|
|
||||||
|
|
||||||
const db = new Database(dbPath);
|
|
||||||
|
|
||||||
// Enable foreign keys
|
|
||||||
db.pragma('foreign_keys = ON');
|
|
||||||
|
|
||||||
console.log('Initializing database schema...');
|
|
||||||
|
|
||||||
// Dogs table - NO sire/dam columns, only litter_id
|
|
||||||
db.exec(`
|
|
||||||
CREATE TABLE IF NOT EXISTS dogs (
|
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
||||||
name TEXT NOT NULL,
|
|
||||||
registration_number TEXT UNIQUE,
|
|
||||||
breed TEXT NOT NULL,
|
|
||||||
sex TEXT NOT NULL CHECK(sex IN ('male', 'female')),
|
|
||||||
birth_date DATE,
|
|
||||||
color TEXT,
|
|
||||||
microchip TEXT,
|
|
||||||
photo_urls TEXT, -- JSON array of photo URLs
|
|
||||||
notes TEXT,
|
|
||||||
litter_id INTEGER,
|
|
||||||
is_active INTEGER DEFAULT 1,
|
|
||||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
|
||||||
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
|
||||||
FOREIGN KEY (litter_id) REFERENCES litters(id) ON DELETE SET NULL
|
|
||||||
)
|
|
||||||
`);
|
|
||||||
|
|
||||||
// Create unique index for microchip that allows NULL values
|
|
||||||
db.exec(`
|
|
||||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_dogs_microchip
|
|
||||||
ON dogs(microchip)
|
|
||||||
WHERE microchip IS NOT NULL
|
|
||||||
`);
|
|
||||||
|
|
||||||
// Parents table - Stores sire/dam relationships
|
|
||||||
db.exec(`
|
|
||||||
CREATE TABLE IF NOT EXISTS parents (
|
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
||||||
dog_id INTEGER NOT NULL,
|
|
||||||
parent_id INTEGER NOT NULL,
|
|
||||||
parent_type TEXT NOT NULL CHECK(parent_type IN ('sire', 'dam')),
|
|
||||||
FOREIGN KEY (dog_id) REFERENCES dogs(id) ON DELETE CASCADE,
|
|
||||||
FOREIGN KEY (parent_id) REFERENCES dogs(id) ON DELETE CASCADE,
|
|
||||||
UNIQUE(dog_id, parent_type)
|
|
||||||
)
|
|
||||||
`);
|
|
||||||
|
|
||||||
// Litters table - Breeding records
|
|
||||||
db.exec(`
|
|
||||||
CREATE TABLE IF NOT EXISTS litters (
|
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
||||||
sire_id INTEGER NOT NULL,
|
|
||||||
dam_id INTEGER NOT NULL,
|
|
||||||
breeding_date DATE NOT NULL,
|
|
||||||
whelping_date DATE,
|
|
||||||
puppy_count INTEGER DEFAULT 0,
|
|
||||||
notes TEXT,
|
|
||||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
|
||||||
FOREIGN KEY (sire_id) REFERENCES dogs(id) ON DELETE CASCADE,
|
|
||||||
FOREIGN KEY (dam_id) REFERENCES dogs(id) ON DELETE CASCADE
|
|
||||||
)
|
|
||||||
`);
|
|
||||||
|
|
||||||
// Health records table
|
|
||||||
db.exec(`
|
|
||||||
CREATE TABLE IF NOT EXISTS health_records (
|
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
||||||
dog_id INTEGER NOT NULL,
|
|
||||||
record_type TEXT NOT NULL CHECK(record_type IN ('test', 'vaccination', 'exam', 'treatment', 'certification')),
|
|
||||||
test_name TEXT,
|
|
||||||
test_date DATE NOT NULL,
|
|
||||||
result TEXT,
|
|
||||||
document_url TEXT,
|
|
||||||
notes TEXT,
|
|
||||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
|
||||||
FOREIGN KEY (dog_id) REFERENCES dogs(id) ON DELETE CASCADE
|
|
||||||
)
|
|
||||||
`);
|
|
||||||
|
|
||||||
// Heat cycles table
|
|
||||||
db.exec(`
|
|
||||||
CREATE TABLE IF NOT EXISTS heat_cycles (
|
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
||||||
dog_id INTEGER NOT NULL,
|
|
||||||
start_date DATE NOT NULL,
|
|
||||||
end_date DATE,
|
|
||||||
progesterone_peak_date DATE,
|
|
||||||
breeding_date DATE,
|
|
||||||
breeding_successful INTEGER DEFAULT 0,
|
|
||||||
notes TEXT,
|
|
||||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
|
||||||
FOREIGN KEY (dog_id) REFERENCES dogs(id) ON DELETE CASCADE
|
|
||||||
)
|
|
||||||
`);
|
|
||||||
|
|
||||||
// Traits table - Genetic trait tracking
|
|
||||||
db.exec(`
|
|
||||||
CREATE TABLE IF NOT EXISTS traits (
|
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
||||||
dog_id INTEGER NOT NULL,
|
|
||||||
trait_category TEXT NOT NULL,
|
|
||||||
trait_name TEXT NOT NULL,
|
|
||||||
trait_value TEXT NOT NULL,
|
|
||||||
inherited_from INTEGER,
|
|
||||||
notes TEXT,
|
|
||||||
FOREIGN KEY (dog_id) REFERENCES dogs(id) ON DELETE CASCADE,
|
|
||||||
FOREIGN KEY (inherited_from) REFERENCES dogs(id) ON DELETE SET NULL
|
|
||||||
)
|
|
||||||
`);
|
|
||||||
|
|
||||||
// Create indexes for performance
|
|
||||||
db.exec(`
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_dogs_name ON dogs(name);
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_dogs_registration ON dogs(registration_number);
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_dogs_litter ON dogs(litter_id);
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_parents_dog ON parents(dog_id);
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_parents_parent ON parents(parent_id);
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_litters_sire ON litters(sire_id);
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_litters_dam ON litters(dam_id);
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_health_dog ON health_records(dog_id);
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_heat_dog ON heat_cycles(dog_id);
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_traits_dog ON traits(dog_id);
|
|
||||||
`);
|
|
||||||
|
|
||||||
// Create trigger for updated_at
|
|
||||||
db.exec(`
|
|
||||||
CREATE TRIGGER IF NOT EXISTS update_dogs_timestamp
|
|
||||||
AFTER UPDATE ON dogs
|
|
||||||
FOR EACH ROW
|
|
||||||
BEGIN
|
|
||||||
UPDATE dogs SET updated_at = CURRENT_TIMESTAMP WHERE id = NEW.id;
|
|
||||||
END;
|
|
||||||
`);
|
|
||||||
|
|
||||||
console.log('✓ Database schema initialized successfully!');
|
|
||||||
console.log('✓ Dogs table: NO sire/dam columns, uses parents table');
|
|
||||||
console.log('✓ Parents table: Stores sire/dam relationships');
|
|
||||||
console.log('✓ Litters table: Links puppies via litter_id');
|
|
||||||
|
|
||||||
db.close();
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
function getDatabase() {
|
function getDatabase() {
|
||||||
const dbPath = process.env.DB_PATH || path.join(__dirname, '../../data/breedr.db');
|
|
||||||
const db = new Database(dbPath);
|
|
||||||
db.pragma('foreign_keys = ON');
|
|
||||||
return db;
|
return db;
|
||||||
}
|
}
|
||||||
|
|
||||||
module.exports = { initDatabase, getDatabase };
|
function initDatabase() {
|
||||||
|
db.pragma('foreign_keys = ON');
|
||||||
|
db.pragma('journal_mode = WAL');
|
||||||
|
|
||||||
// Run initialization if called directly
|
// ── Dogs ────────────────────────────────────────────────────────────────
|
||||||
if (require.main === module) {
|
db.exec(`
|
||||||
const dbPath = process.env.DB_PATH || path.join(__dirname, '../../data/breedr.db');
|
CREATE TABLE IF NOT EXISTS dogs (
|
||||||
console.log('\n==========================================');
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
console.log('BREEDR Database Initialization');
|
name TEXT NOT NULL,
|
||||||
console.log('==========================================');
|
registration_number TEXT,
|
||||||
console.log(`Database: ${dbPath}`);
|
breed TEXT NOT NULL,
|
||||||
console.log('==========================================\n');
|
sex TEXT NOT NULL CHECK(sex IN ('male', 'female')),
|
||||||
initDatabase(dbPath);
|
birth_date TEXT,
|
||||||
console.log('\n✓ Database ready!\n');
|
color TEXT,
|
||||||
|
microchip TEXT,
|
||||||
|
litter_id INTEGER,
|
||||||
|
is_active INTEGER DEFAULT 1,
|
||||||
|
is_champion INTEGER DEFAULT 0,
|
||||||
|
is_external INTEGER DEFAULT 0,
|
||||||
|
chic_number TEXT,
|
||||||
|
age_at_death TEXT,
|
||||||
|
cause_of_death TEXT,
|
||||||
|
photo_urls TEXT DEFAULT '[]',
|
||||||
|
notes TEXT,
|
||||||
|
created_at TEXT DEFAULT (datetime('now')),
|
||||||
|
updated_at TEXT DEFAULT (datetime('now'))
|
||||||
|
)
|
||||||
|
`);
|
||||||
|
|
||||||
|
// migrate: add columns if missing (safe on existing DBs)
|
||||||
|
const dogMigrations = [
|
||||||
|
['is_champion', 'INTEGER DEFAULT 0'],
|
||||||
|
['is_external', 'INTEGER DEFAULT 0'],
|
||||||
|
['chic_number', 'TEXT'],
|
||||||
|
['age_at_death', 'TEXT'],
|
||||||
|
['cause_of_death', 'TEXT'],
|
||||||
|
];
|
||||||
|
for (const [col, def] of dogMigrations) {
|
||||||
|
try { db.exec(`ALTER TABLE dogs ADD COLUMN ${col} ${def}`); } catch (_) { /* already exists */ }
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Parents ──────────────────────────────────────────────────────────────
|
||||||
|
db.exec(`
|
||||||
|
CREATE TABLE IF NOT EXISTS parents (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
dog_id INTEGER NOT NULL,
|
||||||
|
parent_id INTEGER NOT NULL,
|
||||||
|
parent_type TEXT NOT NULL CHECK(parent_type IN ('sire', 'dam')),
|
||||||
|
FOREIGN KEY (dog_id) REFERENCES dogs(id),
|
||||||
|
FOREIGN KEY (parent_id) REFERENCES dogs(id)
|
||||||
|
)
|
||||||
|
`);
|
||||||
|
|
||||||
|
// ── Breeding Records ─────────────────────────────────────────────────────
|
||||||
|
db.exec(`
|
||||||
|
CREATE TABLE IF NOT EXISTS breeding_records (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
sire_id INTEGER NOT NULL,
|
||||||
|
dam_id INTEGER NOT NULL,
|
||||||
|
breeding_date TEXT,
|
||||||
|
due_date TEXT,
|
||||||
|
conception_method TEXT CHECK(conception_method IN ('natural', 'ai', 'frozen', 'surgical')),
|
||||||
|
notes TEXT,
|
||||||
|
created_at TEXT DEFAULT (datetime('now')),
|
||||||
|
updated_at TEXT DEFAULT (datetime('now')),
|
||||||
|
FOREIGN KEY (sire_id) REFERENCES dogs(id),
|
||||||
|
FOREIGN KEY (dam_id) REFERENCES dogs(id)
|
||||||
|
)
|
||||||
|
`);
|
||||||
|
|
||||||
|
// ── Litters ──────────────────────────────────────────────────────────────
|
||||||
|
db.exec(`
|
||||||
|
CREATE TABLE IF NOT EXISTS litters (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
breeding_id INTEGER,
|
||||||
|
sire_id INTEGER NOT NULL,
|
||||||
|
dam_id INTEGER NOT NULL,
|
||||||
|
whelp_date TEXT,
|
||||||
|
total_count INTEGER DEFAULT 0,
|
||||||
|
male_count INTEGER DEFAULT 0,
|
||||||
|
female_count INTEGER DEFAULT 0,
|
||||||
|
stillborn_count INTEGER DEFAULT 0,
|
||||||
|
notes TEXT,
|
||||||
|
created_at TEXT DEFAULT (datetime('now')),
|
||||||
|
updated_at TEXT DEFAULT (datetime('now')),
|
||||||
|
FOREIGN KEY (breeding_id) REFERENCES breeding_records(id),
|
||||||
|
FOREIGN KEY (sire_id) REFERENCES dogs(id),
|
||||||
|
FOREIGN KEY (dam_id) REFERENCES dogs(id)
|
||||||
|
)
|
||||||
|
`);
|
||||||
|
|
||||||
|
// ── Health Records (OFA-extended) ─────────────────────────────────────────
|
||||||
|
db.exec(`
|
||||||
|
CREATE TABLE IF NOT EXISTS health_records (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
dog_id INTEGER NOT NULL,
|
||||||
|
record_type TEXT NOT NULL,
|
||||||
|
test_type TEXT,
|
||||||
|
test_name TEXT,
|
||||||
|
test_date TEXT NOT NULL,
|
||||||
|
ofa_result TEXT,
|
||||||
|
ofa_number TEXT,
|
||||||
|
performed_by TEXT,
|
||||||
|
expires_at TEXT,
|
||||||
|
document_url TEXT,
|
||||||
|
result TEXT,
|
||||||
|
vet_name TEXT,
|
||||||
|
next_due TEXT,
|
||||||
|
notes TEXT,
|
||||||
|
created_at TEXT DEFAULT (datetime('now')),
|
||||||
|
updated_at TEXT DEFAULT (datetime('now')),
|
||||||
|
FOREIGN KEY (dog_id) REFERENCES dogs(id)
|
||||||
|
)
|
||||||
|
`);
|
||||||
|
|
||||||
|
// migrate: add OFA-specific columns if missing (covers existing DBs)
|
||||||
|
const healthMigrations = [
|
||||||
|
['test_type', 'TEXT'],
|
||||||
|
['ofa_result', 'TEXT'],
|
||||||
|
['ofa_number', 'TEXT'],
|
||||||
|
['performed_by', 'TEXT'],
|
||||||
|
['expires_at', 'TEXT'],
|
||||||
|
['document_url', 'TEXT'],
|
||||||
|
['result', 'TEXT'],
|
||||||
|
['vet_name', 'TEXT'],
|
||||||
|
['next_due', 'TEXT'],
|
||||||
|
];
|
||||||
|
for (const [col, def] of healthMigrations) {
|
||||||
|
try { db.exec(`ALTER TABLE health_records ADD COLUMN ${col} ${def}`); } catch (_) { /* already exists */ }
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Genetic Tests (DNA Panel) ──────────────────────────────────────────────
|
||||||
|
db.exec(`
|
||||||
|
CREATE TABLE IF NOT EXISTS genetic_tests (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
dog_id INTEGER NOT NULL,
|
||||||
|
test_provider TEXT,
|
||||||
|
marker TEXT NOT NULL,
|
||||||
|
result TEXT NOT NULL CHECK(result IN ('clear', 'carrier', 'affected', 'not_tested')),
|
||||||
|
test_date TEXT,
|
||||||
|
document_url TEXT,
|
||||||
|
notes TEXT,
|
||||||
|
created_at TEXT DEFAULT (datetime('now')),
|
||||||
|
updated_at TEXT DEFAULT (datetime('now')),
|
||||||
|
FOREIGN KEY (dog_id) REFERENCES dogs(id)
|
||||||
|
)
|
||||||
|
`);
|
||||||
|
|
||||||
|
// ── Cancer History ────────────────────────────────────────────────────────
|
||||||
|
db.exec(`
|
||||||
|
CREATE TABLE IF NOT EXISTS cancer_history (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
dog_id INTEGER NOT NULL,
|
||||||
|
cancer_type TEXT,
|
||||||
|
age_at_diagnosis TEXT,
|
||||||
|
age_at_death TEXT,
|
||||||
|
cause_of_death TEXT,
|
||||||
|
notes TEXT,
|
||||||
|
created_at TEXT DEFAULT (datetime('now')),
|
||||||
|
updated_at TEXT DEFAULT (datetime('now')),
|
||||||
|
FOREIGN KEY (dog_id) REFERENCES dogs(id)
|
||||||
|
)
|
||||||
|
`);
|
||||||
|
|
||||||
|
// ── Settings ──────────────────────────────────────────────────────────────
|
||||||
|
db.exec(`
|
||||||
|
CREATE TABLE IF NOT EXISTS settings (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
kennel_name TEXT DEFAULT 'BREEDR',
|
||||||
|
kennel_tagline TEXT,
|
||||||
|
kennel_address TEXT,
|
||||||
|
kennel_phone TEXT,
|
||||||
|
kennel_email TEXT,
|
||||||
|
kennel_website TEXT,
|
||||||
|
kennel_akc_id TEXT,
|
||||||
|
kennel_breed TEXT,
|
||||||
|
owner_name TEXT,
|
||||||
|
created_at TEXT DEFAULT (datetime('now')),
|
||||||
|
updated_at TEXT DEFAULT (datetime('now'))
|
||||||
|
)
|
||||||
|
`);
|
||||||
|
|
||||||
|
const kennelCols = [
|
||||||
|
['kennel_name', "TEXT DEFAULT 'BREEDR'"],
|
||||||
|
['kennel_tagline', 'TEXT'],
|
||||||
|
['kennel_address', 'TEXT'],
|
||||||
|
['kennel_phone', 'TEXT'],
|
||||||
|
['kennel_email', 'TEXT'],
|
||||||
|
['kennel_website', 'TEXT'],
|
||||||
|
['kennel_akc_id', 'TEXT'],
|
||||||
|
['kennel_breed', 'TEXT'],
|
||||||
|
['owner_name', 'TEXT'],
|
||||||
|
];
|
||||||
|
for (const [col, def] of kennelCols) {
|
||||||
|
try { db.exec(`ALTER TABLE settings ADD COLUMN ${col} ${def}`); } catch (_) { /* already exists */ }
|
||||||
|
}
|
||||||
|
|
||||||
|
const existing = db.prepare('SELECT id FROM settings LIMIT 1').get();
|
||||||
|
if (!existing) {
|
||||||
|
db.prepare(`INSERT INTO settings (kennel_name) VALUES (?)`).run('BREEDR');
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('✓ Database initialized successfully');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
module.exports = { getDatabase, initDatabase };
|
||||||
|
|||||||
@@ -30,7 +30,6 @@ class MigrationRunner {
|
|||||||
const result = this.db.prepare('SELECT version FROM schema_version ORDER BY version DESC LIMIT 1').get();
|
const result = this.db.prepare('SELECT version FROM schema_version ORDER BY version DESC LIMIT 1').get();
|
||||||
return result ? result.version : 0;
|
return result ? result.version : 0;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
// schema_version table doesn't exist, create it
|
|
||||||
this.db.exec(`
|
this.db.exec(`
|
||||||
CREATE TABLE IF NOT EXISTS schema_version (
|
CREATE TABLE IF NOT EXISTS schema_version (
|
||||||
version INTEGER PRIMARY KEY,
|
version INTEGER PRIMARY KEY,
|
||||||
@@ -59,6 +58,19 @@ class MigrationRunner {
|
|||||||
return columns.some(col => col.name === 'litter_id');
|
return columns.some(col => col.name === 'litter_id');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Check if health_records has the old restrictive CHECK constraint on record_type
|
||||||
|
healthRecordsHasOldConstraint() {
|
||||||
|
try {
|
||||||
|
const row = this.db.prepare(
|
||||||
|
"SELECT sql FROM sqlite_master WHERE type='table' AND name='health_records'"
|
||||||
|
).get();
|
||||||
|
if (!row) return false;
|
||||||
|
return row.sql.includes("'test', 'vaccination', 'exam', 'treatment', 'certification'");
|
||||||
|
} catch (_) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Migration 1: Remove sire/dam columns, use parents table
|
// Migration 1: Remove sire/dam columns, use parents table
|
||||||
migration001_removeOldParentColumns() {
|
migration001_removeOldParentColumns() {
|
||||||
console.log('[Migration 001] Checking for old sire/dam columns...');
|
console.log('[Migration 001] Checking for old sire/dam columns...');
|
||||||
@@ -74,7 +86,6 @@ class MigrationRunner {
|
|||||||
this.db.exec('BEGIN TRANSACTION');
|
this.db.exec('BEGIN TRANSACTION');
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Ensure parents table exists
|
|
||||||
this.db.exec(`
|
this.db.exec(`
|
||||||
CREATE TABLE IF NOT EXISTS parents (
|
CREATE TABLE IF NOT EXISTS parents (
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
@@ -90,14 +101,12 @@ class MigrationRunner {
|
|||||||
CREATE INDEX IF NOT EXISTS idx_parents_parent ON parents(parent_id);
|
CREATE INDEX IF NOT EXISTS idx_parents_parent ON parents(parent_id);
|
||||||
`);
|
`);
|
||||||
|
|
||||||
// Backup current dogs table
|
|
||||||
this.db.exec('DROP TABLE IF EXISTS dogs_migration_backup');
|
this.db.exec('DROP TABLE IF EXISTS dogs_migration_backup');
|
||||||
this.db.exec('CREATE TABLE dogs_migration_backup AS SELECT * FROM dogs');
|
this.db.exec('CREATE TABLE dogs_migration_backup AS SELECT * FROM dogs');
|
||||||
|
|
||||||
const backupCount = this.db.prepare('SELECT COUNT(*) as count FROM dogs_migration_backup').get();
|
const backupCount = this.db.prepare('SELECT COUNT(*) as count FROM dogs_migration_backup').get();
|
||||||
console.log(`[Migration 001] Backed up ${backupCount.count} dogs`);
|
console.log(`[Migration 001] Backed up ${backupCount.count} dogs`);
|
||||||
|
|
||||||
// Migrate parent relationships to parents table
|
|
||||||
const columns = this.db.prepare("PRAGMA table_info(dogs_migration_backup)").all();
|
const columns = this.db.prepare("PRAGMA table_info(dogs_migration_backup)").all();
|
||||||
const hasSire = columns.some(col => col.name === 'sire');
|
const hasSire = columns.some(col => col.name === 'sire');
|
||||||
const hasDam = columns.some(col => col.name === 'dam');
|
const hasDam = columns.some(col => col.name === 'dam');
|
||||||
@@ -119,11 +128,9 @@ class MigrationRunner {
|
|||||||
console.log(`[Migration 001] Migrated ${damResult.changes} dam relationships`);
|
console.log(`[Migration 001] Migrated ${damResult.changes} dam relationships`);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Drop old dogs table
|
|
||||||
this.db.exec('DROP TABLE dogs');
|
this.db.exec('DROP TABLE dogs');
|
||||||
console.log('[Migration 001] Dropped old dogs table');
|
console.log('[Migration 001] Dropped old dogs table');
|
||||||
|
|
||||||
// Create new dogs table with correct schema
|
|
||||||
this.db.exec(`
|
this.db.exec(`
|
||||||
CREATE TABLE dogs (
|
CREATE TABLE dogs (
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
@@ -147,10 +154,9 @@ class MigrationRunner {
|
|||||||
`);
|
`);
|
||||||
console.log('[Migration 001] Created new dogs table');
|
console.log('[Migration 001] Created new dogs table');
|
||||||
|
|
||||||
// Restore data (excluding sire/dam columns)
|
|
||||||
const columnList = ['id', 'name', 'registration_number', 'microchip', 'sex', 'birth_date', 'breed', 'color', 'weight', 'height', 'notes', 'photo_urls', 'is_active', 'created_at', 'updated_at'];
|
const columnList = ['id', 'name', 'registration_number', 'microchip', 'sex', 'birth_date', 'breed', 'color', 'weight', 'height', 'notes', 'photo_urls', 'is_active', 'created_at', 'updated_at'];
|
||||||
if (hasLitterId) {
|
if (hasLitterId) {
|
||||||
columnList.splice(11, 0, 'litter_id'); // Insert after notes
|
columnList.splice(11, 0, 'litter_id');
|
||||||
}
|
}
|
||||||
|
|
||||||
const columnsStr = columnList.join(', ');
|
const columnsStr = columnList.join(', ');
|
||||||
@@ -159,7 +165,6 @@ class MigrationRunner {
|
|||||||
const restoredCount = this.db.prepare('SELECT COUNT(*) as count FROM dogs').get();
|
const restoredCount = this.db.prepare('SELECT COUNT(*) as count FROM dogs').get();
|
||||||
console.log(`[Migration 001] Restored ${restoredCount.count} dogs`);
|
console.log(`[Migration 001] Restored ${restoredCount.count} dogs`);
|
||||||
|
|
||||||
// Create indexes
|
|
||||||
this.db.exec(`
|
this.db.exec(`
|
||||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_dogs_microchip
|
CREATE UNIQUE INDEX IF NOT EXISTS idx_dogs_microchip
|
||||||
ON dogs(microchip) WHERE microchip IS NOT NULL;
|
ON dogs(microchip) WHERE microchip IS NOT NULL;
|
||||||
@@ -168,9 +173,7 @@ class MigrationRunner {
|
|||||||
CREATE INDEX IF NOT EXISTS idx_dogs_registration ON dogs(registration_number);
|
CREATE INDEX IF NOT EXISTS idx_dogs_registration ON dogs(registration_number);
|
||||||
`);
|
`);
|
||||||
|
|
||||||
// Clean up backup
|
|
||||||
this.db.exec('DROP TABLE dogs_migration_backup');
|
this.db.exec('DROP TABLE dogs_migration_backup');
|
||||||
|
|
||||||
this.db.exec('COMMIT');
|
this.db.exec('COMMIT');
|
||||||
console.log('[Migration 001] ✓ Migration complete!');
|
console.log('[Migration 001] ✓ Migration complete!');
|
||||||
|
|
||||||
@@ -204,6 +207,90 @@ class MigrationRunner {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Migration 3: Remove old restrictive CHECK constraint on health_records.record_type
|
||||||
|
// Uses dynamic column detection so it works regardless of which columns exist in the old table
|
||||||
|
migration003_removeHealthRecordTypeConstraint() {
|
||||||
|
console.log('[Migration 003] Checking health_records.record_type constraint...');
|
||||||
|
|
||||||
|
if (!this.healthRecordsHasOldConstraint()) {
|
||||||
|
console.log('[Migration 003] No old constraint found, skipping');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('[Migration 003] Rebuilding health_records table to remove old CHECK constraint...');
|
||||||
|
|
||||||
|
this.db.exec('BEGIN TRANSACTION');
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Backup existing records
|
||||||
|
this.db.exec('DROP TABLE IF EXISTS health_records_migration_backup');
|
||||||
|
this.db.exec('CREATE TABLE health_records_migration_backup AS SELECT * FROM health_records');
|
||||||
|
|
||||||
|
const backupCount = this.db.prepare('SELECT COUNT(*) as count FROM health_records_migration_backup').get();
|
||||||
|
console.log(`[Migration 003] Backed up ${backupCount.count} health records`);
|
||||||
|
|
||||||
|
// Dynamically get the columns that actually exist in the backup
|
||||||
|
// This handles old DBs that may be missing newer columns like updated_at
|
||||||
|
const existingCols = this.db.prepare('PRAGMA table_info(health_records_migration_backup)').all();
|
||||||
|
const existingColNames = existingCols.map(c => c.name);
|
||||||
|
console.log(`[Migration 003] Existing columns: ${existingColNames.join(', ')}`);
|
||||||
|
|
||||||
|
// Drop old constrained table
|
||||||
|
this.db.exec('DROP TABLE health_records');
|
||||||
|
|
||||||
|
// Recreate WITHOUT the old CHECK constraint
|
||||||
|
this.db.exec(`
|
||||||
|
CREATE TABLE health_records (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
dog_id INTEGER NOT NULL,
|
||||||
|
record_type TEXT NOT NULL,
|
||||||
|
test_type TEXT,
|
||||||
|
test_name TEXT,
|
||||||
|
test_date TEXT NOT NULL,
|
||||||
|
ofa_result TEXT,
|
||||||
|
ofa_number TEXT,
|
||||||
|
performed_by TEXT,
|
||||||
|
expires_at TEXT,
|
||||||
|
document_url TEXT,
|
||||||
|
result TEXT,
|
||||||
|
vet_name TEXT,
|
||||||
|
next_due TEXT,
|
||||||
|
notes TEXT,
|
||||||
|
created_at TEXT DEFAULT (datetime('now')),
|
||||||
|
updated_at TEXT DEFAULT (datetime('now')),
|
||||||
|
FOREIGN KEY (dog_id) REFERENCES dogs(id) ON DELETE CASCADE
|
||||||
|
)
|
||||||
|
`);
|
||||||
|
|
||||||
|
// Only restore columns that existed in the backup — new columns get their DEFAULT values
|
||||||
|
const newCols = ['id', 'dog_id', 'record_type', 'test_type', 'test_name', 'test_date',
|
||||||
|
'ofa_result', 'ofa_number', 'performed_by', 'expires_at',
|
||||||
|
'document_url', 'result', 'vet_name', 'next_due', 'notes',
|
||||||
|
'created_at', 'updated_at'];
|
||||||
|
const colsToRestore = newCols.filter(c => existingColNames.includes(c));
|
||||||
|
const colList = colsToRestore.join(', ');
|
||||||
|
|
||||||
|
console.log(`[Migration 003] Restoring columns: ${colList}`);
|
||||||
|
|
||||||
|
this.db.exec(`
|
||||||
|
INSERT INTO health_records (${colList})
|
||||||
|
SELECT ${colList} FROM health_records_migration_backup
|
||||||
|
`);
|
||||||
|
|
||||||
|
const restoredCount = this.db.prepare('SELECT COUNT(*) as count FROM health_records').get();
|
||||||
|
console.log(`[Migration 003] Restored ${restoredCount.count} health records`);
|
||||||
|
|
||||||
|
this.db.exec('DROP TABLE health_records_migration_backup');
|
||||||
|
this.db.exec('COMMIT');
|
||||||
|
console.log('[Migration 003] ✓ health_records constraint removed successfully!');
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
this.db.exec('ROLLBACK');
|
||||||
|
console.error('[Migration 003] ✗ Migration failed:', error.message);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Validate final schema
|
// Validate final schema
|
||||||
validateSchema() {
|
validateSchema() {
|
||||||
console.log('[Validation] Checking database schema...');
|
console.log('[Validation] Checking database schema...');
|
||||||
@@ -240,6 +327,10 @@ class MigrationRunner {
|
|||||||
const tables = this.db.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name='litters'").all();
|
const tables = this.db.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name='litters'").all();
|
||||||
return tables.length > 0;
|
return tables.length > 0;
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'health_records has no old record_type CHECK constraint',
|
||||||
|
test: () => !this.healthRecordsHasOldConstraint()
|
||||||
}
|
}
|
||||||
];
|
];
|
||||||
|
|
||||||
@@ -273,7 +364,6 @@ class MigrationRunner {
|
|||||||
const currentVersion = this.getSchemaVersion();
|
const currentVersion = this.getSchemaVersion();
|
||||||
console.log(`Current schema version: ${currentVersion}\n`);
|
console.log(`Current schema version: ${currentVersion}\n`);
|
||||||
|
|
||||||
// Run migrations in order
|
|
||||||
if (currentVersion < 1) {
|
if (currentVersion < 1) {
|
||||||
this.migration001_removeOldParentColumns();
|
this.migration001_removeOldParentColumns();
|
||||||
this.recordMigration(1, 'Migrate sire/dam columns to parents table');
|
this.recordMigration(1, 'Migrate sire/dam columns to parents table');
|
||||||
@@ -284,7 +374,11 @@ class MigrationRunner {
|
|||||||
this.recordMigration(2, 'Add litter_id column to dogs table');
|
this.recordMigration(2, 'Add litter_id column to dogs table');
|
||||||
}
|
}
|
||||||
|
|
||||||
// Validate final schema
|
if (currentVersion < 3) {
|
||||||
|
this.migration003_removeHealthRecordTypeConstraint();
|
||||||
|
this.recordMigration(3, 'Remove old record_type CHECK constraint from health_records');
|
||||||
|
}
|
||||||
|
|
||||||
console.log('');
|
console.log('');
|
||||||
const isValid = this.validateSchema();
|
const isValid = this.validateSchema();
|
||||||
|
|
||||||
@@ -306,7 +400,6 @@ class MigrationRunner {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Function to run migrations
|
|
||||||
function runMigrations(dbPath) {
|
function runMigrations(dbPath) {
|
||||||
const runner = new MigrationRunner(dbPath);
|
const runner = new MigrationRunner(dbPath);
|
||||||
return runner.runMigrations();
|
return runner.runMigrations();
|
||||||
@@ -314,7 +407,6 @@ function runMigrations(dbPath) {
|
|||||||
|
|
||||||
module.exports = { MigrationRunner, runMigrations };
|
module.exports = { MigrationRunner, runMigrations };
|
||||||
|
|
||||||
// Run migrations if called directly
|
|
||||||
if (require.main === module) {
|
if (require.main === module) {
|
||||||
const dbPath = process.env.DB_PATH || path.join(__dirname, '../../data/breedr.db');
|
const dbPath = process.env.DB_PATH || path.join(__dirname, '../../data/breedr.db');
|
||||||
runMigrations(dbPath);
|
runMigrations(dbPath);
|
||||||
|
|||||||
113
server/index.js
113
server/index.js
@@ -1,78 +1,72 @@
|
|||||||
const express = require('express');
|
const express = require('express');
|
||||||
const cors = require('cors');
|
const cors = require('cors');
|
||||||
const helmet = require('helmet');
|
const helmet = require('helmet');
|
||||||
const path = require('path');
|
const path = require('path');
|
||||||
const fs = require('fs');
|
const fs = require('fs');
|
||||||
|
const { runMigrations } = require('./db/migrations');
|
||||||
const { initDatabase } = require('./db/init');
|
const { initDatabase } = require('./db/init');
|
||||||
|
const { logStartupBanner } = require('./utils/startupLog');
|
||||||
|
|
||||||
const app = express();
|
const app = express();
|
||||||
const PORT = process.env.PORT || 3000;
|
const PORT = process.env.PORT || 3000;
|
||||||
const DB_PATH = process.env.DB_PATH || path.join(__dirname, '../data/breedr.db');
|
|
||||||
|
// Ensure required directories exist
|
||||||
const UPLOAD_PATH = process.env.UPLOAD_PATH || path.join(__dirname, '../uploads');
|
const UPLOAD_PATH = process.env.UPLOAD_PATH || path.join(__dirname, '../uploads');
|
||||||
const STATIC_PATH = process.env.STATIC_PATH || path.join(__dirname, '../static');
|
const STATIC_PATH = process.env.STATIC_PATH || path.join(__dirname, '../static');
|
||||||
|
const DATA_DIR = process.env.DATA_DIR || path.join(__dirname, '../data');
|
||||||
|
|
||||||
// Ensure directories exist
|
[DATA_DIR, UPLOAD_PATH, STATIC_PATH].forEach(dir => {
|
||||||
const dataDir = path.dirname(DB_PATH);
|
if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
|
||||||
if (!fs.existsSync(dataDir)) {
|
});
|
||||||
fs.mkdirSync(dataDir, { recursive: true });
|
|
||||||
}
|
// Run migrations BEFORE initializing the DB connection used by routes
|
||||||
if (!fs.existsSync(UPLOAD_PATH)) {
|
const DB_PATH = process.env.DB_PATH || path.join(__dirname, '../data/breedr.db');
|
||||||
fs.mkdirSync(UPLOAD_PATH, { recursive: true });
|
console.log('Running database migrations...');
|
||||||
}
|
try {
|
||||||
if (!fs.existsSync(STATIC_PATH)) {
|
runMigrations(DB_PATH);
|
||||||
fs.mkdirSync(STATIC_PATH, { recursive: true });
|
} catch (err) {
|
||||||
|
console.error('Migration failed — aborting startup:', err.message);
|
||||||
|
process.exit(1);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Initialize database schema (creates tables if they don't exist)
|
// Init DB (path is managed internally by db/init.js)
|
||||||
console.log('Initializing database...');
|
console.log('Initializing database...');
|
||||||
initDatabase(DB_PATH);
|
initDatabase();
|
||||||
|
const dbStatus = '✓ Connected';
|
||||||
console.log('✓ Database ready!\n');
|
console.log('✓ Database ready!\n');
|
||||||
|
|
||||||
// Middleware
|
// ── Middleware ─────────────────────────────────────────────────────────
|
||||||
app.use(helmet({
|
app.use(helmet({ contentSecurityPolicy: false }));
|
||||||
contentSecurityPolicy: false, // Allow inline scripts for React
|
|
||||||
}));
|
|
||||||
app.use(cors());
|
app.use(cors());
|
||||||
app.use(express.json());
|
app.use(express.json());
|
||||||
app.use(express.urlencoded({ extended: true }));
|
app.use(express.urlencoded({ extended: true }));
|
||||||
|
|
||||||
// Static asset routes — registered BEFORE React catch-all so they are
|
// ── Static file serving ──────────────────────────────────────────────
|
||||||
// resolved directly and never fall through to index.html
|
|
||||||
app.use('/uploads', express.static(UPLOAD_PATH));
|
app.use('/uploads', express.static(UPLOAD_PATH));
|
||||||
app.use('/static', express.static(STATIC_PATH));
|
app.use('/static', express.static(STATIC_PATH));
|
||||||
|
app.use('/uploads', (_req, res) => res.status(404).json({ error: 'Upload not found' }));
|
||||||
|
app.use('/static', (_req, res) => res.status(404).json({ error: 'Static asset not found' }));
|
||||||
|
|
||||||
// Explicit 404 for missing asset files so the catch-all never intercepts them
|
// ── API Routes ──────────────────────────────────────────────────────────
|
||||||
app.use('/uploads', (req, res) => res.status(404).json({ error: 'Upload not found' }));
|
app.use('/api/dogs', require('./routes/dogs'));
|
||||||
app.use('/static', (req, res) => res.status(404).json({ error: 'Static asset not found' }));
|
app.use('/api/litters', require('./routes/litters'));
|
||||||
|
app.use('/api/health', require('./routes/health'));
|
||||||
// API Routes
|
app.use('/api/genetics', require('./routes/genetics'));
|
||||||
app.use('/api/dogs', require('./routes/dogs'));
|
|
||||||
app.use('/api/litters', require('./routes/litters'));
|
|
||||||
app.use('/api/health', require('./routes/health'));
|
|
||||||
app.use('/api/pedigree', require('./routes/pedigree'));
|
app.use('/api/pedigree', require('./routes/pedigree'));
|
||||||
app.use('/api/breeding', require('./routes/breeding'));
|
app.use('/api/breeding', require('./routes/breeding'));
|
||||||
|
app.use('/api/settings', require('./routes/settings'));
|
||||||
|
|
||||||
// Health check endpoint
|
// ── Production SPA fallback ────────────────────────────────────────────────
|
||||||
app.get('/api/health', (req, res) => {
|
|
||||||
res.json({ status: 'ok', timestamp: new Date().toISOString() });
|
|
||||||
});
|
|
||||||
|
|
||||||
// Serve React frontend in production
|
|
||||||
// The catch-all is intentionally placed AFTER all asset/API routes above.
|
|
||||||
// express.static(clientBuildPath) handles real build assets (JS/CSS chunks).
|
|
||||||
// The scoped '*' only fires for HTML5 client-side routes (e.g. /dogs, /litters).
|
|
||||||
if (process.env.NODE_ENV === 'production') {
|
if (process.env.NODE_ENV === 'production') {
|
||||||
const clientBuildPath = path.join(__dirname, '../client/dist');
|
const clientBuild = path.join(__dirname, '../client/dist');
|
||||||
app.use(express.static(clientBuildPath));
|
app.use(express.static(clientBuild));
|
||||||
|
app.get(/^(?!\/(?:api|static|uploads)\/).*$/, (_req, res) => {
|
||||||
// Only send index.html for non-asset, non-api paths
|
res.sendFile(path.join(clientBuild, 'index.html'));
|
||||||
app.get(/^(?!\/(api|static|uploads)\/).*$/, (req, res) => {
|
|
||||||
res.sendFile(path.join(clientBuildPath, 'index.html'));
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Error handling middleware
|
// ── Global error handler ──────────────────────────────────────────────────
|
||||||
app.use((err, req, res, next) => {
|
app.use((err, _req, res, _next) => {
|
||||||
console.error('Error:', err);
|
console.error('Error:', err);
|
||||||
res.status(err.status || 500).json({
|
res.status(err.status || 500).json({
|
||||||
error: err.message || 'Internal server error',
|
error: err.message || 'Internal server error',
|
||||||
@@ -80,17 +74,16 @@ app.use((err, req, res, next) => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
// Start server
|
|
||||||
app.listen(PORT, '0.0.0.0', () => {
|
app.listen(PORT, '0.0.0.0', () => {
|
||||||
console.log(`\n🐕 BREEDR Server Running`);
|
logStartupBanner({
|
||||||
console.log(`==============================`);
|
appName: 'BREEDR',
|
||||||
console.log(`Environment: ${process.env.NODE_ENV || 'development'}`);
|
port: PORT,
|
||||||
console.log(`Port: ${PORT}`);
|
environment: process.env.NODE_ENV || 'development',
|
||||||
console.log(`Database: ${DB_PATH}`);
|
dataDir: DATA_DIR,
|
||||||
console.log(`Uploads: ${UPLOAD_PATH}`);
|
uploadPath: UPLOAD_PATH,
|
||||||
console.log(`Static: ${STATIC_PATH}`);
|
staticPath: STATIC_PATH,
|
||||||
console.log(`Access: http://localhost:${PORT}`);
|
dbStatus: dbStatus
|
||||||
console.log(`==============================\n`);
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
module.exports = app;
|
module.exports = app;
|
||||||
|
|||||||
@@ -11,35 +11,130 @@ router.get('/heat-cycles/dog/:dogId', (req, res) => {
|
|||||||
WHERE dog_id = ?
|
WHERE dog_id = ?
|
||||||
ORDER BY start_date DESC
|
ORDER BY start_date DESC
|
||||||
`).all(req.params.dogId);
|
`).all(req.params.dogId);
|
||||||
|
|
||||||
res.json(cycles);
|
res.json(cycles);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
res.status(500).json({ error: error.message });
|
res.status(500).json({ error: error.message });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// GET all active heat cycles
|
// GET all active heat cycles (with dog info)
|
||||||
router.get('/heat-cycles/active', (req, res) => {
|
router.get('/heat-cycles/active', (req, res) => {
|
||||||
try {
|
try {
|
||||||
const db = getDatabase();
|
const db = getDatabase();
|
||||||
const cycles = db.prepare(`
|
const cycles = db.prepare(`
|
||||||
SELECT hc.*, d.name as dog_name, d.registration_number
|
SELECT hc.*, d.name as dog_name, d.registration_number, d.breed, d.birth_date
|
||||||
FROM heat_cycles hc
|
FROM heat_cycles hc
|
||||||
JOIN dogs d ON hc.dog_id = d.id
|
JOIN dogs d ON hc.dog_id = d.id
|
||||||
WHERE hc.end_date IS NULL OR hc.end_date >= date('now', '-30 days')
|
WHERE hc.end_date IS NULL OR hc.end_date >= date('now', '-30 days')
|
||||||
ORDER BY hc.start_date DESC
|
ORDER BY hc.start_date DESC
|
||||||
`).all();
|
`).all();
|
||||||
|
|
||||||
res.json(cycles);
|
res.json(cycles);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
res.status(500).json({ error: error.message });
|
res.status(500).json({ error: error.message });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// GET all heat cycles (all dogs, for calendar population)
|
||||||
|
router.get('/heat-cycles', (req, res) => {
|
||||||
|
try {
|
||||||
|
const db = getDatabase();
|
||||||
|
const { year, month } = req.query;
|
||||||
|
let query = `
|
||||||
|
SELECT hc.*, d.name as dog_name, d.registration_number, d.breed
|
||||||
|
FROM heat_cycles hc
|
||||||
|
JOIN dogs d ON hc.dog_id = d.id
|
||||||
|
`;
|
||||||
|
const params = [];
|
||||||
|
if (year && month) {
|
||||||
|
query += ` WHERE strftime('%Y', hc.start_date) = ? AND strftime('%m', hc.start_date) = ?`;
|
||||||
|
params.push(year, month.toString().padStart(2, '0'));
|
||||||
|
}
|
||||||
|
query += ' ORDER BY hc.start_date DESC';
|
||||||
|
const cycles = db.prepare(query).all(...params);
|
||||||
|
res.json(cycles);
|
||||||
|
} catch (error) {
|
||||||
|
res.status(500).json({ error: error.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// GET breeding date suggestions for a heat cycle
|
||||||
|
// Returns optimal breeding window based on start_date (days 9-15 of cycle)
|
||||||
|
router.get('/heat-cycles/:id/suggestions', (req, res) => {
|
||||||
|
try {
|
||||||
|
const db = getDatabase();
|
||||||
|
const cycle = db.prepare(`
|
||||||
|
SELECT hc.*, d.name as dog_name
|
||||||
|
FROM heat_cycles hc
|
||||||
|
JOIN dogs d ON hc.dog_id = d.id
|
||||||
|
WHERE hc.id = ?
|
||||||
|
`).get(req.params.id);
|
||||||
|
|
||||||
|
if (!cycle) return res.status(404).json({ error: 'Heat cycle not found' });
|
||||||
|
|
||||||
|
const start = new Date(cycle.start_date);
|
||||||
|
|
||||||
|
const addDays = (d, n) => {
|
||||||
|
const r = new Date(d);
|
||||||
|
r.setDate(r.getDate() + n);
|
||||||
|
return r.toISOString().split('T')[0];
|
||||||
|
};
|
||||||
|
|
||||||
|
// Standard canine heat cycle windows
|
||||||
|
res.json({
|
||||||
|
cycle_id: cycle.id,
|
||||||
|
dog_name: cycle.dog_name,
|
||||||
|
start_date: cycle.start_date,
|
||||||
|
windows: [
|
||||||
|
{
|
||||||
|
label: 'Proestrus',
|
||||||
|
description: 'Bleeding begins, not yet receptive',
|
||||||
|
start: addDays(start, 0),
|
||||||
|
end: addDays(start, 8),
|
||||||
|
color: 'pink',
|
||||||
|
type: 'proestrus'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Optimal Breeding Window',
|
||||||
|
description: 'Estrus — highest fertility, best time to breed',
|
||||||
|
start: addDays(start, 9),
|
||||||
|
end: addDays(start, 15),
|
||||||
|
color: 'green',
|
||||||
|
type: 'optimal'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Late Estrus',
|
||||||
|
description: 'Fertility declining but breeding still possible',
|
||||||
|
start: addDays(start, 16),
|
||||||
|
end: addDays(start, 21),
|
||||||
|
color: 'yellow',
|
||||||
|
type: 'late'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Diestrus',
|
||||||
|
description: 'Cycle ending, not receptive',
|
||||||
|
start: addDays(start, 22),
|
||||||
|
end: addDays(start, 28),
|
||||||
|
color: 'gray',
|
||||||
|
type: 'diestrus'
|
||||||
|
}
|
||||||
|
],
|
||||||
|
// If a breeding_date was logged, compute whelping estimate
|
||||||
|
whelping: cycle.breeding_date ? {
|
||||||
|
breeding_date: cycle.breeding_date,
|
||||||
|
earliest: addDays(new Date(cycle.breeding_date), 58),
|
||||||
|
expected: addDays(new Date(cycle.breeding_date), 63),
|
||||||
|
latest: addDays(new Date(cycle.breeding_date), 68)
|
||||||
|
} : null
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
res.status(500).json({ error: error.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
// POST create heat cycle
|
// POST create heat cycle
|
||||||
router.post('/heat-cycles', (req, res) => {
|
router.post('/heat-cycles', (req, res) => {
|
||||||
try {
|
try {
|
||||||
const { dog_id, start_date, end_date, progesterone_peak_date, breeding_date, breeding_successful, notes } = req.body;
|
const { dog_id, start_date, end_date, breeding_date, breeding_successful, notes } = req.body;
|
||||||
|
|
||||||
if (!dog_id || !start_date) {
|
if (!dog_id || !start_date) {
|
||||||
return res.status(400).json({ error: 'Dog ID and start date are required' });
|
return res.status(400).json({ error: 'Dog ID and start date are required' });
|
||||||
@@ -54,12 +149,11 @@ router.post('/heat-cycles', (req, res) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const result = db.prepare(`
|
const result = db.prepare(`
|
||||||
INSERT INTO heat_cycles (dog_id, start_date, end_date, progesterone_peak_date, breeding_date, breeding_successful, notes)
|
INSERT INTO heat_cycles (dog_id, start_date, end_date, breeding_date, breeding_successful, notes)
|
||||||
VALUES (?, ?, ?, ?, ?, ?, ?)
|
VALUES (?, ?, ?, ?, ?, ?)
|
||||||
`).run(dog_id, start_date, end_date, progesterone_peak_date, breeding_date, breeding_successful || 0, notes);
|
`).run(dog_id, start_date, end_date || null, breeding_date || null, breeding_successful || 0, notes || null);
|
||||||
|
|
||||||
const cycle = db.prepare('SELECT * FROM heat_cycles WHERE id = ?').get(result.lastInsertRowid);
|
const cycle = db.prepare('SELECT * FROM heat_cycles WHERE id = ?').get(result.lastInsertRowid);
|
||||||
|
|
||||||
res.status(201).json(cycle);
|
res.status(201).json(cycle);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
res.status(500).json({ error: error.message });
|
res.status(500).json({ error: error.message });
|
||||||
@@ -69,16 +163,13 @@ router.post('/heat-cycles', (req, res) => {
|
|||||||
// PUT update heat cycle
|
// PUT update heat cycle
|
||||||
router.put('/heat-cycles/:id', (req, res) => {
|
router.put('/heat-cycles/:id', (req, res) => {
|
||||||
try {
|
try {
|
||||||
const { start_date, end_date, progesterone_peak_date, breeding_date, breeding_successful, notes } = req.body;
|
const { start_date, end_date, breeding_date, breeding_successful, notes } = req.body;
|
||||||
|
|
||||||
const db = getDatabase();
|
const db = getDatabase();
|
||||||
db.prepare(`
|
db.prepare(`
|
||||||
UPDATE heat_cycles
|
UPDATE heat_cycles
|
||||||
SET start_date = ?, end_date = ?, progesterone_peak_date = ?,
|
SET start_date = ?, end_date = ?, breeding_date = ?, breeding_successful = ?, notes = ?
|
||||||
breeding_date = ?, breeding_successful = ?, notes = ?
|
|
||||||
WHERE id = ?
|
WHERE id = ?
|
||||||
`).run(start_date, end_date, progesterone_peak_date, breeding_date, breeding_successful, notes, req.params.id);
|
`).run(start_date, end_date || null, breeding_date || null, breeding_successful || 0, notes || null, req.params.id);
|
||||||
|
|
||||||
const cycle = db.prepare('SELECT * FROM heat_cycles WHERE id = ?').get(req.params.id);
|
const cycle = db.prepare('SELECT * FROM heat_cycles WHERE id = ?').get(req.params.id);
|
||||||
res.json(cycle);
|
res.json(cycle);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -97,32 +188,20 @@ router.delete('/heat-cycles/:id', (req, res) => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// GET calculate expected whelping date
|
// GET whelping calculator (standalone)
|
||||||
router.get('/whelping-calculator', (req, res) => {
|
router.get('/whelping-calculator', (req, res) => {
|
||||||
try {
|
try {
|
||||||
const { breeding_date } = req.query;
|
const { breeding_date } = req.query;
|
||||||
|
|
||||||
if (!breeding_date) {
|
if (!breeding_date) {
|
||||||
return res.status(400).json({ error: 'Breeding date is required' });
|
return res.status(400).json({ error: 'Breeding date is required' });
|
||||||
}
|
}
|
||||||
|
|
||||||
const breedDate = new Date(breeding_date);
|
const breedDate = new Date(breeding_date);
|
||||||
|
const addDays = (d, n) => { const r = new Date(d); r.setDate(r.getDate() + n); return r.toISOString().split('T')[0]; };
|
||||||
// Average gestation: 63 days, range 58-68 days
|
|
||||||
const expectedDate = new Date(breedDate);
|
|
||||||
expectedDate.setDate(expectedDate.getDate() + 63);
|
|
||||||
|
|
||||||
const earliestDate = new Date(breedDate);
|
|
||||||
earliestDate.setDate(earliestDate.getDate() + 58);
|
|
||||||
|
|
||||||
const latestDate = new Date(breedDate);
|
|
||||||
latestDate.setDate(latestDate.getDate() + 68);
|
|
||||||
|
|
||||||
res.json({
|
res.json({
|
||||||
breeding_date: breeding_date,
|
breeding_date,
|
||||||
expected_whelping_date: expectedDate.toISOString().split('T')[0],
|
expected_whelping_date: addDays(breedDate, 63),
|
||||||
earliest_date: earliestDate.toISOString().split('T')[0],
|
earliest_date: addDays(breedDate, 58),
|
||||||
latest_date: latestDate.toISOString().split('T')[0],
|
latest_date: addDays(breedDate, 68),
|
||||||
gestation_days: 63
|
gestation_days: 63
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|||||||
@@ -1,11 +1,10 @@
|
|||||||
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 multer = require('multer');
|
||||||
const path = require('path');
|
const path = require('path');
|
||||||
const fs = require('fs');
|
const fs = require('fs');
|
||||||
|
|
||||||
// Configure multer for photo uploads
|
|
||||||
const storage = multer.diskStorage({
|
const storage = multer.diskStorage({
|
||||||
destination: (req, file, cb) => {
|
destination: (req, file, cb) => {
|
||||||
const uploadPath = process.env.UPLOAD_PATH || path.join(__dirname, '../../uploads');
|
const uploadPath = process.env.UPLOAD_PATH || path.join(__dirname, '../../uploads');
|
||||||
@@ -19,12 +18,10 @@ const storage = multer.diskStorage({
|
|||||||
|
|
||||||
const upload = multer({
|
const upload = multer({
|
||||||
storage,
|
storage,
|
||||||
limits: { fileSize: 10 * 1024 * 1024 }, // 10MB limit
|
limits: { fileSize: 10 * 1024 * 1024 },
|
||||||
fileFilter: (req, file, cb) => {
|
fileFilter: (req, file, cb) => {
|
||||||
const allowedTypes = /jpeg|jpg|png|gif|webp/;
|
const allowed = /jpeg|jpg|png|gif|webp/;
|
||||||
const extname = allowedTypes.test(path.extname(file.originalname).toLowerCase());
|
if (allowed.test(path.extname(file.originalname).toLowerCase()) && allowed.test(file.mimetype)) {
|
||||||
const mimetype = allowedTypes.test(file.mimetype);
|
|
||||||
if (extname && mimetype) {
|
|
||||||
cb(null, true);
|
cb(null, true);
|
||||||
} else {
|
} else {
|
||||||
cb(new Error('Only image files are allowed'));
|
cb(new Error('Only image files are allowed'));
|
||||||
@@ -32,68 +29,125 @@ const upload = multer({
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Helper function to convert empty strings to null
|
const emptyToNull = (v) => (v === '' || v === undefined) ? null : v;
|
||||||
const emptyToNull = (value) => {
|
|
||||||
return (value === '' || value === undefined) ? null : value;
|
|
||||||
};
|
|
||||||
|
|
||||||
// GET all dogs
|
// ── Shared SELECT columns ────────────────────────────────────────────────
|
||||||
|
const DOG_COLS = `
|
||||||
|
id, name, registration_number, breed, sex, birth_date,
|
||||||
|
color, microchip, photo_urls, notes, litter_id, is_active,
|
||||||
|
is_champion, is_external, created_at, updated_at
|
||||||
|
`;
|
||||||
|
|
||||||
|
// ── Helper: attach parents to a list of dogs ─────────────────────────────
|
||||||
|
function attachParents(db, dogs) {
|
||||||
|
const parentStmt = db.prepare(`
|
||||||
|
SELECT p.parent_type, d.id, d.name, d.is_champion, d.is_external
|
||||||
|
FROM parents p
|
||||||
|
JOIN dogs d ON p.parent_id = d.id
|
||||||
|
WHERE p.dog_id = ?
|
||||||
|
`);
|
||||||
|
dogs.forEach(dog => {
|
||||||
|
dog.photo_urls = dog.photo_urls ? JSON.parse(dog.photo_urls) : [];
|
||||||
|
const parents = parentStmt.all(dog.id);
|
||||||
|
dog.sire = parents.find(p => p.parent_type === 'sire') || null;
|
||||||
|
dog.dam = parents.find(p => p.parent_type === 'dam') || null;
|
||||||
|
});
|
||||||
|
return dogs;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── GET dogs
|
||||||
|
// Default: kennel dogs only (is_external = 0)
|
||||||
|
// ?include_external=1 : all active dogs (kennel + external)
|
||||||
|
// ?external_only=1 : external dogs only
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────
|
||||||
router.get('/', (req, res) => {
|
router.get('/', (req, res) => {
|
||||||
try {
|
try {
|
||||||
const db = getDatabase();
|
const db = getDatabase();
|
||||||
|
const includeExternal = req.query.include_external === '1' || req.query.include_external === 'true';
|
||||||
|
const externalOnly = req.query.external_only === '1' || req.query.external_only === 'true';
|
||||||
|
|
||||||
|
let whereClause;
|
||||||
|
if (externalOnly) {
|
||||||
|
whereClause = 'WHERE is_active = 1 AND is_external = 1';
|
||||||
|
} else if (includeExternal) {
|
||||||
|
whereClause = 'WHERE is_active = 1';
|
||||||
|
} else {
|
||||||
|
whereClause = 'WHERE is_active = 1 AND is_external = 0';
|
||||||
|
}
|
||||||
|
|
||||||
const dogs = db.prepare(`
|
const dogs = db.prepare(`
|
||||||
SELECT id, name, registration_number, breed, sex, birth_date,
|
SELECT ${DOG_COLS}
|
||||||
color, microchip, photo_urls, notes, litter_id, is_active,
|
|
||||||
created_at, updated_at
|
|
||||||
FROM dogs
|
FROM dogs
|
||||||
WHERE is_active = 1
|
${whereClause}
|
||||||
ORDER BY name
|
ORDER BY name
|
||||||
`).all();
|
`).all();
|
||||||
|
|
||||||
// Parse photo_urls JSON
|
res.json(attachParents(db, dogs));
|
||||||
dogs.forEach(dog => {
|
|
||||||
dog.photo_urls = dog.photo_urls ? JSON.parse(dog.photo_urls) : [];
|
|
||||||
});
|
|
||||||
|
|
||||||
res.json(dogs);
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error fetching dogs:', error);
|
console.error('Error fetching dogs:', error);
|
||||||
res.status(500).json({ error: error.message });
|
res.status(500).json({ error: error.message });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// GET single dog by ID with parents and offspring
|
// ── GET all dogs (kennel + external) for dropdowns/pairing/pedigree ──────────
|
||||||
|
// Kept for backwards-compat; equivalent to GET /?include_external=1
|
||||||
|
router.get('/all', (req, res) => {
|
||||||
|
try {
|
||||||
|
const db = getDatabase();
|
||||||
|
const dogs = db.prepare(`
|
||||||
|
SELECT ${DOG_COLS}
|
||||||
|
FROM dogs
|
||||||
|
WHERE is_active = 1
|
||||||
|
ORDER BY name
|
||||||
|
`).all();
|
||||||
|
res.json(attachParents(db, dogs));
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error fetching all dogs:', error);
|
||||||
|
res.status(500).json({ error: error.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── GET external dogs only (is_external = 1) ──────────────────────────────
|
||||||
|
// Kept for backwards-compat; equivalent to GET /?external_only=1
|
||||||
|
router.get('/external', (req, res) => {
|
||||||
|
try {
|
||||||
|
const db = getDatabase();
|
||||||
|
const dogs = db.prepare(`
|
||||||
|
SELECT ${DOG_COLS}
|
||||||
|
FROM dogs
|
||||||
|
WHERE is_active = 1 AND is_external = 1
|
||||||
|
ORDER BY name
|
||||||
|
`).all();
|
||||||
|
res.json(attachParents(db, dogs));
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error fetching external dogs:', error);
|
||||||
|
res.status(500).json({ error: error.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── GET single dog (with parents + offspring) ──────────────────────────
|
||||||
router.get('/:id', (req, res) => {
|
router.get('/:id', (req, res) => {
|
||||||
try {
|
try {
|
||||||
const db = getDatabase();
|
const db = getDatabase();
|
||||||
const dog = db.prepare(`
|
const dog = db.prepare(`SELECT ${DOG_COLS} FROM dogs WHERE id = ?`).get(req.params.id);
|
||||||
SELECT id, name, registration_number, breed, sex, birth_date,
|
|
||||||
color, microchip, photo_urls, notes, litter_id, is_active,
|
|
||||||
created_at, updated_at
|
|
||||||
FROM dogs
|
|
||||||
WHERE id = ?
|
|
||||||
`).get(req.params.id);
|
|
||||||
|
|
||||||
if (!dog) {
|
if (!dog) return res.status(404).json({ error: 'Dog not found' });
|
||||||
return res.status(404).json({ error: 'Dog not found' });
|
|
||||||
}
|
|
||||||
|
|
||||||
dog.photo_urls = dog.photo_urls ? JSON.parse(dog.photo_urls) : [];
|
dog.photo_urls = dog.photo_urls ? JSON.parse(dog.photo_urls) : [];
|
||||||
|
|
||||||
// Get parents from parents table
|
|
||||||
const parents = db.prepare(`
|
const parents = db.prepare(`
|
||||||
SELECT p.parent_type, d.*
|
SELECT p.parent_type, d.id, d.name, d.is_champion, d.is_external
|
||||||
FROM parents p
|
FROM parents p
|
||||||
JOIN dogs d ON p.parent_id = d.id
|
JOIN dogs d ON p.parent_id = d.id
|
||||||
WHERE p.dog_id = ?
|
WHERE p.dog_id = ?
|
||||||
`).all(req.params.id);
|
`).all(req.params.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;
|
||||||
|
|
||||||
// Get offspring
|
|
||||||
dog.offspring = db.prepare(`
|
dog.offspring = db.prepare(`
|
||||||
SELECT d.* FROM dogs d
|
SELECT d.id, d.name, d.sex, d.is_champion, d.is_external
|
||||||
|
FROM dogs d
|
||||||
JOIN parents p ON d.id = p.dog_id
|
JOIN parents p ON d.id = p.dog_id
|
||||||
WHERE p.parent_id = ? AND d.is_active = 1
|
WHERE p.parent_id = ? AND d.is_active = 1
|
||||||
`).all(req.params.id);
|
`).all(req.params.id);
|
||||||
@@ -105,66 +159,48 @@ router.get('/:id', (req, res) => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// POST create new dog
|
// ── POST create dog ─────────────────────────────────────────────────────
|
||||||
router.post('/', (req, res) => {
|
router.post('/', (req, res) => {
|
||||||
try {
|
try {
|
||||||
const { name, registration_number, breed, sex, birth_date, color, microchip, notes, sire_id, dam_id, litter_id } = req.body;
|
const { name, registration_number, breed, sex, birth_date, color,
|
||||||
|
microchip, notes, sire_id, dam_id, litter_id, is_champion, is_external } = req.body;
|
||||||
console.log('Creating dog with data:', { name, breed, sex, sire_id, dam_id, litter_id });
|
|
||||||
|
|
||||||
if (!name || !breed || !sex) {
|
if (!name || !breed || !sex) {
|
||||||
return res.status(400).json({ error: 'Name, breed, and sex are required' });
|
return res.status(400).json({ error: 'Name, breed, and sex are required' });
|
||||||
}
|
}
|
||||||
|
|
||||||
const db = getDatabase();
|
const db = getDatabase();
|
||||||
|
|
||||||
// Insert dog (dogs table has NO sire/dam columns)
|
|
||||||
const result = db.prepare(`
|
const result = db.prepare(`
|
||||||
INSERT INTO dogs (name, registration_number, breed, sex, birth_date, color, microchip, notes, litter_id, photo_urls)
|
INSERT INTO dogs (name, registration_number, breed, sex, birth_date, color,
|
||||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
microchip, notes, litter_id, photo_urls, is_champion, is_external)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
`).run(
|
`).run(
|
||||||
name,
|
name,
|
||||||
emptyToNull(registration_number),
|
emptyToNull(registration_number),
|
||||||
breed,
|
breed, sex,
|
||||||
sex,
|
|
||||||
emptyToNull(birth_date),
|
emptyToNull(birth_date),
|
||||||
emptyToNull(color),
|
emptyToNull(color),
|
||||||
emptyToNull(microchip),
|
emptyToNull(microchip),
|
||||||
emptyToNull(notes),
|
emptyToNull(notes),
|
||||||
emptyToNull(litter_id),
|
emptyToNull(litter_id),
|
||||||
'[]'
|
'[]',
|
||||||
|
is_champion ? 1 : 0,
|
||||||
|
is_external ? 1 : 0
|
||||||
);
|
);
|
||||||
|
|
||||||
const dogId = result.lastInsertRowid;
|
const dogId = result.lastInsertRowid;
|
||||||
console.log(`✓ Dog inserted with ID: ${dogId}`);
|
|
||||||
|
|
||||||
// Add sire relationship if provided
|
|
||||||
if (sire_id && sire_id !== '' && sire_id !== null) {
|
if (sire_id && sire_id !== '' && sire_id !== null) {
|
||||||
console.log(` Adding sire relationship: dog ${dogId} -> sire ${sire_id}`);
|
db.prepare('INSERT INTO parents (dog_id, parent_id, parent_type) VALUES (?, ?, ?)').run(dogId, sire_id, 'sire');
|
||||||
db.prepare('INSERT INTO parents (dog_id, parent_id, parent_type) VALUES (?, ?, ?)').
|
|
||||||
run(dogId, sire_id, 'sire');
|
|
||||||
console.log(` ✓ Sire relationship added`);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Add dam relationship if provided
|
|
||||||
if (dam_id && dam_id !== '' && dam_id !== null) {
|
if (dam_id && dam_id !== '' && dam_id !== null) {
|
||||||
console.log(` Adding dam relationship: dog ${dogId} -> dam ${dam_id}`);
|
db.prepare('INSERT INTO parents (dog_id, parent_id, parent_type) VALUES (?, ?, ?)').run(dogId, dam_id, 'dam');
|
||||||
db.prepare('INSERT INTO parents (dog_id, parent_id, parent_type) VALUES (?, ?, ?)').
|
|
||||||
run(dogId, dam_id, 'dam');
|
|
||||||
console.log(` ✓ Dam relationship added`);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fetch the created dog
|
const dog = db.prepare(`SELECT ${DOG_COLS} FROM dogs WHERE id = ?`).get(dogId);
|
||||||
const dog = db.prepare(`
|
|
||||||
SELECT id, name, registration_number, breed, sex, birth_date,
|
|
||||||
color, microchip, photo_urls, notes, litter_id, is_active,
|
|
||||||
created_at, updated_at
|
|
||||||
FROM dogs
|
|
||||||
WHERE id = ?
|
|
||||||
`).get(dogId);
|
|
||||||
dog.photo_urls = [];
|
dog.photo_urls = [];
|
||||||
|
|
||||||
console.log(`✓ Dog created successfully: ${dog.name} (ID: ${dogId})`);
|
console.log(`✔ Dog created: ${dog.name} (ID: ${dogId}, external: ${dog.is_external})`);
|
||||||
res.status(201).json(dog);
|
res.status(201).json(dog);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error creating dog:', error);
|
console.error('Error creating dog:', error);
|
||||||
@@ -172,66 +208,45 @@ router.post('/', (req, res) => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// PUT update dog
|
// ── PUT update dog ───────────────────────────────────────────────────────
|
||||||
router.put('/:id', (req, res) => {
|
router.put('/:id', (req, res) => {
|
||||||
try {
|
try {
|
||||||
const { name, registration_number, breed, sex, birth_date, color, microchip, notes, sire_id, dam_id, litter_id } = req.body;
|
const { name, registration_number, breed, sex, birth_date, color,
|
||||||
|
microchip, notes, sire_id, dam_id, litter_id, is_champion, is_external } = req.body;
|
||||||
console.log(`Updating dog ${req.params.id} with data:`, { name, breed, sex, sire_id, dam_id, litter_id });
|
|
||||||
|
|
||||||
const db = getDatabase();
|
const db = getDatabase();
|
||||||
|
|
||||||
// Update dog record (dogs table has NO sire/dam columns)
|
|
||||||
db.prepare(`
|
db.prepare(`
|
||||||
UPDATE dogs
|
UPDATE dogs
|
||||||
SET name = ?, registration_number = ?, breed = ?, sex = ?,
|
SET name = ?, registration_number = ?, breed = ?, sex = ?,
|
||||||
birth_date = ?, color = ?, microchip = ?, notes = ?, litter_id = ?
|
birth_date = ?, color = ?, microchip = ?, notes = ?,
|
||||||
|
litter_id = ?, is_champion = ?, is_external = ?, updated_at = datetime('now')
|
||||||
WHERE id = ?
|
WHERE id = ?
|
||||||
`).run(
|
`).run(
|
||||||
name,
|
name,
|
||||||
emptyToNull(registration_number),
|
emptyToNull(registration_number),
|
||||||
breed,
|
breed, sex,
|
||||||
sex,
|
|
||||||
emptyToNull(birth_date),
|
emptyToNull(birth_date),
|
||||||
emptyToNull(color),
|
emptyToNull(color),
|
||||||
emptyToNull(microchip),
|
emptyToNull(microchip),
|
||||||
emptyToNull(notes),
|
emptyToNull(notes),
|
||||||
emptyToNull(litter_id),
|
emptyToNull(litter_id),
|
||||||
|
is_champion ? 1 : 0,
|
||||||
|
is_external ? 1 : 0,
|
||||||
req.params.id
|
req.params.id
|
||||||
);
|
);
|
||||||
console.log(` ✓ Dog record updated`);
|
|
||||||
|
|
||||||
// Remove existing parent relationships
|
|
||||||
db.prepare('DELETE FROM parents WHERE dog_id = ?').run(req.params.id);
|
db.prepare('DELETE FROM parents WHERE dog_id = ?').run(req.params.id);
|
||||||
console.log(` ✓ Old parent relationships removed`);
|
|
||||||
|
|
||||||
// Add new sire relationship if provided
|
|
||||||
if (sire_id && sire_id !== '' && sire_id !== null) {
|
if (sire_id && sire_id !== '' && sire_id !== null) {
|
||||||
console.log(` Adding sire relationship: dog ${req.params.id} -> sire ${sire_id}`);
|
db.prepare('INSERT INTO parents (dog_id, parent_id, parent_type) VALUES (?, ?, ?)').run(req.params.id, sire_id, 'sire');
|
||||||
db.prepare('INSERT INTO parents (dog_id, parent_id, parent_type) VALUES (?, ?, ?)').
|
|
||||||
run(req.params.id, sire_id, 'sire');
|
|
||||||
console.log(` ✓ Sire relationship added`);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Add new dam relationship if provided
|
|
||||||
if (dam_id && dam_id !== '' && dam_id !== null) {
|
if (dam_id && dam_id !== '' && dam_id !== null) {
|
||||||
console.log(` Adding dam relationship: dog ${req.params.id} -> dam ${dam_id}`);
|
db.prepare('INSERT INTO parents (dog_id, parent_id, parent_type) VALUES (?, ?, ?)').run(req.params.id, dam_id, 'dam');
|
||||||
db.prepare('INSERT INTO parents (dog_id, parent_id, parent_type) VALUES (?, ?, ?)').
|
|
||||||
run(req.params.id, dam_id, 'dam');
|
|
||||||
console.log(` ✓ Dam relationship added`);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fetch updated dog
|
const dog = db.prepare(`SELECT ${DOG_COLS} FROM dogs WHERE id = ?`).get(req.params.id);
|
||||||
const dog = db.prepare(`
|
|
||||||
SELECT id, name, registration_number, breed, sex, birth_date,
|
|
||||||
color, microchip, photo_urls, notes, litter_id, is_active,
|
|
||||||
created_at, updated_at
|
|
||||||
FROM dogs
|
|
||||||
WHERE id = ?
|
|
||||||
`).get(req.params.id);
|
|
||||||
dog.photo_urls = dog.photo_urls ? JSON.parse(dog.photo_urls) : [];
|
dog.photo_urls = dog.photo_urls ? JSON.parse(dog.photo_urls) : [];
|
||||||
|
|
||||||
console.log(`✓ Dog updated successfully: ${dog.name} (ID: ${req.params.id})`);
|
console.log(`✔ Dog updated: ${dog.name} (ID: ${req.params.id})`);
|
||||||
res.json(dog);
|
res.json(dog);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error updating dog:', error);
|
console.error('Error updating dog:', error);
|
||||||
@@ -239,36 +254,39 @@ router.put('/:id', (req, res) => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// DELETE dog (soft delete)
|
// ── DELETE dog (hard delete with cascade) ───────────────────────────────
|
||||||
router.delete('/:id', (req, res) => {
|
router.delete('/:id', (req, res) => {
|
||||||
try {
|
try {
|
||||||
const db = getDatabase();
|
const db = getDatabase();
|
||||||
db.prepare('UPDATE dogs SET is_active = 0 WHERE id = ?').run(req.params.id);
|
const existing = db.prepare('SELECT id, name FROM dogs WHERE id = ?').get(req.params.id);
|
||||||
console.log(`✓ Dog soft-deleted: ID ${req.params.id}`);
|
if (!existing) return res.status(404).json({ error: 'Dog not found' });
|
||||||
res.json({ message: 'Dog deleted successfully' });
|
|
||||||
|
const id = req.params.id;
|
||||||
|
db.prepare('DELETE FROM parents WHERE parent_id = ?').run(id);
|
||||||
|
db.prepare('DELETE FROM parents WHERE dog_id = ?').run(id);
|
||||||
|
db.prepare('DELETE FROM health_records WHERE dog_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`);
|
||||||
|
res.json({ success: true, message: `${existing.name} has been deleted` });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error deleting dog:', error);
|
console.error('Error deleting dog:', error);
|
||||||
res.status(500).json({ error: error.message });
|
res.status(500).json({ error: error.message });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// POST upload photo for dog
|
// ── POST upload photo ────────────────────────────────────────────────────
|
||||||
router.post('/:id/photos', upload.single('photo'), (req, res) => {
|
router.post('/:id/photos', upload.single('photo'), (req, res) => {
|
||||||
try {
|
try {
|
||||||
if (!req.file) {
|
if (!req.file) return res.status(400).json({ error: 'No file uploaded' });
|
||||||
return res.status(400).json({ error: 'No file uploaded' });
|
|
||||||
}
|
|
||||||
|
|
||||||
const db = getDatabase();
|
const db = getDatabase();
|
||||||
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 = dog.photo_urls ? JSON.parse(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);
|
||||||
|
|
||||||
res.json({ url: `/uploads/${req.file.filename}`, photos: photoUrls });
|
res.json({ url: `/uploads/${req.file.filename}`, photos: photoUrls });
|
||||||
@@ -278,27 +296,22 @@ router.post('/:id/photos', upload.single('photo'), (req, res) => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// DELETE photo from dog
|
// ── DELETE photo ──────────────────────────────────────────────────────
|
||||||
router.delete('/:id/photos/:photoIndex', (req, res) => {
|
router.delete('/:id/photos/:photoIndex', (req, res) => {
|
||||||
try {
|
try {
|
||||||
const db = getDatabase();
|
const db = getDatabase();
|
||||||
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) {
|
const photoUrls = dog.photo_urls ? JSON.parse(dog.photo_urls) : [];
|
||||||
return res.status(404).json({ error: 'Dog not found' });
|
|
||||||
}
|
|
||||||
|
|
||||||
const photoUrls = dog.photo_urls ? JSON.parse(dog.photo_urls) : [];
|
|
||||||
const photoIndex = parseInt(req.params.photoIndex);
|
const photoIndex = parseInt(req.params.photoIndex);
|
||||||
|
|
||||||
if (photoIndex >= 0 && photoIndex < photoUrls.length) {
|
if (photoIndex >= 0 && photoIndex < photoUrls.length) {
|
||||||
const photoPath = path.join(process.env.UPLOAD_PATH || path.join(__dirname, '../../uploads'), path.basename(photoUrls[photoIndex]));
|
const photoPath = path.join(
|
||||||
|
process.env.UPLOAD_PATH || path.join(__dirname, '../../uploads'),
|
||||||
// Delete file from disk
|
path.basename(photoUrls[photoIndex])
|
||||||
if (fs.existsSync(photoPath)) {
|
);
|
||||||
fs.unlinkSync(photoPath);
|
if (fs.existsSync(photoPath)) fs.unlinkSync(photoPath);
|
||||||
}
|
|
||||||
|
|
||||||
photoUrls.splice(photoIndex, 1);
|
photoUrls.splice(photoIndex, 1);
|
||||||
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);
|
||||||
}
|
}
|
||||||
|
|||||||
158
server/routes/genetics.js
Normal file
158
server/routes/genetics.js
Normal file
@@ -0,0 +1,158 @@
|
|||||||
|
const express = require('express');
|
||||||
|
const router = express.Router();
|
||||||
|
const { getDatabase } = require('../db/init');
|
||||||
|
|
||||||
|
// Golden Retriever panel markers tracked by Breedr
|
||||||
|
const GR_MARKERS = [
|
||||||
|
'PRA1', 'PRA2', 'prcd-PRA', 'GR-PRA1', 'GR-PRA2',
|
||||||
|
'ICH1', 'ICH2', 'NCL', 'DM', 'MD'
|
||||||
|
];
|
||||||
|
|
||||||
|
// GET all genetic tests for a dog
|
||||||
|
router.get('/dog/:dogId', (req, res) => {
|
||||||
|
try {
|
||||||
|
const db = getDatabase();
|
||||||
|
const tests = db.prepare(`
|
||||||
|
SELECT * FROM genetic_tests
|
||||||
|
WHERE dog_id = ?
|
||||||
|
ORDER BY marker ASC
|
||||||
|
`).all(req.params.dogId);
|
||||||
|
|
||||||
|
// Return a full panel including not_tested placeholders
|
||||||
|
const byMarker = {};
|
||||||
|
for (const t of tests) byMarker[t.marker] = t;
|
||||||
|
|
||||||
|
const panel = GR_MARKERS.map(marker => ({
|
||||||
|
marker,
|
||||||
|
...(byMarker[marker] || { result: 'not_tested', dog_id: Number(req.params.dogId) })
|
||||||
|
}));
|
||||||
|
|
||||||
|
res.json({ tests, panel });
|
||||||
|
} catch (error) {
|
||||||
|
res.status(500).json({ error: error.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// GET pairing risk — compare sire + dam carrier status
|
||||||
|
// Usage: GET /api/genetics/pairing-risk?sireId=1&damId=2
|
||||||
|
router.get('/pairing-risk', (req, res) => {
|
||||||
|
try {
|
||||||
|
const { sireId, damId } = req.query;
|
||||||
|
if (!sireId || !damId) {
|
||||||
|
return res.status(400).json({ error: 'sireId and damId are required' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const db = getDatabase();
|
||||||
|
|
||||||
|
const getResults = (dogId) => {
|
||||||
|
const rows = db.prepare('SELECT marker, result FROM genetic_tests WHERE dog_id = ?').all(dogId);
|
||||||
|
const map = {};
|
||||||
|
for (const r of rows) map[r.marker] = r.result;
|
||||||
|
return map;
|
||||||
|
};
|
||||||
|
|
||||||
|
const sireResults = getResults(sireId);
|
||||||
|
const damResults = getResults(damId);
|
||||||
|
|
||||||
|
const risks = [];
|
||||||
|
for (const marker of GR_MARKERS) {
|
||||||
|
const s = sireResults[marker] || 'not_tested';
|
||||||
|
const d = damResults[marker] || 'not_tested';
|
||||||
|
|
||||||
|
// Both affected or carrier x carrier = risk
|
||||||
|
if (
|
||||||
|
(s === 'affected' || d === 'affected') ||
|
||||||
|
(s === 'carrier' && d === 'carrier')
|
||||||
|
) {
|
||||||
|
risks.push({
|
||||||
|
marker,
|
||||||
|
sire_result: s,
|
||||||
|
dam_result: d,
|
||||||
|
risk_level: (s === 'affected' || d === 'affected') ? 'high' : 'moderate',
|
||||||
|
note: s === 'affected' || d === 'affected'
|
||||||
|
? 'One or both parents are affected — do not breed'
|
||||||
|
: 'Both parents are carriers — 25% chance of affected offspring',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
sire_id: Number(sireId),
|
||||||
|
dam_id: Number(damId),
|
||||||
|
risks,
|
||||||
|
safe_to_pair: risks.length === 0,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
res.status(500).json({ error: error.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// GET single genetic test
|
||||||
|
router.get('/:id', (req, res) => {
|
||||||
|
try {
|
||||||
|
const db = getDatabase();
|
||||||
|
const test = db.prepare('SELECT * FROM genetic_tests WHERE id = ?').get(req.params.id);
|
||||||
|
if (!test) return res.status(404).json({ error: 'Genetic test not found' });
|
||||||
|
res.json(test);
|
||||||
|
} catch (error) {
|
||||||
|
res.status(500).json({ error: error.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// POST create genetic test
|
||||||
|
router.post('/', (req, res) => {
|
||||||
|
try {
|
||||||
|
const { dog_id, test_provider, marker, result, test_date, document_url, notes } = req.body;
|
||||||
|
|
||||||
|
if (!dog_id || !marker || !result) {
|
||||||
|
return res.status(400).json({ error: 'dog_id, marker, and result are required' });
|
||||||
|
}
|
||||||
|
if (!['clear', 'carrier', 'affected', 'not_tested'].includes(result)) {
|
||||||
|
return res.status(400).json({ error: 'result must be: clear | carrier | affected | not_tested' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const db = getDatabase();
|
||||||
|
const dbResult = db.prepare(`
|
||||||
|
INSERT INTO genetic_tests (dog_id, test_provider, marker, result, test_date, document_url, notes)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?)
|
||||||
|
`).run(dog_id, test_provider || null, marker, result, test_date || null, document_url || null, notes || null);
|
||||||
|
|
||||||
|
const test = db.prepare('SELECT * FROM genetic_tests WHERE id = ?').get(dbResult.lastInsertRowid);
|
||||||
|
res.status(201).json(test);
|
||||||
|
} catch (error) {
|
||||||
|
res.status(500).json({ error: error.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// PUT update genetic test
|
||||||
|
router.put('/:id', (req, res) => {
|
||||||
|
try {
|
||||||
|
const { test_provider, marker, result, test_date, document_url, notes } = req.body;
|
||||||
|
|
||||||
|
const db = getDatabase();
|
||||||
|
db.prepare(`
|
||||||
|
UPDATE genetic_tests
|
||||||
|
SET test_provider = ?, marker = ?, result = ?, test_date = ?,
|
||||||
|
document_url = ?, notes = ?, updated_at = datetime('now')
|
||||||
|
WHERE id = ?
|
||||||
|
`).run(test_provider || null, marker, result, test_date || null, document_url || null, notes || null, req.params.id);
|
||||||
|
|
||||||
|
const test = db.prepare('SELECT * FROM genetic_tests WHERE id = ?').get(req.params.id);
|
||||||
|
res.json(test);
|
||||||
|
} catch (error) {
|
||||||
|
res.status(500).json({ error: error.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// DELETE genetic test
|
||||||
|
router.delete('/:id', (req, res) => {
|
||||||
|
try {
|
||||||
|
const db = getDatabase();
|
||||||
|
db.prepare('DELETE FROM genetic_tests WHERE id = ?').run(req.params.id);
|
||||||
|
res.json({ message: 'Genetic test deleted successfully' });
|
||||||
|
} catch (error) {
|
||||||
|
res.status(500).json({ error: error.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
module.exports = router;
|
||||||
@@ -2,6 +2,45 @@ const express = require('express');
|
|||||||
const router = express.Router();
|
const router = express.Router();
|
||||||
const { getDatabase } = require('../db/init');
|
const { getDatabase } = require('../db/init');
|
||||||
|
|
||||||
|
// OFA tests that count toward GRCA eligibility
|
||||||
|
const GRCA_REQUIRED = ['hip_ofa', 'hip_pennhip', 'elbow_ofa', 'heart_ofa', 'heart_echo', 'eye_caer'];
|
||||||
|
const GRCA_CORE = {
|
||||||
|
hip: ['hip_ofa', 'hip_pennhip'],
|
||||||
|
elbow: ['elbow_ofa'],
|
||||||
|
heart: ['heart_ofa', 'heart_echo'],
|
||||||
|
eye: ['eye_caer'],
|
||||||
|
};
|
||||||
|
|
||||||
|
// Helper: compute clearance summary for a dog
|
||||||
|
function getClearanceSummary(db, dogId) {
|
||||||
|
const records = db.prepare(`
|
||||||
|
SELECT test_type, ofa_result, ofa_number, expires_at, test_date
|
||||||
|
FROM health_records
|
||||||
|
WHERE dog_id = ? AND test_type IS NOT NULL
|
||||||
|
ORDER BY test_date DESC
|
||||||
|
`).all(dogId);
|
||||||
|
|
||||||
|
const today = new Date();
|
||||||
|
const in90 = new Date(); in90.setDate(today.getDate() + 90);
|
||||||
|
|
||||||
|
const summary = {};
|
||||||
|
for (const [group, types] of Object.entries(GRCA_CORE)) {
|
||||||
|
const match = records.find(r => types.includes(r.test_type));
|
||||||
|
if (!match) {
|
||||||
|
summary[group] = { status: 'missing', record: null };
|
||||||
|
} else {
|
||||||
|
let status = 'pass';
|
||||||
|
if (match.expires_at) {
|
||||||
|
const exp = new Date(match.expires_at);
|
||||||
|
if (exp < today) status = 'expired';
|
||||||
|
else if (exp <= in90) status = 'expiring_soon';
|
||||||
|
}
|
||||||
|
summary[group] = { status, record: match };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return summary;
|
||||||
|
}
|
||||||
|
|
||||||
// GET all health records for a dog
|
// GET all health records for a dog
|
||||||
router.get('/dog/:dogId', (req, res) => {
|
router.get('/dog/:dogId', (req, res) => {
|
||||||
try {
|
try {
|
||||||
@@ -11,23 +50,65 @@ router.get('/dog/:dogId', (req, res) => {
|
|||||||
WHERE dog_id = ?
|
WHERE dog_id = ?
|
||||||
ORDER BY test_date DESC
|
ORDER BY test_date DESC
|
||||||
`).all(req.params.dogId);
|
`).all(req.params.dogId);
|
||||||
|
|
||||||
res.json(records);
|
res.json(records);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
res.status(500).json({ error: error.message });
|
res.status(500).json({ error: error.message });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// GET clearance summary (Hip / Elbow / Heart / Eyes) for a dog
|
||||||
|
router.get('/dog/:dogId/clearance-summary', (req, res) => {
|
||||||
|
try {
|
||||||
|
const db = getDatabase();
|
||||||
|
const dog = db.prepare('SELECT id, birth_date, chic_number FROM dogs WHERE id = ?').get(req.params.dogId);
|
||||||
|
if (!dog) return res.status(404).json({ error: 'Dog not found' });
|
||||||
|
|
||||||
|
const summary = getClearanceSummary(db, dog.id);
|
||||||
|
|
||||||
|
// Age check: must be >= 24 months for hip/elbow
|
||||||
|
let ageEligible = false;
|
||||||
|
if (dog.birth_date) {
|
||||||
|
const months = (new Date() - new Date(dog.birth_date)) / (1000 * 60 * 60 * 24 * 30.44);
|
||||||
|
ageEligible = months >= 24;
|
||||||
|
}
|
||||||
|
|
||||||
|
const allPass = Object.values(summary).every(s => ['pass', 'expiring_soon'].includes(s.status));
|
||||||
|
const grca_eligible = allPass && ageEligible;
|
||||||
|
|
||||||
|
res.json({ summary, grca_eligible, age_eligible: ageEligible, chic_number: dog.chic_number });
|
||||||
|
} catch (error) {
|
||||||
|
res.status(500).json({ error: error.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// GET CHIC eligibility check
|
||||||
|
router.get('/dog/:dogId/chic-eligible', (req, res) => {
|
||||||
|
try {
|
||||||
|
const db = getDatabase();
|
||||||
|
const dog = db.prepare('SELECT id, chic_number FROM dogs WHERE id = ?').get(req.params.dogId);
|
||||||
|
if (!dog) return res.status(404).json({ error: 'Dog not found' });
|
||||||
|
|
||||||
|
const summary = getClearanceSummary(db, dog.id);
|
||||||
|
const missing = Object.entries(summary)
|
||||||
|
.filter(([, v]) => v.status === 'missing')
|
||||||
|
.map(([k]) => k);
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
chic_eligible: missing.length === 0,
|
||||||
|
chic_number: dog.chic_number || null,
|
||||||
|
missing_tests: missing,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
res.status(500).json({ error: error.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
// GET single health record
|
// GET single health record
|
||||||
router.get('/:id', (req, res) => {
|
router.get('/:id', (req, res) => {
|
||||||
try {
|
try {
|
||||||
const db = getDatabase();
|
const db = getDatabase();
|
||||||
const record = db.prepare('SELECT * FROM health_records WHERE id = ?').get(req.params.id);
|
const record = db.prepare('SELECT * FROM health_records WHERE id = ?').get(req.params.id);
|
||||||
|
if (!record) return res.status(404).json({ error: 'Health record not found' });
|
||||||
if (!record) {
|
|
||||||
return res.status(404).json({ error: 'Health record not found' });
|
|
||||||
}
|
|
||||||
|
|
||||||
res.json(record);
|
res.json(record);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
res.status(500).json({ error: error.message });
|
res.status(500).json({ error: error.message });
|
||||||
@@ -37,20 +118,30 @@ router.get('/:id', (req, res) => {
|
|||||||
// POST create health record
|
// POST create health record
|
||||||
router.post('/', (req, res) => {
|
router.post('/', (req, res) => {
|
||||||
try {
|
try {
|
||||||
const { dog_id, record_type, test_name, test_date, result, document_url, notes } = req.body;
|
const {
|
||||||
|
dog_id, record_type, test_type, test_name, test_date,
|
||||||
|
ofa_result, ofa_number, performed_by, expires_at,
|
||||||
|
document_url, result, vet_name, next_due, notes
|
||||||
|
} = req.body;
|
||||||
|
|
||||||
if (!dog_id || !record_type || !test_date) {
|
if (!dog_id || !record_type || !test_date) {
|
||||||
return res.status(400).json({ error: 'Dog ID, record type, and test date are required' });
|
return res.status(400).json({ error: 'dog_id, record_type, and test_date are required' });
|
||||||
}
|
}
|
||||||
|
|
||||||
const db = getDatabase();
|
const db = getDatabase();
|
||||||
const dbResult = db.prepare(`
|
const dbResult = db.prepare(`
|
||||||
INSERT INTO health_records (dog_id, record_type, test_name, test_date, result, document_url, notes)
|
INSERT INTO health_records
|
||||||
VALUES (?, ?, ?, ?, ?, ?, ?)
|
(dog_id, record_type, test_type, test_name, test_date,
|
||||||
`).run(dog_id, record_type, test_name, test_date, result, document_url, notes);
|
ofa_result, ofa_number, performed_by, expires_at,
|
||||||
|
document_url, result, vet_name, next_due, notes)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
|
`).run(
|
||||||
|
dog_id, record_type, test_type || null, test_name || null, test_date,
|
||||||
|
ofa_result || null, ofa_number || null, performed_by || null, expires_at || null,
|
||||||
|
document_url || null, result || null, vet_name || null, next_due || null, notes || null
|
||||||
|
);
|
||||||
|
|
||||||
const record = db.prepare('SELECT * FROM health_records WHERE id = ?').get(dbResult.lastInsertRowid);
|
const record = db.prepare('SELECT * FROM health_records WHERE id = ?').get(dbResult.lastInsertRowid);
|
||||||
|
|
||||||
res.status(201).json(record);
|
res.status(201).json(record);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
res.status(500).json({ error: error.message });
|
res.status(500).json({ error: error.message });
|
||||||
@@ -60,14 +151,26 @@ router.post('/', (req, res) => {
|
|||||||
// PUT update health record
|
// PUT update health record
|
||||||
router.put('/:id', (req, res) => {
|
router.put('/:id', (req, res) => {
|
||||||
try {
|
try {
|
||||||
const { record_type, test_name, test_date, result, document_url, notes } = req.body;
|
const {
|
||||||
|
record_type, test_type, test_name, test_date,
|
||||||
|
ofa_result, ofa_number, performed_by, expires_at,
|
||||||
|
document_url, result, vet_name, next_due, notes
|
||||||
|
} = req.body;
|
||||||
|
|
||||||
const db = getDatabase();
|
const db = getDatabase();
|
||||||
db.prepare(`
|
db.prepare(`
|
||||||
UPDATE health_records
|
UPDATE health_records
|
||||||
SET record_type = ?, test_name = ?, test_date = ?, result = ?, document_url = ?, notes = ?
|
SET record_type = ?, test_type = ?, test_name = ?, test_date = ?,
|
||||||
|
ofa_result = ?, ofa_number = ?, performed_by = ?, expires_at = ?,
|
||||||
|
document_url = ?, result = ?, vet_name = ?, next_due = ?, notes = ?,
|
||||||
|
updated_at = datetime('now')
|
||||||
WHERE id = ?
|
WHERE id = ?
|
||||||
`).run(record_type, test_name, test_date, result, document_url, notes, req.params.id);
|
`).run(
|
||||||
|
record_type, test_type || null, test_name || null, test_date,
|
||||||
|
ofa_result || null, ofa_number || null, performed_by || null, expires_at || null,
|
||||||
|
document_url || null, result || null, vet_name || null, next_due || null, notes || null,
|
||||||
|
req.params.id
|
||||||
|
);
|
||||||
|
|
||||||
const record = db.prepare('SELECT * FROM health_records WHERE id = ?').get(req.params.id);
|
const record = db.prepare('SELECT * FROM health_records WHERE id = ?').get(req.params.id);
|
||||||
res.json(record);
|
res.json(record);
|
||||||
|
|||||||
@@ -16,17 +16,13 @@ router.get('/', (req, res) => {
|
|||||||
ORDER BY l.breeding_date DESC
|
ORDER BY l.breeding_date DESC
|
||||||
`).all();
|
`).all();
|
||||||
|
|
||||||
// Get puppies for each litter using litter_id
|
|
||||||
litters.forEach(litter => {
|
litters.forEach(litter => {
|
||||||
litter.puppies = db.prepare(`
|
litter.puppies = db.prepare(`
|
||||||
SELECT * FROM dogs WHERE litter_id = ? AND is_active = 1
|
SELECT * FROM dogs WHERE litter_id = ? AND is_active = 1
|
||||||
`).all(litter.id);
|
`).all(litter.id);
|
||||||
|
|
||||||
litter.puppies.forEach(puppy => {
|
litter.puppies.forEach(puppy => {
|
||||||
puppy.photo_urls = puppy.photo_urls ? JSON.parse(puppy.photo_urls) : [];
|
puppy.photo_urls = puppy.photo_urls ? JSON.parse(puppy.photo_urls) : [];
|
||||||
});
|
});
|
||||||
|
|
||||||
// Update puppy_count based on actual puppies
|
|
||||||
litter.actual_puppy_count = litter.puppies.length;
|
litter.actual_puppy_count = litter.puppies.length;
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -36,14 +32,14 @@ router.get('/', (req, res) => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// GET single litter
|
// GET single litter with puppies
|
||||||
router.get('/:id', (req, res) => {
|
router.get('/:id', (req, res) => {
|
||||||
try {
|
try {
|
||||||
const db = getDatabase();
|
const db = getDatabase();
|
||||||
const litter = db.prepare(`
|
const litter = db.prepare(`
|
||||||
SELECT l.*,
|
SELECT l.*,
|
||||||
s.*, s.name as sire_name,
|
s.name as sire_name, s.registration_number as sire_reg, s.breed as sire_breed,
|
||||||
d.*, d.name as dam_name
|
d.name as dam_name, d.registration_number as dam_reg, d.breed as dam_breed
|
||||||
FROM litters l
|
FROM litters l
|
||||||
JOIN dogs s ON l.sire_id = s.id
|
JOIN dogs s ON l.sire_id = s.id
|
||||||
JOIN dogs d ON l.dam_id = d.id
|
JOIN dogs d ON l.dam_id = d.id
|
||||||
@@ -54,7 +50,6 @@ router.get('/:id', (req, res) => {
|
|||||||
return res.status(404).json({ error: 'Litter not found' });
|
return res.status(404).json({ error: 'Litter not found' });
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get puppies using litter_id
|
|
||||||
litter.puppies = db.prepare(`
|
litter.puppies = db.prepare(`
|
||||||
SELECT * FROM dogs WHERE litter_id = ? AND is_active = 1
|
SELECT * FROM dogs WHERE litter_id = ? AND is_active = 1
|
||||||
`).all(litter.id);
|
`).all(litter.id);
|
||||||
@@ -74,7 +69,7 @@ router.get('/:id', (req, res) => {
|
|||||||
// POST create new litter
|
// POST create new litter
|
||||||
router.post('/', (req, res) => {
|
router.post('/', (req, res) => {
|
||||||
try {
|
try {
|
||||||
const { sire_id, dam_id, breeding_date, whelping_date, notes } = req.body;
|
const { sire_id, dam_id, breeding_date, whelping_date, puppy_count, notes } = req.body;
|
||||||
|
|
||||||
if (!sire_id || !dam_id || !breeding_date) {
|
if (!sire_id || !dam_id || !breeding_date) {
|
||||||
return res.status(400).json({ error: 'Sire, dam, and breeding date are required' });
|
return res.status(400).json({ error: 'Sire, dam, and breeding date are required' });
|
||||||
@@ -82,7 +77,6 @@ router.post('/', (req, res) => {
|
|||||||
|
|
||||||
const db = getDatabase();
|
const db = getDatabase();
|
||||||
|
|
||||||
// Verify sire is male and dam is female
|
|
||||||
const sire = db.prepare('SELECT sex FROM dogs WHERE id = ?').get(sire_id);
|
const sire = db.prepare('SELECT sex FROM dogs WHERE id = ?').get(sire_id);
|
||||||
const dam = db.prepare('SELECT sex FROM dogs WHERE id = ?').get(dam_id);
|
const dam = db.prepare('SELECT sex FROM dogs WHERE id = ?').get(dam_id);
|
||||||
|
|
||||||
@@ -94,12 +88,11 @@ router.post('/', (req, res) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const result = db.prepare(`
|
const result = db.prepare(`
|
||||||
INSERT INTO litters (sire_id, dam_id, breeding_date, whelping_date, notes)
|
INSERT INTO litters (sire_id, dam_id, breeding_date, whelping_date, puppy_count, notes)
|
||||||
VALUES (?, ?, ?, ?, ?)
|
VALUES (?, ?, ?, ?, ?, ?)
|
||||||
`).run(sire_id, dam_id, breeding_date, whelping_date, notes);
|
`).run(sire_id, dam_id, breeding_date, whelping_date || null, puppy_count || 0, notes || null);
|
||||||
|
|
||||||
const litter = db.prepare('SELECT * FROM litters WHERE id = ?').get(result.lastInsertRowid);
|
const litter = db.prepare('SELECT * FROM litters WHERE id = ?').get(result.lastInsertRowid);
|
||||||
|
|
||||||
res.status(201).json(litter);
|
res.status(201).json(litter);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
res.status(500).json({ error: error.message });
|
res.status(500).json({ error: error.message });
|
||||||
@@ -110,13 +103,12 @@ router.post('/', (req, res) => {
|
|||||||
router.put('/:id', (req, res) => {
|
router.put('/:id', (req, res) => {
|
||||||
try {
|
try {
|
||||||
const { breeding_date, whelping_date, puppy_count, notes } = req.body;
|
const { breeding_date, whelping_date, puppy_count, notes } = req.body;
|
||||||
|
|
||||||
const db = getDatabase();
|
const db = getDatabase();
|
||||||
db.prepare(`
|
db.prepare(`
|
||||||
UPDATE litters
|
UPDATE litters
|
||||||
SET breeding_date = ?, whelping_date = ?, puppy_count = ?, notes = ?
|
SET breeding_date = ?, whelping_date = ?, puppy_count = ?, notes = ?
|
||||||
WHERE id = ?
|
WHERE id = ?
|
||||||
`).run(breeding_date, whelping_date, puppy_count, notes, req.params.id);
|
`).run(breeding_date, whelping_date || null, puppy_count || 0, notes || null, req.params.id);
|
||||||
|
|
||||||
const litter = db.prepare('SELECT * FROM litters WHERE id = ?').get(req.params.id);
|
const litter = db.prepare('SELECT * FROM litters WHERE id = ?').get(req.params.id);
|
||||||
res.json(litter);
|
res.json(litter);
|
||||||
@@ -131,22 +123,14 @@ router.post('/:id/puppies/:puppyId', (req, res) => {
|
|||||||
const { id: litterId, puppyId } = req.params;
|
const { id: litterId, puppyId } = req.params;
|
||||||
const db = getDatabase();
|
const db = getDatabase();
|
||||||
|
|
||||||
// Verify litter exists
|
|
||||||
const litter = db.prepare('SELECT sire_id, dam_id FROM litters WHERE id = ?').get(litterId);
|
const litter = db.prepare('SELECT sire_id, dam_id FROM litters WHERE id = ?').get(litterId);
|
||||||
if (!litter) {
|
if (!litter) return res.status(404).json({ error: 'Litter not found' });
|
||||||
return res.status(404).json({ error: 'Litter not found' });
|
|
||||||
}
|
|
||||||
|
|
||||||
// Verify puppy exists
|
|
||||||
const puppy = db.prepare('SELECT id FROM dogs WHERE id = ?').get(puppyId);
|
const puppy = db.prepare('SELECT id FROM dogs WHERE id = ?').get(puppyId);
|
||||||
if (!puppy) {
|
if (!puppy) return res.status(404).json({ error: 'Puppy not found' });
|
||||||
return res.status(404).json({ error: 'Puppy not found' });
|
|
||||||
}
|
|
||||||
|
|
||||||
// Link puppy to litter
|
|
||||||
db.prepare('UPDATE dogs SET litter_id = ? WHERE id = ?').run(litterId, puppyId);
|
db.prepare('UPDATE dogs SET litter_id = ? WHERE id = ?').run(litterId, puppyId);
|
||||||
|
|
||||||
// Also update parent relationships if not set
|
|
||||||
const existingParents = db.prepare('SELECT parent_type FROM parents WHERE dog_id = ?').all(puppyId);
|
const existingParents = db.prepare('SELECT parent_type FROM parents WHERE dog_id = ?').all(puppyId);
|
||||||
const hasSire = existingParents.some(p => p.parent_type === 'sire');
|
const hasSire = existingParents.some(p => p.parent_type === 'sire');
|
||||||
const hasDam = existingParents.some(p => p.parent_type === 'dam');
|
const hasDam = existingParents.some(p => p.parent_type === 'dam');
|
||||||
@@ -169,26 +153,77 @@ router.delete('/:id/puppies/:puppyId', (req, res) => {
|
|||||||
try {
|
try {
|
||||||
const { puppyId } = req.params;
|
const { puppyId } = req.params;
|
||||||
const db = getDatabase();
|
const db = getDatabase();
|
||||||
|
|
||||||
db.prepare('UPDATE dogs SET litter_id = NULL WHERE id = ?').run(puppyId);
|
db.prepare('UPDATE dogs SET litter_id = NULL WHERE id = ?').run(puppyId);
|
||||||
|
|
||||||
res.json({ message: 'Puppy removed from litter' });
|
res.json({ message: 'Puppy removed from litter' });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
res.status(500).json({ error: error.message });
|
res.status(500).json({ error: error.message });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ─── Puppy Weight / Health Log ───────────────────────────────────────────────
|
||||||
|
|
||||||
|
// GET weight/health logs for a puppy
|
||||||
|
router.get('/:litterId/puppies/:puppyId/logs', (req, res) => {
|
||||||
|
try {
|
||||||
|
const db = getDatabase();
|
||||||
|
// Use health_records table with note field to store weight logs
|
||||||
|
const logs = db.prepare(`
|
||||||
|
SELECT * FROM health_records
|
||||||
|
WHERE dog_id = ? AND record_type = 'weight_log'
|
||||||
|
ORDER BY record_date ASC
|
||||||
|
`).all(req.params.puppyId);
|
||||||
|
res.json(logs);
|
||||||
|
} catch (error) {
|
||||||
|
res.status(500).json({ error: error.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// POST add weight/health log entry for a puppy
|
||||||
|
router.post('/:litterId/puppies/:puppyId/logs', (req, res) => {
|
||||||
|
try {
|
||||||
|
const { puppyId } = req.params;
|
||||||
|
const { record_date, weight_oz, weight_lbs, notes, record_type } = req.body;
|
||||||
|
|
||||||
|
if (!record_date) return res.status(400).json({ error: 'record_date is required' });
|
||||||
|
|
||||||
|
const db = getDatabase();
|
||||||
|
|
||||||
|
// Store weight as notes JSON in health_records
|
||||||
|
const description = JSON.stringify({
|
||||||
|
weight_oz: weight_oz || null,
|
||||||
|
weight_lbs: weight_lbs || null,
|
||||||
|
notes: notes || ''
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = db.prepare(`
|
||||||
|
INSERT INTO health_records (dog_id, record_type, record_date, description, vet_name)
|
||||||
|
VALUES (?, ?, ?, ?, ?)
|
||||||
|
`).run(puppyId, record_type || 'weight_log', record_date, description, null);
|
||||||
|
|
||||||
|
const log = db.prepare('SELECT * FROM health_records WHERE id = ?').get(result.lastInsertRowid);
|
||||||
|
res.status(201).json(log);
|
||||||
|
} catch (error) {
|
||||||
|
res.status(500).json({ error: error.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// DELETE weight/health log entry
|
||||||
|
router.delete('/:litterId/puppies/:puppyId/logs/:logId', (req, res) => {
|
||||||
|
try {
|
||||||
|
const db = getDatabase();
|
||||||
|
db.prepare('DELETE FROM health_records WHERE id = ? AND dog_id = ?').run(req.params.logId, req.params.puppyId);
|
||||||
|
res.json({ message: 'Log entry deleted' });
|
||||||
|
} catch (error) {
|
||||||
|
res.status(500).json({ error: error.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
// DELETE litter
|
// DELETE litter
|
||||||
router.delete('/:id', (req, res) => {
|
router.delete('/:id', (req, res) => {
|
||||||
try {
|
try {
|
||||||
const db = getDatabase();
|
const db = getDatabase();
|
||||||
|
|
||||||
// Remove litter_id from associated puppies
|
|
||||||
db.prepare('UPDATE dogs SET litter_id = NULL WHERE litter_id = ?').run(req.params.id);
|
db.prepare('UPDATE dogs SET litter_id = NULL WHERE litter_id = ?').run(req.params.id);
|
||||||
|
|
||||||
// Delete the litter
|
|
||||||
db.prepare('DELETE FROM litters WHERE id = ?').run(req.params.id);
|
db.prepare('DELETE FROM litters WHERE id = ?').run(req.params.id);
|
||||||
|
|
||||||
res.json({ message: 'Litter deleted successfully' });
|
res.json({ message: 'Litter deleted successfully' });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
res.status(500).json({ error: error.message });
|
res.status(500).json({ error: error.message });
|
||||||
|
|||||||
@@ -2,73 +2,199 @@ const express = require('express');
|
|||||||
const router = express.Router();
|
const router = express.Router();
|
||||||
const { getDatabase } = require('../db/init');
|
const { getDatabase } = require('../db/init');
|
||||||
|
|
||||||
// Helper function to calculate inbreeding coefficient
|
/**
|
||||||
function calculateCOI(sireId, damId, generations = 5) {
|
* getAncestorMap(db, dogId, maxGen)
|
||||||
const db = getDatabase();
|
* Returns Map<id, [{ id, name, generation }, ...]>
|
||||||
|
* INCLUDES dogId itself at generation 0 so direct parent-offspring
|
||||||
|
* pairings are correctly detected by calculateCOI.
|
||||||
|
*/
|
||||||
|
function getAncestorMap(db, dogId, maxGen = 6) {
|
||||||
|
const map = new Map();
|
||||||
|
|
||||||
// Get all ancestors for both parents
|
function recurse(id, gen) {
|
||||||
function getAncestors(dogId, currentGen = 0, maxGen = generations) {
|
if (gen > maxGen) return;
|
||||||
if (currentGen >= maxGen) return [];
|
const dog = db.prepare('SELECT id, name FROM dogs WHERE id = ?').get(id);
|
||||||
|
if (!dog) return;
|
||||||
const parents = db.prepare(`
|
if (!map.has(id)) map.set(id, []);
|
||||||
SELECT p.parent_type, p.parent_id, d.name
|
map.get(id).push({ id: dog.id, name: dog.name, generation: gen });
|
||||||
FROM parents p
|
if (map.get(id).length === 1) {
|
||||||
JOIN dogs d ON p.parent_id = d.id
|
const parents = db.prepare(`
|
||||||
WHERE p.dog_id = ?
|
SELECT p.parent_id FROM parents p WHERE p.dog_id = ?
|
||||||
`).all(dogId);
|
`).all(id);
|
||||||
|
parents.forEach(p => recurse(p.parent_id, gen + 1));
|
||||||
const ancestors = parents.map(p => ({
|
}
|
||||||
id: p.parent_id,
|
|
||||||
name: p.name,
|
|
||||||
type: p.parent_type,
|
|
||||||
generation: currentGen + 1
|
|
||||||
}));
|
|
||||||
|
|
||||||
parents.forEach(p => {
|
|
||||||
ancestors.push(...getAncestors(p.parent_id, currentGen + 1, maxGen));
|
|
||||||
});
|
|
||||||
|
|
||||||
return ancestors;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const sireAncestors = getAncestors(sireId);
|
recurse(parseInt(dogId), 0);
|
||||||
const damAncestors = getAncestors(damId);
|
return map;
|
||||||
|
}
|
||||||
|
|
||||||
// Find common ancestors
|
/**
|
||||||
const commonAncestors = [];
|
* isDirectRelation(db, sireId, damId)
|
||||||
sireAncestors.forEach(sireAnc => {
|
* Returns { related, relationship } if one dog is a direct ancestor
|
||||||
damAncestors.forEach(damAnc => {
|
* of the other within 3 generations.
|
||||||
if (sireAnc.id === damAnc.id) {
|
*/
|
||||||
commonAncestors.push({
|
function isDirectRelation(db, sireId, damId) {
|
||||||
id: sireAnc.id,
|
const sid = parseInt(sireId);
|
||||||
name: sireAnc.name,
|
const did = parseInt(damId);
|
||||||
sireGen: sireAnc.generation,
|
const sireMap = getAncestorMap(db, sid, 3);
|
||||||
damGen: damAnc.generation
|
const damMap = getAncestorMap(db, did, 3);
|
||||||
});
|
|
||||||
}
|
if (damMap.has(sid)) {
|
||||||
});
|
const gen = damMap.get(sid)[0].generation;
|
||||||
});
|
const label = gen === 1 ? 'parent' : gen === 2 ? 'grandparent' : `generation-${gen} ancestor`;
|
||||||
|
return { related: true, relationship: `Sire is the ${label} of the selected dam` };
|
||||||
|
}
|
||||||
|
if (sireMap.has(did)) {
|
||||||
|
const gen = sireMap.get(did)[0].generation;
|
||||||
|
const label = gen === 1 ? 'parent' : gen === 2 ? 'grandparent' : `generation-${gen} ancestor`;
|
||||||
|
return { related: true, relationship: `Dam is the ${label} of the selected sire` };
|
||||||
|
}
|
||||||
|
return { related: false, relationship: null };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* calculateCOI(db, sireId, damId)
|
||||||
|
* Wright Path Coefficient method.
|
||||||
|
* Dogs included at gen 0 in their own maps so parent x offspring
|
||||||
|
* yields ~25% COI.
|
||||||
|
*
|
||||||
|
* Fix: do NOT exclude sid/did from commonIds globally.
|
||||||
|
* - Exclude `did` from sireMap keys (the dam itself can't be a
|
||||||
|
* common ancestor of the sire's side for THIS pairing's offspring)
|
||||||
|
* - Exclude `sid` from damMap keys (same logic for sire)
|
||||||
|
* This preserves the case where the sire IS a common ancestor in the
|
||||||
|
* dam's ancestry (parent x offspring) while still avoiding reflexive
|
||||||
|
* self-loops.
|
||||||
|
*/
|
||||||
|
function calculateCOI(db, sireId, damId) {
|
||||||
|
const sid = parseInt(sireId);
|
||||||
|
const did = parseInt(damId);
|
||||||
|
const sireMap = getAncestorMap(db, sid);
|
||||||
|
const damMap = getAncestorMap(db, did);
|
||||||
|
|
||||||
|
// Common ancestors: in BOTH maps, but:
|
||||||
|
// - not the dam itself appearing in sireMap (would be a loop)
|
||||||
|
// - not the sire itself appearing in damMap already handled below
|
||||||
|
// We collect all IDs present in both, excluding only the direct
|
||||||
|
// subjects (did from sireMap side, sid excluded already since we
|
||||||
|
// iterate sireMap keys — but sid IS in sireMap at gen 0, and if
|
||||||
|
// damMap also has sid, that is the parent×offspring case we WANT).
|
||||||
|
const commonIds = [...sireMap.keys()].filter(
|
||||||
|
id => damMap.has(id) && id !== did
|
||||||
|
);
|
||||||
|
|
||||||
// Calculate COI using path coefficient method
|
|
||||||
let coi = 0;
|
let coi = 0;
|
||||||
const processed = new Set();
|
const processedPaths = new Set();
|
||||||
|
const commonAncestorList = [];
|
||||||
|
|
||||||
commonAncestors.forEach(anc => {
|
commonIds.forEach(ancId => {
|
||||||
const key = `${anc.id}-${anc.sireGen}-${anc.damGen}`;
|
const sireOccs = sireMap.get(ancId);
|
||||||
if (!processed.has(key)) {
|
const damOccs = damMap.get(ancId);
|
||||||
processed.add(key);
|
|
||||||
const pathLength = anc.sireGen + anc.damGen;
|
sireOccs.forEach(so => {
|
||||||
coi += Math.pow(0.5, pathLength);
|
damOccs.forEach(do_ => {
|
||||||
}
|
const key = `${ancId}-${so.generation}-${do_.generation}`;
|
||||||
|
if (!processedPaths.has(key)) {
|
||||||
|
processedPaths.add(key);
|
||||||
|
coi += Math.pow(0.5, so.generation + do_.generation + 1);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
const closestSire = sireOccs.reduce((a, b) => a.generation < b.generation ? a : b);
|
||||||
|
const closestDam = damOccs.reduce((a, b) => a.generation < b.generation ? a : b);
|
||||||
|
commonAncestorList.push({
|
||||||
|
id: ancId,
|
||||||
|
name: sireOccs[0].name,
|
||||||
|
sireGen: closestSire.generation,
|
||||||
|
damGen: closestDam.generation
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
return {
|
return {
|
||||||
coefficient: Math.round(coi * 10000) / 100, // Percentage with 2 decimals
|
coefficient: coi,
|
||||||
commonAncestors: [...new Map(commonAncestors.map(a => [a.id, a])).values()]
|
commonAncestors: commonAncestorList
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// GET pedigree tree for a dog
|
// =====================================================================
|
||||||
|
// IMPORTANT: Specific named routes MUST be registered BEFORE
|
||||||
|
// the /:id wildcard, or Express will match 'relations' and
|
||||||
|
// 'trial-pairing' as dog IDs and return 404/wrong data.
|
||||||
|
// =====================================================================
|
||||||
|
|
||||||
|
// POST /api/pedigree/trial-pairing (alias for /coi)
|
||||||
|
router.post(['/trial-pairing', '/coi'], (req, res) => {
|
||||||
|
try {
|
||||||
|
const { sire_id, dam_id } = req.body;
|
||||||
|
if (!sire_id || !dam_id) {
|
||||||
|
return res.status(400).json({ error: 'Both sire_id and dam_id are required' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const db = getDatabase();
|
||||||
|
const sire = db.prepare("SELECT * FROM dogs WHERE id = ? AND sex = 'male'").get(sire_id);
|
||||||
|
const dam = db.prepare("SELECT * FROM dogs WHERE id = ? AND sex = 'female'").get(dam_id);
|
||||||
|
|
||||||
|
if (!sire || !dam) {
|
||||||
|
return res.status(404).json({ error: 'Invalid sire or dam \u2014 check sex values in database' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const relation = isDirectRelation(db, sire_id, dam_id);
|
||||||
|
const result = calculateCOI(db, sire_id, dam_id);
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
sire: { id: sire.id, name: sire.name },
|
||||||
|
dam: { id: dam.id, name: dam.name },
|
||||||
|
coi: result.coefficient,
|
||||||
|
commonAncestors: result.commonAncestors,
|
||||||
|
directRelation: relation.related ? relation.relationship : null,
|
||||||
|
recommendation: result.coefficient < 0.05 ? 'Low risk'
|
||||||
|
: result.coefficient < 0.10 ? 'Moderate risk'
|
||||||
|
: 'High risk'
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
res.status(500).json({ error: error.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// GET /api/pedigree/:id/coi
|
||||||
|
router.get('/:id/coi', (req, res) => {
|
||||||
|
try {
|
||||||
|
const db = getDatabase();
|
||||||
|
const parents = db.prepare('SELECT parent_type, parent_id FROM parents WHERE dog_id = ?').all(req.params.id);
|
||||||
|
const sire = parents.find(p => p.parent_type === 'sire');
|
||||||
|
const dam = parents.find(p => p.parent_type === 'dam');
|
||||||
|
|
||||||
|
if (!sire || !dam) {
|
||||||
|
return res.json({ coi: 0, commonAncestors: [], message: 'Incomplete parent data' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = calculateCOI(db, sire.parent_id, dam.parent_id);
|
||||||
|
res.json({
|
||||||
|
coi: result.coefficient,
|
||||||
|
commonAncestors: result.commonAncestors
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
res.status(500).json({ error: error.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// GET /api/pedigree/relations/:sireId/:damId
|
||||||
|
router.get('/relations/:sireId/:damId', (req, res) => {
|
||||||
|
try {
|
||||||
|
const db = getDatabase();
|
||||||
|
res.json(isDirectRelation(db, req.params.sireId, req.params.damId));
|
||||||
|
} catch (error) {
|
||||||
|
res.status(500).json({ error: error.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// =====================================================================
|
||||||
|
// Wildcard routes last
|
||||||
|
// =====================================================================
|
||||||
|
|
||||||
|
// GET /api/pedigree/:id
|
||||||
router.get('/:id', (req, res) => {
|
router.get('/:id', (req, res) => {
|
||||||
try {
|
try {
|
||||||
const db = getDatabase();
|
const db = getDatabase();
|
||||||
@@ -76,42 +202,29 @@ router.get('/:id', (req, res) => {
|
|||||||
|
|
||||||
function buildTree(dogId, currentGen = 0) {
|
function buildTree(dogId, currentGen = 0) {
|
||||||
if (currentGen >= generations) return null;
|
if (currentGen >= generations) return null;
|
||||||
|
|
||||||
const dog = db.prepare('SELECT * FROM dogs WHERE id = ?').get(dogId);
|
const dog = db.prepare('SELECT * FROM dogs WHERE id = ?').get(dogId);
|
||||||
if (!dog) return null;
|
if (!dog) return null;
|
||||||
|
|
||||||
dog.photo_urls = dog.photo_urls ? JSON.parse(dog.photo_urls) : [];
|
dog.photo_urls = dog.photo_urls ? JSON.parse(dog.photo_urls) : [];
|
||||||
|
const parents = db.prepare('SELECT parent_type, parent_id FROM parents WHERE dog_id = ?').all(dogId);
|
||||||
const parents = db.prepare(`
|
|
||||||
SELECT p.parent_type, p.parent_id
|
|
||||||
FROM parents p
|
|
||||||
WHERE p.dog_id = ?
|
|
||||||
`).all(dogId);
|
|
||||||
|
|
||||||
const sire = parents.find(p => p.parent_type === 'sire');
|
const sire = parents.find(p => p.parent_type === 'sire');
|
||||||
const dam = parents.find(p => p.parent_type === 'dam');
|
const dam = parents.find(p => p.parent_type === 'dam');
|
||||||
|
|
||||||
return {
|
return {
|
||||||
...dog,
|
...dog,
|
||||||
generation: currentGen,
|
generation: currentGen,
|
||||||
sire: sire ? buildTree(sire.parent_id, currentGen + 1) : null,
|
sire: sire ? buildTree(sire.parent_id, currentGen + 1) : null,
|
||||||
dam: dam ? buildTree(dam.parent_id, currentGen + 1) : null
|
dam: dam ? buildTree(dam.parent_id, currentGen + 1) : null
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
const tree = buildTree(req.params.id);
|
const tree = buildTree(req.params.id);
|
||||||
|
if (!tree) return res.status(404).json({ error: 'Dog not found' });
|
||||||
if (!tree) {
|
|
||||||
return res.status(404).json({ error: 'Dog not found' });
|
|
||||||
}
|
|
||||||
|
|
||||||
res.json(tree);
|
res.json(tree);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
res.status(500).json({ error: error.message });
|
res.status(500).json({ error: error.message });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// GET reverse pedigree (descendants)
|
// GET /api/pedigree/:id/descendants
|
||||||
router.get('/:id/descendants', (req, res) => {
|
router.get('/:id/descendants', (req, res) => {
|
||||||
try {
|
try {
|
||||||
const db = getDatabase();
|
const db = getDatabase();
|
||||||
@@ -119,67 +232,27 @@ router.get('/:id/descendants', (req, res) => {
|
|||||||
|
|
||||||
function buildDescendantTree(dogId, currentGen = 0) {
|
function buildDescendantTree(dogId, currentGen = 0) {
|
||||||
if (currentGen >= generations) return null;
|
if (currentGen >= generations) return null;
|
||||||
|
|
||||||
const dog = db.prepare('SELECT * FROM dogs WHERE id = ?').get(dogId);
|
const dog = db.prepare('SELECT * FROM dogs WHERE id = ?').get(dogId);
|
||||||
if (!dog) return null;
|
if (!dog) return null;
|
||||||
|
|
||||||
dog.photo_urls = dog.photo_urls ? JSON.parse(dog.photo_urls) : [];
|
dog.photo_urls = dog.photo_urls ? JSON.parse(dog.photo_urls) : [];
|
||||||
|
|
||||||
const offspring = db.prepare(`
|
const offspring = db.prepare(`
|
||||||
SELECT DISTINCT d.id, d.name, d.sex, d.birth_date
|
SELECT DISTINCT d.id, d.name, d.sex, d.birth_date
|
||||||
FROM dogs d
|
FROM dogs d JOIN parents p ON d.id = p.dog_id
|
||||||
JOIN parents p ON d.id = p.dog_id
|
|
||||||
WHERE p.parent_id = ? AND d.is_active = 1
|
WHERE p.parent_id = ? AND d.is_active = 1
|
||||||
`).all(dogId);
|
`).all(dogId);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
...dog,
|
...dog,
|
||||||
generation: currentGen,
|
generation: currentGen,
|
||||||
offspring: offspring.map(child => buildDescendantTree(child.id, currentGen + 1))
|
offspring: offspring.map(c => buildDescendantTree(c.id, currentGen + 1))
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
const tree = buildDescendantTree(req.params.id);
|
const tree = buildDescendantTree(req.params.id);
|
||||||
|
if (!tree) return res.status(404).json({ error: 'Dog not found' });
|
||||||
if (!tree) {
|
|
||||||
return res.status(404).json({ error: 'Dog not found' });
|
|
||||||
}
|
|
||||||
|
|
||||||
res.json(tree);
|
res.json(tree);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
res.status(500).json({ error: error.message });
|
res.status(500).json({ error: error.message });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// POST calculate COI for a trial pairing
|
|
||||||
router.post('/trial-pairing', (req, res) => {
|
|
||||||
try {
|
|
||||||
const { sire_id, dam_id } = req.body;
|
|
||||||
|
|
||||||
if (!sire_id || !dam_id) {
|
|
||||||
return res.status(400).json({ error: 'Both sire_id and dam_id are required' });
|
|
||||||
}
|
|
||||||
|
|
||||||
const db = getDatabase();
|
|
||||||
const sire = db.prepare('SELECT * FROM dogs WHERE id = ? AND sex = "male"').get(sire_id);
|
|
||||||
const dam = db.prepare('SELECT * FROM dogs WHERE id = ? AND sex = "female"').get(dam_id);
|
|
||||||
|
|
||||||
if (!sire || !dam) {
|
|
||||||
return res.status(404).json({ error: 'Invalid sire or dam' });
|
|
||||||
}
|
|
||||||
|
|
||||||
const result = calculateCOI(sire_id, dam_id);
|
|
||||||
|
|
||||||
res.json({
|
|
||||||
sire: { id: sire.id, name: sire.name },
|
|
||||||
dam: { id: dam.id, name: dam.name },
|
|
||||||
coi: result.coefficient,
|
|
||||||
commonAncestors: result.commonAncestors,
|
|
||||||
recommendation: result.coefficient < 5 ? 'Low risk' : result.coefficient < 10 ? 'Moderate risk' : 'High risk'
|
|
||||||
});
|
|
||||||
} catch (error) {
|
|
||||||
res.status(500).json({ error: error.message });
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
module.exports = router;
|
module.exports = router;
|
||||||
64
server/routes/settings.js
Normal file
64
server/routes/settings.js
Normal file
@@ -0,0 +1,64 @@
|
|||||||
|
const express = require('express');
|
||||||
|
const router = express.Router();
|
||||||
|
const { getDatabase } = require('../db/init');
|
||||||
|
|
||||||
|
// Allowed columns — whitelist prevents arbitrary SQL column injection
|
||||||
|
const ALLOWED_KEYS = [
|
||||||
|
'kennel_name',
|
||||||
|
'kennel_tagline',
|
||||||
|
'kennel_address',
|
||||||
|
'kennel_phone',
|
||||||
|
'kennel_email',
|
||||||
|
'kennel_website',
|
||||||
|
'kennel_akc_id',
|
||||||
|
'kennel_breed',
|
||||||
|
'owner_name',
|
||||||
|
];
|
||||||
|
|
||||||
|
// GET /api/settings
|
||||||
|
router.get('/', (req, res) => {
|
||||||
|
try {
|
||||||
|
const db = getDatabase();
|
||||||
|
// Always returns exactly one row (seeded in init.js)
|
||||||
|
const row = db.prepare(`SELECT ${ALLOWED_KEYS.join(', ')} FROM settings LIMIT 1`).get();
|
||||||
|
res.json(row || {});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error fetching settings:', error);
|
||||||
|
res.status(500).json({ error: error.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// PUT /api/settings
|
||||||
|
router.put('/', (req, res) => {
|
||||||
|
try {
|
||||||
|
const db = getDatabase();
|
||||||
|
const updates = req.body || {};
|
||||||
|
|
||||||
|
// Build SET clause only for allowed keys that were sent
|
||||||
|
const fields = Object.keys(updates).filter(k => ALLOWED_KEYS.includes(k));
|
||||||
|
|
||||||
|
if (fields.length === 0) {
|
||||||
|
return res.status(400).json({ error: 'No valid settings fields provided' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const setClause = fields.map(f => `${f} = ?`).join(', ');
|
||||||
|
const values = fields.map(f => updates[f] == null ? null : String(updates[f]));
|
||||||
|
|
||||||
|
// Ensure a row exists, then update it
|
||||||
|
const existing = db.prepare('SELECT id FROM settings LIMIT 1').get();
|
||||||
|
if (!existing) {
|
||||||
|
db.prepare(`INSERT INTO settings (kennel_name) VALUES ('BREEDR')`).run();
|
||||||
|
}
|
||||||
|
|
||||||
|
db.prepare(`UPDATE settings SET ${setClause}, updated_at = datetime('now') WHERE id = (SELECT id FROM settings LIMIT 1)`)
|
||||||
|
.run(...values);
|
||||||
|
|
||||||
|
const row = db.prepare(`SELECT ${ALLOWED_KEYS.join(', ')} FROM settings LIMIT 1`).get();
|
||||||
|
res.json(row || {});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error saving settings:', error);
|
||||||
|
res.status(500).json({ error: error.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
module.exports = router;
|
||||||
167
server/utils/README.md
Normal file
167
server/utils/README.md
Normal file
@@ -0,0 +1,167 @@
|
|||||||
|
# Server Utilities
|
||||||
|
|
||||||
|
## Startup Log (`startupLog.js`)
|
||||||
|
|
||||||
|
Comprehensive server startup logging utility that displays system information, configuration, and health checks on application boot.
|
||||||
|
|
||||||
|
### Features
|
||||||
|
|
||||||
|
- **ASCII Banner** - Eye-catching branded header with BREEDR logo
|
||||||
|
- **Application Info** - Version, environment, timestamp, Node.js version
|
||||||
|
- **Server Configuration** - Port, access URL, database status
|
||||||
|
- **Directory Status** - Checks existence and write permissions for data/uploads/static directories
|
||||||
|
- **System Resources** - Hostname, platform, architecture, CPU, memory
|
||||||
|
- **Process Info** - PID, heap usage, uptime
|
||||||
|
|
||||||
|
### Usage
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
const { logStartupBanner } = require('./utils/startupLog');
|
||||||
|
|
||||||
|
app.listen(PORT, '0.0.0.0', () => {
|
||||||
|
logStartupBanner({
|
||||||
|
appName: 'BREEDR',
|
||||||
|
port: PORT,
|
||||||
|
environment: process.env.NODE_ENV || 'development',
|
||||||
|
dataDir: DATA_DIR,
|
||||||
|
uploadPath: UPLOAD_PATH,
|
||||||
|
staticPath: STATIC_PATH,
|
||||||
|
dbStatus: '✓ Connected'
|
||||||
|
});
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
### Configuration Options
|
||||||
|
|
||||||
|
| Option | Type | Default | Description |
|
||||||
|
|--------|------|---------|-------------|
|
||||||
|
| `appName` | string | `'BREEDR'` | Application name |
|
||||||
|
| `port` | number | `3000` | Server port |
|
||||||
|
| `environment` | string | `'development'` | Environment (development/production) |
|
||||||
|
| `dataDir` | string | `'./data'` | Data directory path |
|
||||||
|
| `uploadPath` | string | `'./uploads'` | Uploads directory path |
|
||||||
|
| `staticPath` | string | `'./static'` | Static assets directory path |
|
||||||
|
| `dbStatus` | string | `'unknown'` | Database connection status |
|
||||||
|
|
||||||
|
### Exported Functions
|
||||||
|
|
||||||
|
#### `logStartupBanner(config)`
|
||||||
|
|
||||||
|
Displays the complete startup banner with all system information.
|
||||||
|
|
||||||
|
**Parameters:**
|
||||||
|
- `config` (object) - Configuration options (see table above)
|
||||||
|
|
||||||
|
**Returns:** void
|
||||||
|
|
||||||
|
#### `getSystemInfo()`
|
||||||
|
|
||||||
|
Returns system information object.
|
||||||
|
|
||||||
|
**Returns:**
|
||||||
|
```javascript
|
||||||
|
{
|
||||||
|
hostname: string,
|
||||||
|
platform: string,
|
||||||
|
arch: string,
|
||||||
|
nodeVersion: string,
|
||||||
|
cpuCores: number,
|
||||||
|
totalMemory: string, // in GB
|
||||||
|
freeMemory: string, // in GB
|
||||||
|
uptime: string // in seconds
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### `getProcessInfo()`
|
||||||
|
|
||||||
|
Returns current process information.
|
||||||
|
|
||||||
|
**Returns:**
|
||||||
|
```javascript
|
||||||
|
{
|
||||||
|
pid: number,
|
||||||
|
heapUsed: string, // in MB
|
||||||
|
heapTotal: string, // in MB
|
||||||
|
external: string // in MB
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### `checkDirectories(dirs)`
|
||||||
|
|
||||||
|
Checks directory existence and write permissions.
|
||||||
|
|
||||||
|
**Parameters:**
|
||||||
|
- `dirs` (array) - Array of `{ name, path }` objects
|
||||||
|
|
||||||
|
**Returns:**
|
||||||
|
```javascript
|
||||||
|
{
|
||||||
|
[name]: {
|
||||||
|
exists: boolean,
|
||||||
|
path: string,
|
||||||
|
writable: boolean
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### `getAppVersion()`
|
||||||
|
|
||||||
|
Reads version from package.json.
|
||||||
|
|
||||||
|
**Returns:** string - Version number or 'unknown'
|
||||||
|
|
||||||
|
### Example Output
|
||||||
|
|
||||||
|
```
|
||||||
|
╔══════════════════════════════════════════════════════════╗
|
||||||
|
║ ║
|
||||||
|
║ ██████╗ ██████╗ ███████╗███████╗██████╗ ██████╗ ║
|
||||||
|
║ Dog Breeding Genealogy Management System ║
|
||||||
|
╚══════════════════════════════════════════════════════════╝
|
||||||
|
|
||||||
|
┌─────────────────────────────────────────────────────────┐
|
||||||
|
│ 📦 APPLICATION INFO │
|
||||||
|
├─────────────────────────────────────────────────────────┤
|
||||||
|
│ Version : 0.6.0 │
|
||||||
|
│ Environment : production │
|
||||||
|
│ Node.js : v18.19.0 │
|
||||||
|
└─────────────────────────────────────────────────────────┘
|
||||||
|
|
||||||
|
┌─────────────────────────────────────────────────────────┐
|
||||||
|
│ 🌐 SERVER CONFIGURATION │
|
||||||
|
├─────────────────────────────────────────────────────────┤
|
||||||
|
│ Port : 3000 │
|
||||||
|
│ Access URL : http://localhost:3000 │
|
||||||
|
│ Database : ✓ Connected │
|
||||||
|
└─────────────────────────────────────────────────────────┘
|
||||||
|
|
||||||
|
🚀 Server is ready and listening for connections
|
||||||
|
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||||||
|
```
|
||||||
|
|
||||||
|
### Benefits
|
||||||
|
|
||||||
|
1. **Instant System Visibility** - See all critical system info at startup
|
||||||
|
2. **Troubleshooting** - Quickly identify configuration or resource issues
|
||||||
|
3. **Professional Logging** - Clean, organized output for production environments
|
||||||
|
4. **Directory Health** - Immediate feedback on filesystem permissions
|
||||||
|
5. **Resource Monitoring** - Memory and process info at a glance
|
||||||
|
|
||||||
|
### Integration Checklist
|
||||||
|
|
||||||
|
- [x] Create `server/utils/startupLog.js`
|
||||||
|
- [x] Update `server/index.js` to import and call `logStartupBanner()`
|
||||||
|
- [x] Replace simple console.log startup with comprehensive banner
|
||||||
|
- [x] Test in development environment
|
||||||
|
- [ ] Test in production Docker container
|
||||||
|
- [ ] Verify all directory checks work correctly
|
||||||
|
- [ ] Update main README.md if needed
|
||||||
|
|
||||||
|
### Future Enhancements
|
||||||
|
|
||||||
|
- [ ] Add color support using chalk or similar library
|
||||||
|
- [ ] Log to file option for production environments
|
||||||
|
- [ ] Add API endpoint status checks
|
||||||
|
- [ ] Display loaded routes count
|
||||||
|
- [ ] Show database migration status
|
||||||
|
- [ ] Add startup time measurement
|
||||||
177
server/utils/startupLog.js
Normal file
177
server/utils/startupLog.js
Normal file
@@ -0,0 +1,177 @@
|
|||||||
|
const os = require('os');
|
||||||
|
const path = require('path');
|
||||||
|
const fs = require('fs');
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Startup Log Utility
|
||||||
|
* Displays comprehensive system information and branding on server start
|
||||||
|
*/
|
||||||
|
|
||||||
|
function getSystemInfo() {
|
||||||
|
return {
|
||||||
|
hostname: os.hostname(),
|
||||||
|
platform: os.platform(),
|
||||||
|
arch: os.arch(),
|
||||||
|
nodeVersion: process.version,
|
||||||
|
cpuCores: os.cpus().length,
|
||||||
|
totalMemory: (os.totalmem() / 1024 / 1024 / 1024).toFixed(2) + ' GB',
|
||||||
|
freeMemory: (os.freemem() / 1024 / 1024 / 1024).toFixed(2) + ' GB',
|
||||||
|
uptime: process.uptime().toFixed(2) + 's'
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function getProcessInfo() {
|
||||||
|
const memUsage = process.memoryUsage();
|
||||||
|
return {
|
||||||
|
pid: process.pid,
|
||||||
|
heapUsed: (memUsage.heapUsed / 1024 / 1024).toFixed(2) + ' MB',
|
||||||
|
heapTotal: (memUsage.heapTotal / 1024 / 1024).toFixed(2) + ' MB',
|
||||||
|
external: (memUsage.external / 1024 / 1024).toFixed(2) + ' MB'
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function checkDirectories(dirs) {
|
||||||
|
const status = {};
|
||||||
|
dirs.forEach(({ name, path: dirPath }) => {
|
||||||
|
status[name] = {
|
||||||
|
exists: fs.existsSync(dirPath),
|
||||||
|
path: dirPath,
|
||||||
|
writable: false
|
||||||
|
};
|
||||||
|
|
||||||
|
// Check write permissions if directory exists
|
||||||
|
if (status[name].exists) {
|
||||||
|
try {
|
||||||
|
fs.accessSync(dirPath, fs.constants.W_OK);
|
||||||
|
status[name].writable = true;
|
||||||
|
} catch (err) {
|
||||||
|
status[name].writable = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return status;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getAppVersion() {
|
||||||
|
try {
|
||||||
|
const packagePath = path.join(__dirname, '../../package.json');
|
||||||
|
if (fs.existsSync(packagePath)) {
|
||||||
|
const pkg = JSON.parse(fs.readFileSync(packagePath, 'utf8'));
|
||||||
|
return pkg.version || 'unknown';
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
// Silently fail
|
||||||
|
}
|
||||||
|
return 'unknown';
|
||||||
|
}
|
||||||
|
|
||||||
|
function logStartupBanner(config = {}) {
|
||||||
|
const {
|
||||||
|
appName = 'BREEDR',
|
||||||
|
port = 3000,
|
||||||
|
environment = 'development',
|
||||||
|
dataDir = './data',
|
||||||
|
uploadPath = './uploads',
|
||||||
|
staticPath = './static',
|
||||||
|
dbStatus = 'unknown'
|
||||||
|
} = config;
|
||||||
|
|
||||||
|
const version = getAppVersion();
|
||||||
|
const sysInfo = getSystemInfo();
|
||||||
|
const procInfo = getProcessInfo();
|
||||||
|
const timestamp = new Date().toISOString();
|
||||||
|
|
||||||
|
const directories = [
|
||||||
|
{ name: 'Data', path: dataDir },
|
||||||
|
{ name: 'Uploads', path: uploadPath },
|
||||||
|
{ name: 'Static', path: staticPath }
|
||||||
|
];
|
||||||
|
const dirStatus = checkDirectories(directories);
|
||||||
|
|
||||||
|
// ASCII Banner
|
||||||
|
console.log('\n');
|
||||||
|
console.log('╔══════════════════════════════════════════════════════════╗');
|
||||||
|
console.log('║ ║');
|
||||||
|
console.log('║ ██████╗ ██████╗ ███████╗███████╗██████╗ ██████╗ ║');
|
||||||
|
console.log('║ ██╔══██╗██╔══██╗██╔════╝██╔════╝██╔══██╗██╔══██╗ ║');
|
||||||
|
console.log('║ ██████╔╝██████╔╝█████╗ █████╗ ██║ ██║██████╔╝ ║');
|
||||||
|
console.log('║ ██╔══██╗██╔══██╗██╔══╝ ██╔══╝ ██║ ██║██╔══██╗ ║');
|
||||||
|
console.log('║ ██████╔╝██║ ██║███████╗███████╗██████╔╝██║ ██║ ║');
|
||||||
|
console.log('║ ╚═════╝ ╚═╝ ╚═╝╚══════╝╚══════╝╚═════╝ ╚═╝ ╚═╝ ║');
|
||||||
|
console.log('║ ║');
|
||||||
|
console.log('║ Dog Breeding Genealogy Management System ║');
|
||||||
|
console.log('║ ║');
|
||||||
|
console.log('╚══════════════════════════════════════════════════════════╝');
|
||||||
|
console.log('');
|
||||||
|
|
||||||
|
// Application Info
|
||||||
|
console.log('┌─────────────────────────────────────────────────────────┐');
|
||||||
|
console.log('│ 📦 APPLICATION INFO │');
|
||||||
|
console.log('├─────────────────────────────────────────────────────────┤');
|
||||||
|
console.log(`│ Version : ${version.padEnd(40)} │`);
|
||||||
|
console.log(`│ Environment : ${environment.padEnd(40)} │`);
|
||||||
|
console.log(`│ Started : ${timestamp.padEnd(40)} │`);
|
||||||
|
console.log(`│ Node.js : ${sysInfo.nodeVersion.padEnd(40)} │`);
|
||||||
|
console.log('└─────────────────────────────────────────────────────────┘');
|
||||||
|
console.log('');
|
||||||
|
|
||||||
|
// Server Configuration
|
||||||
|
console.log('┌─────────────────────────────────────────────────────────┐');
|
||||||
|
console.log('│ 🌐 SERVER CONFIGURATION │');
|
||||||
|
console.log('├─────────────────────────────────────────────────────────┤');
|
||||||
|
console.log(`│ Port : ${String(port).padEnd(40)} │`);
|
||||||
|
console.log(`│ Access URL : http://localhost:${port}${' '.repeat(27)} │`);
|
||||||
|
console.log(`│ Database : ${dbStatus.padEnd(40)} │`);
|
||||||
|
console.log('└─────────────────────────────────────────────────────────┘');
|
||||||
|
console.log('');
|
||||||
|
|
||||||
|
// Directory Status
|
||||||
|
console.log('┌─────────────────────────────────────────────────────────┐');
|
||||||
|
console.log('│ 📁 DIRECTORY STATUS │');
|
||||||
|
console.log('├─────────────────────────────────────────────────────────┤');
|
||||||
|
Object.entries(dirStatus).forEach(([name, status]) => {
|
||||||
|
const statusIcon = status.exists ? (status.writable ? '✓' : '⚠') : '✗';
|
||||||
|
const statusText = status.exists ? (status.writable ? 'OK' : 'READ-ONLY') : 'MISSING';
|
||||||
|
console.log(`│ ${statusIcon} ${name.padEnd(10)} : ${statusText.padEnd(10)} ${status.path.substring(0, 25).padEnd(25)} │`);
|
||||||
|
});
|
||||||
|
console.log('└─────────────────────────────────────────────────────────┘');
|
||||||
|
console.log('');
|
||||||
|
|
||||||
|
// System Resources
|
||||||
|
console.log('┌─────────────────────────────────────────────────────────┐');
|
||||||
|
console.log('│ 💻 SYSTEM RESOURCES │');
|
||||||
|
console.log('├─────────────────────────────────────────────────────────┤');
|
||||||
|
console.log(`│ Hostname : ${sysInfo.hostname.padEnd(40)} │`);
|
||||||
|
console.log(`│ Platform : ${sysInfo.platform.padEnd(40)} │`);
|
||||||
|
console.log(`│ Architecture : ${sysInfo.arch.padEnd(40)} │`);
|
||||||
|
console.log(`│ CPU Cores : ${String(sysInfo.cpuCores).padEnd(40)} │`);
|
||||||
|
console.log(`│ Total Memory : ${sysInfo.totalMemory.padEnd(40)} │`);
|
||||||
|
console.log(`│ Free Memory : ${sysInfo.freeMemory.padEnd(40)} │`);
|
||||||
|
console.log('└─────────────────────────────────────────────────────────┘');
|
||||||
|
console.log('');
|
||||||
|
|
||||||
|
// Process Info
|
||||||
|
console.log('┌─────────────────────────────────────────────────────────┐');
|
||||||
|
console.log('│ ⚙️ PROCESS INFO │');
|
||||||
|
console.log('├─────────────────────────────────────────────────────────┤');
|
||||||
|
console.log(`│ PID : ${String(procInfo.pid).padEnd(40)} │`);
|
||||||
|
console.log(`│ Heap Used : ${procInfo.heapUsed.padEnd(40)} │`);
|
||||||
|
console.log(`│ Heap Total : ${procInfo.heapTotal.padEnd(40)} │`);
|
||||||
|
console.log(`│ External : ${procInfo.external.padEnd(40)} │`);
|
||||||
|
console.log(`│ Uptime : ${sysInfo.uptime.padEnd(40)} │`);
|
||||||
|
console.log('└─────────────────────────────────────────────────────────┘');
|
||||||
|
console.log('');
|
||||||
|
|
||||||
|
// Ready message
|
||||||
|
console.log('🚀 Server is ready and listening for connections');
|
||||||
|
console.log('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━');
|
||||||
|
console.log('');
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
logStartupBanner,
|
||||||
|
getSystemInfo,
|
||||||
|
getProcessInfo,
|
||||||
|
checkDirectories,
|
||||||
|
getAppVersion
|
||||||
|
};
|
||||||
Reference in New Issue
Block a user