MyKindlebox/mykindlebox/client/src/pages/Herd.jsx

372 lines
13 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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