Rework Add Rabbit flow for mobile and clarity
- Pedigree renders as stacked Sire/Dam sections below 1280px instead of a sideways-scrolling tree; larger touch targets in the ancestor popup - Step lives in ?step= so the back gesture moves between steps; sticky Back/Next/Save bar and a clickable stepper; beforeunload guard - Steps stay mounted, fixing pedigree entries lost when going back - Review shows formatted values, hides empty rows, per-section Edit links - Failed photo upload after create now lands on the new rabbit with a notice instead of allowing a duplicate save - Details form: primary fields first, secondary fields collapsible on Add, name error only after a Next attempt; "Use as color name" button Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
3874c33a90
commit
5d29e2b370
|
|
@ -62,10 +62,12 @@ docker-compose.yml — orchestrates both services for self-hosted deployment
|
|||
| `src/lib/ocrPreprocess.js` | OCR Pedigree Scanning, Phase 2. Canvas-only (no `jimp`) image normalization for OCR. Exports `preprocessImage(file)` — decode → downscale to `MAX_WIDTH` (1600px, never upscaling) → grayscale (luminance) → binarize to pure black/white in one pixel pass → return a PNG `Blob`. Also exports the tunable `MAX_WIDTH` and `BINARIZE_THRESHOLD` (binarization cutoff, tuned to **103** during Step 2.2 — see progress.md; a fixed global threshold, deliberately not Otsu). This processed blob is the canonical image for the whole OCR pipeline: the scanner OCRs it and the zone picker's bounding boxes live in its coordinate space, so the original file is discarded after preprocessing. |
|
||||
| `src/lib/pedigree.js` | Pedigree slot definitions (`GEN1`/`GEN2`/`GEN3`, 14 ancestor slots mapping 1:1 to the schema's `_name_text` columns) and the client-side chain-authority resolver. `resolveEffectiveNode(path, state, cascadeCache)` mirrors `server/routes/rabbits.ts`'s `resolveAncestorNode` — see the pedigree convention note below. Also exports `seedPedigreeStateFromRabbit`/`cascadeFromAncestors` (used by `EditRabbit.jsx` to pre-populate from an existing record) and `buildPedigreePayload`/`buildPedigreeSummary` (flat API-ready fields vs. a display-only summary array, both derived from the same resolver). `serializeManualAncestor`/`parseManualAncestor`/`formatManualAncestorDisplay` encode/decode a manually-entered ancestor's Farm/Name/Ear Number/Weight/DOB as a JSON string in that same `_name_text` column — see the manual-ancestor convention note below. |
|
||||
| `src/lib/genetics.js` | `LOCI` (option config for all seven loci pill-groups) and `generateColorString(loci)`, which combines A/B/D/E/C/En into a single description string (`vv` and `cc` short-circuit to "BEW"/"REW"). Also `hasAnyLocusSet` (drives `GeneticsForm`'s default-expanded state on Edit). |
|
||||
| `src/components/PedigreeTree.jsx` | Self-contained pedigree input (Phase 7 Step 7.1) — manages its own `state`/`cascadeCache` and reports out via `onChange(fields, summary)` rather than being a fully controlled component, since its internal shape (per-slot mode/id/name plus an async ancestor cache) isn't something a parent should need to reconstruct. Accepts optional `initialState`/`initialCascade` for `EditRabbit.jsx`. A locked (cascade-derived) node renders non-interactively with a 🔒 and is not clickable — see the pedigree convention note below. |
|
||||
| `src/components/RabbitDetailsForm.jsx` | Controlled form (Step 7.2) over the same snake_case field names the API uses. Weight toggles between one decimal input and an lbs+oz pair based on a `weightUnit` prop; photo selection is reported via a separate `onPhotoChange(file)` callback since it's always a second request (`uploadRabbitPhoto`) after the rabbit row exists. |
|
||||
| `src/components/GeneticsForm.jsx` | Collapsible pill-group genetics section (Step 7.3) over `lib/genetics.js`'s `LOCI` config, with a read-only generated color string field. |
|
||||
| `src/components/ReviewStep.jsx` | Read-only summary (Step 7.4) of pedigree/details/genetics before save, with a shared `dash()` helper for empty values. |
|
||||
| `src/components/PedigreeTree.jsx` | Self-contained pedigree input (Phase 7 Step 7.1) — manages its own `state`/`cascadeCache` and reports out via `onChange(fields, summary)` rather than being a fully controlled component, since its internal shape (per-slot mode/id/name plus an async ancestor cache) isn't something a parent should need to reconstruct. Accepts optional `initialState`/`initialCascade` for `EditRabbit.jsx`. A locked (cascade-derived) node renders non-interactively with a 🔒 and is not clickable — see the pedigree convention note below. **Responsive layout:** at `≥1280px` (`useMediaQuery`, `hooks/useMediaQuery.js`) it renders the 3-column tree; below that the same `AncestorSlot`s render as a stacked, indented list (`StackedBranch`) in "Sire's side"/"Dam's side" cards, so phones never scroll sideways. Popup drop-direction accounts for the bottom chrome (action bar + mobile nav). |
|
||||
| `src/components/RabbitDetailsForm.jsx` | Controlled form (Step 7.2) over the same snake_case field names the API uses. Weight toggles between one decimal input and an lbs+oz pair based on a `weightUnit` prop; photo selection is reported via a separate `onPhotoChange(file)` callback since it's always a second request (`uploadRabbitPhoto`) after the rabbit row exists. Primary fields (Name, Farm, Ear #, Sex, DOB, Breed, Color, Weight, Photo) come first; Status/Location/Acquired/Death sit in a "More details" section that `collapseSecondary` (Add flow only) starts closed. The name error only shows when `showValidation` is set (Add sets it after a failed Next). |
|
||||
| `src/components/GeneticsForm.jsx` | Collapsible pill-group genetics section (Step 7.3) over `lib/genetics.js`'s `LOCI` config, with a read-only generated color string field. Optional `onUseColor(color)` adds a "Use as color name" button (Add + Edit wire it to `details.color_name`). |
|
||||
| `src/components/ReviewStep.jsx` | Read-only summary (Step 7.4) of pedigree/details/genetics before save. Formats values for display (sex/status labels, `formatDate`, `formatWeight` in the user's unit), hides empty rows (Sire/Dam always shown; deeper ancestors only when set) and shows genetics as "Not set" when empty. Each section has an Edit button calling `onEdit(step)`. Action buttons live in `AddRabbit`'s `StickyActionBar`, not here. |
|
||||
| `src/components/StickyActionBar.jsx` | Bottom-pinned form action row (sticky above the mobile bottom nav, `bottom-0` on desktop). Used by the Add Rabbit flow for Back/Next/Save. |
|
||||
| `src/pages/AddRabbit.jsx` (flow notes) | The current step is a `?step=` URL param (pushed, so the Back gesture moves between steps; step 3 falls back to 2 without a name). All steps stay mounted and are hidden with CSS so `PedigreeTree`'s internal state survives going back / Review "Edit". Step 1 has an optional name field feeding the tree's root label. A `beforeunload` guard warns on reload/close with unsaved input (in-app link navigation is not guarded — `BrowserRouter` has no `useBlocker`). On save, a failed photo upload still navigates to the created rabbit with a `location.state.notice` (shown by `RabbitDetail`) instead of leaving the user on Review, where re-saving would create a duplicate. |
|
||||
| `src/lib/theme.js` | Theme registry (post-Phase-11 feature) — the single source of truth for the *list* of selectable themes (`classic`/`midnight`/`ocean`/`berry`/`sunset`/`carnival`), their labels/descriptions, the Settings preview swatches, and each theme's browser-chrome `theme-color`. The actual palettes are CSS variables in `index.css` (one `[data-theme='…']` block per theme); this module only knows ids + preview metadata. `applyTheme(id)` sets `document.documentElement.dataset.theme` (which swaps the variables), updates the `theme-color` meta, and mirrors the id to `localStorage` (key `mykindlebox-theme`). `normalizeTheme` falls back to `classic` for unknown ids so a stray DB/cache value never leaves the app unstyled. See the theming convention note below. |
|
||||
| `src/lib/breeds.js` | The ARBA-recognized rabbit breed list (`ARBA_BREEDS`) powering the breed picker, plus `isCustomBreed(breed)` (true for a non-empty value not on the list). Suggestion list only — `BreedSelect` keeps a free-text fallback, so a breed not on this list still saves. Add new ARBA breeds here. |
|
||||
| `src/components/BreedSelect.jsx` | The breed picker (combobox): a text input bound **directly** to `value` (so anything typed is a valid free-text breed) with a filtered dropdown of `ARBA_BREEDS` underneath for guided selection. Reuses `lib/keyboardNav.js` for the same arrow-key roving focus as `RabbitAutocomplete`/`PedigreeTree`. Shows a "Custom breed" hint when the value isn't an ARBA breed. Used by `RabbitDetailsForm.jsx` (the rabbit Breed field) and `Settings.jsx` (the Default Breed setting). |
|
||||
|
|
|
|||
|
|
@ -1,3 +1,5 @@
|
|||
import { STATUS_LABELS } from '../lib/format'
|
||||
|
||||
const STATUS_CLASSES = {
|
||||
active: 'bg-accent text-surface',
|
||||
for_sale: 'bg-accent-warm text-surface',
|
||||
|
|
@ -7,15 +9,6 @@ const STATUS_CLASSES = {
|
|||
other: 'bg-border text-text-primary',
|
||||
}
|
||||
|
||||
const STATUS_LABELS = {
|
||||
active: 'Active',
|
||||
for_sale: 'For Sale',
|
||||
on_hold: 'On Hold',
|
||||
sold: 'Sold',
|
||||
deceased: 'Deceased',
|
||||
other: 'Other',
|
||||
}
|
||||
|
||||
function Badge({ status }) {
|
||||
return (
|
||||
<span
|
||||
|
|
|
|||
|
|
@ -1,7 +1,24 @@
|
|||
import { useState } from 'react'
|
||||
import { LOCI, generateColorString } from '../lib/genetics'
|
||||
|
||||
function GeneticsForm({ value, onChange, defaultExpanded = false }) {
|
||||
function ChevronIcon({ open }) {
|
||||
return (
|
||||
<svg
|
||||
viewBox="0 0 20 20"
|
||||
className={`h-4 w-4 text-text-secondary transition-transform ${open ? 'rotate-180' : ''}`}
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.75"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path d="m5 8 5 5 5-5" strokeLinecap="round" strokeLinejoin="round" />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
// `onUseColor`, when given, adds a button that copies the generated color
|
||||
// string into the rabbit's Color Name field.
|
||||
function GeneticsForm({ value, onChange, defaultExpanded = false, onUseColor }) {
|
||||
const [expanded, setExpanded] = useState(defaultExpanded)
|
||||
const colorString = generateColorString(value)
|
||||
|
||||
|
|
@ -14,24 +31,26 @@ function GeneticsForm({ value, onChange, defaultExpanded = false }) {
|
|||
<button
|
||||
type="button"
|
||||
onClick={() => setExpanded((v) => !v)}
|
||||
aria-expanded={expanded}
|
||||
className="flex w-full items-center justify-between px-4 py-3 font-body text-sm font-medium text-text-primary"
|
||||
>
|
||||
<span>Genetics (optional)</span>
|
||||
<span className="text-text-secondary">{expanded ? '▲' : '▼'}</span>
|
||||
<ChevronIcon open={expanded} />
|
||||
</button>
|
||||
|
||||
{expanded && (
|
||||
<div className="space-y-3 border-t border-border px-4 py-4">
|
||||
{LOCI.map((locus) => (
|
||||
<div key={locus.key} className="flex flex-wrap items-center gap-2">
|
||||
<div key={locus.key} className="flex flex-col gap-1.5 sm:flex-row sm:flex-wrap sm:items-center sm:gap-2">
|
||||
<span className="w-20 shrink-0 font-body text-sm text-text-secondary">{locus.label}</span>
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{locus.options.map((opt) => (
|
||||
<button
|
||||
key={opt.value}
|
||||
type="button"
|
||||
aria-pressed={value[locus.key] === opt.value}
|
||||
onClick={() => setLocus(locus.key, opt.value)}
|
||||
className={`rounded-full border px-3 py-1 font-body text-xs ${
|
||||
className={`rounded-full border px-3 py-1.5 font-body text-sm md:py-1 md:text-xs ${
|
||||
value[locus.key] === opt.value
|
||||
? 'border-accent bg-accent text-surface'
|
||||
: 'border-border bg-surface-raised text-text-primary hover:bg-surface-sunken'
|
||||
|
|
@ -48,13 +67,25 @@ function GeneticsForm({ value, onChange, defaultExpanded = false }) {
|
|||
<label className="block font-body text-sm font-medium text-text-primary">
|
||||
Generated color string
|
||||
</label>
|
||||
<div className="mt-1 flex gap-2">
|
||||
<input
|
||||
type="text"
|
||||
readOnly
|
||||
value={colorString}
|
||||
placeholder="Select loci to generate a description"
|
||||
className="mt-1 w-full rounded-md border border-border bg-surface-sunken px-3 py-2 font-body text-sm text-text-secondary"
|
||||
className="min-w-0 flex-1 rounded-md border border-border bg-surface-sunken px-3 py-2 font-body text-sm text-text-secondary"
|
||||
/>
|
||||
{onUseColor && (
|
||||
<button
|
||||
type="button"
|
||||
disabled={!colorString}
|
||||
onClick={() => onUseColor(colorString)}
|
||||
className="shrink-0 rounded-md border border-border px-3 py-2 font-body text-sm font-medium text-text-primary hover:bg-surface-sunken disabled:cursor-not-allowed disabled:opacity-50"
|
||||
>
|
||||
Use as color name
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -1,7 +1,9 @@
|
|||
import { useEffect, useRef, useState } from 'react'
|
||||
import { searchRabbits, getRabbitAncestors } from '../api'
|
||||
import { handleResultKeyDown, handleSearchInputKeyDown } from '../lib/keyboardNav'
|
||||
import { useMediaQuery } from '../hooks/useMediaQuery'
|
||||
import {
|
||||
ALL_SLOTS,
|
||||
GEN1,
|
||||
GEN2,
|
||||
GEN3,
|
||||
|
|
@ -21,6 +23,32 @@ const EMPTY_MANUAL_FORM = { name: '', farm: '', ear_number: '', weight_lbs: '0',
|
|||
// mounts, so the very first click is positioned correctly.
|
||||
const POPUP_MAX_HEIGHT = 360
|
||||
|
||||
// Viewport space at the bottom covered by fixed/sticky chrome (the Add Rabbit
|
||||
// action bar, plus the bottom nav on phones), so a popup that would open
|
||||
// underneath it flips upward instead.
|
||||
function bottomChromeHeight() {
|
||||
return window.matchMedia('(max-width: 767px)').matches ? 136 : 72
|
||||
}
|
||||
|
||||
// The 3-column tree needs ~900px of content width; below that (phones,
|
||||
// tablets, small laptops with the sidebar) the same slots render as a stacked,
|
||||
// indented list instead of a sideways-scrolling tree.
|
||||
const WIDE_TREE_QUERY = '(min-width: 1280px)'
|
||||
|
||||
const SLOT_BY_PATH = new Map(ALL_SLOTS.map((entry) => [entry.path.join('.'), entry]))
|
||||
|
||||
function LockIcon() {
|
||||
return (
|
||||
<svg viewBox="0 0 20 20" className="h-4 w-4 shrink-0 text-text-secondary" fill="currentColor" aria-label="Locked">
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
d="M10 2a4 4 0 0 0-4 4v2H5a1 1 0 0 0-1 1v8a1 1 0 0 0 1 1h10a1 1 0 0 0 1-1V9a1 1 0 0 0-1-1h-1V6a4 4 0 0 0-4-4Zm2 6V6a2 2 0 1 0-4 0v2h4Z"
|
||||
clipRule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
// `dob` on the manual-ancestor form is a real date input value; `dobUnknown`
|
||||
// is the separate "Unknown" checkbox. They collapse into one stored string
|
||||
// ("Unknown" or an actual date) only at save time, via serializeManualAncestor.
|
||||
|
|
@ -55,7 +83,7 @@ function AncestorSlot({ path, slot, label, state, setSlotState, cascadeCache, fe
|
|||
function updateDropUp() {
|
||||
if (!triggerRef.current) return
|
||||
const rect = triggerRef.current.getBoundingClientRect()
|
||||
const spaceBelow = window.innerHeight - rect.bottom
|
||||
const spaceBelow = window.innerHeight - bottomChromeHeight() - rect.bottom
|
||||
const spaceAbove = rect.top
|
||||
setDropUp(spaceBelow < POPUP_MAX_HEIGHT + 8 && spaceAbove > spaceBelow)
|
||||
}
|
||||
|
|
@ -102,9 +130,7 @@ function AncestorSlot({ path, slot, label, state, setSlotState, cascadeCache, fe
|
|||
<p className="truncate font-body text-xs text-text-secondary">{label}</p>
|
||||
<p className="truncate font-body text-sm text-text-secondary italic">{text}</p>
|
||||
</div>
|
||||
<span aria-label="locked" className="text-text-secondary">
|
||||
🔒
|
||||
</span>
|
||||
<LockIcon />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -166,7 +192,7 @@ function AncestorSlot({ path, slot, label, state, setSlotState, cascadeCache, fe
|
|||
|
||||
{open && (
|
||||
<div
|
||||
className={`absolute left-0 z-10 w-64 rounded-md border border-border bg-surface-raised p-2 shadow-sm ${
|
||||
className={`absolute left-0 z-30 w-full min-w-[16rem] max-w-sm rounded-md border border-border bg-surface-raised p-2 shadow-lg ${
|
||||
dropUp ? 'bottom-full mb-1' : 'top-full mt-1'
|
||||
}`}
|
||||
>
|
||||
|
|
@ -174,14 +200,14 @@ function AncestorSlot({ path, slot, label, state, setSlotState, cascadeCache, fe
|
|||
<button
|
||||
type="button"
|
||||
onClick={() => setMode('search')}
|
||||
className={`flex-1 rounded px-2 py-1 text-xs ${mode === 'search' ? 'bg-accent text-surface' : 'bg-surface-sunken text-text-secondary'}`}
|
||||
className={`flex-1 rounded px-2 py-2 text-sm md:py-1 md:text-xs ${mode === 'search' ? 'bg-accent text-surface' : 'bg-surface-sunken text-text-secondary'}`}
|
||||
>
|
||||
Search existing
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setMode('manual')}
|
||||
className={`flex-1 rounded px-2 py-1 text-xs ${mode === 'manual' ? 'bg-accent text-surface' : 'bg-surface-sunken text-text-secondary'}`}
|
||||
className={`flex-1 rounded px-2 py-2 text-sm md:py-1 md:text-xs ${mode === 'manual' ? 'bg-accent text-surface' : 'bg-surface-sunken text-text-secondary'}`}
|
||||
>
|
||||
Enter manually
|
||||
</button>
|
||||
|
|
@ -197,7 +223,7 @@ function AncestorSlot({ path, slot, label, state, setSlotState, cascadeCache, fe
|
|||
onChange={(e) => setQuery(e.target.value)}
|
||||
onKeyDown={(e) => handleSearchInputKeyDown(e, { containerEl: containerRef.current, close })}
|
||||
placeholder="Search by name…"
|
||||
className="w-full rounded border border-border px-2 py-1 text-sm focus:outline-none focus:ring-2 focus:ring-accent"
|
||||
className="w-full rounded border border-border px-2 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-accent md:py-1"
|
||||
/>
|
||||
<ul className="mt-1 max-h-40 overflow-auto">
|
||||
{results.map((rabbit, index) => (
|
||||
|
|
@ -213,7 +239,7 @@ function AncestorSlot({ path, slot, label, state, setSlotState, cascadeCache, fe
|
|||
close,
|
||||
})
|
||||
}
|
||||
className="w-full rounded px-2 py-1 text-left text-sm hover:bg-surface-sunken"
|
||||
className="w-full rounded px-2 py-2 text-left text-sm hover:bg-surface-sunken md:py-1"
|
||||
>
|
||||
{rabbit.name}
|
||||
{rabbit.farm ? ` (${rabbit.farm})` : ''}
|
||||
|
|
@ -235,21 +261,21 @@ function AncestorSlot({ path, slot, label, state, setSlotState, cascadeCache, fe
|
|||
value={manualForm.farm}
|
||||
onChange={(e) => setManualField('farm', e.target.value)}
|
||||
placeholder="Farm Name *"
|
||||
className="w-full rounded border border-border px-2 py-1 text-sm focus:outline-none focus:ring-2 focus:ring-accent"
|
||||
className="w-full rounded border border-border px-2 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-accent md:py-1"
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
value={manualForm.name}
|
||||
onChange={(e) => setManualField('name', e.target.value)}
|
||||
placeholder="Rabbit Name *"
|
||||
className="w-full rounded border border-border px-2 py-1 text-sm focus:outline-none focus:ring-2 focus:ring-accent"
|
||||
className="w-full rounded border border-border px-2 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-accent md:py-1"
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
value={manualForm.ear_number}
|
||||
onChange={(e) => setManualField('ear_number', e.target.value.toUpperCase())}
|
||||
placeholder="Ear Number *"
|
||||
className="w-full rounded border border-border px-2 py-1 text-sm uppercase focus:outline-none focus:ring-2 focus:ring-accent"
|
||||
className="w-full rounded border border-border px-2 py-2 text-sm uppercase md:py-1 focus:outline-none focus:ring-2 focus:ring-accent"
|
||||
/>
|
||||
<div>
|
||||
<label className="block px-0.5 font-body text-xs text-text-secondary">Weight (lbs)</label>
|
||||
|
|
@ -258,7 +284,7 @@ function AncestorSlot({ path, slot, label, state, setSlotState, cascadeCache, fe
|
|||
step="0.01"
|
||||
value={manualForm.weight_lbs}
|
||||
onChange={(e) => setManualField('weight_lbs', e.target.value)}
|
||||
className="mt-0.5 w-full rounded border border-border px-2 py-1 text-sm focus:outline-none focus:ring-2 focus:ring-accent"
|
||||
className="mt-0.5 w-full rounded border border-border px-2 py-2 text-sm md:py-1 focus:outline-none focus:ring-2 focus:ring-accent"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
|
|
@ -268,7 +294,7 @@ function AncestorSlot({ path, slot, label, state, setSlotState, cascadeCache, fe
|
|||
value={manualForm.dob}
|
||||
disabled={manualForm.dobUnknown}
|
||||
onChange={(e) => setManualField('dob', e.target.value)}
|
||||
className="mt-0.5 w-full rounded border border-border px-2 py-1 text-sm focus:outline-none focus:ring-2 focus:ring-accent disabled:cursor-not-allowed disabled:bg-surface-sunken disabled:text-text-secondary"
|
||||
className="mt-0.5 w-full rounded border border-border px-2 py-2 text-sm md:py-1 focus:outline-none focus:ring-2 focus:ring-accent disabled:cursor-not-allowed disabled:bg-surface-sunken disabled:text-text-secondary"
|
||||
/>
|
||||
</div>
|
||||
<label className="flex items-center gap-1.5 px-0.5 font-body text-xs text-text-secondary">
|
||||
|
|
@ -290,7 +316,7 @@ function AncestorSlot({ path, slot, label, state, setSlotState, cascadeCache, fe
|
|||
type="button"
|
||||
onClick={saveManual}
|
||||
disabled={!canSaveManual}
|
||||
className="w-full rounded bg-accent px-2 py-1 text-xs text-surface disabled:opacity-50"
|
||||
className="w-full rounded bg-accent px-2 py-2 text-sm font-medium text-surface disabled:opacity-50 md:py-1.5 md:text-xs"
|
||||
>
|
||||
Save
|
||||
</button>
|
||||
|
|
@ -300,7 +326,7 @@ function AncestorSlot({ path, slot, label, state, setSlotState, cascadeCache, fe
|
|||
<button
|
||||
type="button"
|
||||
onClick={markUnknown}
|
||||
className="mt-2 w-full rounded px-2 py-1 text-left text-xs text-text-secondary hover:bg-surface-sunken"
|
||||
className="mt-2 w-full rounded px-2 py-2 text-left text-sm text-text-secondary hover:bg-surface-sunken md:py-1 md:text-xs"
|
||||
>
|
||||
Mark as Unknown
|
||||
</button>
|
||||
|
|
@ -329,6 +355,29 @@ function Generation({ entries, state, setSlotState, cascadeCache, fetchCascade }
|
|||
)
|
||||
}
|
||||
|
||||
// One ancestor plus, indented beneath it, its own sire/dam branches — the
|
||||
// stacked (narrow-screen) rendering of the same slots the wide tree shows.
|
||||
function StackedBranch({ path, slotProps }) {
|
||||
const entry = SLOT_BY_PATH.get(path.join('.'))
|
||||
const childPaths = [
|
||||
[...path, 'sire'],
|
||||
[...path, 'dam'],
|
||||
].filter((p) => SLOT_BY_PATH.has(p.join('.')))
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-2">
|
||||
<AncestorSlot path={entry.path} slot={entry.slot} label={entry.label} {...slotProps} />
|
||||
{childPaths.length > 0 && (
|
||||
<div className="ml-2 flex flex-col gap-2 border-l-2 border-border pl-3">
|
||||
{childPaths.map((childPath) => (
|
||||
<StackedBranch key={childPath.join('.')} path={childPath} slotProps={slotProps} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function PedigreeTree({
|
||||
subjectName = 'New Rabbit',
|
||||
initialState,
|
||||
|
|
@ -341,6 +390,7 @@ function PedigreeTree({
|
|||
// sets state synchronously (it's called from a mount effect below). An id is
|
||||
// never removed: on failure the cache stores null, and we don't retry.
|
||||
const cascadeFetchStarted = useRef(new Set())
|
||||
const wide = useMediaQuery(WIDE_TREE_QUERY)
|
||||
|
||||
function setSlotState(slot, value) {
|
||||
setState((prev) => ({ ...prev, [slot]: value }))
|
||||
|
|
@ -370,12 +420,24 @@ function PedigreeTree({
|
|||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [])
|
||||
|
||||
if (!wide) {
|
||||
const slotProps = { state, setSlotState, cascadeCache, fetchCascade }
|
||||
return (
|
||||
// The tree needs ~900px; on narrower screens the outer wrapper scrolls
|
||||
// sideways rather than the min-width stretching the whole page.
|
||||
<div className="-mx-4 overflow-x-auto md:mx-0">
|
||||
<p className="px-4 pt-2 font-body text-xs text-text-secondary xl:hidden">Swipe sideways to see every generation.</p>
|
||||
<div className="flex w-full min-w-[900px] items-center gap-6 p-4 pb-[300px]">
|
||||
<div className="flex flex-col gap-4">
|
||||
{GEN1.map(({ path, slot }) => (
|
||||
<section key={slot} className="rounded-lg border border-border bg-surface-raised p-3 shadow-sm">
|
||||
<h3 className="mb-2 font-display text-base font-bold text-text-primary">
|
||||
{slot === 'sire' ? "Sire's side" : "Dam's side"}
|
||||
</h3>
|
||||
<StackedBranch path={path} slotProps={slotProps} />
|
||||
</section>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex w-full items-center gap-6 p-4 pb-[300px]">
|
||||
<div className="flex h-16 w-32 shrink-0 items-center justify-center rounded-md border-2 border-accent bg-surface px-3 text-center">
|
||||
<span className="truncate font-body text-sm font-medium text-text-primary">{subjectName}</span>
|
||||
</div>
|
||||
|
|
@ -383,7 +445,6 @@ function PedigreeTree({
|
|||
<Generation entries={GEN2} state={state} setSlotState={setSlotState} cascadeCache={cascadeCache} fetchCascade={fetchCascade} />
|
||||
<Generation entries={GEN3} state={state} setSlotState={setSlotState} cascadeCache={cascadeCache} fetchCascade={fetchCascade} />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -16,6 +16,10 @@ const SEX_OPTIONS = [
|
|||
{ value: 'unknown', label: 'Unknown' },
|
||||
]
|
||||
|
||||
const INPUT_CLASS =
|
||||
'mt-1 w-full rounded-md border border-border px-3 py-2 font-body text-sm focus:outline-none focus:ring-2 focus:ring-accent'
|
||||
const LABEL_CLASS = 'block font-body text-sm font-medium text-text-primary'
|
||||
|
||||
function lbsToOz(decimalLbs) {
|
||||
if (decimalLbs === null || decimalLbs === undefined || decimalLbs === '') return { lbs: '', oz: '' }
|
||||
const total = Number(decimalLbs)
|
||||
|
|
@ -31,8 +35,26 @@ function ozToLbs(lbs, oz) {
|
|||
return l + o / 16
|
||||
}
|
||||
|
||||
function RabbitDetailsForm({ value, onChange, onPhotoChange, weightUnit = 'decimal_lbs', showValidation = false }) {
|
||||
// The fields most people fill in when adding a rabbit come first; the rest
|
||||
// (status, location, acquired/death dates) sit in a "More details" section.
|
||||
// With `collapseSecondary` (the Add flow) that section starts closed unless it
|
||||
// already holds a non-default value; Edit shows everything open.
|
||||
function RabbitDetailsForm({
|
||||
value,
|
||||
onChange,
|
||||
onPhotoChange,
|
||||
weightUnit = 'decimal_lbs',
|
||||
showValidation = false,
|
||||
collapseSecondary = false,
|
||||
nameInputId = 'rabbit-name',
|
||||
}) {
|
||||
const [photoPreview, setPhotoPreview] = useState(null)
|
||||
const [moreOpen, setMoreOpen] = useState(
|
||||
() =>
|
||||
!collapseSecondary ||
|
||||
Boolean(value.location || value.acquired_date || value.death_date) ||
|
||||
(value.status ?? 'active') !== 'active'
|
||||
)
|
||||
const { lbs: ozLbs, oz: ozOz } = lbsToOz(value.weight_lbs)
|
||||
|
||||
function setField(field, fieldValue) {
|
||||
|
|
@ -49,41 +71,60 @@ function RabbitDetailsForm({ value, onChange, onPhotoChange, weightUnit = 'decim
|
|||
const nameInvalid = showValidation && !value.name?.trim()
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
|
||||
<div className="md:col-span-2">
|
||||
<label className="block font-body text-sm font-medium text-text-primary">
|
||||
<label htmlFor={nameInputId} className={LABEL_CLASS}>
|
||||
Name <span className="text-danger">*</span>
|
||||
</label>
|
||||
<input
|
||||
id={nameInputId}
|
||||
type="text"
|
||||
value={value.name ?? ''}
|
||||
onChange={(e) => setField('name', e.target.value)}
|
||||
aria-invalid={nameInvalid || undefined}
|
||||
aria-describedby={nameInvalid ? `${nameInputId}-error` : undefined}
|
||||
className={`mt-1 w-full rounded-md border px-3 py-2 font-body text-sm focus:outline-none focus:ring-2 focus:ring-accent ${
|
||||
nameInvalid ? 'border-danger' : 'border-border'
|
||||
}`}
|
||||
/>
|
||||
{nameInvalid && <p className="mt-1 font-body text-xs text-danger">Name is required</p>}
|
||||
{nameInvalid && (
|
||||
<p id={`${nameInputId}-error`} className="mt-1 font-body text-xs text-danger">
|
||||
Give this rabbit a name to continue.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block font-body text-sm font-medium text-text-primary">Farm / Prefix</label>
|
||||
<label className={LABEL_CLASS}>Farm / Prefix</label>
|
||||
<input
|
||||
type="text"
|
||||
value={value.farm ?? ''}
|
||||
onChange={(e) => setField('farm', e.target.value)}
|
||||
className="mt-1 w-full rounded-md border border-border px-3 py-2 font-body text-sm focus:outline-none focus:ring-2 focus:ring-accent"
|
||||
className={INPUT_CLASS}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block font-body text-sm font-medium text-text-primary">Sex</label>
|
||||
<div className="mt-1 flex gap-1">
|
||||
<label className={LABEL_CLASS}>Ear Number</label>
|
||||
<input
|
||||
type="text"
|
||||
value={value.ear_number ?? ''}
|
||||
onChange={(e) => setField('ear_number', e.target.value.toUpperCase())}
|
||||
className={`${INPUT_CLASS} uppercase`}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<span className={LABEL_CLASS}>Sex</span>
|
||||
<div className="mt-1 flex gap-1" role="group" aria-label="Sex">
|
||||
{SEX_OPTIONS.map((opt) => (
|
||||
<button
|
||||
key={opt.value}
|
||||
type="button"
|
||||
aria-pressed={value.sex === opt.value}
|
||||
onClick={() => setField('sex', opt.value)}
|
||||
className={`flex-1 rounded-md border px-3 py-2 font-body text-sm ${
|
||||
className={`min-h-[44px] flex-1 rounded-md border px-3 py-2 font-body text-sm md:min-h-0 ${
|
||||
value.sex === opt.value
|
||||
? 'border-accent bg-accent text-surface'
|
||||
: 'border-border bg-surface-raised text-text-primary hover:bg-surface-sunken'
|
||||
|
|
@ -96,97 +137,110 @@ function RabbitDetailsForm({ value, onChange, onPhotoChange, weightUnit = 'decim
|
|||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block font-body text-sm font-medium text-text-primary">Date of Birth</label>
|
||||
<label className={LABEL_CLASS}>Date of Birth</label>
|
||||
<input
|
||||
type="date"
|
||||
value={value.dob ?? ''}
|
||||
onChange={(e) => setField('dob', e.target.value)}
|
||||
className="mt-1 w-full rounded-md border border-border px-3 py-2 font-body text-sm focus:outline-none focus:ring-2 focus:ring-accent"
|
||||
className={INPUT_CLASS}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block font-body text-sm font-medium text-text-primary">Acquired Date</label>
|
||||
<label className={LABEL_CLASS}>Breed</label>
|
||||
<BreedSelect value={value.breed ?? ''} onChange={(breed) => setField('breed', breed)} />
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className={LABEL_CLASS}>Color Name</label>
|
||||
<input
|
||||
type="date"
|
||||
value={value.acquired_date ?? ''}
|
||||
onChange={(e) => setField('acquired_date', e.target.value || null)}
|
||||
className="mt-1 w-full rounded-md border border-border px-3 py-2 font-body text-sm focus:outline-none focus:ring-2 focus:ring-accent"
|
||||
/>
|
||||
<p className="mt-1 font-body text-xs text-text-secondary">If added to the herd after birth</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block font-body text-sm font-medium text-text-primary">Death Date</label>
|
||||
<input
|
||||
type="date"
|
||||
value={value.death_date ?? ''}
|
||||
onChange={(e) => setField('death_date', e.target.value || null)}
|
||||
className="mt-1 w-full rounded-md border border-border px-3 py-2 font-body text-sm focus:outline-none focus:ring-2 focus:ring-accent"
|
||||
type="text"
|
||||
value={value.color_name ?? ''}
|
||||
onChange={(e) => setField('color_name', e.target.value)}
|
||||
placeholder="e.g. Broken Black Tort"
|
||||
className={INPUT_CLASS}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block font-body text-sm font-medium text-text-primary">Weight</label>
|
||||
<label className={LABEL_CLASS}>Weight</label>
|
||||
{weightUnit === 'lbs_oz' ? (
|
||||
<div className="mt-1 flex gap-2">
|
||||
<input
|
||||
type="number"
|
||||
inputMode="numeric"
|
||||
min="0"
|
||||
value={ozLbs}
|
||||
onChange={(e) => setField('weight_lbs', ozToLbs(e.target.value, ozOz))}
|
||||
placeholder="lbs"
|
||||
aria-label="Weight, pounds"
|
||||
className="w-1/2 rounded-md border border-border px-3 py-2 font-body text-sm focus:outline-none focus:ring-2 focus:ring-accent"
|
||||
/>
|
||||
<input
|
||||
type="number"
|
||||
inputMode="numeric"
|
||||
min="0"
|
||||
max="15"
|
||||
value={ozOz}
|
||||
onChange={(e) => setField('weight_lbs', ozToLbs(ozLbs, e.target.value))}
|
||||
placeholder="oz"
|
||||
aria-label="Weight, ounces"
|
||||
className="w-1/2 rounded-md border border-border px-3 py-2 font-body text-sm focus:outline-none focus:ring-2 focus:ring-accent"
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<input
|
||||
type="number"
|
||||
inputMode="decimal"
|
||||
min="0"
|
||||
step="0.01"
|
||||
value={value.weight_lbs ?? ''}
|
||||
onChange={(e) => setField('weight_lbs', e.target.value === '' ? null : Number(e.target.value))}
|
||||
placeholder="decimal lbs"
|
||||
className="mt-1 w-full rounded-md border border-border px-3 py-2 font-body text-sm focus:outline-none focus:ring-2 focus:ring-accent"
|
||||
className={INPUT_CLASS}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block font-body text-sm font-medium text-text-primary">Ear Number</label>
|
||||
<div className="md:col-span-2">
|
||||
<label className={LABEL_CLASS}>Photo</label>
|
||||
<input
|
||||
type="text"
|
||||
value={value.ear_number ?? ''}
|
||||
onChange={(e) => setField('ear_number', e.target.value.toUpperCase())}
|
||||
className="mt-1 w-full rounded-md border border-border px-3 py-2 font-body text-sm uppercase focus:outline-none focus:ring-2 focus:ring-accent"
|
||||
type="file"
|
||||
accept="image/jpeg,image/png,image/webp"
|
||||
onChange={handlePhotoSelect}
|
||||
className="mt-1 block w-full font-body text-sm text-text-secondary file:mr-3 file:min-h-[40px] file:cursor-pointer file:rounded-md file:border file:border-solid file:border-border file:bg-surface-raised file:px-3 file:py-2 file:font-body file:text-sm file:font-medium file:text-text-primary hover:file:bg-surface-sunken"
|
||||
/>
|
||||
{photoPreview && (
|
||||
<img src={photoPreview} alt="Preview" className="mt-2 h-24 w-24 rounded-md object-cover" />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block font-body text-sm font-medium text-text-primary">Location</label>
|
||||
<input
|
||||
type="text"
|
||||
value={value.location ?? ''}
|
||||
onChange={(e) => setField('location', e.target.value)}
|
||||
className="mt-1 w-full rounded-md border border-border px-3 py-2 font-body text-sm focus:outline-none focus:ring-2 focus:ring-accent"
|
||||
/>
|
||||
</div>
|
||||
<div className={collapseSecondary ? 'rounded-lg border border-border' : ''}>
|
||||
{collapseSecondary && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setMoreOpen((v) => !v)}
|
||||
aria-expanded={moreOpen}
|
||||
className="flex w-full items-center justify-between gap-3 px-4 py-3 text-left font-body text-sm font-medium text-text-primary"
|
||||
>
|
||||
<span>More details</span>
|
||||
<span className="font-normal text-text-secondary">Status, location, dates</span>
|
||||
</button>
|
||||
)}
|
||||
|
||||
{moreOpen && (
|
||||
<div
|
||||
className={`grid grid-cols-1 gap-4 md:grid-cols-2 ${
|
||||
collapseSecondary ? 'border-t border-border px-4 py-4' : ''
|
||||
}`}
|
||||
>
|
||||
<div>
|
||||
<label className="block font-body text-sm font-medium text-text-primary">Status</label>
|
||||
<label className={LABEL_CLASS}>Status</label>
|
||||
<select
|
||||
value={value.status ?? 'active'}
|
||||
onChange={(e) => setField('status', e.target.value)}
|
||||
className="mt-1 w-full rounded-md border border-border bg-surface-raised px-3 py-2 font-body text-sm focus:outline-none focus:ring-2 focus:ring-accent"
|
||||
className={`${INPUT_CLASS} bg-surface-raised`}
|
||||
>
|
||||
{STATUS_OPTIONS.map((opt) => (
|
||||
<option key={opt.value} value={opt.value}>
|
||||
|
|
@ -197,31 +251,37 @@ function RabbitDetailsForm({ value, onChange, onPhotoChange, weightUnit = 'decim
|
|||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block font-body text-sm font-medium text-text-primary">Breed</label>
|
||||
<BreedSelect value={value.breed ?? ''} onChange={(breed) => setField('breed', breed)} />
|
||||
<label className={LABEL_CLASS}>Location</label>
|
||||
<input
|
||||
type="text"
|
||||
value={value.location ?? ''}
|
||||
onChange={(e) => setField('location', e.target.value)}
|
||||
placeholder="Cage or hutch"
|
||||
className={INPUT_CLASS}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block font-body text-sm font-medium text-text-primary">Color Name</label>
|
||||
<label className={LABEL_CLASS}>Acquired Date</label>
|
||||
<input
|
||||
type="text"
|
||||
value={value.color_name ?? ''}
|
||||
onChange={(e) => setField('color_name', e.target.value)}
|
||||
placeholder="e.g. Broken Black Tort"
|
||||
className="mt-1 w-full rounded-md border border-border px-3 py-2 font-body text-sm focus:outline-none focus:ring-2 focus:ring-accent"
|
||||
type="date"
|
||||
value={value.acquired_date ?? ''}
|
||||
onChange={(e) => setField('acquired_date', e.target.value || null)}
|
||||
className={INPUT_CLASS}
|
||||
/>
|
||||
<p className="mt-1 font-body text-xs text-text-secondary">If added to the herd after birth</p>
|
||||
</div>
|
||||
|
||||
<div className="md:col-span-2">
|
||||
<label className="block font-body text-sm font-medium text-text-primary">Photo</label>
|
||||
<div>
|
||||
<label className={LABEL_CLASS}>Death Date</label>
|
||||
<input
|
||||
type="file"
|
||||
accept="image/jpeg,image/png,image/webp"
|
||||
onChange={handlePhotoSelect}
|
||||
className="mt-1 block w-full font-body text-sm text-text-secondary"
|
||||
type="date"
|
||||
value={value.death_date ?? ''}
|
||||
onChange={(e) => setField('death_date', e.target.value || null)}
|
||||
className={INPUT_CLASS}
|
||||
/>
|
||||
{photoPreview && (
|
||||
<img src={photoPreview} alt="Preview" className="mt-2 h-24 w-24 rounded-md object-cover" />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -1,74 +1,98 @@
|
|||
import Button from './Button'
|
||||
import Card from './Card'
|
||||
import { LOCI, generateColorString } from '../lib/genetics'
|
||||
import { SEX_LABELS, STATUS_LABELS, formatDate, formatWeight } from '../lib/format'
|
||||
|
||||
const DETAIL_FIELDS = [
|
||||
{ key: 'name', label: 'Name' },
|
||||
{ key: 'farm', label: 'Farm / Prefix' },
|
||||
{ key: 'sex', label: 'Sex' },
|
||||
{ key: 'dob', label: 'Date of Birth' },
|
||||
{ key: 'weight_lbs', label: 'Weight (lbs)' },
|
||||
{ key: 'ear_number', label: 'Ear Number' },
|
||||
{ key: 'location', label: 'Location' },
|
||||
{ key: 'status', label: 'Status' },
|
||||
{ key: 'breed', label: 'Breed' },
|
||||
{ key: 'color_name', label: 'Color Name' },
|
||||
]
|
||||
|
||||
function dash(value) {
|
||||
return value === null || value === undefined || value === '' ? '—' : String(value)
|
||||
// Display order + formatting for the Details card. Empty values are skipped
|
||||
// (except Name, which is required), so the review shows what was entered
|
||||
// rather than a wall of dashes.
|
||||
function detailRows(details, weightUnit) {
|
||||
return [
|
||||
['Name', details.name],
|
||||
['Farm / Prefix', details.farm],
|
||||
['Ear Number', details.ear_number],
|
||||
['Sex', SEX_LABELS[details.sex] ?? details.sex],
|
||||
['Date of Birth', formatDate(details.dob)],
|
||||
['Breed', details.breed],
|
||||
['Color Name', details.color_name],
|
||||
['Weight', formatWeight(details.weight_lbs, weightUnit)],
|
||||
['Status', STATUS_LABELS[details.status] ?? details.status],
|
||||
['Location', details.location],
|
||||
['Acquired', formatDate(details.acquired_date)],
|
||||
['Death Date', formatDate(details.death_date)],
|
||||
].filter(([label, value]) => label === 'Name' || (value !== null && value !== undefined && value !== ''))
|
||||
}
|
||||
|
||||
function ReviewStep({ details, pedigreeSummary, genetics, onBack, onSave, saving = false }) {
|
||||
function locusLabel(locus, value) {
|
||||
return locus.options.find((opt) => opt.value === value)?.label ?? value
|
||||
}
|
||||
|
||||
function Section({ title, onEdit, children }) {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<Card className="p-4">
|
||||
<h3 className="mb-3 font-display text-lg text-text-primary">Pedigree</h3>
|
||||
<dl className="grid grid-cols-1 gap-2 md:grid-cols-2">
|
||||
{pedigreeSummary.map((node) => (
|
||||
<div key={node.slot} className="flex justify-between gap-2 border-b border-border py-1">
|
||||
<dt className="font-body text-sm text-text-secondary">{node.label}</dt>
|
||||
<dd className="font-body text-sm text-text-primary">{dash(node.display)}</dd>
|
||||
<div className="mb-3 flex items-center justify-between gap-2">
|
||||
<h3 className="font-display text-lg font-bold text-text-primary">{title}</h3>
|
||||
{onEdit && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onEdit}
|
||||
className="-my-2 rounded-md px-3 py-2 font-body text-sm font-medium text-accent hover:bg-surface-sunken"
|
||||
>
|
||||
Edit
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</dl>
|
||||
{children}
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
<Card className="p-4">
|
||||
<h3 className="mb-3 font-display text-lg text-text-primary">Details</h3>
|
||||
<dl className="grid grid-cols-1 gap-2 md:grid-cols-2">
|
||||
{DETAIL_FIELDS.map((field) => (
|
||||
<div key={field.key} className="flex justify-between gap-2 border-b border-border py-1">
|
||||
<dt className="font-body text-sm text-text-secondary">{field.label}</dt>
|
||||
<dd className="font-body text-sm text-text-primary">{dash(details[field.key])}</dd>
|
||||
function Rows({ rows }) {
|
||||
return (
|
||||
<dl className="grid grid-cols-1 gap-x-6 md:grid-cols-2">
|
||||
{rows.map(([label, value]) => (
|
||||
<div key={label} className="flex justify-between gap-4 border-b border-border py-2">
|
||||
<dt className="shrink-0 font-body text-sm text-text-secondary">{label}</dt>
|
||||
<dd className="min-w-0 break-words text-right font-body text-sm text-text-primary">{value || '—'}</dd>
|
||||
</div>
|
||||
))}
|
||||
</dl>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
<Card className="p-4">
|
||||
<h3 className="mb-3 font-display text-lg text-text-primary">Genetics</h3>
|
||||
<dl className="grid grid-cols-1 gap-2 md:grid-cols-2">
|
||||
{LOCI.map((locus) => (
|
||||
<div key={locus.key} className="flex justify-between gap-2 border-b border-border py-1">
|
||||
<dt className="font-body text-sm text-text-secondary">{locus.label}</dt>
|
||||
<dd className="font-body text-sm text-text-primary">{dash(genetics[locus.key])}</dd>
|
||||
</div>
|
||||
))}
|
||||
</dl>
|
||||
function ReviewStep({ details, pedigreeSummary, genetics, weightUnit, onEdit }) {
|
||||
// Sire and Dam always show (so "Unknown" parents are visible at a glance);
|
||||
// deeper ancestors only when something was actually entered.
|
||||
const pedigreeRows = pedigreeSummary
|
||||
.filter((node) => node.slot === 'sire' || node.slot === 'dam' || (node.display && node.display !== 'Unknown'))
|
||||
.map((node) => [node.label, node.display])
|
||||
|
||||
const setLoci = LOCI.filter((locus) => genetics[locus.key])
|
||||
const colorString = generateColorString(genetics)
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<Section title="Pedigree" onEdit={() => onEdit(1)}>
|
||||
<Rows rows={pedigreeRows} />
|
||||
</Section>
|
||||
|
||||
<Section title="Details" onEdit={() => onEdit(2)}>
|
||||
<Rows rows={detailRows(details, weightUnit)} />
|
||||
</Section>
|
||||
|
||||
<Section title="Genetics" onEdit={() => onEdit(2)}>
|
||||
{setLoci.length === 0 ? (
|
||||
<p className="font-body text-sm text-text-secondary">Not set — you can add genotypes later from Edit.</p>
|
||||
) : (
|
||||
<>
|
||||
<Rows rows={setLoci.map((locus) => [locus.label, locusLabel(locus, genetics[locus.key])])} />
|
||||
{colorString && (
|
||||
<p className="mt-3 font-body text-sm text-text-secondary">
|
||||
Color: <span className="text-text-primary">{dash(generateColorString(genetics))}</span>
|
||||
Color: <span className="text-text-primary">{colorString}</span>
|
||||
</p>
|
||||
</Card>
|
||||
|
||||
<div className="flex justify-end gap-3">
|
||||
<Button type="button" variant="secondary" onClick={onBack} disabled={saving}>
|
||||
Back
|
||||
</Button>
|
||||
<Button type="button" variant="primary" onClick={onSave} disabled={saving}>
|
||||
{saving ? 'Saving…' : 'Save Rabbit'}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</Section>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,13 @@
|
|||
// Form actions pinned to the bottom of the viewport while the page scrolls, so
|
||||
// Next/Save is always in thumb reach. On phones it sits just above the fixed
|
||||
// bottom nav (56px + safe-area inset, see Layout.jsx); the negative margins
|
||||
// stretch it across <main>'s gutter.
|
||||
function StickyActionBar({ children }) {
|
||||
return (
|
||||
<div className="sticky bottom-[calc(3.5rem+env(safe-area-inset-bottom))] z-10 -mx-4 border-t border-border bg-surface px-4 py-3 md:bottom-0 md:-mx-8 md:px-8">
|
||||
<div className="flex items-center justify-end gap-3">{children}</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default StickyActionBar
|
||||
|
|
@ -0,0 +1,14 @@
|
|||
import { useSyncExternalStore } from 'react'
|
||||
|
||||
// Live boolean for a CSS media query, for the rare layout switch that CSS
|
||||
// alone can't do (e.g. rendering a structurally different component tree).
|
||||
export function useMediaQuery(query) {
|
||||
return useSyncExternalStore(
|
||||
(onChange) => {
|
||||
const mql = window.matchMedia(query)
|
||||
mql.addEventListener('change', onChange)
|
||||
return () => mql.removeEventListener('change', onChange)
|
||||
},
|
||||
() => window.matchMedia(query).matches
|
||||
)
|
||||
}
|
||||
|
|
@ -4,6 +4,15 @@ export const SEX_LABELS = {
|
|||
unknown: 'Unknown',
|
||||
}
|
||||
|
||||
export const STATUS_LABELS = {
|
||||
active: 'Active',
|
||||
for_sale: 'For Sale',
|
||||
on_hold: 'On Hold',
|
||||
sold: 'Sold',
|
||||
deceased: 'Deceased',
|
||||
other: 'Other',
|
||||
}
|
||||
|
||||
export function formatAge(dob) {
|
||||
if (!dob) return 'Age unknown'
|
||||
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { useState } from 'react'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useNavigate, useSearchParams } from 'react-router-dom'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import PageHeader from '../components/PageHeader'
|
||||
|
|
@ -6,11 +6,13 @@ import PedigreeTree from '../components/PedigreeTree'
|
|||
import RabbitDetailsForm from '../components/RabbitDetailsForm'
|
||||
import GeneticsForm from '../components/GeneticsForm'
|
||||
import ReviewStep from '../components/ReviewStep'
|
||||
import StickyActionBar from '../components/StickyActionBar'
|
||||
import Button from '../components/Button'
|
||||
import { getSettings, getRabbit, createRabbit, uploadRabbitPhoto } from '../api'
|
||||
import { createInitialPedigreeState } from '../lib/pedigree'
|
||||
|
||||
const STEP_LABELS = ['Pedigree', 'Details', 'Review']
|
||||
const NAME_INPUT_ID = 'add-rabbit-name'
|
||||
|
||||
const INITIAL_DETAILS = {
|
||||
name: '',
|
||||
|
|
@ -35,14 +37,54 @@ const INITIAL_GENETICS = {
|
|||
locus_v: null,
|
||||
}
|
||||
|
||||
function Stepper({ step, canReach, onSelect }) {
|
||||
return (
|
||||
<ol className="flex items-center gap-2">
|
||||
{STEP_LABELS.map((label, i) => {
|
||||
const n = i + 1
|
||||
const current = n === step
|
||||
const done = n < step
|
||||
return (
|
||||
<li key={label} className="flex min-w-0 flex-1 items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onSelect(n)}
|
||||
disabled={current || !canReach(n)}
|
||||
aria-current={current ? 'step' : undefined}
|
||||
className="flex min-h-[40px] min-w-0 items-center gap-2 rounded-md pr-2 text-left disabled:cursor-default"
|
||||
>
|
||||
<span
|
||||
className={`flex h-7 w-7 shrink-0 items-center justify-center rounded-full font-body text-sm font-medium ${
|
||||
current
|
||||
? 'bg-accent text-surface'
|
||||
: done
|
||||
? 'border-2 border-accent text-accent'
|
||||
: 'border border-border text-text-secondary'
|
||||
}`}
|
||||
>
|
||||
{n}
|
||||
</span>
|
||||
<span
|
||||
className={`truncate font-body text-sm ${current ? 'font-medium text-text-primary' : 'text-text-secondary'}`}
|
||||
>
|
||||
{label}
|
||||
</span>
|
||||
</button>
|
||||
{n < STEP_LABELS.length && <span className="h-px flex-1 bg-border" aria-hidden="true" />}
|
||||
</li>
|
||||
)
|
||||
})}
|
||||
</ol>
|
||||
)
|
||||
}
|
||||
|
||||
function AddRabbit() {
|
||||
const navigate = useNavigate()
|
||||
const [searchParams] = useSearchParams()
|
||||
const [searchParams, setSearchParams] = useSearchParams()
|
||||
const sireId = searchParams.get('sire')
|
||||
const damId = searchParams.get('dam')
|
||||
const prefillDob = searchParams.get('dob')
|
||||
|
||||
const [step, setStep] = useState(1)
|
||||
const [details, setDetails] = useState(() => ({
|
||||
...INITIAL_DETAILS,
|
||||
dob: prefillDob ?? INITIAL_DETAILS.dob,
|
||||
|
|
@ -54,6 +96,35 @@ function AddRabbit() {
|
|||
const [saving, setSaving] = useState(false)
|
||||
const [error, setError] = useState(null)
|
||||
const [detailsDefaulted, setDetailsDefaulted] = useState(false)
|
||||
// The name error only shows once the user has tried to move past step 2,
|
||||
// not the moment they arrive on an empty form.
|
||||
const [triedNext, setTriedNext] = useState(false)
|
||||
|
||||
const step2Valid = details.name.trim().length > 0
|
||||
|
||||
// The step lives in the URL (pushed, not replaced) so the browser / Android
|
||||
// back gesture moves between steps instead of abandoning the whole flow.
|
||||
// Review can't be reached without a name (e.g. after a reload, which resets
|
||||
// the form), so it falls back to Details.
|
||||
const requestedStep = Math.min(3, Math.max(1, Number(searchParams.get('step')) || 1))
|
||||
const step = requestedStep === 3 && !step2Valid ? 2 : requestedStep
|
||||
|
||||
const isDirty =
|
||||
Boolean(details.name.trim()) ||
|
||||
Boolean(photoFile) ||
|
||||
Object.values(genetics).some(Boolean) ||
|
||||
pedigreeSummary.some((node) => node.display && node.display !== 'Unknown')
|
||||
|
||||
// Warn before a reload / tab close throws away an in-progress entry.
|
||||
useEffect(() => {
|
||||
if (!isDirty || saving) return
|
||||
function onBeforeUnload(e) {
|
||||
e.preventDefault()
|
||||
e.returnValue = ''
|
||||
}
|
||||
window.addEventListener('beforeunload', onBeforeUnload)
|
||||
return () => window.removeEventListener('beforeunload', onBeforeUnload)
|
||||
}, [isDirty, saving])
|
||||
|
||||
const { data: settings } = useQuery({ queryKey: ['settings'], queryFn: getSettings })
|
||||
const { data: sireRabbit, isLoading: sireLoading } = useQuery({
|
||||
|
|
@ -102,85 +173,145 @@ function AddRabbit() {
|
|||
setGenetics((prev) => ({ ...prev, ...partial }))
|
||||
}
|
||||
|
||||
function goToStep(n) {
|
||||
const next = new URLSearchParams(searchParams)
|
||||
if (n === 1) next.delete('step')
|
||||
else next.set('step', String(n))
|
||||
setSearchParams(next)
|
||||
window.scrollTo(0, 0)
|
||||
}
|
||||
|
||||
function handleNextFromDetails() {
|
||||
if (!step2Valid) {
|
||||
setTriedNext(true)
|
||||
document.getElementById(NAME_INPUT_ID)?.focus()
|
||||
return
|
||||
}
|
||||
goToStep(3)
|
||||
}
|
||||
|
||||
async function handleSave() {
|
||||
setSaving(true)
|
||||
setError(null)
|
||||
let created
|
||||
try {
|
||||
const created = await createRabbit({ ...details, ...pedigreeFields, ...genetics })
|
||||
if (photoFile) {
|
||||
await uploadRabbitPhoto(created.id, photoFile)
|
||||
}
|
||||
navigate(`/herd/${created.id}`)
|
||||
created = await createRabbit({ ...details, ...pedigreeFields, ...genetics })
|
||||
} catch (err) {
|
||||
setError(err.message)
|
||||
setSaving(false)
|
||||
return
|
||||
}
|
||||
// The rabbit exists from here on. A failed photo upload must not leave the
|
||||
// user on Review, where pressing Save again would create a duplicate —
|
||||
// go to the new rabbit and say what happened instead.
|
||||
if (photoFile) {
|
||||
try {
|
||||
await uploadRabbitPhoto(created.id, photoFile)
|
||||
} catch {
|
||||
navigate(`/herd/${created.id}`, {
|
||||
replace: true,
|
||||
state: { notice: 'Rabbit saved, but the photo didn’t upload. You can add it again from Edit.' },
|
||||
})
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
const step2Valid = details.name.trim().length > 0
|
||||
|
||||
const headerAction =
|
||||
step === 1 ? (
|
||||
<Button type="button" onClick={() => setStep(2)}>
|
||||
Next
|
||||
</Button>
|
||||
) : step === 2 ? (
|
||||
<div className="flex gap-3">
|
||||
<Button type="button" variant="secondary" onClick={() => setStep(1)}>
|
||||
Back
|
||||
</Button>
|
||||
<Button type="button" onClick={() => setStep(3)} disabled={!step2Valid}>
|
||||
Next
|
||||
</Button>
|
||||
</div>
|
||||
) : null
|
||||
navigate(`/herd/${created.id}`, { replace: true })
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-6">
|
||||
<PageHeader
|
||||
title="Add Rabbit"
|
||||
subtitle={`Step ${step} of 3 — ${STEP_LABELS[step - 1]}`}
|
||||
action={headerAction}
|
||||
/>
|
||||
<PageHeader title="Add Rabbit" />
|
||||
|
||||
<Stepper step={step} canReach={(n) => n < 3 || step2Valid} onSelect={goToStep} />
|
||||
|
||||
{/* All three steps stay mounted and are hidden with CSS, so going back
|
||||
(or jumping from Review via "Edit") keeps the pedigree tree's
|
||||
internal state, the photo preview, and any open sections intact. */}
|
||||
<div className={step === 1 ? 'flex flex-col gap-4' : 'hidden'}>
|
||||
<div>
|
||||
{step === 1 && (
|
||||
<label htmlFor="add-rabbit-subject-name" className="block font-body text-sm font-medium text-text-primary">
|
||||
Rabbit name
|
||||
</label>
|
||||
<input
|
||||
id="add-rabbit-subject-name"
|
||||
type="text"
|
||||
value={details.name}
|
||||
onChange={(e) => updateDetails({ name: e.target.value })}
|
||||
placeholder="You can also fill this in on the next step"
|
||||
className="mt-1 w-full max-w-md rounded-md border border-border px-3 py-2 font-body text-sm focus:outline-none focus:ring-2 focus:ring-accent"
|
||||
/>
|
||||
</div>
|
||||
<p className="font-body text-sm text-text-secondary">
|
||||
Start with the parents. Tap any ancestor to search your herd, enter them manually, or mark them unknown.
|
||||
Picking a rabbit from your herd fills in its ancestors automatically.
|
||||
</p>
|
||||
<PedigreeTree
|
||||
subjectName={details.name.trim() || 'New Rabbit'}
|
||||
initialState={pedigreeInitialState}
|
||||
onChange={(fields, summary) => {
|
||||
setPedigreeFields(fields)
|
||||
setPedigreeSummary(summary)
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{step === 2 && (
|
||||
<div className="space-y-6">
|
||||
<div className={step === 2 ? 'space-y-6' : 'hidden'}>
|
||||
<RabbitDetailsForm
|
||||
value={details}
|
||||
onChange={updateDetails}
|
||||
onPhotoChange={setPhotoFile}
|
||||
weightUnit={settings?.weight_unit}
|
||||
showValidation={!step2Valid}
|
||||
showValidation={triedNext}
|
||||
collapseSecondary
|
||||
nameInputId={NAME_INPUT_ID}
|
||||
/>
|
||||
<GeneticsForm
|
||||
value={genetics}
|
||||
onChange={updateGenetics}
|
||||
defaultExpanded={false}
|
||||
onUseColor={(color) => updateDetails({ color_name: color })}
|
||||
/>
|
||||
<GeneticsForm value={genetics} onChange={updateGenetics} defaultExpanded={false} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{step === 3 && (
|
||||
<div>
|
||||
{error && <p className="mb-4 font-body text-sm text-danger">{error}</p>}
|
||||
{error && (
|
||||
<p role="alert" className="mb-4 font-body text-sm text-danger">
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
<ReviewStep
|
||||
details={details}
|
||||
pedigreeSummary={pedigreeSummary}
|
||||
genetics={genetics}
|
||||
onBack={() => setStep(2)}
|
||||
onSave={handleSave}
|
||||
saving={saving}
|
||||
weightUnit={settings?.weight_unit}
|
||||
onEdit={goToStep}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<StickyActionBar>
|
||||
{step > 1 && (
|
||||
<Button type="button" variant="secondary" onClick={() => goToStep(step - 1)} disabled={saving}>
|
||||
Back
|
||||
</Button>
|
||||
)}
|
||||
{step === 1 && (
|
||||
<Button type="button" onClick={() => goToStep(2)}>
|
||||
Next: Details
|
||||
</Button>
|
||||
)}
|
||||
{step === 2 && (
|
||||
<Button type="button" onClick={handleNextFromDetails}>
|
||||
Next: Review
|
||||
</Button>
|
||||
)}
|
||||
{step === 3 && (
|
||||
<Button type="button" onClick={handleSave} disabled={saving}>
|
||||
{saving ? 'Saving…' : 'Save Rabbit'}
|
||||
</Button>
|
||||
)}
|
||||
</StickyActionBar>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -104,6 +104,7 @@ function EditRabbit() {
|
|||
value={genetics}
|
||||
onChange={updateGenetics}
|
||||
defaultExpanded={hasAnyLocusSet(genetics)}
|
||||
onUseColor={(color) => updateDetails({ color_name: color })}
|
||||
/>
|
||||
|
||||
{error && <p className="font-body text-sm text-danger">{error}</p>}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import { useState } from 'react'
|
||||
import { Link, useParams } from 'react-router-dom'
|
||||
import { Link, useLocation, useParams } from 'react-router-dom'
|
||||
import { useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import PageHeader from '../components/PageHeader'
|
||||
import Card from '../components/Card'
|
||||
|
|
@ -140,6 +140,9 @@ function LitterHistory({ rabbitId }) {
|
|||
function RabbitDetail() {
|
||||
const { id } = useParams()
|
||||
const rabbitId = Number(id)
|
||||
// One-off message handed over by the page that navigated here (e.g. Add
|
||||
// Rabbit when the photo upload failed after the rabbit was created).
|
||||
const notice = useLocation().state?.notice
|
||||
const queryClient = useQueryClient()
|
||||
const [updating, setUpdating] = useState(false)
|
||||
const [error, setError] = useState(null)
|
||||
|
|
@ -231,6 +234,11 @@ function RabbitDetail() {
|
|||
/>
|
||||
|
||||
<div className="space-y-6">
|
||||
{notice && (
|
||||
<p role="status" className="rounded-md border border-accent-warm bg-surface-raised px-4 py-3 font-body text-sm text-text-primary">
|
||||
{notice}
|
||||
</p>
|
||||
)}
|
||||
{error && <p className="font-body text-sm text-danger">{error}</p>}
|
||||
|
||||
<div className="flex flex-col gap-6 lg:flex-row lg:items-start">
|
||||
|
|
|
|||
Loading…
Reference in New Issue