FEAT-12a: Abgabe page — Becken-default grouping + per-group listing composer, clipboard/zip export, AI stub
This commit is contained in:
276
gerbil-manager-web/src/components/GroupComposer.tsx
Normal file
276
gerbil-manager-web/src/components/GroupComposer.tsx
Normal file
@@ -0,0 +1,276 @@
|
||||
import { useMemo, useState } from 'react'
|
||||
import JSZip from 'jszip'
|
||||
import { de } from '../strings/de'
|
||||
import type { Gerbil } from '../api/types'
|
||||
import { listGerbilPhotos, photoSrc, type GerbilPhoto } from '../api/photos'
|
||||
import { generateSaleAd } from '../api/saleAd'
|
||||
import { useApi, useMutation } from '../hooks/useApi'
|
||||
import { formatDate, genderLabel } from '../format/labels'
|
||||
import FarbschlagImage from './FarbschlagImage'
|
||||
|
||||
type SaleStatus = 'free' | 'loose' | 'reserved'
|
||||
|
||||
export interface GroupComposerProps {
|
||||
/** 1-based group number for the heading. */
|
||||
groupNumber: number
|
||||
animals: Gerbil[]
|
||||
/** Resolve a gerbil's Farbschlag display name (stored ColorVariety or '—'). */
|
||||
farbschlagOf: (g: Gerbil) => string
|
||||
}
|
||||
|
||||
/** Group gender label for the heading ("männlich"/"weiblich"/"gemischt"). */
|
||||
function groupGender(animals: Gerbil[]): string {
|
||||
const g = de.pages.abgabe.listing
|
||||
const genders = new Set(animals.map((a) => a.gender))
|
||||
if (genders.size === 1 && genders.has('male')) return g.genderMale
|
||||
if (genders.size === 1 && genders.has('female')) return g.genderFemale
|
||||
return g.genderMixed
|
||||
}
|
||||
|
||||
export default function GroupComposer({ groupNumber, animals, farbschlagOf }: GroupComposerProps) {
|
||||
const t = de.pages.abgabe
|
||||
const [status, setStatus] = useState<SaleStatus>('free')
|
||||
const [reservedName, setReservedName] = useState('')
|
||||
const [tagline, setTagline] = useState('')
|
||||
// Per-animal personality text, seeded from notes on first render.
|
||||
const [personality, setPersonality] = useState<Record<string, string>>(() =>
|
||||
Object.fromEntries(animals.map((a) => [a.id, a.notes ?? ''])),
|
||||
)
|
||||
const [selectedPhotos, setSelectedPhotos] = useState<Record<string, boolean>>({})
|
||||
const [hints, setHints] = useState('')
|
||||
const [notice, setNotice] = useState<string | null>(null)
|
||||
const [aiText, setAiText] = useState<string | null>(null)
|
||||
const [zipping, setZipping] = useState(false)
|
||||
|
||||
const animalIds = animals.map((a) => a.id).join(',')
|
||||
// Load photos per animal; tolerate a missing photo endpoint (-> empty).
|
||||
const photoData = useApi(
|
||||
() =>
|
||||
Promise.all(
|
||||
animals.map((a) =>
|
||||
listGerbilPhotos(a.id)
|
||||
.then((photos) => ({ id: a.id, photos }))
|
||||
.catch(() => ({ id: a.id, photos: [] as GerbilPhoto[] })),
|
||||
),
|
||||
),
|
||||
[animalIds],
|
||||
)
|
||||
const photosById = useMemo(() => {
|
||||
const m = new Map<string, GerbilPhoto[]>()
|
||||
for (const e of photoData.data ?? []) m.set(e.id, e.photos)
|
||||
return m
|
||||
}, [photoData.data])
|
||||
|
||||
const ai = useMutation(generateSaleAd)
|
||||
|
||||
const statusLine = (() => {
|
||||
if (status === 'loose') return `${t.listing.statusLooseReserved} ${reservedName}`.trim()
|
||||
if (status === 'reserved') return t.listing.statusReserved
|
||||
return t.listing.statusFree
|
||||
})()
|
||||
|
||||
const listingText = useMemo(() => {
|
||||
const heading = t.listing.headingFor(groupNumber, groupGender(animals))
|
||||
const lines: string[] = [heading, `${t.listing.status}: ${statusLine}`, '']
|
||||
if (tagline.trim()) lines.push(tagline.trim(), '')
|
||||
for (const a of animals) {
|
||||
const fs = farbschlagOf(a)
|
||||
const born = a.dateOfBirth ? `, ${t.listing.bornOn} ${formatDate(a.dateOfBirth)}` : ''
|
||||
lines.push(`${a.name} – ${fs}${born}`)
|
||||
const p = (personality[a.id] ?? '').trim()
|
||||
if (p) lines.push(p)
|
||||
lines.push('')
|
||||
}
|
||||
return lines.join('\n').trimEnd()
|
||||
}, [groupNumber, animals, statusLine, tagline, personality, farbschlagOf, t])
|
||||
|
||||
function togglePhoto(photoId: string) {
|
||||
setSelectedPhotos((s) => ({ ...s, [photoId]: !isSelected(photoId) }))
|
||||
}
|
||||
// Default: a photo is selected unless explicitly toggled off.
|
||||
function isSelected(photoId: string): boolean {
|
||||
return selectedPhotos[photoId] ?? true
|
||||
}
|
||||
|
||||
async function copyText() {
|
||||
setNotice(null)
|
||||
try {
|
||||
await navigator.clipboard.writeText(aiText ?? listingText)
|
||||
setNotice(t.export.copied)
|
||||
} catch {
|
||||
setNotice(t.export.copyFailed)
|
||||
}
|
||||
}
|
||||
|
||||
async function downloadPhotos() {
|
||||
setNotice(null)
|
||||
const zip = new JSZip()
|
||||
const folder = zip.folder(`gruppe-${groupNumber}`)!
|
||||
let count = 0
|
||||
for (const a of animals) {
|
||||
const photos = (photosById.get(a.id) ?? []).filter((p) => isSelected(p.id))
|
||||
let n = 1
|
||||
for (const p of photos) {
|
||||
try {
|
||||
const blob = await fetch(photoSrc(p)).then((r) => (r.ok ? r.blob() : null))
|
||||
if (!blob) continue
|
||||
const ext = (p.fileName.split('.').pop() ?? 'jpg').toLowerCase()
|
||||
folder.file(`${slug(a.name)}-${n}.${ext}`, blob)
|
||||
n += 1
|
||||
count += 1
|
||||
} catch {
|
||||
/* skip unreachable photo */
|
||||
}
|
||||
}
|
||||
}
|
||||
if (count === 0) {
|
||||
setNotice(t.export.noPhotosSelected)
|
||||
return
|
||||
}
|
||||
try {
|
||||
setZipping(true)
|
||||
const out = await zip.generateAsync({ type: 'blob' })
|
||||
triggerDownload(out, `gruppe-${groupNumber}-fotos.zip`)
|
||||
} catch {
|
||||
setNotice(t.export.zipFailed)
|
||||
} finally {
|
||||
setZipping(false)
|
||||
}
|
||||
}
|
||||
|
||||
async function improveWithAi() {
|
||||
setNotice(null)
|
||||
const result = await ai.run({
|
||||
animals: animals.map((a) => ({
|
||||
name: a.name,
|
||||
farbschlag: farbschlagOf(a),
|
||||
dateOfBirth: a.dateOfBirth,
|
||||
notes: personality[a.id] ?? a.notes ?? null,
|
||||
})),
|
||||
statusLine,
|
||||
hints,
|
||||
})
|
||||
if (result.ok) setAiText(result.value.text)
|
||||
// On failure (endpoint not configured yet) the notice below shows the German hint.
|
||||
}
|
||||
|
||||
return (
|
||||
<section className="group-composer">
|
||||
<h3>{t.listing.headingFor(groupNumber, groupGender(animals))}</h3>
|
||||
|
||||
<div className="group-composer__controls">
|
||||
<label className="field">
|
||||
<span>{t.listing.status}</span>
|
||||
<select value={status} onChange={(e) => setStatus(e.target.value as SaleStatus)}>
|
||||
<option value="free">{t.listing.statusFree}</option>
|
||||
<option value="loose">{t.listing.statusLooseReserved}</option>
|
||||
<option value="reserved">{t.listing.statusReserved}</option>
|
||||
</select>
|
||||
</label>
|
||||
{status === 'loose' && (
|
||||
<label className="field">
|
||||
<span>{t.listing.reservedName}</span>
|
||||
<input
|
||||
className="input"
|
||||
value={reservedName}
|
||||
onChange={(e) => setReservedName(e.target.value)}
|
||||
/>
|
||||
</label>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<label className="field">
|
||||
<span>{t.listing.tagline}</span>
|
||||
<input
|
||||
className="input"
|
||||
value={tagline}
|
||||
placeholder={t.listing.taglinePlaceholder}
|
||||
onChange={(e) => setTagline(e.target.value)}
|
||||
/>
|
||||
</label>
|
||||
|
||||
{animals.map((a) => (
|
||||
<div key={a.id} className="group-composer__animal">
|
||||
<div className="group-composer__animal-head">
|
||||
<FarbschlagImage name={farbschlagOf(a)} size={36} />
|
||||
<strong>{a.name}</strong>
|
||||
<span className="muted">
|
||||
{farbschlagOf(a)} · {genderLabel(a.gender)}
|
||||
{a.dateOfBirth ? ` · ${t.listing.bornOn} ${formatDate(a.dateOfBirth)}` : ''}
|
||||
</span>
|
||||
</div>
|
||||
<label className="field">
|
||||
<span>{t.listing.personality}</span>
|
||||
<textarea
|
||||
value={personality[a.id] ?? ''}
|
||||
placeholder={t.listing.personalityPlaceholder}
|
||||
onChange={(e) => setPersonality((s) => ({ ...s, [a.id]: e.target.value }))}
|
||||
/>
|
||||
</label>
|
||||
<div className="photo-select">
|
||||
{(photosById.get(a.id) ?? []).length === 0 ? (
|
||||
<small className="muted">{t.listing.noPhotos}</small>
|
||||
) : (
|
||||
(photosById.get(a.id) ?? []).map((p) => (
|
||||
<button
|
||||
key={p.id}
|
||||
type="button"
|
||||
className={isSelected(p.id) ? 'photo-chip photo-chip--on' : 'photo-chip'}
|
||||
onClick={() => togglePhoto(p.id)}
|
||||
aria-pressed={isSelected(p.id)}
|
||||
>
|
||||
<img src={photoSrc(p)} alt={p.caption ?? a.name} loading="lazy" />
|
||||
</button>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
||||
<label className="field">
|
||||
<span>{t.ai.hintsLabel}</span>
|
||||
<input className="input" value={hints} onChange={(e) => setHints(e.target.value)} />
|
||||
</label>
|
||||
|
||||
<pre className="listing-preview">{aiText ?? listingText}</pre>
|
||||
|
||||
<div className="group-composer__actions">
|
||||
<button type="button" className="btn btn--primary" onClick={copyText}>
|
||||
{t.export.copyText}
|
||||
</button>
|
||||
<button type="button" className="btn" disabled={zipping} onClick={downloadPhotos}>
|
||||
{zipping ? t.export.zipping : t.export.downloadPhotos}
|
||||
</button>
|
||||
<button type="button" className="btn" disabled={ai.pending} onClick={improveWithAi}>
|
||||
{ai.pending ? t.ai.generating : t.ai.improve}
|
||||
</button>
|
||||
<button type="button" className="btn" title={t.export.finishHint} disabled>
|
||||
{t.export.finish}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{ai.error && <div className="alert alert--warning">{t.ai.notConfigured}</div>}
|
||||
{notice && <div className="alert">{notice}</div>}
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
function slug(name: string): string {
|
||||
return name
|
||||
.toLowerCase()
|
||||
.replace(/[äöü]/g, (c) => ({ ä: 'ae', ö: 'oe', ü: 'ue' })[c] ?? c)
|
||||
.replace(/ß/g, 'ss')
|
||||
.replace(/[^a-z0-9]+/g, '-')
|
||||
.replace(/(^-|-$)/g, '')
|
||||
}
|
||||
|
||||
function triggerDownload(blob: Blob, filename: string) {
|
||||
const url = URL.createObjectURL(blob)
|
||||
const a = document.createElement('a')
|
||||
a.href = url
|
||||
a.download = filename
|
||||
document.body.appendChild(a)
|
||||
a.click()
|
||||
a.remove()
|
||||
URL.revokeObjectURL(url)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
105
gerbil-manager-web/src/pages/abgabe.css
Normal file
105
gerbil-manager-web/src/pages/abgabe.css
Normal 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;
|
||||
}
|
||||
Reference in New Issue
Block a user