FEAT-12a: Abgabe page — Becken-default grouping + per-group listing composer, clipboard/zip export, AI stub

This commit is contained in:
2026-06-06 07:09:48 +02:00
parent e4b7558abd
commit 44f306d18f
3 changed files with 488 additions and 3 deletions

View File

@@ -1,11 +1,115 @@
import { useMemo, useState } from 'react'
import { de } from '../strings/de'
import { listGerbils } from '../api/gerbils'
import { listColorVarieties } from '../api/lookups'
import { condition } from '../api/gridify'
import type { Gerbil } from '../api/types'
import { useApi } from '../hooks/useApi'
import { fromDisplayString, genotypeToFarbschlag } from '../genetics'
import GroupComposer from '../components/GroupComposer'
import './abgabe.css'
const UNGROUPED = 'ungrouped'
export default function AbgabePage() {
// Fleshed out in the next FEAT-12a increments (grouping + listing composer + export).
const t = de.pages.abgabe
const forSale = useApi(
() =>
listGerbils({
filter: condition({ field: 'status', op: '==', value: 'ForSale' }),
orderBy: 'name',
page: 1,
pageSize: 1000,
}),
[],
)
const colorVarieties = useApi(() => listColorVarieties(), [])
const animals = useMemo(() => forSale.data?.items ?? [], [forSale.data])
const colorName = useMemo(
() => new Map((colorVarieties.data ?? []).map((c) => [c.id, c.name])),
[colorVarieties.data],
)
// Manual group assignment, seeded from Becken (enclosure) occupancy.
const [groupOf, setGroupOf] = useState<Record<string, string>>({})
const assignment = (g: Gerbil): string =>
groupOf[g.id] ?? g.enclosureId ?? UNGROUPED
// Ordered distinct group ids (in animal order) -> stable 1-based numbering.
const groupIds = useMemo(() => {
const seen: string[] = []
for (const a of animals) {
const gid = assignment(a)
if (!seen.includes(gid)) seen.push(gid)
}
return seen
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [animals, groupOf])
const farbschlagOf = (g: Gerbil): string => {
if (g.colorVarietyId && colorName.has(g.colorVarietyId)) return colorName.get(g.colorVarietyId)!
if (g.genotype) {
try {
return genotypeToFarbschlag(fromDisplayString(g.genotype))
} catch {
/* fall through */
}
}
return '—'
}
function moveTo(gerbilId: string, groupId: string) {
setGroupOf((s) => ({ ...s, [gerbilId]: groupId }))
}
if (forSale.loading) return <p className="muted">{de.common.loading}</p>
return (
<section className="page">
<h2>{de.pages.abgabe.title}</h2>
<p className="muted">{de.pages.abgabe.subtitle}</p>
<h2>{t.title}</h2>
<p className="muted">{t.subtitle}</p>
{animals.length === 0 ? (
<p className="muted">{t.empty}</p>
) : (
<>
{/* Regrouping roster */}
<div className="abgabe-roster">
<h3>{t.grouping.title}</h3>
<p className="muted">{t.grouping.byEnclosure}</p>
<ul className="abgabe-roster__list">
{animals.map((a) => (
<li key={a.id}>
<span>{a.name}</span>
<select
value={assignment(a)}
onChange={(e) => moveTo(a.id, e.target.value)}
aria-label={t.grouping.moveTo}
>
{groupIds.map((gid, i) => (
<option key={gid} value={gid}>
{t.grouping.groupLabel} {i + 1}
</option>
))}
<option value={`new-${a.id}`}>{t.grouping.newGroup}</option>
</select>
</li>
))}
</ul>
</div>
{groupIds.map((gid, i) => (
<GroupComposer
key={gid}
groupNumber={i + 1}
animals={animals.filter((a) => assignment(a) === gid)}
farbschlagOf={farbschlagOf}
/>
))}
</>
)}
</section>
)
}

View File

@@ -0,0 +1,105 @@
/* FEAT-12a Abgabe — page-scoped styles (keeps index.css contention-free). */
.abgabe-roster {
border: 1px solid var(--color-border);
border-radius: 0.6rem;
padding: 0.75rem 1rem;
margin: 1rem 0;
background: var(--color-surface);
}
.abgabe-roster__list {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
gap: 0.4rem;
}
.abgabe-roster__list li {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.75rem;
}
.abgabe-roster__list select {
width: auto;
min-width: 9rem;
}
.group-composer {
border: 1px solid var(--color-border);
border-radius: 0.6rem;
padding: 1rem;
margin: 1rem 0;
background: var(--color-surface);
}
.group-composer__controls {
display: flex;
flex-wrap: wrap;
gap: 0.75rem;
align-items: flex-end;
}
.group-composer__animal {
border-top: 1px solid var(--color-border);
padding-top: 0.75rem;
margin-top: 0.75rem;
}
.group-composer__animal-head {
display: flex;
align-items: center;
gap: 0.5rem;
flex-wrap: wrap;
margin-bottom: 0.4rem;
}
.photo-select {
display: flex;
flex-wrap: wrap;
gap: 0.4rem;
margin-top: 0.4rem;
}
.photo-chip {
padding: 0;
border: 2px solid var(--color-border);
border-radius: 0.4rem;
background: none;
cursor: pointer;
opacity: 0.5;
line-height: 0;
}
.photo-chip--on {
border-color: var(--color-accent);
opacity: 1;
}
.photo-chip img {
width: 64px;
height: 64px;
object-fit: cover;
border-radius: 0.25rem;
}
.listing-preview {
white-space: pre-wrap;
background: var(--color-bg);
border: 1px solid var(--color-border);
border-radius: 0.5rem;
padding: 0.75rem;
font-family: inherit;
font-size: 0.9rem;
margin: 0.75rem 0;
}
.group-composer__actions {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
}