372 lines
13 KiB
JavaScript
372 lines
13 KiB
JavaScript
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 (
|
||
<svg viewBox="0 0 20 20" className="h-4 w-4 text-text-secondary" fill="none" stroke="currentColor" strokeWidth="1.5">
|
||
<circle cx="9" cy="9" r="6" />
|
||
<path d="M17.5 17.5 13.5 13.5" strokeLinecap="round" />
|
||
</svg>
|
||
)
|
||
}
|
||
|
||
function GridIcon() {
|
||
return (
|
||
<svg viewBox="0 0 20 20" className="h-4 w-4" fill="currentColor">
|
||
<rect x="2" y="2" width="7" height="7" rx="1" />
|
||
<rect x="11" y="2" width="7" height="7" rx="1" />
|
||
<rect x="2" y="11" width="7" height="7" rx="1" />
|
||
<rect x="11" y="11" width="7" height="7" rx="1" />
|
||
</svg>
|
||
)
|
||
}
|
||
|
||
function ListIcon() {
|
||
return (
|
||
<svg viewBox="0 0 20 20" className="h-4 w-4" fill="currentColor">
|
||
<rect x="2" y="3" width="16" height="3" rx="1" />
|
||
<rect x="2" y="8.5" width="16" height="3" rx="1" />
|
||
<rect x="2" y="14" width="16" height="3" rx="1" />
|
||
</svg>
|
||
)
|
||
}
|
||
|
||
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 (
|
||
<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 />
|
||
<input
|
||
type="text"
|
||
value={searchInput}
|
||
onChange={(e) => 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"
|
||
/>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
// 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 (
|
||
<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">
|
||
{SEX_TABS.map((tab) => (
|
||
<button
|
||
key={tab.value}
|
||
type="button"
|
||
onClick={() => onSexChange(tab.value)}
|
||
className={`min-h-[44px] md:min-h-[32px] rounded-md px-3 py-1.5 font-body text-sm transition-colors ${
|
||
sex === tab.value
|
||
? 'bg-accent text-surface'
|
||
: 'text-text-primary hover:bg-surface-sunken'
|
||
}`}
|
||
>
|
||
{tab.label}
|
||
</button>
|
||
))}
|
||
</div>
|
||
|
||
<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} />
|
||
<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>
|
||
|
||
<div className={`${filtersOpen ? 'flex' : 'hidden'} w-full flex-wrap items-center gap-3 md:flex md:w-auto`}>
|
||
<Select
|
||
size="sm"
|
||
value={status}
|
||
onChange={(e) => onStatusChange(e.target.value)}
|
||
aria-label="Filter by status"
|
||
className=""
|
||
>
|
||
{STATUS_OPTIONS.map((option) => (
|
||
<option key={option.value} value={option.value}>
|
||
Status: {option.label}
|
||
</option>
|
||
))}
|
||
</Select>
|
||
|
||
<Select size="sm" value={sort} onChange={(e) => onSortChange(e.target.value)} aria-label="Sort by" className="">
|
||
{SORT_OPTIONS.map((option) => (
|
||
<option key={option.value} value={option.value}>
|
||
Sort: {option.label}
|
||
</option>
|
||
))}
|
||
</Select>
|
||
|
||
<div className="flex gap-1 rounded-md border border-border bg-surface-raised p-1">
|
||
<button
|
||
type="button"
|
||
aria-label="Grid view"
|
||
onClick={() => onViewChange('grid')}
|
||
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'
|
||
}`}
|
||
>
|
||
<GridIcon />
|
||
</button>
|
||
<button
|
||
type="button"
|
||
aria-label="List view"
|
||
onClick={() => onViewChange('list')}
|
||
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'
|
||
}`}
|
||
>
|
||
<ListIcon />
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
function GridSkeleton() {
|
||
return (
|
||
<div className="grid grid-cols-2 gap-4 md:grid-cols-3 lg:grid-cols-4">
|
||
{Array.from({ length: 8 }).map((_, i) => (
|
||
<Card key={i} className="overflow-hidden">
|
||
<div className="aspect-square animate-pulse bg-surface-sunken" />
|
||
<div className="flex flex-col gap-2 p-3">
|
||
<div className="h-4 w-3/4 animate-pulse rounded bg-surface-sunken" />
|
||
<div className="h-3 w-1/2 animate-pulse rounded bg-surface-sunken" />
|
||
</div>
|
||
</Card>
|
||
))}
|
||
</div>
|
||
)
|
||
}
|
||
|
||
function ListSkeleton() {
|
||
return (
|
||
<Card className="divide-y divide-border overflow-hidden">
|
||
{Array.from({ length: 6 }).map((_, i) => (
|
||
<div key={i} className="px-4 py-3">
|
||
<div className="h-4 w-full animate-pulse rounded bg-surface-sunken" />
|
||
</div>
|
||
))}
|
||
</Card>
|
||
)
|
||
}
|
||
|
||
function RabbitGrid({ rabbits }) {
|
||
return (
|
||
<div className="grid grid-cols-2 gap-4 md:grid-cols-3 lg:grid-cols-4">
|
||
{rabbits.map((rabbit) => (
|
||
<RabbitCard key={rabbit.id} rabbit={rabbit} />
|
||
))}
|
||
</div>
|
||
)
|
||
}
|
||
|
||
function RabbitListView({ rabbits }) {
|
||
const weightUnit = useWeightUnit()
|
||
return (
|
||
<Card className="overflow-x-auto">
|
||
<table className="w-full font-body text-sm">
|
||
<thead>
|
||
<tr className="border-b border-border text-left text-text-secondary">
|
||
<th className="px-4 py-2 font-medium">Name</th>
|
||
<th className="px-4 py-2 font-medium">Sex</th>
|
||
<th className="px-4 py-2 font-medium">Color</th>
|
||
<th className="px-4 py-2 font-medium">Age</th>
|
||
<th className="px-4 py-2 font-medium">Weight</th>
|
||
<th className="px-4 py-2 font-medium">Status</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
{rabbits.map((rabbit, i) => (
|
||
<tr
|
||
key={rabbit.id}
|
||
className={i % 2 === 1 ? 'bg-surface-sunken' : ''}
|
||
>
|
||
<td className="p-0">
|
||
<Link
|
||
to={`/herd/${rabbit.id}`}
|
||
className="block px-4 py-3 font-medium text-text-primary hover:underline"
|
||
>
|
||
{rabbit.name}
|
||
</Link>
|
||
</td>
|
||
<td className="px-4 py-3 text-text-primary">{SEX_LABELS[rabbit.sex] ?? rabbit.sex}</td>
|
||
<td className="px-4 py-3 text-text-primary">{rabbit.color_name ?? '—'}</td>
|
||
<td className="px-4 py-3 text-text-primary">{formatAge(rabbit.dob)}</td>
|
||
<td className="px-4 py-3 text-text-primary">{formatWeight(rabbit.weight_lbs, weightUnit) ?? '—'}</td>
|
||
<td className="px-4 py-3">
|
||
<Badge status={rabbit.status} />
|
||
</td>
|
||
</tr>
|
||
))}
|
||
</tbody>
|
||
</table>
|
||
</Card>
|
||
)
|
||
}
|
||
|
||
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 (
|
||
<div className="flex flex-col gap-6">
|
||
<PageHeader
|
||
title="Herd"
|
||
action={
|
||
<Button to="/herd/add" variant="primary">Add Rabbit</Button>
|
||
}
|
||
/>
|
||
|
||
<FilterBar
|
||
sex={sex}
|
||
status={status}
|
||
sort={sort}
|
||
q={q}
|
||
view={view}
|
||
onSexChange={(value) => updateParams({ sex: value })}
|
||
onStatusChange={(value) => updateParams({ status: value })}
|
||
onSortChange={(value) => updateParams({ sort: value })}
|
||
onSearchChange={(value) => updateParams({ q: value })}
|
||
onViewChange={(value) => updateParams({ view: value })}
|
||
/>
|
||
|
||
{isError ? (
|
||
<ErrorPanel title="Couldn’t load your herd" error={error} onRetry={refetch} />
|
||
) : isLoading ? (
|
||
view === 'list' ? <ListSkeleton /> : <GridSkeleton />
|
||
) : rabbits.length === 0 ? (
|
||
<Card>
|
||
<EmptyState
|
||
title="No rabbits found"
|
||
description="Try adjusting your filters or add a new rabbit to get started."
|
||
action={
|
||
<Button to="/herd/add" variant="primary">Add Rabbit</Button>
|
||
}
|
||
/>
|
||
</Card>
|
||
) : view === 'list' ? (
|
||
<RabbitListView rabbits={rabbits} />
|
||
) : (
|
||
<RabbitGrid rabbits={rabbits} />
|
||
)}
|
||
</div>
|
||
)
|
||
}
|
||
|
||
export default Herd
|