feature/dog-forms #1
234
ROADMAP.md
Normal file
234
ROADMAP.md
Normal file
@@ -0,0 +1,234 @@
|
|||||||
|
# BREEDR Development Roadmap
|
||||||
|
|
||||||
|
## ✅ Phase 1: Foundation (COMPLETE)
|
||||||
|
|
||||||
|
### Infrastructure
|
||||||
|
- [x] Docker multi-stage build configuration
|
||||||
|
- [x] SQLite database with automatic initialization
|
||||||
|
- [x] Express.js API server
|
||||||
|
- [x] React 18 frontend with Vite
|
||||||
|
- [x] Git repository structure
|
||||||
|
|
||||||
|
### Database Schema
|
||||||
|
- [x] Dogs table with core fields
|
||||||
|
- [x] Parents relationship table
|
||||||
|
- [x] Litters breeding records
|
||||||
|
- [x] Health records tracking
|
||||||
|
- [x] Heat cycles management
|
||||||
|
- [x] Traits genetic mapping
|
||||||
|
- [x] Indexes and triggers
|
||||||
|
|
||||||
|
### API Endpoints
|
||||||
|
- [x] `/api/dogs` - Full CRUD operations
|
||||||
|
- [x] `/api/pedigree` - Tree generation and COI calculator
|
||||||
|
- [x] `/api/litters` - Breeding records
|
||||||
|
- [x] `/api/health` - Health tracking
|
||||||
|
- [x] `/api/breeding` - Heat cycles and whelping calculator
|
||||||
|
- [x] Photo upload with Multer
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ✅ Phase 2: Core Functionality (COMPLETE)
|
||||||
|
|
||||||
|
### Dog Management
|
||||||
|
- [x] Add new dogs with full form
|
||||||
|
- [x] Edit existing dogs
|
||||||
|
- [x] View dog details
|
||||||
|
- [x] List all dogs with search/filter
|
||||||
|
- [x] Upload multiple photos per dog
|
||||||
|
- [x] Delete photos
|
||||||
|
- [x] Parent selection (sire/dam)
|
||||||
|
|
||||||
|
### User Interface
|
||||||
|
- [x] Dashboard with statistics
|
||||||
|
- [x] Dog list with grid view
|
||||||
|
- [x] Dog detail pages
|
||||||
|
- [x] Modal forms for add/edit
|
||||||
|
- [x] Photo management UI
|
||||||
|
- [x] Search and sex filtering
|
||||||
|
- [x] Responsive navigation
|
||||||
|
|
||||||
|
### Features Implemented
|
||||||
|
- [x] Photo upload and storage
|
||||||
|
- [x] Parent-child relationships
|
||||||
|
- [x] Basic information tracking
|
||||||
|
- [x] Registration numbers
|
||||||
|
- [x] Microchip tracking
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🚧 Phase 3: Breeding Tools (IN PROGRESS)
|
||||||
|
|
||||||
|
### Priority Features
|
||||||
|
- [ ] Interactive pedigree tree visualization
|
||||||
|
- [ ] Integrate React-D3-Tree
|
||||||
|
- [ ] Show 3-5 generations
|
||||||
|
- [ ] Click to navigate
|
||||||
|
- [ ] Zoom and pan controls
|
||||||
|
|
||||||
|
- [ ] Trial Pairing Simulator
|
||||||
|
- [ ] Select sire and dam
|
||||||
|
- [ ] Display COI calculation
|
||||||
|
- [ ] Show common ancestors
|
||||||
|
- [ ] Risk assessment display
|
||||||
|
|
||||||
|
- [ ] Heat Cycle Management
|
||||||
|
- [ ] Add/edit heat cycles
|
||||||
|
- [ ] Track progesterone levels
|
||||||
|
- [ ] Calendar view
|
||||||
|
- [ ] Breeding date suggestions
|
||||||
|
|
||||||
|
- [ ] Litter Management
|
||||||
|
- [ ] Create litter records
|
||||||
|
- [ ] Link puppies to litter
|
||||||
|
- [ ] Track whelping details
|
||||||
|
- [ ] Auto-link parent relationships
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📋 Phase 4: Health & Genetics (PLANNED)
|
||||||
|
|
||||||
|
### Health Records
|
||||||
|
- [ ] Add health test results
|
||||||
|
- [ ] Vaccination tracking
|
||||||
|
- [ ] Medical history timeline
|
||||||
|
- [ ] Document uploads (PDFs, images)
|
||||||
|
- [ ] Alert for expiring vaccinations
|
||||||
|
|
||||||
|
### Genetic Tracking
|
||||||
|
- [ ] Track inherited traits
|
||||||
|
- [ ] Color genetics calculator
|
||||||
|
- [ ] Health clearance status
|
||||||
|
- [ ] Link traits to ancestors
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📋 Phase 5: Advanced Features (PLANNED)
|
||||||
|
|
||||||
|
### Pedigree Tools
|
||||||
|
- [ ] Reverse pedigree (descendants view)
|
||||||
|
- [ ] PDF pedigree generation
|
||||||
|
- [ ] Export to standard formats
|
||||||
|
- [ ] Print-friendly layouts
|
||||||
|
- [ ] Multi-generation COI analysis
|
||||||
|
|
||||||
|
### Breeding Planning
|
||||||
|
- [ ] Breeding calendar
|
||||||
|
- [ ] Heat cycle predictions
|
||||||
|
- [ ] Expected whelping alerts
|
||||||
|
- [ ] Breeding history reports
|
||||||
|
|
||||||
|
### Search & Analytics
|
||||||
|
- [ ] Advanced search filters
|
||||||
|
- [ ] By breed, color, age
|
||||||
|
- [ ] By health clearances
|
||||||
|
- [ ] By registration status
|
||||||
|
- [ ] Statistics dashboard
|
||||||
|
- [ ] Breeding success rates
|
||||||
|
- [ ] Average litter sizes
|
||||||
|
- [ ] Popular pairings
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📋 Phase 6: Polish & Optimization (PLANNED)
|
||||||
|
|
||||||
|
### User Experience
|
||||||
|
- [ ] Loading states for all operations
|
||||||
|
- [ ] Better error messages
|
||||||
|
- [ ] Confirmation dialogs
|
||||||
|
- [ ] Undo functionality
|
||||||
|
- [ ] Keyboard shortcuts
|
||||||
|
|
||||||
|
### Performance
|
||||||
|
- [ ] Image optimization
|
||||||
|
- [ ] Lazy loading
|
||||||
|
- [ ] API caching
|
||||||
|
- [ ] Database query optimization
|
||||||
|
|
||||||
|
### Mobile
|
||||||
|
- [ ] Touch-friendly interface
|
||||||
|
- [ ] Mobile photo capture
|
||||||
|
- [ ] Responsive tables
|
||||||
|
- [ ] Offline mode
|
||||||
|
|
||||||
|
### Documentation
|
||||||
|
- [ ] User manual
|
||||||
|
- [ ] API documentation
|
||||||
|
- [ ] Video tutorials
|
||||||
|
- [ ] FAQ section
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Future Enhancements (BACKLOG)
|
||||||
|
|
||||||
|
### Multi-User Support
|
||||||
|
- [ ] User authentication
|
||||||
|
- [ ] Role-based permissions
|
||||||
|
- [ ] Activity logs
|
||||||
|
- [ ] Shared access
|
||||||
|
|
||||||
|
### Integration
|
||||||
|
- [ ] Import from other systems
|
||||||
|
- [ ] Export to Excel/CSV
|
||||||
|
- [ ] Integration with kennel clubs
|
||||||
|
- [ ] Backup to cloud storage
|
||||||
|
|
||||||
|
### Advanced Genetics
|
||||||
|
- [ ] DNA test result tracking
|
||||||
|
- [ ] Genetic diversity analysis
|
||||||
|
- [ ] Breed-specific calculators
|
||||||
|
- [ ] Health risk predictions
|
||||||
|
|
||||||
|
### Kennel Management
|
||||||
|
- [ ] Breeding contracts
|
||||||
|
- [ ] Buyer tracking
|
||||||
|
- [ ] Financial records
|
||||||
|
- [ ] Stud service management
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Current Sprint Focus
|
||||||
|
|
||||||
|
### Next Up (Priority)
|
||||||
|
1. **Interactive Pedigree Visualization**
|
||||||
|
- Implement React-D3-Tree integration
|
||||||
|
- Connect to `/api/pedigree/:id` endpoint
|
||||||
|
- Add zoom/pan controls
|
||||||
|
- Enable click navigation
|
||||||
|
|
||||||
|
2. **Trial Pairing Tool**
|
||||||
|
- Create pairing form
|
||||||
|
- Display COI calculation
|
||||||
|
- Show common ancestors
|
||||||
|
- Add recommendation system
|
||||||
|
|
||||||
|
3. **Litter Management**
|
||||||
|
- Add litter creation form
|
||||||
|
- Link puppies to litters
|
||||||
|
- Display breeding history
|
||||||
|
- Track whelping outcomes
|
||||||
|
|
||||||
|
### Testing Needed
|
||||||
|
- [ ] Add/edit dog forms
|
||||||
|
- [ ] Photo upload functionality
|
||||||
|
- [ ] Search and filtering
|
||||||
|
- [ ] Parent relationship linking
|
||||||
|
- [ ] API error handling
|
||||||
|
|
||||||
|
### Known Issues
|
||||||
|
- None currently
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## How to Contribute
|
||||||
|
|
||||||
|
1. Pick a feature from "Priority Features"
|
||||||
|
2. Create a feature branch: `feature/feature-name`
|
||||||
|
3. Implement with tests
|
||||||
|
4. Update this roadmap
|
||||||
|
5. Submit for review
|
||||||
|
|
||||||
|
## Version History
|
||||||
|
|
||||||
|
- **v0.2.0** (Current) - Dog CRUD operations complete
|
||||||
|
- **v0.1.0** - Initial foundation with API and database
|
||||||
230
client/src/components/DogForm.jsx
Normal file
230
client/src/components/DogForm.jsx
Normal file
@@ -0,0 +1,230 @@
|
|||||||
|
import { useState, useEffect } from 'react'
|
||||||
|
import { X } from 'lucide-react'
|
||||||
|
import axios from 'axios'
|
||||||
|
|
||||||
|
function DogForm({ dog, onClose, onSave }) {
|
||||||
|
const [formData, setFormData] = useState({
|
||||||
|
name: '',
|
||||||
|
registration_number: '',
|
||||||
|
breed: '',
|
||||||
|
sex: 'male',
|
||||||
|
birth_date: '',
|
||||||
|
color: '',
|
||||||
|
microchip: '',
|
||||||
|
notes: '',
|
||||||
|
sire_id: '',
|
||||||
|
dam_id: ''
|
||||||
|
})
|
||||||
|
const [dogs, setDogs] = useState([])
|
||||||
|
const [loading, setLoading] = useState(false)
|
||||||
|
const [error, setError] = useState('')
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchDogs()
|
||||||
|
if (dog) {
|
||||||
|
setFormData({
|
||||||
|
name: dog.name || '',
|
||||||
|
registration_number: dog.registration_number || '',
|
||||||
|
breed: dog.breed || '',
|
||||||
|
sex: dog.sex || 'male',
|
||||||
|
birth_date: dog.birth_date || '',
|
||||||
|
color: dog.color || '',
|
||||||
|
microchip: dog.microchip || '',
|
||||||
|
notes: dog.notes || '',
|
||||||
|
sire_id: dog.sire?.id || '',
|
||||||
|
dam_id: dog.dam?.id || ''
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}, [dog])
|
||||||
|
|
||||||
|
const fetchDogs = async () => {
|
||||||
|
try {
|
||||||
|
const res = await axios.get('/api/dogs')
|
||||||
|
setDogs(res.data)
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error fetching dogs:', error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleChange = (e) => {
|
||||||
|
const { name, value } = e.target
|
||||||
|
setFormData(prev => ({ ...prev, [name]: value }))
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleSubmit = async (e) => {
|
||||||
|
e.preventDefault()
|
||||||
|
setError('')
|
||||||
|
setLoading(true)
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (dog) {
|
||||||
|
// Update existing dog
|
||||||
|
await axios.put(`/api/dogs/${dog.id}`, formData)
|
||||||
|
} else {
|
||||||
|
// Create new dog
|
||||||
|
await axios.post('/api/dogs', formData)
|
||||||
|
}
|
||||||
|
onSave()
|
||||||
|
onClose()
|
||||||
|
} catch (error) {
|
||||||
|
setError(error.response?.data?.error || 'Failed to save dog')
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const males = dogs.filter(d => d.sex === 'male' && d.id !== dog?.id)
|
||||||
|
const females = dogs.filter(d => d.sex === 'female' && d.id !== dog?.id)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="modal-overlay" onClick={onClose}>
|
||||||
|
<div className="modal-content" onClick={(e) => e.stopPropagation()}>
|
||||||
|
<div className="modal-header">
|
||||||
|
<h2>{dog ? 'Edit Dog' : 'Add New Dog'}</h2>
|
||||||
|
<button className="btn-icon" onClick={onClose}>
|
||||||
|
<X size={24} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form onSubmit={handleSubmit} className="modal-body">
|
||||||
|
{error && <div className="error">{error}</div>}
|
||||||
|
|
||||||
|
<div className="form-grid">
|
||||||
|
<div className="form-group">
|
||||||
|
<label className="label">Name *</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
name="name"
|
||||||
|
className="input"
|
||||||
|
value={formData.name}
|
||||||
|
onChange={handleChange}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="form-group">
|
||||||
|
<label className="label">Registration Number</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
name="registration_number"
|
||||||
|
className="input"
|
||||||
|
value={formData.registration_number}
|
||||||
|
onChange={handleChange}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="form-group">
|
||||||
|
<label className="label">Breed *</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
name="breed"
|
||||||
|
className="input"
|
||||||
|
value={formData.breed}
|
||||||
|
onChange={handleChange}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="form-group">
|
||||||
|
<label className="label">Sex *</label>
|
||||||
|
<select
|
||||||
|
name="sex"
|
||||||
|
className="input"
|
||||||
|
value={formData.sex}
|
||||||
|
onChange={handleChange}
|
||||||
|
required
|
||||||
|
>
|
||||||
|
<option value="male">Male</option>
|
||||||
|
<option value="female">Female</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="form-group">
|
||||||
|
<label className="label">Birth Date</label>
|
||||||
|
<input
|
||||||
|
type="date"
|
||||||
|
name="birth_date"
|
||||||
|
className="input"
|
||||||
|
value={formData.birth_date}
|
||||||
|
onChange={handleChange}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="form-group">
|
||||||
|
<label className="label">Color</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
name="color"
|
||||||
|
className="input"
|
||||||
|
value={formData.color}
|
||||||
|
onChange={handleChange}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="form-group">
|
||||||
|
<label className="label">Microchip Number</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
name="microchip"
|
||||||
|
className="input"
|
||||||
|
value={formData.microchip}
|
||||||
|
onChange={handleChange}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<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 className="form-group" style={{ marginTop: '1rem' }}>
|
||||||
|
<label className="label">Notes</label>
|
||||||
|
<textarea
|
||||||
|
name="notes"
|
||||||
|
className="input"
|
||||||
|
rows="4"
|
||||||
|
value={formData.notes}
|
||||||
|
onChange={handleChange}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="modal-footer">
|
||||||
|
<button type="button" className="btn btn-secondary" onClick={onClose} disabled={loading}>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
<button type="submit" className="btn btn-primary" disabled={loading}>
|
||||||
|
{loading ? 'Saving...' : dog ? 'Update Dog' : 'Add Dog'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default DogForm
|
||||||
@@ -78,17 +78,22 @@ code {
|
|||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.btn:hover {
|
.btn:hover:not(:disabled) {
|
||||||
transform: translateY(-1px);
|
transform: translateY(-1px);
|
||||||
box-shadow: var(--shadow);
|
box-shadow: var(--shadow);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.btn:disabled {
|
||||||
|
opacity: 0.5;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
.btn-primary {
|
.btn-primary {
|
||||||
background: var(--primary);
|
background: var(--primary);
|
||||||
color: white;
|
color: white;
|
||||||
}
|
}
|
||||||
|
|
||||||
.btn-primary:hover {
|
.btn-primary:hover:not(:disabled) {
|
||||||
background: var(--primary-dark);
|
background: var(--primary-dark);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -107,6 +112,24 @@ code {
|
|||||||
color: white;
|
color: white;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.btn-icon {
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 0.5rem;
|
||||||
|
border-radius: 0.375rem;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
transition: all 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-icon:hover {
|
||||||
|
background: var(--bg-secondary);
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
|
||||||
.card {
|
.card {
|
||||||
background: var(--bg);
|
background: var(--bg);
|
||||||
border-radius: 0.5rem;
|
border-radius: 0.5rem;
|
||||||
@@ -114,20 +137,27 @@ code {
|
|||||||
box-shadow: var(--shadow);
|
box-shadow: var(--shadow);
|
||||||
}
|
}
|
||||||
|
|
||||||
.input {
|
.input,
|
||||||
|
textarea {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
padding: 0.625rem;
|
padding: 0.625rem;
|
||||||
border: 1px solid var(--border);
|
border: 1px solid var(--border);
|
||||||
border-radius: 0.375rem;
|
border-radius: 0.375rem;
|
||||||
font-size: 0.875rem;
|
font-size: 0.875rem;
|
||||||
transition: border-color 0.2s;
|
transition: border-color 0.2s;
|
||||||
|
font-family: inherit;
|
||||||
}
|
}
|
||||||
|
|
||||||
.input:focus {
|
.input:focus,
|
||||||
|
textarea:focus {
|
||||||
outline: none;
|
outline: none;
|
||||||
border-color: var(--primary);
|
border-color: var(--primary);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
textarea {
|
||||||
|
resize: vertical;
|
||||||
|
}
|
||||||
|
|
||||||
.label {
|
.label {
|
||||||
display: block;
|
display: block;
|
||||||
margin-bottom: 0.5rem;
|
margin-bottom: 0.5rem;
|
||||||
@@ -162,4 +192,71 @@ code {
|
|||||||
background: #fee2e2;
|
background: #fee2e2;
|
||||||
border-radius: 0.375rem;
|
border-radius: 0.375rem;
|
||||||
margin: 1rem 0;
|
margin: 1rem 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Modal styles */
|
||||||
|
.modal-overlay {
|
||||||
|
position: fixed;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
bottom: 0;
|
||||||
|
background: rgba(0, 0, 0, 0.5);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
z-index: 1000;
|
||||||
|
padding: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-content {
|
||||||
|
background: var(--bg);
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
box-shadow: var(--shadow-lg);
|
||||||
|
max-width: 800px;
|
||||||
|
width: 100%;
|
||||||
|
max-height: 90vh;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
padding: 1.5rem;
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-header h2 {
|
||||||
|
font-size: 1.5rem;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-body {
|
||||||
|
padding: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-footer {
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
gap: 0.75rem;
|
||||||
|
padding-top: 1.5rem;
|
||||||
|
border-top: 1px solid var(--border);
|
||||||
|
margin-top: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Form styles */
|
||||||
|
.form-group {
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
select.input {
|
||||||
|
background: white;
|
||||||
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
@@ -1,12 +1,16 @@
|
|||||||
import { useEffect, useState } from 'react'
|
import { useEffect, useState, useRef } from 'react'
|
||||||
import { useParams, Link } from 'react-router-dom'
|
import { useParams, Link } from 'react-router-dom'
|
||||||
import { Dog, GitBranch, Edit, Trash2 } from 'lucide-react'
|
import { Dog, GitBranch, Edit, Upload, Trash2 } from 'lucide-react'
|
||||||
import axios from 'axios'
|
import axios from 'axios'
|
||||||
|
import DogForm from '../components/DogForm'
|
||||||
|
|
||||||
function DogDetail() {
|
function DogDetail() {
|
||||||
const { id } = useParams()
|
const { id } = useParams()
|
||||||
const [dog, setDog] = useState(null)
|
const [dog, setDog] = useState(null)
|
||||||
const [loading, setLoading] = useState(true)
|
const [loading, setLoading] = useState(true)
|
||||||
|
const [showEditModal, setShowEditModal] = useState(false)
|
||||||
|
const [uploading, setUploading] = useState(false)
|
||||||
|
const fileInputRef = useRef(null)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
fetchDog()
|
fetchDog()
|
||||||
@@ -23,6 +27,40 @@ function DogDetail() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const handlePhotoUpload = async (e) => {
|
||||||
|
const file = e.target.files[0]
|
||||||
|
if (!file) return
|
||||||
|
|
||||||
|
setUploading(true)
|
||||||
|
const formData = new FormData()
|
||||||
|
formData.append('photo', file)
|
||||||
|
|
||||||
|
try {
|
||||||
|
await axios.post(`/api/dogs/${id}/photos`, formData, {
|
||||||
|
headers: { 'Content-Type': 'multipart/form-data' }
|
||||||
|
})
|
||||||
|
fetchDog()
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error uploading photo:', error)
|
||||||
|
alert('Failed to upload photo')
|
||||||
|
} finally {
|
||||||
|
setUploading(false)
|
||||||
|
if (fileInputRef.current) fileInputRef.current.value = ''
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleDeletePhoto = async (photoIndex) => {
|
||||||
|
if (!confirm('Delete this photo?')) return
|
||||||
|
|
||||||
|
try {
|
||||||
|
await axios.delete(`/api/dogs/${id}/photos/${photoIndex}`)
|
||||||
|
fetchDog()
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error deleting photo:', error)
|
||||||
|
alert('Failed to delete photo')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (loading) {
|
if (loading) {
|
||||||
return <div className="container loading">Loading...</div>
|
return <div className="container loading">Loading...</div>
|
||||||
}
|
}
|
||||||
@@ -40,7 +78,7 @@ function DogDetail() {
|
|||||||
<GitBranch size={20} />
|
<GitBranch size={20} />
|
||||||
View Pedigree
|
View Pedigree
|
||||||
</Link>
|
</Link>
|
||||||
<button className="btn btn-secondary">
|
<button className="btn btn-secondary" onClick={() => setShowEditModal(true)}>
|
||||||
<Edit size={20} />
|
<Edit size={20} />
|
||||||
Edit
|
Edit
|
||||||
</button>
|
</button>
|
||||||
@@ -81,11 +119,46 @@ function DogDetail() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="card">
|
<div className="card">
|
||||||
<h2 style={{ marginBottom: '1rem' }}>Photos</h2>
|
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '1rem' }}>
|
||||||
|
<h2>Photos</h2>
|
||||||
|
<button
|
||||||
|
className="btn btn-primary"
|
||||||
|
onClick={() => fileInputRef.current?.click()}
|
||||||
|
disabled={uploading}
|
||||||
|
>
|
||||||
|
<Upload size={18} />
|
||||||
|
{uploading ? 'Uploading...' : 'Upload'}
|
||||||
|
</button>
|
||||||
|
<input
|
||||||
|
ref={fileInputRef}
|
||||||
|
type="file"
|
||||||
|
accept="image/*"
|
||||||
|
onChange={handlePhotoUpload}
|
||||||
|
style={{ display: 'none' }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
{dog.photo_urls && dog.photo_urls.length > 0 ? (
|
{dog.photo_urls && dog.photo_urls.length > 0 ? (
|
||||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(150px, 1fr))', gap: '0.5rem' }}>
|
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(150px, 1fr))', gap: '0.5rem' }}>
|
||||||
{dog.photo_urls.map((url, index) => (
|
{dog.photo_urls.map((url, index) => (
|
||||||
<img key={index} src={url} alt={`${dog.name} ${index + 1}`} style={{ width: '100%', aspectRatio: '1', objectFit: 'cover', borderRadius: '0.375rem' }} />
|
<div key={index} style={{ position: 'relative' }}>
|
||||||
|
<img
|
||||||
|
src={url}
|
||||||
|
alt={`${dog.name} ${index + 1}`}
|
||||||
|
style={{ width: '100%', aspectRatio: '1', objectFit: 'cover', borderRadius: '0.375rem' }}
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
className="btn-icon"
|
||||||
|
onClick={() => handleDeletePhoto(index)}
|
||||||
|
style={{
|
||||||
|
position: 'absolute',
|
||||||
|
top: '0.25rem',
|
||||||
|
right: '0.25rem',
|
||||||
|
background: 'rgba(255,255,255,0.9)'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Trash2 size={16} color="var(--danger)" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
@@ -138,6 +211,17 @@ function DogDetail() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{showEditModal && (
|
||||||
|
<DogForm
|
||||||
|
dog={dog}
|
||||||
|
onClose={() => setShowEditModal(false)}
|
||||||
|
onSave={() => {
|
||||||
|
fetchDog()
|
||||||
|
setShowEditModal(false)
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { useEffect, useState } from 'react'
|
|||||||
import { Link } from 'react-router-dom'
|
import { Link } from 'react-router-dom'
|
||||||
import { Dog, Plus, Search } from 'lucide-react'
|
import { Dog, Plus, Search } from 'lucide-react'
|
||||||
import axios from 'axios'
|
import axios from 'axios'
|
||||||
|
import DogForm from '../components/DogForm'
|
||||||
|
|
||||||
function DogList() {
|
function DogList() {
|
||||||
const [dogs, setDogs] = useState([])
|
const [dogs, setDogs] = useState([])
|
||||||
@@ -47,6 +48,10 @@ function DogList() {
|
|||||||
setFilteredDogs(filtered)
|
setFilteredDogs(filtered)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const handleSave = () => {
|
||||||
|
fetchDogs()
|
||||||
|
}
|
||||||
|
|
||||||
if (loading) {
|
if (loading) {
|
||||||
return <div className="container loading">Loading dogs...</div>
|
return <div className="container loading">Loading dogs...</div>
|
||||||
}
|
}
|
||||||
@@ -111,6 +116,13 @@ function DogList() {
|
|||||||
<p style={{ color: 'var(--text-secondary)' }}>No dogs found matching your search criteria.</p>
|
<p style={{ color: 'var(--text-secondary)' }}>No dogs found matching your search criteria.</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{showAddModal && (
|
||||||
|
<DogForm
|
||||||
|
onClose={() => setShowAddModal(false)}
|
||||||
|
onSave={handleSave}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user