580 lines
27 KiB
JavaScript
580 lines
27 KiB
JavaScript
import { useEffect, useState, useCallback } from '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'
|
||
|
||
// ─── Date helpers ────────────────────────────────────────────────────────────
|
||
const toISO = d => d.toISOString().split('T')[0]
|
||
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())
|
||
|
||
// ─── 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' },
|
||
}
|
||
|
||
// ─── 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 {
|
||
const res = await fetch('/api/breeding/heat-cycles', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ dog_id: parseInt(dogId), start_date: startDate, notes: notes || null })
|
||
})
|
||
if (!res.ok) { const e = await res.json(); throw new Error(e.error || 'Failed to save') }
|
||
onSaved()
|
||
} catch (err) {
|
||
setError(err.message)
|
||
setSaving(false)
|
||
}
|
||
}
|
||
|
||
return (
|
||
<div className="modal-overlay" onClick={e => e.target === e.currentTarget && onClose()}>
|
||
<div className="modal-content" style={{ maxWidth: '480px' }}>
|
||
<div className="modal-header">
|
||
<div style={{ display: 'flex', alignItems: 'center', gap: '0.6rem' }}>
|
||
<Heart size={18} style={{ color: '#f472b6' }} />
|
||
<h2>Start Heat Cycle</h2>
|
||
</div>
|
||
<button className="btn-icon" onClick={onClose}><X size={20} /></button>
|
||
</div>
|
||
<form onSubmit={handleSubmit}>
|
||
<div className="modal-body">
|
||
{error && <div className="error" style={{ marginBottom: '1rem' }}>{error}</div>}
|
||
<div className="form-group">
|
||
<label className="label">Female Dog *</label>
|
||
<select value={dogId} onChange={e => setDogId(e.target.value)} required>
|
||
<option value="">– Select Female –</option>
|
||
{females.map(d => (
|
||
<option key={d.id} value={d.id}>
|
||
{d.name}{d.breed ? ` · ${d.breed}` : ''}
|
||
</option>
|
||
))}
|
||
</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 className="modal-footer">
|
||
<button type="button" className="btn btn-secondary" onClick={onClose}>Cancel</button>
|
||
<button type="submit" className="btn btn-primary" disabled={saving || !dogId}>
|
||
{saving ? 'Saving…' : <><Heart size={15} /> Start Cycle</>}
|
||
</button>
|
||
</div>
|
||
</form>
|
||
</div>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
// ─── 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)
|
||
|
||
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>
|
||
</div>
|
||
|
||
{/* Whelping estimate */}
|
||
{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])
|
||
|
||
// ── 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
|
||
})
|
||
}
|
||
|
||
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
|
||
})
|
||
|
||
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 and optimal breeding windows</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>
|
||
))}
|
||
</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
|
||
|
||
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
|
||
}
|
||
}
|
||
|
||
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>
|
||
)
|
||
})}
|
||
{/* Breeding date marker */}
|
||
{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" />
|
||
)}
|
||
</>
|
||
)}
|
||
</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)
|
||
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>
|
||
)}
|
||
</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>
|
||
)
|
||
}
|