import { useEffect, useState } from 'react' import { Link, useSearchParams } from 'react-router-dom' import { useQuery } from '@tanstack/react-query' import { getRabbits } from '../api' import Card from '../components/Card' import Badge from '../components/Badge' import EmptyState from '../components/EmptyState' import PageHeader from '../components/PageHeader' import Button from '../components/Button' import RabbitCard from '../components/RabbitCard' import { Select } from '../components/FormControls' import ErrorPanel from '../components/ErrorPanel' import { SEX_LABELS, formatAge, formatWeight } from '../lib/format' import { useWeightUnit } from '../hooks/useWeightUnit' const SEX_TABS = [ { value: '', label: 'All' }, { value: 'buck', label: 'Bucks' }, { value: 'doe', label: 'Does' }, { value: 'kits', label: 'Kits' }, // Sex not recorded yet — named 'Unknown' so it isn't confused with the // 'Other' status (which the Status filter groups under Archived). { value: 'unknown', label: 'Unknown' }, ] const SORT_OPTIONS = [ { value: 'name', label: 'Name', apiSort: 'name' }, { value: 'age', label: 'Age', apiSort: 'dob' }, { value: 'recent', label: 'Recent', apiSort: 'created_at' }, { value: 'weight', label: 'Weight', apiSort: 'weight_lbs' }, ] // The Herd page defaults to the active herd so it matches the Dashboard's "Herd" // count and doesn't bury working animals under archived/sold/reference records. // 'archived' (other + deceased) and 'all' (no status filter) are handled // specially in the query below. const DEFAULT_STATUS = 'active' const STATUS_OPTIONS = [ { value: 'active', label: 'Active' }, { value: 'for_sale', label: 'For Sale' }, { value: 'on_hold', label: 'On Hold' }, { value: 'sold', label: 'Sold' }, { value: 'archived', label: 'Archived' }, { value: 'all', label: 'All' }, ] const SEARCH_DEBOUNCE_MS = 300 function SearchIcon() { return ( ) } function GridIcon() { return ( ) } function ListIcon() { return ( ) } function SearchBox({ q, onSearchChange }) { const [searchInput, setSearchInput] = useState(q) useEffect(() => { const timeout = setTimeout(() => { if (searchInput !== q) onSearchChange(searchInput) }, SEARCH_DEBOUNCE_MS) return () => clearTimeout(timeout) // eslint-disable-next-line react-hooks/exhaustive-deps }, [searchInput]) return (
setSearchInput(e.target.value)} placeholder="Name, ear #, color" aria-label="Search herd by name, ear number or color" className="w-full min-w-0 bg-transparent md:w-48 font-body text-sm text-text-primary placeholder:text-text-secondary focus:outline-none" />
) } // 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 }) { const [filtersOpen, setFiltersOpen] = useState(false) const changedCount = (status !== DEFAULT_STATUS ? 1 : 0) + (sort !== 'name' ? 1 : 0) return (
{SEX_TABS.map((tab) => ( ))}
) } function GridSkeleton() { return (
{Array.from({ length: 8 }).map((_, i) => (
))}
) } function ListSkeleton() { return ( {Array.from({ length: 6 }).map((_, i) => (
))} ) } function RabbitGrid({ rabbits }) { return (
{rabbits.map((rabbit) => ( ))}
) } function RabbitListView({ rabbits }) { const weightUnit = useWeightUnit() return ( {rabbits.map((rabbit, i) => ( ))}
Name Sex Color Age Weight Status
{rabbit.name} {SEX_LABELS[rabbit.sex] ?? rabbit.sex} {rabbit.color_name ?? '—'} {formatAge(rabbit.dob)} {formatWeight(rabbit.weight_lbs, weightUnit) ?? '—'}
) } function Herd() { const [searchParams, setSearchParams] = useSearchParams() const sex = searchParams.get('sex') ?? '' const sort = searchParams.get('sort') ?? 'name' const q = searchParams.get('q') ?? '' const view = searchParams.get('view') ?? 'grid' const status = searchParams.get('status') ?? DEFAULT_STATUS function updateParams(updates) { const next = new URLSearchParams(searchParams) for (const [key, value] of Object.entries(updates)) { if (value) next.set(key, value) else next.delete(key) } setSearchParams(next, { replace: true }) } const apiSort = SORT_OPTIONS.find((option) => option.value === sort)?.apiSort ?? 'name' const isArchived = status === 'archived' // 'archived' (other + deceased) and 'all' aren't single backend status values, // so neither sends a status filter to the API — 'archived' is narrowed // client-side below, 'all' shows everything. const apiStatus = isArchived || status === 'all' ? undefined : status const { data, isLoading, isError, error, refetch } = useQuery({ queryKey: ['rabbits', { sex, search: q, sort: apiSort, status: apiStatus }], queryFn: () => getRabbits({ sex: sex || undefined, search: q || undefined, sort: apiSort, status: apiStatus, }), }) let rabbits = data ?? [] if (isArchived) { rabbits = rabbits.filter((rabbit) => rabbit.status === 'other' || rabbit.status === 'deceased') } if (sort === 'recent') { rabbits = [...rabbits].reverse() } return (
Add Rabbit } /> updateParams({ sex: value })} onStatusChange={(value) => updateParams({ status: value })} onSortChange={(value) => updateParams({ sort: value })} onSearchChange={(value) => updateParams({ q: value })} onViewChange={(value) => updateParams({ view: value })} /> {isError ? ( ) : isLoading ? ( view === 'list' ? : ) : rabbits.length === 0 ? ( Add Rabbit } /> ) : view === 'list' ? ( ) : ( )}
) } export default Herd