Design pass: contrast, shared form controls, dialogs, mobile layouts
A - accessibility - on-accent-warm text token (For Sale badge, flags) and darker Sunset/Ocean colors to meet WCAG AA contrast - 44px touch targets for Herd and Shows filters on phones - status badge on non-active Herd grid cards; drop redundant View Herd B - consistency - FormControls (Field, FieldGroup, Input, Select, Textarea, FileInput, Segmented) used by every form, with labels wired to their inputs - ConfirmDialog + useConfirm replace window.confirm and hand-built modals - ErrorPanel with Try Again for failed lists; LoadError built on it C - layout - ActionMenu: rabbit header is Edit, Status, More - Herd folds Status/Sort/view behind a Filters button on phones - Dashboard status counts as a compact second row - Shows list as stacked cards below xl - SVG timeline icons instead of emoji Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
5dcaf9d04b
commit
3542739786
|
|
@ -126,4 +126,18 @@ docker-compose.yml — orchestrates both services for self-hosted deployment
|
||||||
- **Settings** is split into a shared **Rabbitry** card (name, kit prefix, default breed) and a per-user **Your Preferences** card (weight display, theme), each saving only its own keys. Export is labelled **Back Up** and states that Import can't read it (Import = CSV/JSON herd replacement).
|
- **Settings** is split into a shared **Rabbitry** card (name, kit prefix, default breed) and a per-user **Your Preferences** card (weight display, theme), each saving only its own keys. Export is labelled **Back Up** and states that Import can't read it (Import = CSV/JSON herd replacement).
|
||||||
- **Genetics is hidden from the sidebar and mobile More menu** until the calculator is built; the `/genetics` placeholder route still exists. Grandparents are corrected by editing the parent rabbit (Edit Rabbit only re-links sire/dam), by user decision.
|
- **Genetics is hidden from the sidebar and mobile More menu** until the calculator is built; the `/genetics` placeholder route still exists. Grandparents are corrected by editing the parent rabbit (Edit Rabbit only re-links sire/dam), by user decision.
|
||||||
- Herd's sex tab for `sex='unknown'` is labelled **Unknown** (was "Other", which collided with the `other` status).
|
- Herd's sex tab for `sex='unknown'` is labelled **Unknown** (was "Other", which collided with the `other` status).
|
||||||
|
- **Design pass A (2026-10-31)** — accessibility fixes:
|
||||||
|
- Text on `accent-warm` fills uses the **`on-accent-warm`** token (dark ink in every theme; Midnight maps it to `surface`), never `text-surface` — white on the warm oranges fails contrast. Sunset's accent/hover and secondary text and Ocean's secondary text were darkened to reach WCAG AA (≥4.5:1).
|
||||||
|
- Filter controls on Herd and Shows are ≥44px tall on phones (`min-h-[44px] md:min-h-[32px]`, matching `Button size="sm"`).
|
||||||
|
- Herd grid cards badge any non-active status over the photo; the Dashboard's redundant "View Herd" quick action was removed.
|
||||||
|
- **Design pass B (2026-10-31)** — shared UI primitives; use these instead of hand-styling:
|
||||||
|
- **`components/FormControls.jsx`**: `Field` (label + hint/error, wires `htmlFor`/`aria-describedby`/`aria-invalid` to its control via context), `FieldGroup` (same label style for non-input controls such as `RabbitAutocomplete` or `Segmented`), `Input` / `Select` / `Textarea` / `FileInput` (`size="md"` forms: 44px touch, 40px desktop; `size="sm"` filter bars: 44px touch, 32px desktop), and `Segmented` (button-group choice: Sex, Weight Display). `BreedSelect` renders an `Input`, so it picks up a surrounding `Field`'s id. Every form in the app — including the login/setup screens — uses these; the only raw inputs left are search boxes with icons and the pedigree-tree slot editors.
|
||||||
|
- **`components/ConfirmDialog.jsx`** (native `<dialog>` + `showModal()`: focus trap, Escape, top layer; focus starts on Cancel) replaces every `window.confirm` and the hand-built Settings import / remove-user modals. `hooks/useConfirm.jsx` is the promise-based `await confirm({...})` form; render `<ConfirmDialog>` directly when the dialog must show progress (`busy`/`busyLabel`).
|
||||||
|
- **`components/ErrorPanel.jsx`** is the failed-to-load state for lists/sections (title, error message, Try Again → React Query `refetch`); `LoadError` (whole-page record) is built on it. Show Event Detail now uses `LoadError`.
|
||||||
|
- **Design pass C (2026-10-31)** — layout:
|
||||||
|
- **`components/ActionMenu.jsx`**: a "More ▾" button + menu for secondary page actions (`items` = `{label, to}` / `{label, onClick}`). Rabbit Detail's header is Edit · Status · More (Record Breeding / Record Litter for bucks & does, Print Pedigree); Delete stays at the page bottom.
|
||||||
|
- Herd on phones shows the sex tabs + search; Status, Sort and grid/list fold behind a **Filters** button (badged with how many differ from the defaults). Desktop shows everything inline.
|
||||||
|
- Dashboard: Herd / Bucks / Does / Kits are the large tiles; For Sale / On Hold / Sold / Archived are a compact second row (same `--stat-N` theme vars).
|
||||||
|
- Shows: stacked `ShowListCard`s below `xl`; the 9-column table only at `xl`+ where it fits beside the sidebar.
|
||||||
|
- Rabbit timeline markers are inline SVG stroke icons (no emoji anywhere in the client).
|
||||||
- **Offline handling is one app-wide banner, not per-query state**: `Layout.jsx`'s `OfflineBanner` listens to the `online`/`offline` `window` events directly — it isn't wired to React Query's error state. This was a deliberate scope choice: every page already has its own `isError` fallback (so nothing crashes when a fetch fails offline), and threading a distinct "offline" message through every individual query would duplicate that existing handling for a cosmetic improvement only. If a future feature needs to distinguish "offline" from "other network error" *within* a specific page's content (not just a banner), that's new logic, not an extension of this banner.
|
- **Offline handling is one app-wide banner, not per-query state**: `Layout.jsx`'s `OfflineBanner` listens to the `online`/`offline` `window` events directly — it isn't wired to React Query's error state. This was a deliberate scope choice: every page already has its own `isError` fallback (so nothing crashes when a fetch fails offline), and threading a distinct "offline" message through every individual query would duplicate that existing handling for a cosmetic improvement only. If a future feature needs to distinguish "offline" from "other network error" *within* a specific page's content (not just a banner), that's new logic, not an extension of this banner.
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,8 @@ import {
|
||||||
setUnauthorizedHandler,
|
setUnauthorizedHandler,
|
||||||
} from './api'
|
} from './api'
|
||||||
import { AuthContext } from './authContext'
|
import { AuthContext } from './authContext'
|
||||||
|
import Button from './components/Button'
|
||||||
|
import { Field, Input } from './components/FormControls'
|
||||||
|
|
||||||
const MIN_PASSWORD_LENGTH = 8 // mirrors the server's MIN_PASSWORD_LENGTH
|
const MIN_PASSWORD_LENGTH = 8 // mirrors the server's MIN_PASSWORD_LENGTH
|
||||||
|
|
||||||
|
|
@ -29,28 +31,19 @@ function AuthShell({ title, subtitle, onSubmit, error, children }) {
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function Field({ label, hint, ...inputProps }) {
|
function AuthField({ label, hint, ...inputProps }) {
|
||||||
return (
|
return (
|
||||||
<div>
|
<Field label={label} hint={hint}>
|
||||||
<label className="block font-body text-sm font-medium text-text-primary">{label}</label>
|
<Input {...inputProps} />
|
||||||
<input
|
</Field>
|
||||||
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"
|
|
||||||
{...inputProps}
|
|
||||||
/>
|
|
||||||
{hint && <p className="mt-1 font-body text-xs text-text-secondary">{hint}</p>}
|
|
||||||
</div>
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function SubmitButton({ disabled, children }) {
|
function SubmitButton({ disabled, children }) {
|
||||||
return (
|
return (
|
||||||
<button
|
<Button type="submit" disabled={disabled} className="w-full">
|
||||||
type="submit"
|
|
||||||
disabled={disabled}
|
|
||||||
className="w-full rounded-md bg-accent px-4 py-2 font-body text-sm text-surface hover:bg-accent-hover disabled:opacity-50"
|
|
||||||
>
|
|
||||||
{children}
|
{children}
|
||||||
</button>
|
</Button>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -76,7 +69,7 @@ function LoginScreen({ onSuccess }) {
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<AuthShell title="MyKindlebox" onSubmit={handleSubmit} error={error}>
|
<AuthShell title="MyKindlebox" onSubmit={handleSubmit} error={error}>
|
||||||
<Field
|
<AuthField
|
||||||
label="Username"
|
label="Username"
|
||||||
type="text"
|
type="text"
|
||||||
autoFocus
|
autoFocus
|
||||||
|
|
@ -84,7 +77,7 @@ function LoginScreen({ onSuccess }) {
|
||||||
value={username}
|
value={username}
|
||||||
onChange={(e) => setUsername(e.target.value)}
|
onChange={(e) => setUsername(e.target.value)}
|
||||||
/>
|
/>
|
||||||
<Field
|
<AuthField
|
||||||
label="Password"
|
label="Password"
|
||||||
type="password"
|
type="password"
|
||||||
autoComplete="current-password"
|
autoComplete="current-password"
|
||||||
|
|
@ -136,7 +129,7 @@ function SetupScreen({ onSuccess }) {
|
||||||
onSubmit={handleSubmit}
|
onSubmit={handleSubmit}
|
||||||
error={error}
|
error={error}
|
||||||
>
|
>
|
||||||
<Field
|
<AuthField
|
||||||
label="Username"
|
label="Username"
|
||||||
type="text"
|
type="text"
|
||||||
autoFocus
|
autoFocus
|
||||||
|
|
@ -144,7 +137,7 @@ function SetupScreen({ onSuccess }) {
|
||||||
value={username}
|
value={username}
|
||||||
onChange={(e) => setUsername(e.target.value)}
|
onChange={(e) => setUsername(e.target.value)}
|
||||||
/>
|
/>
|
||||||
<Field
|
<AuthField
|
||||||
label="Password"
|
label="Password"
|
||||||
type="password"
|
type="password"
|
||||||
autoComplete="new-password"
|
autoComplete="new-password"
|
||||||
|
|
@ -152,7 +145,7 @@ function SetupScreen({ onSuccess }) {
|
||||||
value={password}
|
value={password}
|
||||||
onChange={(e) => setPassword(e.target.value)}
|
onChange={(e) => setPassword(e.target.value)}
|
||||||
/>
|
/>
|
||||||
<Field
|
<AuthField
|
||||||
label="Confirm password"
|
label="Confirm password"
|
||||||
type="password"
|
type="password"
|
||||||
autoComplete="new-password"
|
autoComplete="new-password"
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,77 @@
|
||||||
|
import { useEffect, useRef, useState } from 'react'
|
||||||
|
import { Link } from 'react-router-dom'
|
||||||
|
import Button from './Button'
|
||||||
|
|
||||||
|
const ITEM_CLASS =
|
||||||
|
'flex min-h-[44px] w-full items-center rounded px-3 text-left font-body text-sm text-text-primary hover:bg-surface-sunken md:min-h-[36px]'
|
||||||
|
|
||||||
|
// A "More" button that opens a short list of secondary actions, so a page
|
||||||
|
// header can keep its primary actions visible without wrapping into several
|
||||||
|
// rows on a phone. `items` is [{ label, to } | { label, onClick }].
|
||||||
|
function ActionMenu({ label = 'More', items, size = 'sm' }) {
|
||||||
|
const [open, setOpen] = useState(false)
|
||||||
|
const ref = useRef(null)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) return
|
||||||
|
function onPointerDown(e) {
|
||||||
|
if (ref.current && !ref.current.contains(e.target)) setOpen(false)
|
||||||
|
}
|
||||||
|
function onKeyDown(e) {
|
||||||
|
if (e.key === 'Escape') setOpen(false)
|
||||||
|
}
|
||||||
|
document.addEventListener('pointerdown', onPointerDown)
|
||||||
|
document.addEventListener('keydown', onKeyDown)
|
||||||
|
return () => {
|
||||||
|
document.removeEventListener('pointerdown', onPointerDown)
|
||||||
|
document.removeEventListener('keydown', onKeyDown)
|
||||||
|
}
|
||||||
|
}, [open])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div ref={ref} className="relative">
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="secondary"
|
||||||
|
size={size}
|
||||||
|
onClick={() => setOpen((v) => !v)}
|
||||||
|
aria-expanded={open}
|
||||||
|
aria-haspopup="menu"
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
<svg viewBox="0 0 20 20" className="ml-1 h-3.5 w-3.5" fill="none" stroke="currentColor" strokeWidth="1.5" aria-hidden="true">
|
||||||
|
<path d="m6 8 4 4 4-4" strokeLinecap="round" strokeLinejoin="round" />
|
||||||
|
</svg>
|
||||||
|
</Button>
|
||||||
|
{open && (
|
||||||
|
<div
|
||||||
|
role="menu"
|
||||||
|
className="absolute right-0 z-30 mt-1 w-52 rounded-lg border border-border bg-surface-raised p-1 shadow-lg"
|
||||||
|
>
|
||||||
|
{items.map((item) =>
|
||||||
|
item.to ? (
|
||||||
|
<Link key={item.label} to={item.to} role="menuitem" className={ITEM_CLASS}>
|
||||||
|
{item.label}
|
||||||
|
</Link>
|
||||||
|
) : (
|
||||||
|
<button
|
||||||
|
key={item.label}
|
||||||
|
type="button"
|
||||||
|
role="menuitem"
|
||||||
|
onClick={() => {
|
||||||
|
setOpen(false)
|
||||||
|
item.onClick()
|
||||||
|
}}
|
||||||
|
className={ITEM_CLASS}
|
||||||
|
>
|
||||||
|
{item.label}
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default ActionMenu
|
||||||
|
|
@ -2,6 +2,7 @@ import { useState } from 'react'
|
||||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||||
import { getShowEvents, createShowEvent, addShowToEvent } from '../api'
|
import { getShowEvents, createShowEvent, addShowToEvent } from '../api'
|
||||||
import Button from './Button'
|
import Button from './Button'
|
||||||
|
import { Field, Input, Select, Textarea } from './FormControls'
|
||||||
|
|
||||||
// Modal launched from a row's "Add to Event" button in the Shows browser.
|
// Modal launched from a row's "Add to Event" button in the Shows browser.
|
||||||
// Lets the user drop the ARBA show into an existing event or spin up a new one
|
// Lets the user drop the ARBA show into an existing event or spin up a new one
|
||||||
|
|
@ -102,44 +103,29 @@ function AddToEventModal({ show, onClose }) {
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{effectiveMode === 'existing' ? (
|
{effectiveMode === 'existing' ? (
|
||||||
<div>
|
<Field label="Event">
|
||||||
<label className="block font-body text-sm font-medium text-text-primary">Event</label>
|
<Select value={selectedId} onChange={(e) => setSelectedId(e.target.value)}>
|
||||||
<select
|
|
||||||
value={selectedId}
|
|
||||||
onChange={(e) => setSelectedId(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"
|
|
||||||
>
|
|
||||||
<option value="">{isLoading ? 'Loading…' : 'Select an event'}</option>
|
<option value="">{isLoading ? 'Loading…' : 'Select an event'}</option>
|
||||||
{events?.map((event) => (
|
{events?.map((event) => (
|
||||||
<option key={event.id} value={event.id}>
|
<option key={event.id} value={event.id}>
|
||||||
{event.name} ({event.show_count} show{event.show_count === 1 ? '' : 's'})
|
{event.name} ({event.show_count} show{event.show_count === 1 ? '' : 's'})
|
||||||
</option>
|
</option>
|
||||||
))}
|
))}
|
||||||
</select>
|
</Select>
|
||||||
</div>
|
</Field>
|
||||||
) : (
|
) : (
|
||||||
<div className="flex flex-col gap-3">
|
<div className="flex flex-col gap-3">
|
||||||
<div>
|
<Field label="Event name" required>
|
||||||
<label className="block font-body text-sm font-medium text-text-primary">
|
<Input
|
||||||
Event name <span className="text-danger">*</span>
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
type="text"
|
type="text"
|
||||||
value={newName}
|
value={newName}
|
||||||
onChange={(e) => setNewName(e.target.value)}
|
onChange={(e) => setNewName(e.target.value)}
|
||||||
placeholder="e.g. Spring Circuit Weekend"
|
placeholder="e.g. Spring Circuit Weekend"
|
||||||
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>
|
</Field>
|
||||||
<div>
|
<Field label="Notes">
|
||||||
<label className="block font-body text-sm font-medium text-text-primary">Notes</label>
|
<Textarea value={newNotes} onChange={(e) => setNewNotes(e.target.value)} rows={2} />
|
||||||
<textarea
|
</Field>
|
||||||
value={newNotes}
|
|
||||||
onChange={(e) => setNewNotes(e.target.value)}
|
|
||||||
rows={2}
|
|
||||||
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>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,7 @@ import { STATUS_LABELS } from '../lib/format'
|
||||||
|
|
||||||
const STATUS_CLASSES = {
|
const STATUS_CLASSES = {
|
||||||
active: 'bg-accent text-surface',
|
active: 'bg-accent text-surface',
|
||||||
for_sale: 'bg-accent-warm text-surface',
|
for_sale: 'bg-accent-warm text-on-accent-warm',
|
||||||
on_hold: 'bg-text-secondary text-surface',
|
on_hold: 'bg-text-secondary text-surface',
|
||||||
sold: 'bg-border text-text-primary',
|
sold: 'bg-border text-text-primary',
|
||||||
deceased: 'bg-danger text-surface',
|
deceased: 'bg-danger text-surface',
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
import { useEffect, useRef, useState } from 'react'
|
import { useEffect, useRef, useState } from 'react'
|
||||||
|
import { Input } from './FormControls'
|
||||||
import { ARBA_BREEDS, isCustomBreed } from '../lib/breeds'
|
import { ARBA_BREEDS, isCustomBreed } from '../lib/breeds'
|
||||||
import { handleResultKeyDown, handleSearchInputKeyDown } from '../lib/keyboardNav'
|
import { handleResultKeyDown, handleSearchInputKeyDown } from '../lib/keyboardNav'
|
||||||
|
|
||||||
|
|
@ -39,7 +40,7 @@ function BreedSelect({ value = '', onChange, placeholder = 'e.g. Holland Lop', i
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div ref={containerRef} className="relative">
|
<div ref={containerRef} className="relative">
|
||||||
<input
|
<Input
|
||||||
ref={inputRef}
|
ref={inputRef}
|
||||||
type="text"
|
type="text"
|
||||||
value={value}
|
value={value}
|
||||||
|
|
@ -53,9 +54,7 @@ function BreedSelect({ value = '', onChange, placeholder = 'e.g. Holland Lop', i
|
||||||
role="combobox"
|
role="combobox"
|
||||||
aria-expanded={open}
|
aria-expanded={open}
|
||||||
autoComplete="off"
|
autoComplete="off"
|
||||||
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 ${
|
aria-invalid={invalid || undefined}
|
||||||
invalid ? 'border-danger' : 'border-border'
|
|
||||||
}`}
|
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{isCustomBreed(value) && (
|
{isCustomBreed(value) && (
|
||||||
|
|
@ -63,7 +62,7 @@ function BreedSelect({ value = '', onChange, placeholder = 'e.g. Holland Lop', i
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{open && (
|
{open && (
|
||||||
<div className="absolute left-0 top-full z-10 mt-1 w-full rounded-md border border-border bg-surface-raised p-1 shadow-sm">
|
<div className="absolute left-0 top-full z-30 mt-1 w-full rounded-md border border-border bg-surface-raised p-1 shadow-sm">
|
||||||
<ul className="max-h-56 overflow-auto">
|
<ul className="max-h-56 overflow-auto">
|
||||||
{matches.map((breed, index) => (
|
{matches.map((breed, index) => (
|
||||||
<li key={breed}>
|
<li key={breed}>
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,56 @@
|
||||||
|
import { useEffect, useId, useRef } from 'react'
|
||||||
|
import Button from './Button'
|
||||||
|
|
||||||
|
// The app's one "are you sure?" dialog, used in place of window.confirm so
|
||||||
|
// confirmations match the theme and read the same everywhere. Mount it only
|
||||||
|
// while it should show. It's a native <dialog> opened with showModal(), which
|
||||||
|
// gives focus trapping, Escape-to-cancel and top-layer stacking (nothing can
|
||||||
|
// render over it) for free. Focus starts on Cancel, the safe choice.
|
||||||
|
function ConfirmDialog({
|
||||||
|
title,
|
||||||
|
children,
|
||||||
|
confirmLabel = 'Confirm',
|
||||||
|
busyLabel,
|
||||||
|
danger = false,
|
||||||
|
busy = false,
|
||||||
|
onConfirm,
|
||||||
|
onCancel,
|
||||||
|
}) {
|
||||||
|
const ref = useRef(null)
|
||||||
|
const titleId = useId()
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const dialog = ref.current
|
||||||
|
if (!dialog.open) dialog.showModal()
|
||||||
|
return () => dialog.close()
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<dialog
|
||||||
|
ref={ref}
|
||||||
|
aria-labelledby={titleId}
|
||||||
|
onCancel={(e) => {
|
||||||
|
e.preventDefault()
|
||||||
|
if (!busy) onCancel()
|
||||||
|
}}
|
||||||
|
className="w-[calc(100%-2rem)] max-w-md rounded-lg border border-border bg-surface-raised p-0 text-text-primary shadow-lg backdrop:bg-black/40"
|
||||||
|
>
|
||||||
|
<div className="space-y-4 p-6">
|
||||||
|
<h2 id={titleId} className="font-display text-lg text-text-primary">
|
||||||
|
{title}
|
||||||
|
</h2>
|
||||||
|
{children && <div className="space-y-2 font-body text-sm text-text-secondary">{children}</div>}
|
||||||
|
<div className="flex justify-end gap-2">
|
||||||
|
<Button variant="secondary" onClick={onCancel} disabled={busy}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button variant={danger ? 'danger' : 'primary'} onClick={onConfirm} disabled={busy}>
|
||||||
|
{busy && busyLabel ? busyLabel : confirmLabel}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</dialog>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default ConfirmDialog
|
||||||
|
|
@ -0,0 +1,26 @@
|
||||||
|
import Button from './Button'
|
||||||
|
import Card from './Card'
|
||||||
|
import EmptyState from './EmptyState'
|
||||||
|
|
||||||
|
// The app's failed-to-load state for a list or section: what failed, why if
|
||||||
|
// known, and a way to retry (usually a React Query `refetch`).
|
||||||
|
function ErrorPanel({ title, error, onRetry, action }) {
|
||||||
|
return (
|
||||||
|
<Card>
|
||||||
|
<EmptyState
|
||||||
|
title={title}
|
||||||
|
description={error?.message || 'Check your connection and try again.'}
|
||||||
|
action={
|
||||||
|
action ??
|
||||||
|
(onRetry ? (
|
||||||
|
<Button variant="secondary" onClick={() => onRetry()}>
|
||||||
|
Try Again
|
||||||
|
</Button>
|
||||||
|
) : null)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</Card>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default ErrorPanel
|
||||||
|
|
@ -0,0 +1,136 @@
|
||||||
|
import { createContext, useContext, useId } from 'react'
|
||||||
|
|
||||||
|
// Shared form controls, so every text box / dropdown in the app has the same
|
||||||
|
// height, border, focus ring and error styling.
|
||||||
|
//
|
||||||
|
// <Field> renders the label, optional hint and error, and hands its generated
|
||||||
|
// id (plus aria-describedby / aria-invalid) to the one control inside it via
|
||||||
|
// context — so labels are always wired to their inputs without threading ids
|
||||||
|
// by hand. A control used outside a <Field> just needs an aria-label.
|
||||||
|
const FieldContext = createContext(null)
|
||||||
|
|
||||||
|
// md: the standard form size (44px touch / 40px desktop, like <Button>).
|
||||||
|
// sm: filter bars and other dense rows (44px touch / 32px desktop).
|
||||||
|
const SIZE_CLASSES = {
|
||||||
|
md: 'min-h-[44px] px-3 py-2 md:min-h-[40px]',
|
||||||
|
sm: 'min-h-[44px] px-3 py-1.5 md:min-h-[32px]',
|
||||||
|
multiline: 'px-3 py-2',
|
||||||
|
}
|
||||||
|
|
||||||
|
function controlClasses({ size = 'md', invalid, className = '' }) {
|
||||||
|
return `rounded-md border bg-surface-raised font-body text-sm text-text-primary focus:outline-none focus:ring-2 focus:ring-accent disabled:cursor-not-allowed disabled:opacity-50 ${
|
||||||
|
invalid ? 'border-danger' : 'border-border'
|
||||||
|
} ${SIZE_CLASSES[size]} ${className}`
|
||||||
|
}
|
||||||
|
|
||||||
|
function useFieldProps(props) {
|
||||||
|
const field = useContext(FieldContext)
|
||||||
|
const invalid = props['aria-invalid'] ?? (field?.invalid || undefined)
|
||||||
|
return {
|
||||||
|
id: props.id ?? field?.id,
|
||||||
|
'aria-describedby': props['aria-describedby'] ?? field?.describedBy,
|
||||||
|
'aria-invalid': invalid,
|
||||||
|
invalid: Boolean(invalid),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Input({ size, className = 'w-full', ...props }) {
|
||||||
|
const { invalid, ...aria } = useFieldProps(props)
|
||||||
|
return <input {...props} {...aria} className={controlClasses({ size, invalid, className })} />
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Select({ size, className = 'w-full', ...props }) {
|
||||||
|
const { invalid, ...aria } = useFieldProps(props)
|
||||||
|
return <select {...props} {...aria} className={controlClasses({ size, invalid, className })} />
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Textarea({ className = 'w-full', rows = 3, ...props }) {
|
||||||
|
const { invalid, ...aria } = useFieldProps(props)
|
||||||
|
return (
|
||||||
|
<textarea
|
||||||
|
rows={rows}
|
||||||
|
{...props}
|
||||||
|
{...aria}
|
||||||
|
className={controlClasses({ size: 'multiline', invalid, className })}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// File pickers are styled through the browser's file: button rather than the
|
||||||
|
// text-box look.
|
||||||
|
export function FileInput(props) {
|
||||||
|
const { id, 'aria-describedby': describedBy } = useFieldProps(props)
|
||||||
|
return (
|
||||||
|
<input
|
||||||
|
type="file"
|
||||||
|
{...props}
|
||||||
|
id={id}
|
||||||
|
aria-describedby={describedBy}
|
||||||
|
className="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"
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// A row of mutually exclusive buttons (Sex, Weight Display) — quicker than a
|
||||||
|
// dropdown for two to four short choices. `options` is [{ value, label }].
|
||||||
|
export function Segmented({ options, value, onChange, label }) {
|
||||||
|
return (
|
||||||
|
<div className="flex gap-1" role="group" aria-label={label}>
|
||||||
|
{options.map((opt) => (
|
||||||
|
<button
|
||||||
|
key={opt.value}
|
||||||
|
type="button"
|
||||||
|
aria-pressed={value === opt.value}
|
||||||
|
onClick={() => onChange(opt.value)}
|
||||||
|
className={`min-h-[44px] flex-1 rounded-md border px-3 py-2 font-body text-sm md:min-h-[40px] ${
|
||||||
|
value === opt.value
|
||||||
|
? 'border-accent bg-accent text-surface'
|
||||||
|
: 'border-border bg-surface-raised text-text-primary hover:bg-surface-sunken'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{opt.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// A label over a control that isn't a single input (a Segmented row, a
|
||||||
|
// picker): same heading style as <Field>, without the label/for wiring.
|
||||||
|
export function FieldGroup({ label, hint, className = '', children }) {
|
||||||
|
return (
|
||||||
|
<div className={className}>
|
||||||
|
<span className="block font-body text-sm font-medium text-text-primary">{label}</span>
|
||||||
|
{hint && <p className="mt-1 font-body text-xs text-text-secondary">{hint}</p>}
|
||||||
|
<div className={hint ? 'mt-2' : 'mt-1'}>{children}</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// `label` may be a node (e.g. a required star); `hint` sits under the control
|
||||||
|
// in secondary text and is replaced by `error` while there is one.
|
||||||
|
export function Field({ label, hint, error, required, className = '', children }) {
|
||||||
|
const id = useId()
|
||||||
|
const messageId = `${id}-message`
|
||||||
|
const message = error || hint
|
||||||
|
return (
|
||||||
|
<div className={className}>
|
||||||
|
<label htmlFor={id} className="block font-body text-sm font-medium text-text-primary">
|
||||||
|
{label}
|
||||||
|
{required && <span className="text-danger"> *</span>}
|
||||||
|
</label>
|
||||||
|
<div className="mt-1">
|
||||||
|
<FieldContext.Provider
|
||||||
|
value={{ id, describedBy: message ? messageId : undefined, invalid: Boolean(error) }}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</FieldContext.Provider>
|
||||||
|
</div>
|
||||||
|
{message && (
|
||||||
|
<p id={messageId} className={`mt-1 font-body text-xs ${error ? 'text-danger' : 'text-text-secondary'}`}>
|
||||||
|
{message}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
import { useState } from 'react'
|
import { useState } from 'react'
|
||||||
import RabbitAutocomplete from './RabbitAutocomplete'
|
import RabbitAutocomplete from './RabbitAutocomplete'
|
||||||
import Button from './Button'
|
import Button from './Button'
|
||||||
|
import { Field, FieldGroup, Input, Textarea } from './FormControls'
|
||||||
|
|
||||||
// Shared by AddLitter/EditLitter (kindle records) and AddBreeding (breeding
|
// Shared by AddLitter/EditLitter (kindle records) and AddBreeding (breeding
|
||||||
// records). A litter row spans the breeding→kindle lifecycle, so both a breeding
|
// records). A litter row spans the breeding→kindle lifecycle, so both a breeding
|
||||||
|
|
@ -51,87 +52,58 @@ function LitterForm({
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<form onSubmit={handleSubmit} className="max-w-md space-y-4">
|
<form onSubmit={handleSubmit} className="max-w-md space-y-4">
|
||||||
<div>
|
<FieldGroup label="Sire">
|
||||||
<label className="block font-body text-xs text-text-secondary">Sire</label>
|
|
||||||
<div className="mt-0.5">
|
|
||||||
<RabbitAutocomplete sex="buck" value={sire} onChange={setSire} />
|
<RabbitAutocomplete sex="buck" value={sire} onChange={setSire} />
|
||||||
</div>
|
</FieldGroup>
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
<FieldGroup label="Dam">
|
||||||
<label className="block font-body text-xs text-text-secondary">Dam</label>
|
|
||||||
<div className="mt-0.5">
|
|
||||||
<RabbitAutocomplete sex="doe" value={dam} onChange={setDam} />
|
<RabbitAutocomplete sex="doe" value={dam} onChange={setDam} />
|
||||||
</div>
|
</FieldGroup>
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
<Field
|
||||||
<label className="block font-body text-xs text-text-secondary">
|
label="Breeding Date"
|
||||||
{isBreeding ? 'Breeding Date *' : 'Breeding Date'}
|
required={isBreeding}
|
||||||
</label>
|
error={isBreeding && showValidation ? 'Breeding date is required' : null}
|
||||||
<input
|
>
|
||||||
|
<Input
|
||||||
type="date"
|
type="date"
|
||||||
value={breedDate}
|
value={breedDate}
|
||||||
onChange={(e) => {
|
onChange={(e) => {
|
||||||
setBreedDate(e.target.value)
|
setBreedDate(e.target.value)
|
||||||
setShowValidation(false)
|
setShowValidation(false)
|
||||||
}}
|
}}
|
||||||
className={`mt-0.5 w-full rounded border px-2 py-1.5 text-sm focus:outline-none focus:ring-2 focus:ring-accent ${
|
|
||||||
isBreeding && showValidation ? 'border-danger' : 'border-border'
|
|
||||||
}`}
|
|
||||||
/>
|
/>
|
||||||
{isBreeding && showValidation && (
|
</Field>
|
||||||
<p className="mt-1 font-body text-xs text-danger">Breeding date is required</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{!isBreeding && (
|
{!isBreeding && (
|
||||||
<>
|
<>
|
||||||
<div>
|
<Field label="Kindle Date" required error={showValidation ? 'Kindle date is required' : null}>
|
||||||
<label className="block font-body text-xs text-text-secondary">Kindle Date *</label>
|
<Input
|
||||||
<input
|
|
||||||
type="date"
|
type="date"
|
||||||
value={kindleDate}
|
value={kindleDate}
|
||||||
onChange={(e) => {
|
onChange={(e) => {
|
||||||
setKindleDate(e.target.value)
|
setKindleDate(e.target.value)
|
||||||
setShowValidation(false)
|
setShowValidation(false)
|
||||||
}}
|
}}
|
||||||
className={`mt-0.5 w-full rounded border px-2 py-1.5 text-sm focus:outline-none focus:ring-2 focus:ring-accent ${
|
|
||||||
showValidation ? 'border-danger' : 'border-border'
|
|
||||||
}`}
|
|
||||||
/>
|
/>
|
||||||
{showValidation && (
|
</Field>
|
||||||
<p className="mt-1 font-body text-xs text-danger">Kindle date is required</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
<Field
|
||||||
<label className="block font-body text-xs text-text-secondary">Kit Count</label>
|
label="Kit Count"
|
||||||
<input
|
hint={
|
||||||
type="number"
|
createsKits
|
||||||
min="0"
|
|
||||||
value={kitCount}
|
|
||||||
onChange={(e) => setKitCount(e.target.value)}
|
|
||||||
className="mt-0.5 w-full rounded border border-border px-2 py-1.5 text-sm focus:outline-none focus:ring-2 focus:ring-accent"
|
|
||||||
/>
|
|
||||||
<p className="mt-1 font-body text-xs text-text-secondary">
|
|
||||||
{createsKits
|
|
||||||
? 'Saving creates this many kits in your herd, named with the Kit Prefix from Settings. Leave blank to add kits one at a time.'
|
? 'Saving creates this many kits in your herd, named with the Kit Prefix from Settings. Leave blank to add kits one at a time.'
|
||||||
: 'Changing the count doesn’t add or remove kit records — use Add Kit on the litter page.'}
|
: 'Changing the count doesn’t add or remove kit records — use Add Kit on the litter page.'
|
||||||
</p>
|
}
|
||||||
</div>
|
>
|
||||||
|
<Input type="number" min="0" value={kitCount} onChange={(e) => setKitCount(e.target.value)} />
|
||||||
|
</Field>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div>
|
<Field label="Notes">
|
||||||
<label className="block font-body text-xs text-text-secondary">Notes</label>
|
<Textarea value={notes} onChange={(e) => setNotes(e.target.value)} />
|
||||||
<textarea
|
</Field>
|
||||||
value={notes}
|
|
||||||
onChange={(e) => setNotes(e.target.value)}
|
|
||||||
rows={3}
|
|
||||||
className="mt-0.5 w-full rounded border border-border px-2 py-1.5 text-sm focus:outline-none focus:ring-2 focus:ring-accent"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{error && <p className="font-body text-sm text-danger">{error}</p>}
|
{error && <p className="font-body text-sm text-danger">{error}</p>}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,5 @@
|
||||||
import Button from './Button'
|
import Button from './Button'
|
||||||
import Card from './Card'
|
import ErrorPanel from './ErrorPanel'
|
||||||
import EmptyState from './EmptyState'
|
|
||||||
import PageHeader from './PageHeader'
|
import PageHeader from './PageHeader'
|
||||||
|
|
||||||
// The failed-fetch state for a page whose main record didn't load — most often
|
// The failed-fetch state for a page whose main record didn't load — most often
|
||||||
|
|
@ -11,17 +10,11 @@ function LoadError({ title, error, backTo, backLabel }) {
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col gap-6">
|
<div className="flex flex-col gap-6">
|
||||||
<PageHeader title={title} />
|
<PageHeader title={title} />
|
||||||
<Card>
|
<ErrorPanel
|
||||||
<EmptyState
|
|
||||||
title={notFound ? `${title} not found` : `Couldn’t load ${title.toLowerCase()}`}
|
title={notFound ? `${title} not found` : `Couldn’t load ${title.toLowerCase()}`}
|
||||||
description={
|
error={notFound ? { message: 'It may have been deleted, or the link is out of date.' } : error}
|
||||||
notFound
|
|
||||||
? 'It may have been deleted, or the link is out of date.'
|
|
||||||
: error?.message || 'Check your connection and try again.'
|
|
||||||
}
|
|
||||||
action={backTo ? <Button to={backTo}>{backLabel}</Button> : null}
|
action={backTo ? <Button to={backTo}>{backLabel}</Button> : null}
|
||||||
/>
|
/>
|
||||||
</Card>
|
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -52,7 +52,7 @@ function PedigreeScan({ result, imageUrl, onScanned, confirmReplace }) {
|
||||||
const file = e.target.files?.[0]
|
const file = e.target.files?.[0]
|
||||||
e.target.value = ''
|
e.target.value = ''
|
||||||
if (!file) return
|
if (!file) return
|
||||||
if (confirmReplace && !confirmReplace()) return
|
if (confirmReplace && !(await confirmReplace())) return
|
||||||
|
|
||||||
setScanning(true)
|
setScanning(true)
|
||||||
setError(null)
|
setError(null)
|
||||||
|
|
|
||||||
|
|
@ -201,7 +201,7 @@ function AncestorSlot({ path, slot, label, state, setSlotState, cascadeCache, fe
|
||||||
<span className="flex w-full items-center gap-1.5 font-body text-xs text-text-secondary">
|
<span className="flex w-full items-center gap-1.5 font-body text-xs text-text-secondary">
|
||||||
<span className="truncate">{label}</span>
|
<span className="truncate">{label}</span>
|
||||||
{flagged && (
|
{flagged && (
|
||||||
<span className="shrink-0 rounded bg-accent-warm px-1.5 font-medium text-surface">Check</span>
|
<span className="shrink-0 rounded bg-accent-warm px-1.5 font-medium text-on-accent-warm">Check</span>
|
||||||
)}
|
)}
|
||||||
</span>
|
</span>
|
||||||
<span
|
<span
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
import { Link } from 'react-router-dom'
|
import { Link } from 'react-router-dom'
|
||||||
|
import Badge from './Badge'
|
||||||
import Card from './Card'
|
import Card from './Card'
|
||||||
import Silhouette from './Silhouette'
|
import Silhouette from './Silhouette'
|
||||||
import { SERVER_BASE } from '../api'
|
import { SERVER_BASE } from '../api'
|
||||||
|
|
@ -14,7 +15,7 @@ function RabbitCard({ rabbit }) {
|
||||||
return (
|
return (
|
||||||
<Link to={`/herd/${rabbit.id}`}>
|
<Link to={`/herd/${rabbit.id}`}>
|
||||||
<Card className="flex h-full flex-col overflow-hidden transition-colors hover:bg-surface-sunken">
|
<Card className="flex h-full flex-col overflow-hidden transition-colors hover:bg-surface-sunken">
|
||||||
<div className="flex aspect-square items-center justify-center bg-surface-sunken">
|
<div className="relative flex aspect-square items-center justify-center bg-surface-sunken">
|
||||||
{rabbit.photo_path ? (
|
{rabbit.photo_path ? (
|
||||||
<img
|
<img
|
||||||
src={`${SERVER_BASE}${rabbit.photo_path}`}
|
src={`${SERVER_BASE}${rabbit.photo_path}`}
|
||||||
|
|
@ -24,6 +25,13 @@ function RabbitCard({ rabbit }) {
|
||||||
) : (
|
) : (
|
||||||
<Silhouette />
|
<Silhouette />
|
||||||
)}
|
)}
|
||||||
|
{/* Active is the norm, so only the exceptions (For Sale, Sold, …) are
|
||||||
|
badged — enough to tell them apart when the Herd filter is "All". */}
|
||||||
|
{rabbit.status !== 'active' && (
|
||||||
|
<span className="absolute left-2 top-2">
|
||||||
|
<Badge status={rabbit.status} />
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-col gap-1 p-3">
|
<div className="flex flex-col gap-1 p-3">
|
||||||
<p className="truncate font-display text-base text-text-primary">{rabbit.name}</p>
|
<p className="truncate font-display text-base text-text-primary">{rabbit.name}</p>
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
import { useState } from 'react'
|
import { useState } from 'react'
|
||||||
import BreedSelect from './BreedSelect'
|
import BreedSelect from './BreedSelect'
|
||||||
|
import { Field, FieldGroup, FileInput, Input, Segmented, Select } from './FormControls'
|
||||||
|
|
||||||
const STATUS_OPTIONS = [
|
const STATUS_OPTIONS = [
|
||||||
{ value: 'active', label: 'Active' },
|
{ value: 'active', label: 'Active' },
|
||||||
|
|
@ -16,10 +17,6 @@ const SEX_OPTIONS = [
|
||||||
{ value: 'unknown', label: 'Unknown' },
|
{ 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) {
|
function lbsToOz(decimalLbs) {
|
||||||
if (decimalLbs === null || decimalLbs === undefined || decimalLbs === '') return { lbs: '', oz: '' }
|
if (decimalLbs === null || decimalLbs === undefined || decimalLbs === '') return { lbs: '', oz: '' }
|
||||||
const total = Number(decimalLbs)
|
const total = Number(decimalLbs)
|
||||||
|
|
@ -73,100 +70,58 @@ function RabbitDetailsForm({
|
||||||
return (
|
return (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
|
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
|
||||||
<div className="md:col-span-2">
|
<Field
|
||||||
<label htmlFor={nameInputId} className={LABEL_CLASS}>
|
label="Name"
|
||||||
Name <span className="text-danger">*</span>
|
required
|
||||||
</label>
|
error={nameInvalid ? 'Give this rabbit a name to continue.' : null}
|
||||||
<input
|
className="md:col-span-2"
|
||||||
|
>
|
||||||
|
<Input
|
||||||
id={nameInputId}
|
id={nameInputId}
|
||||||
type="text"
|
type="text"
|
||||||
value={value.name ?? ''}
|
value={value.name ?? ''}
|
||||||
onChange={(e) => setField('name', e.target.value)}
|
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 && (
|
</Field>
|
||||||
<p id={`${nameInputId}-error`} className="mt-1 font-body text-xs text-danger">
|
|
||||||
Give this rabbit a name to continue.
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
<Field label="Farm / Prefix">
|
||||||
<label className={LABEL_CLASS}>Farm / Prefix</label>
|
<Input type="text" value={value.farm ?? ''} onChange={(e) => setField('farm', e.target.value)} />
|
||||||
<input
|
</Field>
|
||||||
type="text"
|
|
||||||
value={value.farm ?? ''}
|
|
||||||
onChange={(e) => setField('farm', e.target.value)}
|
|
||||||
className={INPUT_CLASS}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
<Field label="Ear Number">
|
||||||
<label className={LABEL_CLASS}>Ear Number</label>
|
<Input
|
||||||
<input
|
|
||||||
type="text"
|
type="text"
|
||||||
value={value.ear_number ?? ''}
|
value={value.ear_number ?? ''}
|
||||||
onChange={(e) => setField('ear_number', e.target.value.toUpperCase())}
|
onChange={(e) => setField('ear_number', e.target.value.toUpperCase())}
|
||||||
className={`${INPUT_CLASS} uppercase`}
|
className="w-full uppercase"
|
||||||
/>
|
/>
|
||||||
</div>
|
</Field>
|
||||||
|
|
||||||
<div>
|
<FieldGroup label="Sex">
|
||||||
<span className={LABEL_CLASS}>Sex</span>
|
<Segmented label="Sex" options={SEX_OPTIONS} value={value.sex} onChange={(sex) => setField('sex', sex)} />
|
||||||
<div className="mt-1 flex gap-1" role="group" aria-label="Sex">
|
</FieldGroup>
|
||||||
{SEX_OPTIONS.map((opt) => (
|
|
||||||
<button
|
|
||||||
key={opt.value}
|
|
||||||
type="button"
|
|
||||||
aria-pressed={value.sex === opt.value}
|
|
||||||
onClick={() => setField('sex', opt.value)}
|
|
||||||
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'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{opt.label}
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
<Field label="Date of Birth">
|
||||||
<label className={LABEL_CLASS}>Date of Birth</label>
|
<Input type="date" value={value.dob ?? ''} onChange={(e) => setField('dob', e.target.value)} />
|
||||||
<input
|
</Field>
|
||||||
type="date"
|
|
||||||
value={value.dob ?? ''}
|
|
||||||
onChange={(e) => setField('dob', e.target.value)}
|
|
||||||
className={INPUT_CLASS}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
<Field label="Breed">
|
||||||
<label className={LABEL_CLASS}>Breed</label>
|
|
||||||
<BreedSelect value={value.breed ?? ''} onChange={(breed) => setField('breed', breed)} />
|
<BreedSelect value={value.breed ?? ''} onChange={(breed) => setField('breed', breed)} />
|
||||||
</div>
|
</Field>
|
||||||
|
|
||||||
<div>
|
<Field label="Color Name">
|
||||||
<label className={LABEL_CLASS}>Color Name</label>
|
<Input
|
||||||
<input
|
|
||||||
type="text"
|
type="text"
|
||||||
value={value.color_name ?? ''}
|
value={value.color_name ?? ''}
|
||||||
onChange={(e) => setField('color_name', e.target.value)}
|
onChange={(e) => setField('color_name', e.target.value)}
|
||||||
placeholder="e.g. Broken Black Tort"
|
placeholder="e.g. Broken Black Tort"
|
||||||
className={INPUT_CLASS}
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</Field>
|
||||||
|
|
||||||
<div>
|
|
||||||
<label className={LABEL_CLASS}>Weight</label>
|
|
||||||
{weightUnit === 'lbs_oz' ? (
|
{weightUnit === 'lbs_oz' ? (
|
||||||
<div className="mt-1 flex gap-2">
|
<FieldGroup label="Weight">
|
||||||
<input
|
<div className="flex gap-2">
|
||||||
|
<Input
|
||||||
type="number"
|
type="number"
|
||||||
inputMode="numeric"
|
inputMode="numeric"
|
||||||
min="0"
|
min="0"
|
||||||
|
|
@ -174,9 +129,9 @@ function RabbitDetailsForm({
|
||||||
onChange={(e) => setField('weight_lbs', ozToLbs(e.target.value, ozOz))}
|
onChange={(e) => setField('weight_lbs', ozToLbs(e.target.value, ozOz))}
|
||||||
placeholder="lbs"
|
placeholder="lbs"
|
||||||
aria-label="Weight, pounds"
|
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"
|
className="w-1/2"
|
||||||
/>
|
/>
|
||||||
<input
|
<Input
|
||||||
type="number"
|
type="number"
|
||||||
inputMode="numeric"
|
inputMode="numeric"
|
||||||
min="0"
|
min="0"
|
||||||
|
|
@ -185,11 +140,13 @@ function RabbitDetailsForm({
|
||||||
onChange={(e) => setField('weight_lbs', ozToLbs(ozLbs, e.target.value))}
|
onChange={(e) => setField('weight_lbs', ozToLbs(ozLbs, e.target.value))}
|
||||||
placeholder="oz"
|
placeholder="oz"
|
||||||
aria-label="Weight, ounces"
|
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"
|
className="w-1/2"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
</FieldGroup>
|
||||||
) : (
|
) : (
|
||||||
<input
|
<Field label="Weight">
|
||||||
|
<Input
|
||||||
type="number"
|
type="number"
|
||||||
inputMode="decimal"
|
inputMode="decimal"
|
||||||
min="0"
|
min="0"
|
||||||
|
|
@ -197,23 +154,16 @@ function RabbitDetailsForm({
|
||||||
value={value.weight_lbs ?? ''}
|
value={value.weight_lbs ?? ''}
|
||||||
onChange={(e) => setField('weight_lbs', e.target.value === '' ? null : Number(e.target.value))}
|
onChange={(e) => setField('weight_lbs', e.target.value === '' ? null : Number(e.target.value))}
|
||||||
placeholder="decimal lbs"
|
placeholder="decimal lbs"
|
||||||
className={INPUT_CLASS}
|
|
||||||
/>
|
/>
|
||||||
|
</Field>
|
||||||
)}
|
)}
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="md:col-span-2">
|
<Field label="Photo" className="md:col-span-2">
|
||||||
<label className={LABEL_CLASS}>Photo</label>
|
<FileInput accept="image/jpeg,image/png,image/webp" onChange={handlePhotoSelect} />
|
||||||
<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 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 && (
|
{photoPreview && (
|
||||||
<img src={photoPreview} alt="Preview" className="mt-2 h-24 w-24 rounded-md object-cover" />
|
<img src={photoPreview} alt="Preview" className="mt-2 h-24 w-24 rounded-md object-cover" />
|
||||||
)}
|
)}
|
||||||
</div>
|
</Field>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className={collapseSecondary ? 'rounded-lg border border-border' : ''}>
|
<div className={collapseSecondary ? 'rounded-lg border border-border' : ''}>
|
||||||
|
|
@ -222,7 +172,7 @@ function RabbitDetailsForm({
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => setMoreOpen((v) => !v)}
|
onClick={() => setMoreOpen((v) => !v)}
|
||||||
aria-expanded={moreOpen}
|
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"
|
className="flex min-h-[44px] 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>More details</span>
|
||||||
<span className="font-normal text-text-secondary">Status, location, dates</span>
|
<span className="font-normal text-text-secondary">Status, location, dates</span>
|
||||||
|
|
@ -235,52 +185,40 @@ function RabbitDetailsForm({
|
||||||
collapseSecondary ? 'border-t border-border px-4 py-4' : ''
|
collapseSecondary ? 'border-t border-border px-4 py-4' : ''
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<div>
|
<Field label="Status">
|
||||||
<label className={LABEL_CLASS}>Status</label>
|
<Select value={value.status ?? 'active'} onChange={(e) => setField('status', e.target.value)}>
|
||||||
<select
|
|
||||||
value={value.status ?? 'active'}
|
|
||||||
onChange={(e) => setField('status', e.target.value)}
|
|
||||||
className={`${INPUT_CLASS} bg-surface-raised`}
|
|
||||||
>
|
|
||||||
{STATUS_OPTIONS.map((opt) => (
|
{STATUS_OPTIONS.map((opt) => (
|
||||||
<option key={opt.value} value={opt.value}>
|
<option key={opt.value} value={opt.value}>
|
||||||
{opt.label}
|
{opt.label}
|
||||||
</option>
|
</option>
|
||||||
))}
|
))}
|
||||||
</select>
|
</Select>
|
||||||
</div>
|
</Field>
|
||||||
|
|
||||||
<div>
|
<Field label="Location">
|
||||||
<label className={LABEL_CLASS}>Location</label>
|
<Input
|
||||||
<input
|
|
||||||
type="text"
|
type="text"
|
||||||
value={value.location ?? ''}
|
value={value.location ?? ''}
|
||||||
onChange={(e) => setField('location', e.target.value)}
|
onChange={(e) => setField('location', e.target.value)}
|
||||||
placeholder="Cage or hutch"
|
placeholder="Cage or hutch"
|
||||||
className={INPUT_CLASS}
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</Field>
|
||||||
|
|
||||||
<div>
|
<Field label="Acquired Date" hint="If added to the herd after birth">
|
||||||
<label className={LABEL_CLASS}>Acquired Date</label>
|
<Input
|
||||||
<input
|
|
||||||
type="date"
|
type="date"
|
||||||
value={value.acquired_date ?? ''}
|
value={value.acquired_date ?? ''}
|
||||||
onChange={(e) => setField('acquired_date', e.target.value || null)}
|
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>
|
</Field>
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
<Field label="Death Date">
|
||||||
<label className={LABEL_CLASS}>Death Date</label>
|
<Input
|
||||||
<input
|
|
||||||
type="date"
|
type="date"
|
||||||
value={value.death_date ?? ''}
|
value={value.death_date ?? ''}
|
||||||
onChange={(e) => setField('death_date', e.target.value || null)}
|
onChange={(e) => setField('death_date', e.target.value || null)}
|
||||||
className={INPUT_CLASS}
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</Field>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -2,21 +2,68 @@ import { useState } from 'react'
|
||||||
import { Link } from 'react-router-dom'
|
import { Link } from 'react-router-dom'
|
||||||
import { useQuery, useQueryClient } from '@tanstack/react-query'
|
import { useQuery, useQueryClient } from '@tanstack/react-query'
|
||||||
import Button from './Button'
|
import Button from './Button'
|
||||||
|
import { Field, Input, Select, Textarea } from './FormControls'
|
||||||
import { getLitters, getEvents, getRabbitShowEntries, createEvent, updateEvent, deleteEvent } from '../api'
|
import { getLitters, getEvents, getRabbitShowEntries, createEvent, updateEvent, deleteEvent } from '../api'
|
||||||
import { formatDate } from '../lib/format'
|
import { formatDate } from '../lib/format'
|
||||||
|
import { useConfirm } from '../hooks/useConfirm'
|
||||||
|
|
||||||
// Visual config per timeline event type. `order` breaks ties when two events
|
// Visual config per timeline event type. `order` breaks ties when two events
|
||||||
// share a date (e.g. birth always sorts above an acquired-on-the-same-day note).
|
// share a date (e.g. birth always sorts above an acquired-on-the-same-day note).
|
||||||
// `dot` is a Tailwind class pair for the rail marker.
|
// `dot` is a Tailwind class pair for the rail marker. Icons are stroke paths
|
||||||
|
// on a 24px grid in the nav-icon style of Layout.jsx (emoji render differently
|
||||||
|
// on every device).
|
||||||
const EVENT_TYPES = {
|
const EVENT_TYPES = {
|
||||||
birth: { icon: '🐣', order: 0, dot: 'bg-accent text-surface' },
|
birth: {
|
||||||
acquired: { icon: '🏡', order: 1, dot: 'bg-accent-warm text-surface' },
|
icon: <path d="M12 3c3.5 0 6 5 6 10a6 6 0 0 1-12 0c0-5 2.5-10 6-10Z" />,
|
||||||
breeding: { icon: '❤', order: 2, dot: 'bg-surface-sunken text-text-primary' },
|
order: 0,
|
||||||
kindle: { icon: '🐰', order: 3, dot: 'bg-accent text-surface' },
|
dot: 'bg-accent text-surface',
|
||||||
show: { icon: '🏆', order: 4, dot: 'bg-accent-warm text-surface' },
|
},
|
||||||
sale: { icon: '🏷️', order: 5, dot: 'bg-accent text-surface' },
|
acquired: {
|
||||||
other: { icon: '📌', order: 6, dot: 'bg-surface-sunken text-text-primary' },
|
icon: <path d="M4 11.5 12 4l8 7.5M6.5 10v10h11V10" />,
|
||||||
death: { icon: '✝', order: 7, dot: 'bg-danger text-white' },
|
order: 1,
|
||||||
|
dot: 'bg-accent-warm text-on-accent-warm',
|
||||||
|
},
|
||||||
|
breeding: {
|
||||||
|
icon: <path d="M12 20s-7.5-4.6-7.5-10.2A4.2 4.2 0 0 1 12 7.4a4.2 4.2 0 0 1 7.5 2.4C19.5 15.4 12 20 12 20Z" />,
|
||||||
|
order: 2,
|
||||||
|
dot: 'bg-surface-sunken text-text-primary',
|
||||||
|
},
|
||||||
|
kindle: {
|
||||||
|
icon: <path d="M4 11h16v7a3 3 0 0 1-3 3H7a3 3 0 0 1-3-3v-7ZM8 7.5C8 6.1 9.8 5 12 5s4 1.1 4 2.5" />,
|
||||||
|
order: 3,
|
||||||
|
dot: 'bg-accent text-surface',
|
||||||
|
},
|
||||||
|
show: {
|
||||||
|
icon: (
|
||||||
|
<>
|
||||||
|
<circle cx="12" cy="9" r="5.5" />
|
||||||
|
<path d="m9 14-1.5 7 4.5-2.5 4.5 2.5L15 14" />
|
||||||
|
</>
|
||||||
|
),
|
||||||
|
order: 4,
|
||||||
|
dot: 'bg-accent-warm text-on-accent-warm',
|
||||||
|
},
|
||||||
|
sale: {
|
||||||
|
icon: (
|
||||||
|
<>
|
||||||
|
<path d="M3.5 12.5V4h8.5l9 9-8.5 8.5-9-9Z" />
|
||||||
|
<circle cx="8" cy="8.5" r="1" />
|
||||||
|
</>
|
||||||
|
),
|
||||||
|
order: 5,
|
||||||
|
dot: 'bg-accent text-surface',
|
||||||
|
},
|
||||||
|
other: {
|
||||||
|
icon: (
|
||||||
|
<>
|
||||||
|
<path d="M12 21s-6.5-6-6.5-11a6.5 6.5 0 0 1 13 0c0 5-6.5 11-6.5 11Z" />
|
||||||
|
<circle cx="12" cy="10" r="2" />
|
||||||
|
</>
|
||||||
|
),
|
||||||
|
order: 6,
|
||||||
|
dot: 'bg-surface-sunken text-text-primary',
|
||||||
|
},
|
||||||
|
death: { icon: <path d="M12 3v18M7 8.5h10" />, order: 7, dot: 'bg-danger text-white' },
|
||||||
}
|
}
|
||||||
|
|
||||||
// The manually-loggable event types (the Add Event dropdown). Breeding is left
|
// The manually-loggable event types (the Add Event dropdown). Breeding is left
|
||||||
|
|
@ -129,9 +176,6 @@ function buildEvents(rabbit, litters, showEntries, logged) {
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
const inputClass =
|
|
||||||
'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'
|
|
||||||
|
|
||||||
function EventForm({ editingEvent, onSave, onCancel, saving }) {
|
function EventForm({ editingEvent, onSave, onCancel, saving }) {
|
||||||
const [form, setForm] = useState(
|
const [form, setForm] = useState(
|
||||||
editingEvent
|
editingEvent
|
||||||
|
|
@ -160,81 +204,54 @@ function EventForm({ editingEvent, onSave, onCancel, saving }) {
|
||||||
Edit {ADD_EVENT_TYPES.find((t) => t.value === form.type)?.label ?? 'Event'}
|
Edit {ADD_EVENT_TYPES.find((t) => t.value === form.type)?.label ?? 'Event'}
|
||||||
</p>
|
</p>
|
||||||
) : (
|
) : (
|
||||||
<div>
|
<Field label="Event Type">
|
||||||
<label className="block font-body text-sm font-medium text-text-primary">Event Type</label>
|
<Select value={form.type} onChange={(e) => setField('type', e.target.value)}>
|
||||||
<select
|
|
||||||
value={form.type}
|
|
||||||
onChange={(e) => setField('type', e.target.value)}
|
|
||||||
className={`${inputClass} bg-surface-raised`}
|
|
||||||
>
|
|
||||||
{ADD_EVENT_TYPES.map((t) => (
|
{ADD_EVENT_TYPES.map((t) => (
|
||||||
<option key={t.value} value={t.value}>
|
<option key={t.value} value={t.value}>
|
||||||
{t.label}
|
{t.label}
|
||||||
</option>
|
</option>
|
||||||
))}
|
))}
|
||||||
</select>
|
</Select>
|
||||||
</div>
|
</Field>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
|
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
|
||||||
<div>
|
<Field label="Date" required>
|
||||||
<label className="block font-body text-sm font-medium text-text-primary">
|
<Input type="date" value={form.event_date} onChange={(e) => setField('event_date', e.target.value)} />
|
||||||
Date <span className="text-danger">*</span>
|
</Field>
|
||||||
</label>
|
<Field label={TITLE_LABEL[form.type]} required>
|
||||||
<input
|
<Input
|
||||||
type="date"
|
|
||||||
value={form.event_date}
|
|
||||||
onChange={(e) => setField('event_date', e.target.value)}
|
|
||||||
className={inputClass}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label className="block font-body text-sm font-medium text-text-primary">
|
|
||||||
{TITLE_LABEL[form.type]} <span className="text-danger">*</span>
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
type="text"
|
type="text"
|
||||||
value={form.title}
|
value={form.title}
|
||||||
onChange={(e) => setField('title', e.target.value)}
|
onChange={(e) => setField('title', e.target.value)}
|
||||||
placeholder={TITLE_PLACEHOLDER[form.type]}
|
placeholder={TITLE_PLACEHOLDER[form.type]}
|
||||||
className={inputClass}
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</Field>
|
||||||
{isShow && (
|
{isShow && (
|
||||||
<>
|
<>
|
||||||
<div>
|
<Field label="Class">
|
||||||
<label className="block font-body text-sm font-medium text-text-primary">Class</label>
|
<Input
|
||||||
<input
|
|
||||||
type="text"
|
type="text"
|
||||||
value={form.show_class}
|
value={form.show_class}
|
||||||
onChange={(e) => setField('show_class', e.target.value)}
|
onChange={(e) => setField('show_class', e.target.value)}
|
||||||
placeholder="e.g. Senior Buck"
|
placeholder="e.g. Senior Buck"
|
||||||
className={inputClass}
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</Field>
|
||||||
<div>
|
<Field label="Placement">
|
||||||
<label className="block font-body text-sm font-medium text-text-primary">Placement</label>
|
<Input
|
||||||
<input
|
|
||||||
type="text"
|
type="text"
|
||||||
value={form.placement}
|
value={form.placement}
|
||||||
onChange={(e) => setField('placement', e.target.value)}
|
onChange={(e) => setField('placement', e.target.value)}
|
||||||
placeholder="e.g. BOB, 1st"
|
placeholder="e.g. BOB, 1st"
|
||||||
className={inputClass}
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</Field>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<Field label="Notes">
|
||||||
<label className="block font-body text-sm font-medium text-text-primary">Notes</label>
|
<Textarea value={form.notes} onChange={(e) => setField('notes', e.target.value)} rows={2} />
|
||||||
<textarea
|
</Field>
|
||||||
value={form.notes}
|
|
||||||
onChange={(e) => setField('notes', e.target.value)}
|
|
||||||
rows={2}
|
|
||||||
className={inputClass}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{isShow && !editingEvent && (
|
{isShow && !editingEvent && (
|
||||||
<p className="font-body text-xs text-text-secondary">
|
<p className="font-body text-xs text-text-secondary">
|
||||||
|
|
@ -281,9 +298,20 @@ function TimelineRow({ event, isLast, onEdit, onDelete }) {
|
||||||
<li className="grid grid-cols-[1.5rem_1fr] gap-x-3">
|
<li className="grid grid-cols-[1.5rem_1fr] gap-x-3">
|
||||||
<div className="flex flex-col items-center">
|
<div className="flex flex-col items-center">
|
||||||
<span
|
<span
|
||||||
className={`z-10 mt-0.5 flex h-6 w-6 shrink-0 items-center justify-center rounded-full text-[0.7rem] ${config.dot}`}
|
className={`z-10 mt-0.5 flex h-6 w-6 shrink-0 items-center justify-center rounded-full ${config.dot}`}
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
className="h-3.5 w-3.5"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="2"
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
aria-hidden="true"
|
||||||
>
|
>
|
||||||
{config.icon}
|
{config.icon}
|
||||||
|
</svg>
|
||||||
</span>
|
</span>
|
||||||
{!isLast && <span className="w-px flex-1 bg-border" />}
|
{!isLast && <span className="w-px flex-1 bg-border" />}
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -320,6 +348,7 @@ function RabbitTimeline({ rabbit }) {
|
||||||
const [formMode, setFormMode] = useState(null) // null | 'add' | event id being edited
|
const [formMode, setFormMode] = useState(null) // null | 'add' | event id being edited
|
||||||
const [saving, setSaving] = useState(false)
|
const [saving, setSaving] = useState(false)
|
||||||
const [error, setError] = useState(null)
|
const [error, setError] = useState(null)
|
||||||
|
const [confirm, confirmDialog] = useConfirm()
|
||||||
|
|
||||||
const { data: litters } = useQuery({ queryKey: ['litters'], queryFn: getLitters })
|
const { data: litters } = useQuery({ queryKey: ['litters'], queryFn: getLitters })
|
||||||
const { data: logged } = useQuery({
|
const { data: logged } = useQuery({
|
||||||
|
|
@ -362,7 +391,8 @@ function RabbitTimeline({ rabbit }) {
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleDelete(ev) {
|
async function handleDelete(ev) {
|
||||||
if (!window.confirm(`Delete the "${ev.title}" event?`)) return
|
const confirmed = await confirm({ title: `Delete “${ev.title}”?`, confirmLabel: 'Delete', danger: true })
|
||||||
|
if (!confirmed) return
|
||||||
setError(null)
|
setError(null)
|
||||||
try {
|
try {
|
||||||
await deleteEvent(ev.id)
|
await deleteEvent(ev.id)
|
||||||
|
|
@ -422,6 +452,7 @@ function RabbitTimeline({ rabbit }) {
|
||||||
))}
|
))}
|
||||||
</ol>
|
</ol>
|
||||||
)}
|
)}
|
||||||
|
{confirmDialog}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -2,14 +2,13 @@ import { useState } from 'react'
|
||||||
import { useQuery, useQueryClient } from '@tanstack/react-query'
|
import { useQuery, useQueryClient } from '@tanstack/react-query'
|
||||||
import Card from './Card'
|
import Card from './Card'
|
||||||
import Button from './Button'
|
import Button from './Button'
|
||||||
|
import ConfirmDialog from './ConfirmDialog'
|
||||||
|
import { Field, Input } from './FormControls'
|
||||||
import { getUsers, createUser, resetUserPassword, deleteUser } from '../api'
|
import { getUsers, createUser, resetUserPassword, deleteUser } from '../api'
|
||||||
import { useAuth } from '../authContext'
|
import { useAuth } from '../authContext'
|
||||||
|
|
||||||
const MIN_PASSWORD_LENGTH = 8 // mirrors the server's MIN_PASSWORD_LENGTH
|
const MIN_PASSWORD_LENGTH = 8 // mirrors the server's MIN_PASSWORD_LENGTH
|
||||||
|
|
||||||
const inputClass =
|
|
||||||
'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'
|
|
||||||
|
|
||||||
// Settings → Users (memory-bank/FEATURE_User_Accounts.md): everyone-equal user
|
// Settings → Users (memory-bank/FEATURE_User_Accounts.md): everyone-equal user
|
||||||
// management for the shared herd. Any signed-in user can add users, reset any
|
// management for the shared herd. Any signed-in user can add users, reset any
|
||||||
// password (that's the "someone forgot theirs" path — no current-password
|
// password (that's the "someone forgot theirs" path — no current-password
|
||||||
|
|
@ -116,19 +115,15 @@ export default function UsersCard({ onToast }) {
|
||||||
</div>
|
</div>
|
||||||
{resetTarget === user.id && (
|
{resetTarget === user.id && (
|
||||||
<div className="mt-2 flex items-end gap-2">
|
<div className="mt-2 flex items-end gap-2">
|
||||||
<div className="flex-1">
|
<Field label={`New password (min ${MIN_PASSWORD_LENGTH} characters)`} className="flex-1">
|
||||||
<label className="block font-body text-xs text-text-secondary">
|
<Input
|
||||||
New password (min {MIN_PASSWORD_LENGTH} characters)
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
type="password"
|
type="password"
|
||||||
autoFocus
|
autoFocus
|
||||||
autoComplete="new-password"
|
autoComplete="new-password"
|
||||||
value={resetPassword}
|
value={resetPassword}
|
||||||
onChange={(e) => setResetPassword(e.target.value)}
|
onChange={(e) => setResetPassword(e.target.value)}
|
||||||
className={inputClass}
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</Field>
|
||||||
<Button
|
<Button
|
||||||
onClick={() => handleReset(user)}
|
onClick={() => handleReset(user)}
|
||||||
disabled={busy || resetPassword.length < MIN_PASSWORD_LENGTH}
|
disabled={busy || resetPassword.length < MIN_PASSWORD_LENGTH}
|
||||||
|
|
@ -143,30 +138,24 @@ export default function UsersCard({ onToast }) {
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<form onSubmit={handleAdd} className="space-y-3 border-t border-border pt-4">
|
<form onSubmit={handleAdd} className="space-y-3 border-t border-border pt-4">
|
||||||
<h3 className="font-body text-sm font-medium text-text-primary">Add a user</h3>
|
<h3 className="font-display text-base text-text-primary">Add a user</h3>
|
||||||
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
|
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
|
||||||
<div>
|
<Field label="Username">
|
||||||
<label className="block font-body text-xs text-text-secondary">Username</label>
|
<Input
|
||||||
<input
|
|
||||||
type="text"
|
type="text"
|
||||||
autoComplete="off"
|
autoComplete="off"
|
||||||
value={newUsername}
|
value={newUsername}
|
||||||
onChange={(e) => setNewUsername(e.target.value)}
|
onChange={(e) => setNewUsername(e.target.value)}
|
||||||
className={inputClass}
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</Field>
|
||||||
<div>
|
<Field label="Password" hint={`At least ${MIN_PASSWORD_LENGTH} characters`}>
|
||||||
<label className="block font-body text-xs text-text-secondary">
|
<Input
|
||||||
Password (min {MIN_PASSWORD_LENGTH} characters)
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
type="password"
|
type="password"
|
||||||
autoComplete="new-password"
|
autoComplete="new-password"
|
||||||
value={newPassword}
|
value={newPassword}
|
||||||
onChange={(e) => setNewPassword(e.target.value)}
|
onChange={(e) => setNewPassword(e.target.value)}
|
||||||
className={inputClass}
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</Field>
|
||||||
</div>
|
</div>
|
||||||
{addError && <p className="font-body text-sm text-danger">{addError}</p>}
|
{addError && <p className="font-body text-sm text-danger">{addError}</p>}
|
||||||
<Button
|
<Button
|
||||||
|
|
@ -178,25 +167,18 @@ export default function UsersCard({ onToast }) {
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
{deleteTarget && (
|
{deleteTarget && (
|
||||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40 p-4">
|
<ConfirmDialog
|
||||||
<Card className="w-full max-w-md space-y-4 p-6">
|
title={`Remove ${deleteTarget.username}?`}
|
||||||
<h2 className="font-display text-lg text-text-primary">
|
confirmLabel="Remove User"
|
||||||
Remove {deleteTarget.username}?
|
busyLabel="Removing…"
|
||||||
</h2>
|
danger
|
||||||
<p className="font-body text-sm text-text-secondary">
|
busy={busy}
|
||||||
They will be signed out everywhere and can no longer log in. The herd data is shared,
|
onConfirm={handleDelete}
|
||||||
so nothing else is deleted.
|
onCancel={() => setDeleteTarget(null)}
|
||||||
</p>
|
>
|
||||||
<div className="flex justify-end gap-2">
|
They will be signed out everywhere and can no longer log in. The herd data is shared, so nothing else is
|
||||||
<Button variant="secondary" onClick={() => setDeleteTarget(null)} disabled={busy}>
|
deleted.
|
||||||
Cancel
|
</ConfirmDialog>
|
||||||
</Button>
|
|
||||||
<Button variant="danger" onClick={handleDelete} disabled={busy}>
|
|
||||||
{busy ? 'Removing…' : 'Remove User'}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</Card>
|
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
</Card>
|
</Card>
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,35 @@
|
||||||
|
import { useState } from 'react'
|
||||||
|
import ConfirmDialog from '../components/ConfirmDialog'
|
||||||
|
|
||||||
|
// Promise-based drop-in for window.confirm:
|
||||||
|
// const [confirm, confirmDialog] = useConfirm()
|
||||||
|
// if (!(await confirm({ title: 'Delete it?', confirmLabel: 'Delete', danger: true }))) return
|
||||||
|
// and render {confirmDialog} somewhere in the component. For a confirmation
|
||||||
|
// that also shows progress while the action runs, render <ConfirmDialog>
|
||||||
|
// directly instead.
|
||||||
|
export function useConfirm() {
|
||||||
|
const [request, setRequest] = useState(null)
|
||||||
|
|
||||||
|
function confirm(options) {
|
||||||
|
return new Promise((resolve) => setRequest({ ...options, resolve }))
|
||||||
|
}
|
||||||
|
|
||||||
|
function settle(result) {
|
||||||
|
request.resolve(result)
|
||||||
|
setRequest(null)
|
||||||
|
}
|
||||||
|
|
||||||
|
const dialog = request ? (
|
||||||
|
<ConfirmDialog
|
||||||
|
title={request.title}
|
||||||
|
confirmLabel={request.confirmLabel}
|
||||||
|
danger={request.danger}
|
||||||
|
onConfirm={() => settle(true)}
|
||||||
|
onCancel={() => settle(false)}
|
||||||
|
>
|
||||||
|
{request.message}
|
||||||
|
</ConfirmDialog>
|
||||||
|
) : null
|
||||||
|
|
||||||
|
return [confirm, dialog]
|
||||||
|
}
|
||||||
|
|
@ -31,6 +31,11 @@
|
||||||
`bg-[var(--stat-N)]` classes (Tailwind only generates classes it can see as
|
`bg-[var(--stat-N)]` classes (Tailwind only generates classes it can see as
|
||||||
complete strings, so the per-tile names are spelled out, not built up). */
|
complete strings, so the per-tile names are spelled out, not built up). */
|
||||||
:root {
|
:root {
|
||||||
|
/* Text on accent-warm fills (the For Sale badge, "Check" flags). The warm
|
||||||
|
oranges are too light for white text in the light themes, so this is the
|
||||||
|
theme's dark ink everywhere — on Midnight that's the surface color. */
|
||||||
|
--color-on-accent-warm: var(--color-text-primary);
|
||||||
|
|
||||||
--stat-fg: var(--color-text-primary);
|
--stat-fg: var(--color-text-primary);
|
||||||
--stat-fg-muted: var(--color-text-secondary);
|
--stat-fg-muted: var(--color-text-secondary);
|
||||||
--stat-1: var(--color-surface-raised);
|
--stat-1: var(--color-surface-raised);
|
||||||
|
|
@ -64,6 +69,7 @@
|
||||||
--color-accent-hover: #94b884;
|
--color-accent-hover: #94b884;
|
||||||
--color-accent-warm: #d9954a;
|
--color-accent-warm: #d9954a;
|
||||||
--color-danger: #d46a6a;
|
--color-danger: #d46a6a;
|
||||||
|
--color-on-accent-warm: var(--color-surface);
|
||||||
}
|
}
|
||||||
|
|
||||||
[data-theme='ocean'] {
|
[data-theme='ocean'] {
|
||||||
|
|
@ -72,7 +78,7 @@
|
||||||
--color-surface-sunken: #e1eaf0;
|
--color-surface-sunken: #e1eaf0;
|
||||||
--color-border: #c2d3de;
|
--color-border: #c2d3de;
|
||||||
--color-text-primary: #14222b;
|
--color-text-primary: #14222b;
|
||||||
--color-text-secondary: #5a7484;
|
--color-text-secondary: #516a79;
|
||||||
--color-accent: #2e6e8e;
|
--color-accent: #2e6e8e;
|
||||||
--color-accent-hover: #245870;
|
--color-accent-hover: #245870;
|
||||||
--color-accent-warm: #e0913b;
|
--color-accent-warm: #e0913b;
|
||||||
|
|
@ -98,9 +104,9 @@
|
||||||
--color-surface-sunken: #f4e6da;
|
--color-surface-sunken: #f4e6da;
|
||||||
--color-border: #e6cdb8;
|
--color-border: #e6cdb8;
|
||||||
--color-text-primary: #2a1c14;
|
--color-text-primary: #2a1c14;
|
||||||
--color-text-secondary: #8a6f5c;
|
--color-text-secondary: #7a604d;
|
||||||
--color-accent: #c9542e;
|
--color-accent: #b84a26;
|
||||||
--color-accent-hover: #a8431f;
|
--color-accent-hover: #9a3d1c;
|
||||||
--color-accent-warm: #e0913b;
|
--color-accent-warm: #e0913b;
|
||||||
--color-danger: #b33a3a;
|
--color-danger: #b33a3a;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -48,8 +48,8 @@ export const THEMES = [
|
||||||
id: 'sunset',
|
id: 'sunset',
|
||||||
label: 'Sunset',
|
label: 'Sunset',
|
||||||
description: 'Warm coral and amber.',
|
description: 'Warm coral and amber.',
|
||||||
themeColor: '#c9542e',
|
themeColor: '#b84a26',
|
||||||
swatch: { surface: '#fbf3ec', accent: '#c9542e', warm: '#e0913b' },
|
swatch: { surface: '#fbf3ec', accent: '#b84a26', warm: '#e0913b' },
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'carnival',
|
id: 'carnival',
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,7 @@ import ReviewStep from '../components/ReviewStep'
|
||||||
import StickyActionBar from '../components/StickyActionBar'
|
import StickyActionBar from '../components/StickyActionBar'
|
||||||
import PedigreeScan from '../components/PedigreeScan'
|
import PedigreeScan from '../components/PedigreeScan'
|
||||||
import Button from '../components/Button'
|
import Button from '../components/Button'
|
||||||
|
import { Field, Input } from '../components/FormControls'
|
||||||
import {
|
import {
|
||||||
getSettings,
|
getSettings,
|
||||||
getRabbit,
|
getRabbit,
|
||||||
|
|
@ -17,6 +18,7 @@ import {
|
||||||
getPedigreeScanStatus,
|
getPedigreeScanStatus,
|
||||||
findExistingRabbit,
|
findExistingRabbit,
|
||||||
} from '../api'
|
} from '../api'
|
||||||
|
import { useConfirm } from '../hooks/useConfirm'
|
||||||
import { createInitialPedigreeState, pedigreeStateFromScan, uncertainScanSlots } from '../lib/pedigree'
|
import { createInitialPedigreeState, pedigreeStateFromScan, uncertainScanSlots } from '../lib/pedigree'
|
||||||
|
|
||||||
const STEP_LABELS = ['Pedigree', 'Details', 'Review']
|
const STEP_LABELS = ['Pedigree', 'Details', 'Review']
|
||||||
|
|
@ -153,6 +155,7 @@ function AddRabbit() {
|
||||||
const [scanImageUrl, setScanImageUrl] = useState(null)
|
const [scanImageUrl, setScanImageUrl] = useState(null)
|
||||||
const [pedigreeSeed, setPedigreeSeed] = useState(null)
|
const [pedigreeSeed, setPedigreeSeed] = useState(null)
|
||||||
const [pedigreeKey, setPedigreeKey] = useState(0)
|
const [pedigreeKey, setPedigreeKey] = useState(0)
|
||||||
|
const [confirm, confirmDialog] = useConfirm()
|
||||||
|
|
||||||
const step2Valid = details.name.trim().length > 0
|
const step2Valid = details.name.trim().length > 0
|
||||||
|
|
||||||
|
|
@ -266,7 +269,14 @@ function AddRabbit() {
|
||||||
// Scanning replaces the tree wholesale, so confirm before discarding entries.
|
// Scanning replaces the tree wholesale, so confirm before discarding entries.
|
||||||
function confirmReplaceWithScan() {
|
function confirmReplaceWithScan() {
|
||||||
const hasEntries = pedigreeSummary.some((node) => node.display && node.display !== 'Unknown')
|
const hasEntries = pedigreeSummary.some((node) => node.display && node.display !== 'Unknown')
|
||||||
return !hasEntries || window.confirm('Replace the ancestors you’ve entered with the scanned pedigree?')
|
return (
|
||||||
|
!hasEntries ||
|
||||||
|
confirm({
|
||||||
|
title: 'Replace your ancestors?',
|
||||||
|
message: 'The scanned pedigree will replace the ancestors you’ve entered so far.',
|
||||||
|
confirmLabel: 'Replace',
|
||||||
|
})
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function goToStep(n) {
|
function goToStep(n) {
|
||||||
|
|
@ -339,19 +349,15 @@ function AddRabbit() {
|
||||||
(or jumping from Review via "Edit") keeps the pedigree tree's
|
(or jumping from Review via "Edit") keeps the pedigree tree's
|
||||||
internal state, the photo preview, and any open sections intact. */}
|
internal state, the photo preview, and any open sections intact. */}
|
||||||
<div className={step === 1 ? 'flex flex-col gap-4' : 'hidden'}>
|
<div className={step === 1 ? 'flex flex-col gap-4' : 'hidden'}>
|
||||||
<div>
|
<Field label="Rabbit name">
|
||||||
<label htmlFor="add-rabbit-subject-name" className="block font-body text-sm font-medium text-text-primary">
|
<Input
|
||||||
Rabbit name
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
id="add-rabbit-subject-name"
|
|
||||||
type="text"
|
type="text"
|
||||||
value={details.name}
|
value={details.name}
|
||||||
onChange={(e) => updateDetails({ name: e.target.value })}
|
onChange={(e) => updateDetails({ name: e.target.value })}
|
||||||
placeholder="You can also fill this in on the next step"
|
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"
|
className="w-full max-w-md"
|
||||||
/>
|
/>
|
||||||
</div>
|
</Field>
|
||||||
<p className="font-body text-sm text-text-secondary">
|
<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.
|
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.
|
Picking a rabbit from your herd fills in its ancestors automatically.
|
||||||
|
|
@ -426,6 +432,7 @@ function AddRabbit() {
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
</StickyActionBar>
|
</StickyActionBar>
|
||||||
|
{confirmDialog}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,7 @@ import { getStats, getLitters, getRabbits, getSettings } from '../api'
|
||||||
import Card from '../components/Card'
|
import Card from '../components/Card'
|
||||||
import Button from '../components/Button'
|
import Button from '../components/Button'
|
||||||
import EmptyState from '../components/EmptyState'
|
import EmptyState from '../components/EmptyState'
|
||||||
|
import ErrorPanel from '../components/ErrorPanel'
|
||||||
import PageHeader from '../components/PageHeader'
|
import PageHeader from '../components/PageHeader'
|
||||||
import { GESTATION_DAYS, addDays, formatDate } from '../lib/format'
|
import { GESTATION_DAYS, addDays, formatDate } from '../lib/format'
|
||||||
|
|
||||||
|
|
@ -19,6 +20,8 @@ const STAT_CARDS = [
|
||||||
{ key: 'bucks', label: 'Bucks', href: '/herd?sex=buck', tile: 'bg-[var(--stat-2)] hover:bg-[var(--stat-2-hover)]' },
|
{ key: 'bucks', label: 'Bucks', href: '/herd?sex=buck', tile: 'bg-[var(--stat-2)] hover:bg-[var(--stat-2-hover)]' },
|
||||||
{ key: 'does', label: 'Does', href: '/herd?sex=doe', tile: 'bg-[var(--stat-3)] hover:bg-[var(--stat-3-hover)]' },
|
{ key: 'does', label: 'Does', href: '/herd?sex=doe', tile: 'bg-[var(--stat-3)] hover:bg-[var(--stat-3-hover)]' },
|
||||||
{ key: 'kits', label: 'Kits', href: '/herd?sex=kits', tile: 'bg-[var(--stat-4)] hover:bg-[var(--stat-4-hover)]' },
|
{ key: 'kits', label: 'Kits', href: '/herd?sex=kits', tile: 'bg-[var(--stat-4)] hover:bg-[var(--stat-4-hover)]' },
|
||||||
|
// The rest are status counts — shown as a smaller second row so the herd
|
||||||
|
// makeup reads first.
|
||||||
{ key: 'for_sale', label: 'For Sale', href: '/herd?status=for_sale', tile: 'bg-[var(--stat-5)] hover:bg-[var(--stat-5-hover)]' },
|
{ key: 'for_sale', label: 'For Sale', href: '/herd?status=for_sale', tile: 'bg-[var(--stat-5)] hover:bg-[var(--stat-5-hover)]' },
|
||||||
{ key: 'on_hold', label: 'On Hold', href: '/herd?status=on_hold', tile: 'bg-[var(--stat-6)] hover:bg-[var(--stat-6-hover)]' },
|
{ key: 'on_hold', label: 'On Hold', href: '/herd?status=on_hold', tile: 'bg-[var(--stat-6)] hover:bg-[var(--stat-6-hover)]' },
|
||||||
{ key: 'sold', label: 'Sold', href: '/herd?status=sold', tile: 'bg-[var(--stat-7)] hover:bg-[var(--stat-7-hover)]' },
|
{ key: 'sold', label: 'Sold', href: '/herd?status=sold', tile: 'bg-[var(--stat-7)] hover:bg-[var(--stat-7-hover)]' },
|
||||||
|
|
@ -26,17 +29,19 @@ const STAT_CARDS = [
|
||||||
]
|
]
|
||||||
|
|
||||||
function StatsBar() {
|
function StatsBar() {
|
||||||
const { data, isLoading, isError } = useQuery({ queryKey: ['stats'], queryFn: getStats })
|
const { data, isLoading, isError, error, refetch } = useQuery({ queryKey: ['stats'], queryFn: getStats })
|
||||||
|
|
||||||
if (isError) {
|
if (isError) {
|
||||||
return (
|
return <ErrorPanel title="Couldn’t load herd counts" error={error} onRetry={refetch} />
|
||||||
<p className="font-body text-sm text-danger">Failed to load dashboard stats.</p>
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const primary = STAT_CARDS.slice(0, 4)
|
||||||
|
const secondary = STAT_CARDS.slice(4)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
<div className="flex flex-col gap-3">
|
||||||
<div className="grid grid-cols-2 gap-3 md:grid-cols-4">
|
<div className="grid grid-cols-2 gap-3 md:grid-cols-4">
|
||||||
{STAT_CARDS.map((card) =>
|
{primary.map((card) =>
|
||||||
isLoading ? (
|
isLoading ? (
|
||||||
<Card key={card.key} className="h-20 animate-pulse bg-surface-sunken" />
|
<Card key={card.key} className="h-20 animate-pulse bg-surface-sunken" />
|
||||||
) : (
|
) : (
|
||||||
|
|
@ -52,14 +57,29 @@ function StatsBar() {
|
||||||
<span className="font-body text-xs font-medium uppercase tracking-wide text-[var(--stat-fg-muted)]">
|
<span className="font-body text-xs font-medium uppercase tracking-wide text-[var(--stat-fg-muted)]">
|
||||||
{card.label}
|
{card.label}
|
||||||
</span>
|
</span>
|
||||||
<span className="font-display text-2xl text-[var(--stat-fg)]">
|
<span className="font-display text-2xl text-[var(--stat-fg)]">{data[card.key]}</span>
|
||||||
{data[card.key]}
|
|
||||||
</span>
|
|
||||||
</div>
|
</div>
|
||||||
</Link>
|
</Link>
|
||||||
)
|
)
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
<div className="grid grid-cols-2 gap-2 sm:grid-cols-4">
|
||||||
|
{secondary.map((card) =>
|
||||||
|
isLoading ? (
|
||||||
|
<Card key={card.key} className="h-11 animate-pulse bg-surface-sunken" />
|
||||||
|
) : (
|
||||||
|
<Link key={card.key} to={card.href}>
|
||||||
|
<div
|
||||||
|
className={`flex min-h-[44px] items-center justify-between gap-2 rounded-lg border border-border px-3 py-2 shadow-sm transition-colors ${card.tile}`}
|
||||||
|
>
|
||||||
|
<span className="font-body text-sm text-[var(--stat-fg-muted)]">{card.label}</span>
|
||||||
|
<span className="font-display text-lg text-[var(--stat-fg)]">{data[card.key]}</span>
|
||||||
|
</div>
|
||||||
|
</Link>
|
||||||
|
)
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -69,7 +89,6 @@ function QuickActions() {
|
||||||
<Button to="/herd/add" variant="primary">Add Rabbit</Button>
|
<Button to="/herd/add" variant="primary">Add Rabbit</Button>
|
||||||
<Button to="/breedings/add" variant="secondary">Record Breeding</Button>
|
<Button to="/breedings/add" variant="secondary">Record Breeding</Button>
|
||||||
<Button to="/litters/add" variant="secondary">Record Litter</Button>
|
<Button to="/litters/add" variant="secondary">Record Litter</Button>
|
||||||
<Button to="/herd" variant="secondary">View Herd</Button>
|
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,7 @@ import GeneticsForm from '../components/GeneticsForm'
|
||||||
import RabbitAutocomplete from '../components/RabbitAutocomplete'
|
import RabbitAutocomplete from '../components/RabbitAutocomplete'
|
||||||
import StickyActionBar from '../components/StickyActionBar'
|
import StickyActionBar from '../components/StickyActionBar'
|
||||||
import LoadError from '../components/LoadError'
|
import LoadError from '../components/LoadError'
|
||||||
|
import { FieldGroup } from '../components/FormControls'
|
||||||
import Button from '../components/Button'
|
import Button from '../components/Button'
|
||||||
import { getRabbit, getRabbitAncestors, getSettings, updateRabbit, uploadRabbitPhoto } from '../api'
|
import { getRabbit, getRabbitAncestors, getSettings, updateRabbit, uploadRabbitPhoto } from '../api'
|
||||||
import { LOCI, hasAnyLocusSet } from '../lib/genetics'
|
import { LOCI, hasAnyLocusSet } from '../lib/genetics'
|
||||||
|
|
@ -45,17 +46,14 @@ function branchTextColumns(side) {
|
||||||
// record) is shown so the user knows what's there before replacing it.
|
// record) is shown so the user knows what's there before replacing it.
|
||||||
function ParentField({ label, sex, value, onChange, manualName }) {
|
function ParentField({ label, sex, value, onChange, manualName }) {
|
||||||
return (
|
return (
|
||||||
<div>
|
<FieldGroup label={label}>
|
||||||
<span className="block font-body text-sm font-medium text-text-primary">{label}</span>
|
|
||||||
<div className="mt-1">
|
|
||||||
<RabbitAutocomplete sex={sex} value={value} onChange={onChange} />
|
<RabbitAutocomplete sex={sex} value={value} onChange={onChange} />
|
||||||
</div>
|
|
||||||
{!value && manualName && (
|
{!value && manualName && (
|
||||||
<p className="mt-1 font-body text-xs text-text-secondary">
|
<p className="mt-1 font-body text-xs text-text-secondary">
|
||||||
Recorded as {formatManualAncestorDisplay(manualName)} — not linked to a herd record.
|
Recorded as {formatManualAncestorDisplay(manualName)} — not linked to a herd record.
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</FieldGroup>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -174,7 +172,7 @@ function EditRabbit() {
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div className="rounded-lg border border-border px-4 py-4">
|
<div className="rounded-lg border border-border px-4 py-4">
|
||||||
<h2 className="font-body text-sm font-medium text-text-primary">Parents</h2>
|
<h2 className="font-display text-lg text-text-primary">Parents</h2>
|
||||||
<p className="mt-1 font-body text-xs text-text-secondary">
|
<p className="mt-1 font-body text-xs text-text-secondary">
|
||||||
Grandparents and beyond come from the parents’ own records — to change one, edit the sire or dam.
|
Grandparents and beyond come from the parents’ own records — to change one, edit the sire or dam.
|
||||||
</p>
|
</p>
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,8 @@ import EmptyState from '../components/EmptyState'
|
||||||
import PageHeader from '../components/PageHeader'
|
import PageHeader from '../components/PageHeader'
|
||||||
import Button from '../components/Button'
|
import Button from '../components/Button'
|
||||||
import RabbitCard from '../components/RabbitCard'
|
import RabbitCard from '../components/RabbitCard'
|
||||||
|
import { Select } from '../components/FormControls'
|
||||||
|
import ErrorPanel from '../components/ErrorPanel'
|
||||||
import { SEX_LABELS, formatAge, formatWeight } from '../lib/format'
|
import { SEX_LABELS, formatAge, formatWeight } from '../lib/format'
|
||||||
import { useWeightUnit } from '../hooks/useWeightUnit'
|
import { useWeightUnit } from '../hooks/useWeightUnit'
|
||||||
|
|
||||||
|
|
@ -86,7 +88,7 @@ function SearchBox({ q, onSearchChange }) {
|
||||||
}, [searchInput])
|
}, [searchInput])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex min-w-0 flex-1 basis-full items-center gap-2 rounded-md border border-border bg-surface-raised px-3 py-1.5 sm:basis-auto md:flex-none">
|
<div className="flex min-h-[44px] md:min-h-[32px] min-w-0 flex-1 items-center gap-2 rounded-md border border-border bg-surface-raised px-3 py-1.5 md:flex-none">
|
||||||
<SearchIcon />
|
<SearchIcon />
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
|
|
@ -100,7 +102,13 @@ function SearchBox({ q, onSearchChange }) {
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// On phones only the sex tabs and search show by default; Status, Sort and the
|
||||||
|
// grid/list toggle fold behind a "Filters" button (badged with how many differ
|
||||||
|
// from the defaults) so the herd starts above the fold.
|
||||||
function FilterBar({ sex, status, sort, q, view, onSexChange, onStatusChange, onSortChange, onSearchChange, onViewChange }) {
|
function FilterBar({ sex, status, sort, q, view, onSexChange, onStatusChange, onSortChange, onSearchChange, onViewChange }) {
|
||||||
|
const [filtersOpen, setFiltersOpen] = useState(false)
|
||||||
|
const changedCount = (status !== DEFAULT_STATUS ? 1 : 0) + (sort !== 'name' ? 1 : 0)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col gap-3 md:flex-row md:items-center md:justify-between">
|
<div className="flex flex-col gap-3 md:flex-row md:items-center md:justify-between">
|
||||||
<div className="flex flex-wrap gap-1 rounded-md border border-border bg-surface-raised p-1">
|
<div className="flex flex-wrap gap-1 rounded-md border border-border bg-surface-raised p-1">
|
||||||
|
|
@ -109,7 +117,7 @@ function FilterBar({ sex, status, sort, q, view, onSexChange, onStatusChange, on
|
||||||
key={tab.value}
|
key={tab.value}
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => onSexChange(tab.value)}
|
onClick={() => onSexChange(tab.value)}
|
||||||
className={`rounded-md px-3 py-1.5 font-body text-sm transition-colors ${
|
className={`min-h-[44px] md:min-h-[32px] rounded-md px-3 py-1.5 font-body text-sm transition-colors ${
|
||||||
sex === tab.value
|
sex === tab.value
|
||||||
? 'bg-accent text-surface'
|
? 'bg-accent text-surface'
|
||||||
: 'text-text-primary hover:bg-surface-sunken'
|
: 'text-text-primary hover:bg-surface-sunken'
|
||||||
|
|
@ -121,38 +129,52 @@ function FilterBar({ sex, status, sort, q, view, onSexChange, onStatusChange, on
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex flex-wrap items-center gap-3">
|
<div className="flex flex-wrap items-center gap-3">
|
||||||
|
<div className="flex w-full gap-2 md:w-auto">
|
||||||
<SearchBox key={q} q={q} onSearchChange={onSearchChange} />
|
<SearchBox key={q} q={q} onSearchChange={onSearchChange} />
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="secondary"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => setFiltersOpen((v) => !v)}
|
||||||
|
aria-expanded={filtersOpen}
|
||||||
|
className="shrink-0 gap-1.5 md:hidden"
|
||||||
|
>
|
||||||
|
Filters
|
||||||
|
{changedCount > 0 && (
|
||||||
|
<span className="rounded-full bg-accent px-1.5 text-xs text-surface">{changedCount}</span>
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
<select
|
<div className={`${filtersOpen ? 'flex' : 'hidden'} w-full flex-wrap items-center gap-3 md:flex md:w-auto`}>
|
||||||
|
<Select
|
||||||
|
size="sm"
|
||||||
value={status}
|
value={status}
|
||||||
onChange={(e) => onStatusChange(e.target.value)}
|
onChange={(e) => onStatusChange(e.target.value)}
|
||||||
className="rounded-md border border-border bg-surface-raised px-3 py-1.5 font-body text-sm text-text-primary focus:outline-none"
|
aria-label="Filter by status"
|
||||||
|
className=""
|
||||||
>
|
>
|
||||||
{STATUS_OPTIONS.map((option) => (
|
{STATUS_OPTIONS.map((option) => (
|
||||||
<option key={option.value} value={option.value}>
|
<option key={option.value} value={option.value}>
|
||||||
Status: {option.label}
|
Status: {option.label}
|
||||||
</option>
|
</option>
|
||||||
))}
|
))}
|
||||||
</select>
|
</Select>
|
||||||
|
|
||||||
<select
|
<Select size="sm" value={sort} onChange={(e) => onSortChange(e.target.value)} aria-label="Sort by" className="">
|
||||||
value={sort}
|
|
||||||
onChange={(e) => onSortChange(e.target.value)}
|
|
||||||
className="rounded-md border border-border bg-surface-raised px-3 py-1.5 font-body text-sm text-text-primary focus:outline-none"
|
|
||||||
>
|
|
||||||
{SORT_OPTIONS.map((option) => (
|
{SORT_OPTIONS.map((option) => (
|
||||||
<option key={option.value} value={option.value}>
|
<option key={option.value} value={option.value}>
|
||||||
Sort: {option.label}
|
Sort: {option.label}
|
||||||
</option>
|
</option>
|
||||||
))}
|
))}
|
||||||
</select>
|
</Select>
|
||||||
|
|
||||||
<div className="flex gap-1 rounded-md border border-border bg-surface-raised p-1">
|
<div className="flex gap-1 rounded-md border border-border bg-surface-raised p-1">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
aria-label="Grid view"
|
aria-label="Grid view"
|
||||||
onClick={() => onViewChange('grid')}
|
onClick={() => onViewChange('grid')}
|
||||||
className={`rounded-md p-1.5 transition-colors ${
|
className={`flex min-h-[44px] min-w-[44px] items-center justify-center rounded-md p-1.5 transition-colors md:min-h-[32px] md:min-w-[32px] ${
|
||||||
view === 'grid' ? 'bg-accent text-surface' : 'text-text-secondary hover:bg-surface-sunken'
|
view === 'grid' ? 'bg-accent text-surface' : 'text-text-secondary hover:bg-surface-sunken'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
|
|
@ -162,7 +184,7 @@ function FilterBar({ sex, status, sort, q, view, onSexChange, onStatusChange, on
|
||||||
type="button"
|
type="button"
|
||||||
aria-label="List view"
|
aria-label="List view"
|
||||||
onClick={() => onViewChange('list')}
|
onClick={() => onViewChange('list')}
|
||||||
className={`rounded-md p-1.5 transition-colors ${
|
className={`flex min-h-[44px] min-w-[44px] items-center justify-center rounded-md p-1.5 transition-colors md:min-h-[32px] md:min-w-[32px] ${
|
||||||
view === 'list' ? 'bg-accent text-surface' : 'text-text-secondary hover:bg-surface-sunken'
|
view === 'list' ? 'bg-accent text-surface' : 'text-text-secondary hover:bg-surface-sunken'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
|
|
@ -171,6 +193,7 @@ function FilterBar({ sex, status, sort, q, view, onSexChange, onStatusChange, on
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -281,7 +304,7 @@ function Herd() {
|
||||||
// client-side below, 'all' shows everything.
|
// client-side below, 'all' shows everything.
|
||||||
const apiStatus = isArchived || status === 'all' ? undefined : status
|
const apiStatus = isArchived || status === 'all' ? undefined : status
|
||||||
|
|
||||||
const { data, isLoading, isError } = useQuery({
|
const { data, isLoading, isError, error, refetch } = useQuery({
|
||||||
queryKey: ['rabbits', { sex, search: q, sort: apiSort, status: apiStatus }],
|
queryKey: ['rabbits', { sex, search: q, sort: apiSort, status: apiStatus }],
|
||||||
queryFn: () =>
|
queryFn: () =>
|
||||||
getRabbits({
|
getRabbits({
|
||||||
|
|
@ -323,7 +346,7 @@ function Herd() {
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{isError ? (
|
{isError ? (
|
||||||
<p className="font-body text-sm text-danger">Failed to load rabbits.</p>
|
<ErrorPanel title="Couldn’t load your herd" error={error} onRetry={refetch} />
|
||||||
) : isLoading ? (
|
) : isLoading ? (
|
||||||
view === 'list' ? <ListSkeleton /> : <GridSkeleton />
|
view === 'list' ? <ListSkeleton /> : <GridSkeleton />
|
||||||
) : rabbits.length === 0 ? (
|
) : rabbits.length === 0 ? (
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,7 @@ import LoadError from '../components/LoadError'
|
||||||
import { getLitter, deleteLitter } from '../api'
|
import { getLitter, deleteLitter } from '../api'
|
||||||
import { GESTATION_DAYS, SEX_LABELS, WEAN_DAYS, addDays, formatDate, formatWeight } from '../lib/format'
|
import { GESTATION_DAYS, SEX_LABELS, WEAN_DAYS, addDays, formatDate, formatWeight } from '../lib/format'
|
||||||
import { useWeightUnit } from '../hooks/useWeightUnit'
|
import { useWeightUnit } from '../hooks/useWeightUnit'
|
||||||
|
import { useConfirm } from '../hooks/useConfirm'
|
||||||
|
|
||||||
function ParentLink({ parent }) {
|
function ParentLink({ parent }) {
|
||||||
if (!parent) return <p className="font-body text-sm text-text-secondary">Unknown</p>
|
if (!parent) return <p className="font-body text-sm text-text-secondary">Unknown</p>
|
||||||
|
|
@ -25,6 +26,7 @@ function LitterDetail() {
|
||||||
const queryClient = useQueryClient()
|
const queryClient = useQueryClient()
|
||||||
const [deleting, setDeleting] = useState(false)
|
const [deleting, setDeleting] = useState(false)
|
||||||
const [error, setError] = useState(null)
|
const [error, setError] = useState(null)
|
||||||
|
const [confirm, confirmDialog] = useConfirm()
|
||||||
const { data: litter, isLoading, isError, error: loadError } = useQuery({
|
const { data: litter, isLoading, isError, error: loadError } = useQuery({
|
||||||
queryKey: ['litter', id],
|
queryKey: ['litter', id],
|
||||||
queryFn: () => getLitter(id),
|
queryFn: () => getLitter(id),
|
||||||
|
|
@ -50,8 +52,15 @@ function LitterDetail() {
|
||||||
const weanDate = kindled ? formatDate(addDays(litter.kindle_date, WEAN_DAYS)) : null
|
const weanDate = kindled ? formatDate(addDays(litter.kindle_date, WEAN_DAYS)) : null
|
||||||
|
|
||||||
async function handleDelete() {
|
async function handleDelete() {
|
||||||
const kitNote = kits.length ? ` Its ${kits.length} kit${kits.length === 1 ? '' : 's'} stay in your herd.` : ''
|
const confirmed = await confirm({
|
||||||
if (!window.confirm(`Delete this ${kindled ? 'litter' : 'breeding'} record?${kitNote}`)) return
|
title: `Delete this ${kindled ? 'litter' : 'breeding'}?`,
|
||||||
|
confirmLabel: 'Delete',
|
||||||
|
danger: true,
|
||||||
|
message: kits.length
|
||||||
|
? `Only the record is deleted — its ${kits.length} kit${kits.length === 1 ? '' : 's'} stay in your herd.`
|
||||||
|
: 'This can’t be undone.',
|
||||||
|
})
|
||||||
|
if (!confirmed) return
|
||||||
setDeleting(true)
|
setDeleting(true)
|
||||||
setError(null)
|
setError(null)
|
||||||
try {
|
try {
|
||||||
|
|
@ -185,6 +194,7 @@ function LitterDetail() {
|
||||||
</Card>
|
</Card>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
{confirmDialog}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,7 @@ import Button from '../components/Button'
|
||||||
import Card from '../components/Card'
|
import Card from '../components/Card'
|
||||||
import EmptyState from '../components/EmptyState'
|
import EmptyState from '../components/EmptyState'
|
||||||
import LitterCard from '../components/LitterCard'
|
import LitterCard from '../components/LitterCard'
|
||||||
|
import ErrorPanel from '../components/ErrorPanel'
|
||||||
import { getLitters } from '../api'
|
import { getLitters } from '../api'
|
||||||
|
|
||||||
function LitterSection({ title, litters }) {
|
function LitterSection({ title, litters }) {
|
||||||
|
|
@ -23,7 +24,7 @@ function LitterSection({ title, litters }) {
|
||||||
// A litter row spans breeding → kindle, so breedings that haven't kindled yet
|
// A litter row spans breeding → kindle, so breedings that haven't kindled yet
|
||||||
// are listed apart from kindled litters.
|
// are listed apart from kindled litters.
|
||||||
function Litters() {
|
function Litters() {
|
||||||
const { data: litters, isLoading, isError } = useQuery({ queryKey: ['litters'], queryFn: getLitters })
|
const { data: litters, isLoading, isError, error, refetch } = useQuery({ queryKey: ['litters'], queryFn: getLitters })
|
||||||
|
|
||||||
const expecting = (litters ?? []).filter((litter) => !litter.kindle_date)
|
const expecting = (litters ?? []).filter((litter) => !litter.kindle_date)
|
||||||
const kindled = (litters ?? []).filter((litter) => litter.kindle_date)
|
const kindled = (litters ?? []).filter((litter) => litter.kindle_date)
|
||||||
|
|
@ -44,7 +45,7 @@ function Litters() {
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{isError ? (
|
{isError ? (
|
||||||
<p className="font-body text-sm text-danger">Failed to load litters.</p>
|
<ErrorPanel title="Couldn’t load litters" error={error} onRetry={refetch} />
|
||||||
) : isLoading ? (
|
) : isLoading ? (
|
||||||
<p className="font-body text-sm text-text-secondary">Loading…</p>
|
<p className="font-body text-sm text-text-secondary">Loading…</p>
|
||||||
) : litters.length === 0 ? (
|
) : litters.length === 0 ? (
|
||||||
|
|
|
||||||
|
|
@ -6,12 +6,14 @@ import Card from '../components/Card'
|
||||||
import Badge from '../components/Badge'
|
import Badge from '../components/Badge'
|
||||||
import Button from '../components/Button'
|
import Button from '../components/Button'
|
||||||
import LoadError from '../components/LoadError'
|
import LoadError from '../components/LoadError'
|
||||||
|
import ActionMenu from '../components/ActionMenu'
|
||||||
import Silhouette from '../components/Silhouette'
|
import Silhouette from '../components/Silhouette'
|
||||||
import PedigreeTreeReadOnly from '../components/PedigreeTreeReadOnly'
|
import PedigreeTreeReadOnly from '../components/PedigreeTreeReadOnly'
|
||||||
import RabbitTimeline from '../components/RabbitTimeline'
|
import RabbitTimeline from '../components/RabbitTimeline'
|
||||||
import { getRabbit, getRabbitAncestors, getLitters, updateRabbit, deleteRabbit, SERVER_BASE } from '../api'
|
import { getRabbit, getRabbitAncestors, getLitters, updateRabbit, deleteRabbit, SERVER_BASE } from '../api'
|
||||||
import { SEX_LABELS, STATUS_LABELS, formatAge, formatWeight, formatDate } from '../lib/format'
|
import { SEX_LABELS, STATUS_LABELS, formatAge, formatWeight, formatDate } from '../lib/format'
|
||||||
import { useWeightUnit } from '../hooks/useWeightUnit'
|
import { useWeightUnit } from '../hooks/useWeightUnit'
|
||||||
|
import { useConfirm } from '../hooks/useConfirm'
|
||||||
import { LOCI } from '../lib/genetics'
|
import { LOCI } from '../lib/genetics'
|
||||||
|
|
||||||
function locusLabel(locusKey, value) {
|
function locusLabel(locusKey, value) {
|
||||||
|
|
@ -162,6 +164,7 @@ function RabbitDetail() {
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
const [updating, setUpdating] = useState(false)
|
const [updating, setUpdating] = useState(false)
|
||||||
const [error, setError] = useState(null)
|
const [error, setError] = useState(null)
|
||||||
|
const [confirm, confirmDialog] = useConfirm()
|
||||||
|
|
||||||
const { data: rabbit, isLoading, isError, error: loadError } = useQuery({
|
const { data: rabbit, isLoading, isError, error: loadError } = useQuery({
|
||||||
queryKey: ['rabbit', id],
|
queryKey: ['rabbit', id],
|
||||||
|
|
@ -196,11 +199,21 @@ function RabbitDetail() {
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleDelete() {
|
async function handleDelete() {
|
||||||
const confirmed = window.confirm(
|
const confirmed = await confirm({
|
||||||
`Permanently delete ${rabbit.name}? This can’t be undone.\n\n` +
|
title: `Delete ${rabbit.name}?`,
|
||||||
'Its kits keep a written copy of it in their pedigrees, and litters it was part of show that parent as unknown. ' +
|
confirmLabel: 'Delete Rabbit',
|
||||||
'Its timeline events are deleted. To keep its history instead, change its status to Deceased, Sold or Other.'
|
danger: true,
|
||||||
)
|
message: (
|
||||||
|
<>
|
||||||
|
<p>This can’t be undone. Its timeline events are deleted too.</p>
|
||||||
|
<p>
|
||||||
|
Its kits keep a written copy of it in their pedigrees, and litters it was part of show that parent as
|
||||||
|
unknown.
|
||||||
|
</p>
|
||||||
|
<p>To keep its history instead, change its status to Deceased, Sold or Other.</p>
|
||||||
|
</>
|
||||||
|
),
|
||||||
|
})
|
||||||
if (!confirmed) return
|
if (!confirmed) return
|
||||||
setUpdating(true)
|
setUpdating(true)
|
||||||
setError(null)
|
setError(null)
|
||||||
|
|
@ -217,6 +230,17 @@ function RabbitDetail() {
|
||||||
// Breeding/litter actions pre-fill this rabbit as sire or dam, so they only
|
// Breeding/litter actions pre-fill this rabbit as sire or dam, so they only
|
||||||
// make sense once its sex is known.
|
// make sense once its sex is known.
|
||||||
const parentQuery = rabbit.sex === 'buck' ? `?sire=${rabbit.id}` : rabbit.sex === 'doe' ? `?dam=${rabbit.id}` : null
|
const parentQuery = rabbit.sex === 'buck' ? `?sire=${rabbit.id}` : rabbit.sex === 'doe' ? `?dam=${rabbit.id}` : null
|
||||||
|
// Edit and Status stay in the header; the rest sit behind "More" so the
|
||||||
|
// header fits on one row on a phone.
|
||||||
|
const moreActions = [
|
||||||
|
...(parentQuery
|
||||||
|
? [
|
||||||
|
{ label: 'Record Breeding', to: `/breedings/add${parentQuery}` },
|
||||||
|
{ label: 'Record Litter', to: `/litters/add${parentQuery}` },
|
||||||
|
]
|
||||||
|
: []),
|
||||||
|
{ label: 'Print Pedigree', to: `/herd/${rabbit.id}/pedigree` },
|
||||||
|
]
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col gap-6">
|
<div className="flex flex-col gap-6">
|
||||||
|
|
@ -228,20 +252,7 @@ function RabbitDetail() {
|
||||||
<Button to={`/herd/${rabbit.id}/edit`} size="sm">
|
<Button to={`/herd/${rabbit.id}/edit`} size="sm">
|
||||||
Edit
|
Edit
|
||||||
</Button>
|
</Button>
|
||||||
{parentQuery && (
|
<label className="inline-flex min-h-[44px] items-center gap-2 rounded-md border border-border bg-surface-raised px-3 font-body text-sm text-text-secondary md:min-h-[32px]">
|
||||||
<>
|
|
||||||
<Button to={`/breedings/add${parentQuery}`} variant="secondary" size="sm">
|
|
||||||
Record Breeding
|
|
||||||
</Button>
|
|
||||||
<Button to={`/litters/add${parentQuery}`} variant="secondary" size="sm">
|
|
||||||
Record Litter
|
|
||||||
</Button>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
<Button to={`/herd/${rabbit.id}/pedigree`} variant="secondary" size="sm">
|
|
||||||
Print Pedigree
|
|
||||||
</Button>
|
|
||||||
<label className="inline-flex min-h-[40px] items-center gap-2 rounded-md border border-border bg-surface-raised px-3 font-body text-sm text-text-secondary md:min-h-[32px]">
|
|
||||||
Status
|
Status
|
||||||
<select
|
<select
|
||||||
value={rabbit.status}
|
value={rabbit.status}
|
||||||
|
|
@ -256,6 +267,7 @@ function RabbitDetail() {
|
||||||
))}
|
))}
|
||||||
</select>
|
</select>
|
||||||
</label>
|
</label>
|
||||||
|
<ActionMenu items={moreActions} />
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
|
@ -303,6 +315,7 @@ function RabbitDetail() {
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
{confirmDialog}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,8 @@ import Card from '../components/Card'
|
||||||
import Button from '../components/Button'
|
import Button from '../components/Button'
|
||||||
import BreedSelect from '../components/BreedSelect'
|
import BreedSelect from '../components/BreedSelect'
|
||||||
import UsersCard from '../components/UsersCard'
|
import UsersCard from '../components/UsersCard'
|
||||||
|
import ConfirmDialog from '../components/ConfirmDialog'
|
||||||
|
import { Field, FieldGroup, Input, Segmented } from '../components/FormControls'
|
||||||
import { useAuth } from '../authContext'
|
import { useAuth } from '../authContext'
|
||||||
import { getSettings, updateSettings, exportDatabaseUrl, importData } from '../api'
|
import { getSettings, updateSettings, exportDatabaseUrl, importData } from '../api'
|
||||||
import { THEMES, applyTheme, DEFAULT_THEME } from '../lib/theme'
|
import { THEMES, applyTheme, DEFAULT_THEME } from '../lib/theme'
|
||||||
|
|
@ -133,40 +135,32 @@ function Settings() {
|
||||||
<p className="mt-1 font-body text-sm text-text-secondary">Shared by everyone who signs in.</p>
|
<p className="mt-1 font-body text-sm text-text-secondary">Shared by everyone who signs in.</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<Field label="Rabbitry Name">
|
||||||
<label className="block font-body text-sm font-medium text-text-primary">Rabbitry Name</label>
|
<Input
|
||||||
<input
|
|
||||||
type="text"
|
type="text"
|
||||||
value={form.rabbitry_name}
|
value={form.rabbitry_name}
|
||||||
onChange={(e) => setForm({ ...form, rabbitry_name: e.target.value })}
|
onChange={(e) => setForm({ ...form, rabbitry_name: 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>
|
</Field>
|
||||||
|
|
||||||
<div>
|
<Field
|
||||||
<label className="block font-body text-sm font-medium text-text-primary">Kit Prefix</label>
|
label="Kit Prefix"
|
||||||
<input
|
hint={`Auto-generated kit names use this prefix (e.g. "${form.kit_prefix || 'Kit'}1").`}
|
||||||
|
>
|
||||||
|
<Input
|
||||||
type="text"
|
type="text"
|
||||||
value={form.kit_prefix}
|
value={form.kit_prefix}
|
||||||
onChange={(e) => setForm({ ...form, kit_prefix: e.target.value })}
|
onChange={(e) => setForm({ ...form, kit_prefix: e.target.value })}
|
||||||
placeholder="e.g. RAG"
|
placeholder="e.g. RAG"
|
||||||
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">
|
</Field>
|
||||||
Auto-generated kit names use this prefix (e.g. "{form.kit_prefix || 'Kit'}1").
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
<Field label="Default Breed" hint="Pre-fills the breed field when adding a new rabbit.">
|
||||||
<label className="block font-body text-sm font-medium text-text-primary">Default Breed</label>
|
|
||||||
<BreedSelect
|
<BreedSelect
|
||||||
value={form.default_breed}
|
value={form.default_breed}
|
||||||
onChange={(breed) => setForm({ ...form, default_breed: breed })}
|
onChange={(breed) => setForm({ ...form, default_breed: breed })}
|
||||||
/>
|
/>
|
||||||
<p className="mt-1 font-body text-xs text-text-secondary">
|
</Field>
|
||||||
Pre-fills the breed field when adding a new rabbit.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Button onClick={() => handleSave(['rabbitry_name', 'kit_prefix', 'default_breed'])} disabled={saving}>
|
<Button onClick={() => handleSave(['rabbitry_name', 'kit_prefix', 'default_breed'])} disabled={saving}>
|
||||||
{saving ? 'Saving…' : 'Save Rabbitry Settings'}
|
{saving ? 'Saving…' : 'Save Rabbitry Settings'}
|
||||||
|
|
@ -178,32 +172,17 @@ function Settings() {
|
||||||
<h2 className="font-display text-lg text-text-primary">Your Preferences</h2>
|
<h2 className="font-display text-lg text-text-primary">Your Preferences</h2>
|
||||||
<p className="mt-1 font-body text-sm text-text-secondary">Only affect your account.</p>
|
<p className="mt-1 font-body text-sm text-text-secondary">Only affect your account.</p>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<FieldGroup label="Weight Display">
|
||||||
<label className="block font-body text-sm font-medium text-text-primary">Weight Display</label>
|
<Segmented
|
||||||
<div className="mt-1 flex gap-1">
|
label="Weight Display"
|
||||||
{WEIGHT_UNIT_OPTIONS.map((opt) => (
|
options={WEIGHT_UNIT_OPTIONS}
|
||||||
<button
|
value={form.weight_unit}
|
||||||
key={opt.value}
|
onChange={(weight_unit) => setForm({ ...form, weight_unit })}
|
||||||
type="button"
|
/>
|
||||||
onClick={() => setForm({ ...form, weight_unit: opt.value })}
|
</FieldGroup>
|
||||||
className={`flex-1 rounded-md border px-3 py-2 font-body text-sm ${
|
|
||||||
form.weight_unit === opt.value
|
|
||||||
? 'border-accent bg-accent text-surface'
|
|
||||||
: 'border-border bg-surface-raised text-text-primary hover:bg-surface-sunken'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{opt.label}
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
<FieldGroup label="Theme" hint="Selecting a theme previews it instantly. Save to keep it across your devices.">
|
||||||
<label className="block font-body text-sm font-medium text-text-primary">Theme</label>
|
<div className="grid grid-cols-2 gap-2 sm:grid-cols-3">
|
||||||
<p className="mt-1 font-body text-xs text-text-secondary">
|
|
||||||
Selecting a theme previews it instantly. Save to keep it across your devices.
|
|
||||||
</p>
|
|
||||||
<div className="mt-2 grid grid-cols-2 gap-2 sm:grid-cols-3">
|
|
||||||
{THEMES.map((theme) => (
|
{THEMES.map((theme) => (
|
||||||
<button
|
<button
|
||||||
key={theme.id}
|
key={theme.id}
|
||||||
|
|
@ -236,7 +215,7 @@ function Settings() {
|
||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</FieldGroup>
|
||||||
|
|
||||||
<Button onClick={() => handleSave(['weight_unit', 'theme'])} disabled={saving}>
|
<Button onClick={() => handleSave(['weight_unit', 'theme'])} disabled={saving}>
|
||||||
{saving ? 'Saving…' : 'Save Preferences'}
|
{saving ? 'Saving…' : 'Save Preferences'}
|
||||||
|
|
@ -295,24 +274,19 @@ function Settings() {
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{pendingFile && (
|
{pendingFile && (
|
||||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40 p-4">
|
<ConfirmDialog
|
||||||
<Card className="w-full max-w-md space-y-4 p-6">
|
title="Replace all data?"
|
||||||
<h2 className="font-display text-lg text-text-primary">Replace all data?</h2>
|
confirmLabel="Delete & Import"
|
||||||
<p className="font-body text-sm text-text-secondary">
|
busyLabel="Importing…"
|
||||||
Importing <span className="font-medium text-text-primary">{pendingFile.name}</span> will
|
danger
|
||||||
permanently delete <span className="font-medium text-danger">all current rabbits and litters</span>{' '}
|
busy={importing}
|
||||||
before loading the file. This cannot be undone.
|
onConfirm={handleConfirmImport}
|
||||||
</p>
|
onCancel={() => setPendingFile(null)}
|
||||||
<div className="flex justify-end gap-2">
|
>
|
||||||
<Button variant="secondary" onClick={() => setPendingFile(null)} disabled={importing}>
|
Importing <span className="font-medium text-text-primary">{pendingFile.name}</span> will permanently
|
||||||
Cancel
|
delete <span className="font-medium text-danger">all current rabbits and litters</span> before loading the
|
||||||
</Button>
|
file. This cannot be undone.
|
||||||
<Button variant="danger" onClick={handleConfirmImport} disabled={importing}>
|
</ConfirmDialog>
|
||||||
{importing ? 'Importing...' : 'Delete & Import'}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</Card>
|
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<Toast message={toast} />
|
<Toast message={toast} />
|
||||||
|
|
|
||||||
|
|
@ -16,6 +16,9 @@ import {
|
||||||
import Card from '../components/Card'
|
import Card from '../components/Card'
|
||||||
import Button from '../components/Button'
|
import Button from '../components/Button'
|
||||||
import PageHeader from '../components/PageHeader'
|
import PageHeader from '../components/PageHeader'
|
||||||
|
import ConfirmDialog from '../components/ConfirmDialog'
|
||||||
|
import LoadError from '../components/LoadError'
|
||||||
|
import { Field, Input, Textarea } from '../components/FormControls'
|
||||||
import EmptyState from '../components/EmptyState'
|
import EmptyState from '../components/EmptyState'
|
||||||
import RabbitPicker from '../components/RabbitPicker'
|
import RabbitPicker from '../components/RabbitPicker'
|
||||||
import ReportModal from '../components/ReportModal'
|
import ReportModal from '../components/ReportModal'
|
||||||
|
|
@ -48,19 +51,12 @@ function EditEventForm({ event, onDone }) {
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card className="flex flex-col gap-3 p-4">
|
<Card className="flex flex-col gap-3 p-4">
|
||||||
<input
|
<Field label="Event name" required>
|
||||||
type="text"
|
<Input type="text" value={name} onChange={(e) => setName(e.target.value)} />
|
||||||
value={name}
|
</Field>
|
||||||
onChange={(e) => setName(e.target.value)}
|
<Field label="Notes">
|
||||||
className="rounded-md border border-border px-3 py-2 font-body text-sm focus:outline-none focus:ring-2 focus:ring-accent"
|
<Textarea value={notes} onChange={(e) => setNotes(e.target.value)} rows={2} />
|
||||||
/>
|
</Field>
|
||||||
<textarea
|
|
||||||
value={notes}
|
|
||||||
onChange={(e) => setNotes(e.target.value)}
|
|
||||||
rows={2}
|
|
||||||
placeholder="Notes"
|
|
||||||
className="rounded-md border border-border px-3 py-2 font-body text-sm focus:outline-none focus:ring-2 focus:ring-accent"
|
|
||||||
/>
|
|
||||||
{mutation.isError && <p className="font-body text-sm text-danger">{mutation.error.message}</p>}
|
{mutation.isError && <p className="font-body text-sm text-danger">{mutation.error.message}</p>}
|
||||||
<div className="flex justify-end gap-2">
|
<div className="flex justify-end gap-2">
|
||||||
<Button variant="secondary" size="sm" onClick={onDone} disabled={mutation.isPending}>
|
<Button variant="secondary" size="sm" onClick={onDone} disabled={mutation.isPending}>
|
||||||
|
|
@ -126,19 +122,23 @@ function JudgesSection({ eventId, show }) {
|
||||||
</ul>
|
</ul>
|
||||||
) : null}
|
) : null}
|
||||||
<div className="mt-2 flex flex-wrap gap-2">
|
<div className="mt-2 flex flex-wrap gap-2">
|
||||||
<input
|
<Input
|
||||||
|
size="sm"
|
||||||
type="text"
|
type="text"
|
||||||
value={name}
|
value={name}
|
||||||
onChange={(e) => setName(e.target.value)}
|
onChange={(e) => setName(e.target.value)}
|
||||||
placeholder="Judge name"
|
placeholder="Judge name"
|
||||||
className="flex-1 rounded-md border border-border px-2 py-1 font-body text-sm focus:outline-none focus:ring-2 focus:ring-accent"
|
aria-label="Judge name"
|
||||||
|
className="min-w-0 flex-1"
|
||||||
/>
|
/>
|
||||||
<input
|
<Input
|
||||||
|
size="sm"
|
||||||
type="text"
|
type="text"
|
||||||
value={arba}
|
value={arba}
|
||||||
onChange={(e) => setArba(e.target.value)}
|
onChange={(e) => setArba(e.target.value)}
|
||||||
placeholder="ARBA # (opt.)"
|
placeholder="ARBA # (opt.)"
|
||||||
className="w-28 rounded-md border border-border px-2 py-1 font-body text-sm focus:outline-none focus:ring-2 focus:ring-accent"
|
aria-label="Judge ARBA number (optional)"
|
||||||
|
className="w-28"
|
||||||
/>
|
/>
|
||||||
<Button variant="secondary" size="sm" onClick={() => add.mutate()} disabled={name.trim().length === 0 || add.isPending}>
|
<Button variant="secondary" size="sm" onClick={() => add.mutate()} disabled={name.trim().length === 0 || add.isPending}>
|
||||||
Add Judge
|
Add Judge
|
||||||
|
|
@ -225,7 +225,7 @@ function PlacementBadges({ placement }) {
|
||||||
</span>
|
</span>
|
||||||
) : null}
|
) : null}
|
||||||
{flags.map((f) => (
|
{flags.map((f) => (
|
||||||
<span key={f.key} className="rounded-full bg-accent-warm px-2 py-0.5 text-xs text-surface">
|
<span key={f.key} className="rounded-full bg-accent-warm px-2 py-0.5 text-xs text-on-accent-warm">
|
||||||
{f.label}
|
{f.label}
|
||||||
</span>
|
</span>
|
||||||
))}
|
))}
|
||||||
|
|
@ -267,13 +267,14 @@ function PlacementEditor({ eventId, showId, entryId, placement }) {
|
||||||
<div className="flex flex-wrap items-end gap-3 rounded-md bg-surface-sunken p-3">
|
<div className="flex flex-wrap items-end gap-3 rounded-md bg-surface-sunken p-3">
|
||||||
<label className="flex flex-col gap-1 font-body text-xs text-text-secondary">
|
<label className="flex flex-col gap-1 font-body text-xs text-text-secondary">
|
||||||
Placement
|
Placement
|
||||||
<input
|
<Input
|
||||||
|
size="sm"
|
||||||
type="text"
|
type="text"
|
||||||
value={form.placement}
|
value={form.placement}
|
||||||
onChange={(e) => edit({ placement: e.target.value })}
|
onChange={(e) => edit({ placement: e.target.value })}
|
||||||
onBlur={() => save.mutate(form)}
|
onBlur={() => save.mutate(form)}
|
||||||
placeholder="1st, BOB…"
|
placeholder="1st, BOB…"
|
||||||
className="w-28 rounded-md border border-border px-2 py-1 text-sm text-text-primary focus:outline-none focus:ring-2 focus:ring-accent"
|
className="w-28"
|
||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
|
|
||||||
|
|
@ -283,7 +284,7 @@ function PlacementEditor({ eventId, showId, entryId, placement }) {
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => commit({ legs_earned: Math.max(0, form.legs_earned - 1) })}
|
onClick={() => commit({ legs_earned: Math.max(0, form.legs_earned - 1) })}
|
||||||
className="rounded-md border border-border px-2 py-1 text-sm text-text-primary"
|
className="min-h-[44px] min-w-[44px] rounded-md border border-border bg-surface-raised text-sm text-text-primary hover:bg-surface-sunken md:min-h-[32px] md:min-w-[32px]"
|
||||||
aria-label="Decrease legs"
|
aria-label="Decrease legs"
|
||||||
>
|
>
|
||||||
−
|
−
|
||||||
|
|
@ -292,7 +293,7 @@ function PlacementEditor({ eventId, showId, entryId, placement }) {
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => commit({ legs_earned: Math.min(3, form.legs_earned + 1) })}
|
onClick={() => commit({ legs_earned: Math.min(3, form.legs_earned + 1) })}
|
||||||
className="rounded-md border border-border px-2 py-1 text-sm text-text-primary"
|
className="min-h-[44px] min-w-[44px] rounded-md border border-border bg-surface-raised text-sm text-text-primary hover:bg-surface-sunken md:min-h-[32px] md:min-w-[32px]"
|
||||||
aria-label="Increase legs"
|
aria-label="Increase legs"
|
||||||
>
|
>
|
||||||
+
|
+
|
||||||
|
|
@ -314,12 +315,13 @@ function PlacementEditor({ eventId, showId, entryId, placement }) {
|
||||||
|
|
||||||
<label className="flex flex-col gap-1 font-body text-xs text-text-secondary">
|
<label className="flex flex-col gap-1 font-body text-xs text-text-secondary">
|
||||||
Notes
|
Notes
|
||||||
<input
|
<Input
|
||||||
|
size="sm"
|
||||||
type="text"
|
type="text"
|
||||||
value={form.notes}
|
value={form.notes}
|
||||||
onChange={(e) => edit({ notes: e.target.value })}
|
onChange={(e) => edit({ notes: e.target.value })}
|
||||||
onBlur={() => save.mutate(form)}
|
onBlur={() => save.mutate(form)}
|
||||||
className="w-36 rounded-md border border-border px-2 py-1 text-sm text-text-primary focus:outline-none focus:ring-2 focus:ring-accent"
|
className="w-36"
|
||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
|
|
||||||
|
|
@ -371,21 +373,25 @@ function EntryRow({ eventId, showId, entry, multiShow }) {
|
||||||
</p>
|
</p>
|
||||||
{meta ? <p className="font-body text-xs text-text-secondary">{meta}</p> : null}
|
{meta ? <p className="font-body text-xs text-text-secondary">{meta}</p> : null}
|
||||||
</div>
|
</div>
|
||||||
<input
|
<Input
|
||||||
|
size="sm"
|
||||||
type="text"
|
type="text"
|
||||||
value={entryClass}
|
value={entryClass}
|
||||||
onChange={(e) => setEntryClass(e.target.value)}
|
onChange={(e) => setEntryClass(e.target.value)}
|
||||||
onBlur={saveClass}
|
onBlur={saveClass}
|
||||||
placeholder="Class (e.g. Sr Buck)"
|
placeholder="Class (e.g. Sr Buck)"
|
||||||
className="w-full rounded-md border border-border px-3 py-1.5 font-body text-sm focus:outline-none focus:ring-2 focus:ring-accent md:w-40"
|
aria-label="Class"
|
||||||
|
className="w-full md:w-40"
|
||||||
/>
|
/>
|
||||||
<input
|
<Input
|
||||||
|
size="sm"
|
||||||
type="text"
|
type="text"
|
||||||
value={notes}
|
value={notes}
|
||||||
onChange={(e) => setNotes(e.target.value)}
|
onChange={(e) => setNotes(e.target.value)}
|
||||||
onBlur={saveNotes}
|
onBlur={saveNotes}
|
||||||
placeholder="Notes"
|
placeholder="Notes"
|
||||||
className="w-full rounded-md border border-border px-3 py-1.5 font-body text-sm focus:outline-none focus:ring-2 focus:ring-accent md:w-44"
|
aria-label="Entry notes"
|
||||||
|
className="w-full md:w-44"
|
||||||
/>
|
/>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
|
|
@ -570,7 +576,7 @@ function ResultsTab({ event }) {
|
||||||
</span>
|
</span>
|
||||||
) : null}
|
) : null}
|
||||||
{PLACEMENT_FLAGS.filter((f) => row.flags[f.key] > 0).map((f) => (
|
{PLACEMENT_FLAGS.filter((f) => row.flags[f.key] > 0).map((f) => (
|
||||||
<span key={f.key} className="rounded-full bg-accent-warm px-2 py-0.5 text-xs text-surface">
|
<span key={f.key} className="rounded-full bg-accent-warm px-2 py-0.5 text-xs text-on-accent-warm">
|
||||||
{f.label}
|
{f.label}
|
||||||
{row.flags[f.key] > 1 ? ` ×${row.flags[f.key]}` : ''}
|
{row.flags[f.key] > 1 ? ` ×${row.flags[f.key]}` : ''}
|
||||||
</span>
|
</span>
|
||||||
|
|
@ -618,8 +624,9 @@ function ShowEventDetail() {
|
||||||
const queryClient = useQueryClient()
|
const queryClient = useQueryClient()
|
||||||
const [editing, setEditing] = useState(false)
|
const [editing, setEditing] = useState(false)
|
||||||
const [tab, setTab] = useState('shows')
|
const [tab, setTab] = useState('shows')
|
||||||
|
const [confirmingDelete, setConfirmingDelete] = useState(false)
|
||||||
|
|
||||||
const { data: event, isLoading, isError } = useQuery({
|
const { data: event, isLoading, isError, error: loadError } = useQuery({
|
||||||
queryKey: ['show-event', id],
|
queryKey: ['show-event', id],
|
||||||
queryFn: () => getShowEvent(id),
|
queryFn: () => getShowEvent(id),
|
||||||
})
|
})
|
||||||
|
|
@ -633,7 +640,7 @@ function ShowEventDetail() {
|
||||||
})
|
})
|
||||||
|
|
||||||
if (isError) {
|
if (isError) {
|
||||||
return <p className="p-6 font-body text-sm text-danger">Failed to load this event.</p>
|
return <LoadError title="Show Event" error={loadError} backTo="/shows/events" backLabel="Back to My Events" />
|
||||||
}
|
}
|
||||||
if (isLoading || !event) {
|
if (isLoading || !event) {
|
||||||
return <PageHeader title="Show Event" subtitle="Loading…" />
|
return <PageHeader title="Show Event" subtitle="Loading…" />
|
||||||
|
|
@ -655,9 +662,7 @@ function ShowEventDetail() {
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
variant="danger"
|
variant="danger"
|
||||||
onClick={() => {
|
onClick={() => setConfirmingDelete(true)}
|
||||||
if (window.confirm('Delete this event? Its shows and entries will be removed too.')) remove.mutate()
|
|
||||||
}}
|
|
||||||
disabled={remove.isPending}
|
disabled={remove.isPending}
|
||||||
>
|
>
|
||||||
Delete
|
Delete
|
||||||
|
|
@ -707,6 +712,21 @@ function ShowEventDetail() {
|
||||||
) : (
|
) : (
|
||||||
<ResultsTab event={event} />
|
<ResultsTab event={event} />
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{confirmingDelete && (
|
||||||
|
<ConfirmDialog
|
||||||
|
title={`Delete ${event.name}?`}
|
||||||
|
confirmLabel="Delete Event"
|
||||||
|
busyLabel="Deleting…"
|
||||||
|
danger
|
||||||
|
busy={remove.isPending}
|
||||||
|
onConfirm={() => remove.mutate()}
|
||||||
|
onCancel={() => setConfirmingDelete(false)}
|
||||||
|
>
|
||||||
|
<p>Its shows and entries will be removed too. This can’t be undone.</p>
|
||||||
|
{remove.isError && <p className="text-danger">{remove.error.message}</p>}
|
||||||
|
</ConfirmDialog>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,8 @@ import { getShowEvents, createShowEvent } from '../api'
|
||||||
import Card from '../components/Card'
|
import Card from '../components/Card'
|
||||||
import Button from '../components/Button'
|
import Button from '../components/Button'
|
||||||
import PageHeader from '../components/PageHeader'
|
import PageHeader from '../components/PageHeader'
|
||||||
|
import { Field, Input, Textarea } from '../components/FormControls'
|
||||||
|
import ErrorPanel from '../components/ErrorPanel'
|
||||||
import EmptyState from '../components/EmptyState'
|
import EmptyState from '../components/EmptyState'
|
||||||
|
|
||||||
function formatDate(iso) {
|
function formatDate(iso) {
|
||||||
|
|
@ -40,20 +42,17 @@ function NewEventForm({ onCreated }) {
|
||||||
return (
|
return (
|
||||||
<Card className="flex flex-col gap-3 p-4">
|
<Card className="flex flex-col gap-3 p-4">
|
||||||
<h3 className="font-display text-base text-text-primary">New event</h3>
|
<h3 className="font-display text-base text-text-primary">New event</h3>
|
||||||
<input
|
<Field label="Event name" required>
|
||||||
|
<Input
|
||||||
type="text"
|
type="text"
|
||||||
value={name}
|
value={name}
|
||||||
onChange={(e) => setName(e.target.value)}
|
onChange={(e) => setName(e.target.value)}
|
||||||
placeholder="Event name"
|
placeholder="e.g. Spring Circuit Weekend"
|
||||||
className="rounded-md border border-border px-3 py-2 font-body text-sm focus:outline-none focus:ring-2 focus:ring-accent"
|
|
||||||
/>
|
|
||||||
<textarea
|
|
||||||
value={notes}
|
|
||||||
onChange={(e) => setNotes(e.target.value)}
|
|
||||||
rows={2}
|
|
||||||
placeholder="Notes (optional)"
|
|
||||||
className="rounded-md border border-border px-3 py-2 font-body text-sm focus:outline-none focus:ring-2 focus:ring-accent"
|
|
||||||
/>
|
/>
|
||||||
|
</Field>
|
||||||
|
<Field label="Notes">
|
||||||
|
<Textarea value={notes} onChange={(e) => setNotes(e.target.value)} rows={2} />
|
||||||
|
</Field>
|
||||||
{mutation.isError && <p className="font-body text-sm text-danger">{mutation.error.message}</p>}
|
{mutation.isError && <p className="font-body text-sm text-danger">{mutation.error.message}</p>}
|
||||||
<div className="flex justify-end gap-2">
|
<div className="flex justify-end gap-2">
|
||||||
<Button variant="secondary" size="sm" onClick={() => onCreated(null)} disabled={mutation.isPending}>
|
<Button variant="secondary" size="sm" onClick={() => onCreated(null)} disabled={mutation.isPending}>
|
||||||
|
|
@ -73,7 +72,7 @@ function NewEventForm({ onCreated }) {
|
||||||
}
|
}
|
||||||
|
|
||||||
function ShowEvents() {
|
function ShowEvents() {
|
||||||
const { data: events, isLoading, isError } = useQuery({
|
const { data: events, isLoading, isError, error, refetch } = useQuery({
|
||||||
queryKey: ['show-events'],
|
queryKey: ['show-events'],
|
||||||
queryFn: getShowEvents,
|
queryFn: getShowEvents,
|
||||||
})
|
})
|
||||||
|
|
@ -97,7 +96,7 @@ function ShowEvents() {
|
||||||
{creating && <NewEventForm onCreated={() => setCreating(false)} />}
|
{creating && <NewEventForm onCreated={() => setCreating(false)} />}
|
||||||
|
|
||||||
{isError ? (
|
{isError ? (
|
||||||
<p className="font-body text-sm text-danger">Failed to load events.</p>
|
<ErrorPanel title="Couldn’t load your events" error={error} onRetry={refetch} />
|
||||||
) : isLoading ? (
|
) : isLoading ? (
|
||||||
<Card className="p-8">
|
<Card className="p-8">
|
||||||
<p className="font-body text-sm text-text-secondary">Loading events…</p>
|
<p className="font-body text-sm text-text-secondary">Loading events…</p>
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,7 @@ import Button from '../components/Button'
|
||||||
import PageHeader from '../components/PageHeader'
|
import PageHeader from '../components/PageHeader'
|
||||||
import EmptyState from '../components/EmptyState'
|
import EmptyState from '../components/EmptyState'
|
||||||
import AddToEventModal from '../components/AddToEventModal'
|
import AddToEventModal from '../components/AddToEventModal'
|
||||||
|
import { Input } from '../components/FormControls'
|
||||||
|
|
||||||
const PAGE_SIZE = 50
|
const PAGE_SIZE = 50
|
||||||
|
|
||||||
|
|
@ -71,7 +72,7 @@ function MultiSelect({ label, options, selected, onChange }) {
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => setOpen((v) => !v)}
|
onClick={() => setOpen((v) => !v)}
|
||||||
className="flex items-center gap-2 rounded-md border border-border bg-surface-raised px-3 py-1.5 font-body text-sm text-text-primary"
|
className="flex min-h-[44px] md:min-h-[32px] items-center gap-2 rounded-md border border-border bg-surface-raised px-3 py-1.5 font-body text-sm text-text-primary"
|
||||||
>
|
>
|
||||||
<span>{label}</span>
|
<span>{label}</span>
|
||||||
{selected.length > 0 && (
|
{selected.length > 0 && (
|
||||||
|
|
@ -93,7 +94,7 @@ function MultiSelect({ label, options, selected, onChange }) {
|
||||||
{options.map((option) => (
|
{options.map((option) => (
|
||||||
<label
|
<label
|
||||||
key={option.value}
|
key={option.value}
|
||||||
className="flex cursor-pointer items-center gap-2 rounded px-2 py-1 font-body text-sm text-text-primary hover:bg-surface-sunken"
|
className="flex min-h-[40px] cursor-pointer items-center gap-2 rounded px-2 py-1 font-body md:min-h-0 text-sm text-text-primary hover:bg-surface-sunken"
|
||||||
>
|
>
|
||||||
<input
|
<input
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
|
|
@ -118,6 +119,66 @@ function MapPinIcon() {
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Phone/tablet layout for one show: the same fields as a table row, stacked,
|
||||||
|
// so the list never scrolls sideways.
|
||||||
|
function ShowListCard({ show: s, onAdd }) {
|
||||||
|
const place = [s.city, s.state].filter(Boolean).join(', ')
|
||||||
|
return (
|
||||||
|
<Card className="flex flex-col gap-2 p-4 font-body text-sm">
|
||||||
|
<div className="flex items-start justify-between gap-3">
|
||||||
|
<div className="min-w-0">
|
||||||
|
<p className="font-medium text-text-primary">{s.club_name}</p>
|
||||||
|
<p className="text-xs text-text-secondary">
|
||||||
|
{s.show_type_full || s.show_type}
|
||||||
|
{s.special_breed ? ` · ${s.special_breed}` : ''}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<p className="shrink-0 whitespace-nowrap text-text-primary">{s.raw_date || s.date || '—'}</p>
|
||||||
|
</div>
|
||||||
|
<p className="text-text-secondary">
|
||||||
|
{place || '—'}
|
||||||
|
{s.district ? ` · District ${s.district}` : ''}
|
||||||
|
</p>
|
||||||
|
{s.secretary || s.email ? (
|
||||||
|
<p className="text-text-secondary">
|
||||||
|
{s.secretary}
|
||||||
|
{s.secretary && s.email ? ' · ' : ''}
|
||||||
|
{s.email ? (
|
||||||
|
<a href={`mailto:${s.email}`} className="break-all text-accent hover:underline">
|
||||||
|
{s.email}
|
||||||
|
</a>
|
||||||
|
) : null}
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
<div className="flex flex-wrap items-center gap-x-4">
|
||||||
|
{s.maps_url ? (
|
||||||
|
<a
|
||||||
|
href={s.maps_url}
|
||||||
|
target="_blank"
|
||||||
|
rel="noreferrer"
|
||||||
|
className="inline-flex min-h-[44px] items-center gap-1 text-accent hover:underline"
|
||||||
|
>
|
||||||
|
<MapPinIcon /> Map
|
||||||
|
</a>
|
||||||
|
) : null}
|
||||||
|
{s.catalog_url ? (
|
||||||
|
<a
|
||||||
|
href={s.catalog_url}
|
||||||
|
target="_blank"
|
||||||
|
rel="noreferrer"
|
||||||
|
className="inline-flex min-h-[44px] items-center text-accent hover:underline"
|
||||||
|
>
|
||||||
|
Catalog
|
||||||
|
</a>
|
||||||
|
) : null}
|
||||||
|
<Button variant="secondary" size="sm" onClick={onAdd} className="ml-auto">
|
||||||
|
Add to Event
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
function Shows() {
|
function Shows() {
|
||||||
const queryClient = useQueryClient()
|
const queryClient = useQueryClient()
|
||||||
const { data, isLoading, isError, error } = useQuery({
|
const { data, isLoading, isError, error } = useQuery({
|
||||||
|
|
@ -222,36 +283,40 @@ function Shows() {
|
||||||
|
|
||||||
<label className="flex flex-col gap-1 font-body text-xs text-text-secondary">
|
<label className="flex flex-col gap-1 font-body text-xs text-text-secondary">
|
||||||
From
|
From
|
||||||
<input
|
<Input
|
||||||
|
size="sm"
|
||||||
type="date"
|
type="date"
|
||||||
value={dateFrom}
|
value={dateFrom}
|
||||||
onChange={(e) => { setDateFrom(e.target.value); setPage(1) }}
|
onChange={(e) => { setDateFrom(e.target.value); setPage(1) }}
|
||||||
className="rounded-md border border-border bg-surface-raised px-3 py-1.5 text-sm text-text-primary focus:outline-none"
|
className=""
|
||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
<label className="flex flex-col gap-1 font-body text-xs text-text-secondary">
|
<label className="flex flex-col gap-1 font-body text-xs text-text-secondary">
|
||||||
To
|
To
|
||||||
<input
|
<Input
|
||||||
|
size="sm"
|
||||||
type="date"
|
type="date"
|
||||||
value={dateTo}
|
value={dateTo}
|
||||||
onChange={(e) => { setDateTo(e.target.value); setPage(1) }}
|
onChange={(e) => { setDateTo(e.target.value); setPage(1) }}
|
||||||
className="rounded-md border border-border bg-surface-raised px-3 py-1.5 text-sm text-text-primary focus:outline-none"
|
className=""
|
||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
|
|
||||||
<input
|
<Input
|
||||||
|
size="sm"
|
||||||
type="text"
|
type="text"
|
||||||
value={keyword}
|
value={keyword}
|
||||||
onChange={(e) => { setKeyword(e.target.value); setPage(1) }}
|
onChange={(e) => { setKeyword(e.target.value); setPage(1) }}
|
||||||
placeholder="Club, city, or secretary"
|
placeholder="Club, city, or secretary"
|
||||||
className="w-56 rounded-md border border-border bg-surface-raised px-3 py-1.5 font-body text-sm text-text-primary placeholder:text-text-secondary focus:outline-none"
|
aria-label="Search by club, city, or secretary"
|
||||||
|
className="w-56"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{hasActiveFilters ? (
|
{hasActiveFilters ? (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={resetFilters}
|
onClick={resetFilters}
|
||||||
className="rounded-md px-3 py-1.5 font-body text-sm text-accent hover:bg-surface-sunken"
|
className="min-h-[44px] md:min-h-[32px] rounded-md px-3 py-1.5 font-body text-sm text-accent hover:bg-surface-sunken"
|
||||||
>
|
>
|
||||||
Reset Filters
|
Reset Filters
|
||||||
</button>
|
</button>
|
||||||
|
|
@ -289,7 +354,13 @@ function Shows() {
|
||||||
<p className="font-body text-sm text-text-secondary">
|
<p className="font-body text-sm text-text-secondary">
|
||||||
{filtered.length} show{filtered.length === 1 ? '' : 's'}
|
{filtered.length} show{filtered.length === 1 ? '' : 's'}
|
||||||
</p>
|
</p>
|
||||||
<Card className="overflow-x-auto">
|
<div className="grid grid-cols-1 gap-3 md:grid-cols-2 xl:hidden">
|
||||||
|
{pageRows.map((s, i) => (
|
||||||
|
<ShowListCard key={s.sanction_number || i} show={s} onAdd={() => setAddingShow(s)} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
{/* The full table needs ~900px, which only fits beside the sidebar on wide screens. */}
|
||||||
|
<Card className="hidden overflow-x-auto xl:block">
|
||||||
<table className="w-full min-w-[900px] font-body text-sm">
|
<table className="w-full min-w-[900px] font-body text-sm">
|
||||||
<thead>
|
<thead>
|
||||||
<tr className="border-b border-border text-left text-text-secondary">
|
<tr className="border-b border-border text-left text-text-secondary">
|
||||||
|
|
|
||||||
|
|
@ -18,6 +18,7 @@ export default {
|
||||||
accent: 'var(--color-accent)',
|
accent: 'var(--color-accent)',
|
||||||
'accent-hover': 'var(--color-accent-hover)',
|
'accent-hover': 'var(--color-accent-hover)',
|
||||||
'accent-warm': 'var(--color-accent-warm)',
|
'accent-warm': 'var(--color-accent-warm)',
|
||||||
|
'on-accent-warm': 'var(--color-on-accent-warm)',
|
||||||
danger: 'var(--color-danger)',
|
danger: 'var(--color-danger)',
|
||||||
},
|
},
|
||||||
fontFamily: {
|
fontFamily: {
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue