Files
GerbilManager/gerbil-manager-web/src/components/GroupComposer.tsx
Gulum b83e96f552 feat: Rennmausakte, Zucht-Suffix, Gehege-Bilder, Toasts, Infinite-Scroll
Detailseite (Rennmausakte):
- Neugestaltung: Hero-Foto + Overlay, Schnellfakten-Pills, Karten-Sektionen,
  Charakter als Chips, getabte Fotos/Gesundheit/Gewicht.
- Eltern (Vater/Mutter) als Links; Charakter-Karte klappt bei Status
  Abgabe/Verstorben/Abgegeben ein (nur Zucht/Liebhaber offen).
- Gehege wird bei abgegebenen/verstorbenen Tieren ausgeblendet (Detail + Formular).

Listen:
- Infinite Scroll auf allen Listen (Rennmäuse, Würfe, Gehege, Verträge,
  Anfragen, Kontakte) via useInfiniteList/useInfiniteSentinel; stabile
  Sortierung mit id-Tiebreaker (keine doppelten Keys), Back-to-top-Button.
- Kontakte: Rolle-Filter (Züchter/Abnehmer) als Quick-Chips + Sticky-Header.

Zucht-Nachname:
- Namens-Anhängsel der Zucht in den Einstellungen + je Züchter-Kontakt
  (Backend-Spalten + Migration); eigene Tiere zeigen „Name + Suffix".
- „Eigene Zucht" ist die Standard-Herkunft neuer Tiere.

Weiteres:
- Gehege-Bilder: Upload/Galerie auf der Gehege-Detailseite (Backend
  EnclosurePhoto + Endpoints + Migration, geteilte Dateiablage).
- Toast-Rückmeldungen für alle Speichern-Aktionen.
- Checkboxen durch mobile-freundliche Toggle-Schalter ersetzt.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-13 13:35:30 +02:00

293 lines
10 KiB
TypeScript
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 { useMemo, useState } from 'react'
import { useNavigate } from 'react-router-dom'
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 { traitLabels } from '../format/traits'
import FarbschlagImage from './FarbschlagImage'
import Charakterbogen from './Charakterbogen'
import { useGerbilName } from './breederSuffix'
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 gerbilName = useGerbilName()
const navigate = useNavigate()
const [status, setStatus] = useState<SaleStatus>('free')
const [reservedName, setReservedName] = useState('')
const [tagline, setTagline] = useState('')
// Per-animal character note, seeded from the persisted characterNote (or notes).
const [personality, setPersonality] = useState<Record<string, string>>(() =>
Object.fromEntries(animals.map((a) => [a.id, a.characterNote ?? a.notes ?? ''])),
)
// Per-animal selected trait keys, seeded from the persisted Charakterbogen.
const [traitsByAnimal, setTraitsByAnimal] = useState<Record<string, string[]>>(() =>
Object.fromEntries(animals.map((a) => [a.id, a.characterTraits ?? []])),
)
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: a.notes ?? null,
traits: traitLabels(traitsByAnimal[a.id] ?? []),
characterNote: personality[a.id] ?? 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>{gerbilName(a) || a.name}</strong>
<span className="muted">
{farbschlagOf(a)} · {genderLabel(a.gender)}
{a.dateOfBirth ? ` · ${t.listing.bornOn} ${formatDate(a.dateOfBirth)}` : ''}
</span>
</div>
<Charakterbogen
traits={traitsByAnimal[a.id] ?? []}
note={personality[a.id] ?? ''}
onTraitsChange={(tr) => setTraitsByAnimal((s) => ({ ...s, [a.id]: tr }))}
onNoteChange={(v) => setPersonality((s) => ({ ...s, [a.id]: v }))}
/>
<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"
onClick={() =>
navigate(`/vertraege/neu?tiere=${animals.map((a) => a.id).join(',')}`)
}
>
{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)
}