FEAT-12a: ForSale scaffolding — status+ForSale, abgabe strings, /abgabe route+nav, sale-ad API stub, detail action, jszip

This commit is contained in:
2026-06-06 01:07:43 +02:00
parent 188b33618e
commit e4b7558abd
9 changed files with 293 additions and 5 deletions

View File

@@ -8,6 +8,7 @@
"name": "gerbil-manager-web",
"version": "0.0.0",
"dependencies": {
"jszip": "^3.10.1",
"react": "^19.2.6",
"react-d3-tree": "^3.6.6",
"react-dom": "^19.2.6",
@@ -15,6 +16,7 @@
},
"devDependencies": {
"@eslint/js": "^10.0.1",
"@playwright/test": "^1.60.0",
"@types/node": "^24.12.3",
"@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3",
@@ -603,6 +605,22 @@
"url": "https://github.com/sponsors/Boshen"
}
},
"node_modules/@playwright/test": {
"version": "1.60.0",
"resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.60.0.tgz",
"integrity": "sha512-O71yZIbAh/PxDMNGns37GHBIfrVkEVyn+AXyIa5dOTfb4/xNvRWV+Vv/NMbNCtODB/pO7vLlF2OTmMVLhmr7Ag==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"playwright": "1.60.0"
},
"bin": {
"playwright": "cli.js"
},
"engines": {
"node": ">=18"
}
},
"node_modules/@rolldown/binding-android-arm64": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.3.tgz",
@@ -1528,6 +1546,12 @@
"url": "https://opencollective.com/express"
}
},
"node_modules/core-util-is": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.3.tgz",
"integrity": "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==",
"license": "MIT"
},
"node_modules/cross-spawn": {
"version": "7.0.6",
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
@@ -2136,6 +2160,12 @@
"node": ">= 4"
}
},
"node_modules/immediate": {
"version": "3.0.6",
"resolved": "https://registry.npmjs.org/immediate/-/immediate-3.0.6.tgz",
"integrity": "sha512-XXOFtyqDjNDAQxVfYxuF7g9Il/IbWmmlQg2MYKOH8ExIT1qg6xc4zyS3HaEEATgs1btfzxq15ciUiY7gjSXRGQ==",
"license": "MIT"
},
"node_modules/imurmurhash": {
"version": "0.1.4",
"resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz",
@@ -2146,6 +2176,12 @@
"node": ">=0.8.19"
}
},
"node_modules/inherits": {
"version": "2.0.4",
"resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz",
"integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==",
"license": "ISC"
},
"node_modules/is-extglob": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz",
@@ -2169,6 +2205,12 @@
"node": ">=0.10.0"
}
},
"node_modules/isarray": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz",
"integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==",
"license": "MIT"
},
"node_modules/isexe": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz",
@@ -2229,6 +2271,18 @@
"node": ">=6"
}
},
"node_modules/jszip": {
"version": "3.10.1",
"resolved": "https://registry.npmjs.org/jszip/-/jszip-3.10.1.tgz",
"integrity": "sha512-xXDvecyTpGLrqFrvkrUSoxxfJI5AH7U8zxxtVclpsUtMCq4JQ290LY8AW5c7Ggnr/Y/oK+bQMbqK2qmtk3pN4g==",
"license": "(MIT OR GPL-3.0-or-later)",
"dependencies": {
"lie": "~3.3.0",
"pako": "~1.0.2",
"readable-stream": "~2.3.6",
"setimmediate": "^1.0.5"
}
},
"node_modules/keyv": {
"version": "4.5.4",
"resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz",
@@ -2253,6 +2307,15 @@
"node": ">= 0.8.0"
}
},
"node_modules/lie": {
"version": "3.3.0",
"resolved": "https://registry.npmjs.org/lie/-/lie-3.3.0.tgz",
"integrity": "sha512-UaiMJzeWRlEujzAuw5LokY1L5ecNQYZKfmyZ9L7wDHb/p5etKaxXhohBcrw0EYby+G/NA52vRSN4N39dxHAIwQ==",
"license": "MIT",
"dependencies": {
"immediate": "~3.0.5"
}
},
"node_modules/lightningcss": {
"version": "1.32.0",
"resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz",
@@ -2694,6 +2757,12 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/pako": {
"version": "1.0.11",
"resolved": "https://registry.npmjs.org/pako/-/pako-1.0.11.tgz",
"integrity": "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==",
"license": "(MIT AND Zlib)"
},
"node_modules/path-exists": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz",
@@ -2741,6 +2810,53 @@
"url": "https://github.com/sponsors/jonschlinkert"
}
},
"node_modules/playwright": {
"version": "1.60.0",
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.60.0.tgz",
"integrity": "sha512-hheHdokM8cdqCb0lcE3s+zT4t4W+vvjpGxsZlDnikarzx8tSzMebh3UiFtgqwFwnTnjYQcsyMF8ei2mCO/tpeA==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"playwright-core": "1.60.0"
},
"bin": {
"playwright": "cli.js"
},
"engines": {
"node": ">=18"
},
"optionalDependencies": {
"fsevents": "2.3.2"
}
},
"node_modules/playwright-core": {
"version": "1.60.0",
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.60.0.tgz",
"integrity": "sha512-9bW6zvX/m0lEbgTKJ6YppOKx8H3VOPBMOCFh2irXFOT4BbHgrx5hPjwJYLT40Lu+4qtD36qKc/Hn56StUW57IA==",
"dev": true,
"license": "Apache-2.0",
"bin": {
"playwright-core": "cli.js"
},
"engines": {
"node": ">=18"
}
},
"node_modules/playwright/node_modules/fsevents": {
"version": "2.3.2",
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
"integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
"dev": true,
"hasInstallScript": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
}
},
"node_modules/postcss": {
"version": "8.5.15",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.15.tgz",
@@ -2780,6 +2896,12 @@
"node": ">= 0.8.0"
}
},
"node_modules/process-nextick-args": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz",
"integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==",
"license": "MIT"
},
"node_modules/prop-types": {
"version": "15.8.1",
"resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz",
@@ -2893,6 +3015,21 @@
"react-dom": ">=18"
}
},
"node_modules/readable-stream": {
"version": "2.3.8",
"resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz",
"integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==",
"license": "MIT",
"dependencies": {
"core-util-is": "~1.0.0",
"inherits": "~2.0.3",
"isarray": "~1.0.0",
"process-nextick-args": "~2.0.0",
"safe-buffer": "~5.1.1",
"string_decoder": "~1.1.1",
"util-deprecate": "~1.0.1"
}
},
"node_modules/rolldown": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.0.3.tgz",
@@ -2927,6 +3064,12 @@
"@rolldown/binding-win32-x64-msvc": "1.0.3"
}
},
"node_modules/safe-buffer": {
"version": "5.1.2",
"resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz",
"integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==",
"license": "MIT"
},
"node_modules/scheduler": {
"version": "0.27.0",
"resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz",
@@ -2949,6 +3092,12 @@
"integrity": "sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==",
"license": "MIT"
},
"node_modules/setimmediate": {
"version": "1.0.5",
"resolved": "https://registry.npmjs.org/setimmediate/-/setimmediate-1.0.5.tgz",
"integrity": "sha512-MATJdZp8sLqDl/68LfQmbP8zKPLQNV6BIZoIgrscFDQ+RsvK/BxeDQOgyxKKoh0y/8h3BqVFnCqQ/gd+reiIXA==",
"license": "MIT"
},
"node_modules/shebang-command": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz",
@@ -3003,6 +3152,15 @@
"dev": true,
"license": "MIT"
},
"node_modules/string_decoder": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz",
"integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==",
"license": "MIT",
"dependencies": {
"safe-buffer": "~5.1.0"
}
},
"node_modules/tinybench": {
"version": "2.9.0",
"resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz",
@@ -3167,6 +3325,12 @@
"punycode": "^2.1.0"
}
},
"node_modules/util-deprecate": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz",
"integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==",
"license": "MIT"
},
"node_modules/uuid": {
"version": "8.3.2",
"resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz",

View File

@@ -12,6 +12,7 @@
"test:watch": "vitest"
},
"dependencies": {
"jszip": "^3.10.1",
"react": "^19.2.6",
"react-d3-tree": "^3.6.6",
"react-dom": "^19.2.6",
@@ -19,6 +20,7 @@
},
"devDependencies": {
"@eslint/js": "^10.0.1",
"@playwright/test": "^1.60.0",
"@types/node": "^24.12.3",
"@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3",

View File

@@ -7,6 +7,7 @@ import GerbilsPage from './pages/GerbilsPage'
import GerbilDetailPage from './pages/GerbilDetailPage'
import GerbilFormPage from './pages/GerbilFormPage'
import GenetikPage from './pages/GenetikPage'
import AbgabePage from './pages/AbgabePage'
import HomePage from './pages/HomePage'
import KontaktePage from './pages/KontaktePage'
import KontaktDetailPage from './pages/KontaktDetailPage'
@@ -51,6 +52,7 @@ export default function App() {
<Route path=":id/bearbeiten" element={<KontaktFormPage />} />
</Route>
<Route path="genetik" element={<GenetikPage />} />
<Route path="abgabe" element={<AbgabePage />} />
<Route path="statistik" element={<StatistikPage />} />
<Route path="*" element={<NotFoundPage />} />
</Route>

View File

@@ -0,0 +1,37 @@
/**
* Sale-ad (Abgabe-Inserat) AI assist — FEAT-12a frontend stub.
*
* The backend endpoint (POST /gerbils/sale-ad, Claude-powered) is FEAT-12a-backend
* and lands only once Julian provides an API key. Until then this call 404s/503s
* and the UI shows a German "API-Schlüssel noch nicht konfiguriert" disabled state.
*
* REQUEST SHAPE (defined here; backend must match):
* {
* animals: [{ name, farbschlag, dateOfBirth, notes }],
* statusLine: string, // e.g. "FREI" / "LOCKER RESERVIERT Anna"
* hints: string // free-text style/wishes from the user
* }
* -> { text: string } // the assembled/improved German listing
*/
import { api } from './client'
export interface SaleAdAnimal {
name: string
farbschlag: string | null
dateOfBirth: string | null
notes: string | null
}
export interface SaleAdRequest {
animals: SaleAdAnimal[]
statusLine: string
hints: string
}
export interface SaleAdResponse {
text: string
}
export function generateSaleAd(request: SaleAdRequest): Promise<SaleAdResponse> {
return api.post<SaleAdResponse>('/gerbils/sale-ad', request)
}

View File

@@ -10,9 +10,9 @@
export type Gender = 'unknown' | 'male' | 'female'
export const GENDERS: Gender[] = ['unknown', 'male', 'female']
/** Gerbil.Status — C# enum { Active, Deceased, GivenAway }. */
export type GerbilStatus = 'Active' | 'Deceased' | 'GivenAway'
export const GERBIL_STATUSES: GerbilStatus[] = ['Active', 'Deceased', 'GivenAway']
/** Gerbil.Status — C# enum { Active, Deceased, GivenAway, ForSale }. */
export type GerbilStatus = 'Active' | 'Deceased' | 'GivenAway' | 'ForSale'
export const GERBIL_STATUSES: GerbilStatus[] = ['Active', 'ForSale', 'Deceased', 'GivenAway']
/** ISO date string "YYYY-MM-DD" (maps to C# DateOnly). */
export type DateOnlyString = string

View File

@@ -21,6 +21,7 @@ const PRIMARY: NavItem[] = [
const SECONDARY: NavItem[] = [
{ to: '/becken', label: de.nav.enclosures, icon: '🛁' },
{ to: '/kontakte', label: de.nav.contacts, icon: '📇' },
{ to: '/abgabe', label: de.nav.forSale, icon: '🏡' },
{ to: '/statistik', label: de.nav.statistics, icon: '📊' },
]

View File

@@ -0,0 +1,11 @@
import { de } from '../strings/de'
export default function AbgabePage() {
// Fleshed out in the next FEAT-12a increments (grouping + listing composer + export).
return (
<section className="page">
<h2>{de.pages.abgabe.title}</h2>
<p className="muted">{de.pages.abgabe.subtitle}</p>
</section>
)
}

View File

@@ -1,9 +1,9 @@
import { useMemo, useState, type ReactNode } from 'react'
import { Link, useParams } from 'react-router-dom'
import { de } from '../strings/de'
import { getGerbil } from '../api/gerbils'
import { getGerbil, updateGerbil } from '../api/gerbils'
import { listColorVarieties, listContacts, listEnclosures, listLitters } from '../api/lookups'
import { useApi } from '../hooks/useApi'
import { useApi, useMutation } from '../hooks/useApi'
import { formatDate, genderLabel, statusLabel } from '../format/labels'
import { fromDisplayString, genotypeToFarbschlag, toDisplayString } from '../genetics'
import FarbschlagImage from '../components/FarbschlagImage'
@@ -42,6 +42,7 @@ export default function GerbilDetailPage() {
const [tab, setTab] = useState<DetailTab>('photos')
const gerbil = useApi(() => getGerbil(id), [id])
const forSale = useMutation(() => updateGerbil(id, { status: 'ForSale' }))
const colorVarieties = useApi(() => listColorVarieties(), [])
const enclosures = useApi(() => listEnclosures(), [])
const contacts = useApi(() => listContacts(), [])
@@ -96,6 +97,19 @@ export default function GerbilDetailPage() {
<Link to={`/genetik?parent=${g.id}`} className="btn">
{t.detail.testMating}
</Link>
{g.status !== 'ForSale' && (
<button
type="button"
className="btn"
disabled={forSale.pending}
onClick={async () => {
const r = await forSale.run()
if (r.ok) gerbil.reload()
}}
>
{de.pages.abgabe.markAction}
</button>
)}
<Link to={`/rennmaeuse/${g.id}/stammbaum`} className="btn">
{de.pages.stammbaum.openButton}
</Link>

View File

@@ -21,6 +21,8 @@ export const de = {
statistics: 'Statistik',
// FEAT-7 (Kevin): „Mehr“-Overflow auf dem Smartphone
more: 'Mehr',
// FEAT-12a (Kevin): Abgabe
forSale: 'Abgabe',
openMenu: 'Menü öffnen',
closeMenu: 'Menü schließen',
mainNavigation: 'Hauptnavigation',
@@ -119,6 +121,7 @@ export const de = {
Active: 'Aktiv',
Deceased: 'Verstorben',
GivenAway: 'Abgegeben',
ForSale: 'Zur Abgabe',
},
},
// ── FEAT-3 (Kevin): Zucht — Würfe + Zuchtpaare ──
@@ -221,6 +224,60 @@ export const de = {
noOffspring: 'Keine lebend geborenen Nachkommen möglich.',
warningsTitle: 'Hinweise',
},
// ── FEAT-12a (Kevin): Abgabe (For-Sale / Inserate) ──
abgabe: {
title: 'Abgabe',
subtitle: 'Abgabetiere gruppieren und Inserate erstellen',
empty: 'Keine Tiere zur Abgabe. Stelle Tiere über „Zur Abgabe stellen“ ein.',
markAction: 'Zur Abgabe stellen',
markMultiAction: 'Ausgewählte zur Abgabe stellen',
grouping: {
title: 'Gruppen',
byEnclosure: 'Nach Becken vorgeschlagen',
ungrouped: 'Ohne Becken',
moveTo: 'Verschieben nach',
newGroup: 'Neue Gruppe',
groupLabel: 'Gruppe',
},
// Inserat-Komposer (Vorlage der Züchterin)
listing: {
headingFor: (n: number, gender: string) => `Gruppe ${n} (${gender})`,
genderMale: 'männlich',
genderFemale: 'weiblich',
genderMixed: 'gemischt',
status: 'Status',
statusFree: 'FREI',
statusLooseReserved: 'LOCKER RESERVIERT',
statusReserved: 'RESERVIERT',
reservedName: 'Name (bei Reservierung)',
tagline: 'Überschrift (emotionaler Einzeiler)',
taglinePlaceholder: 'z. B. „Großer und kleiner Bruder“ Männliches Rennmaus-Duo sucht ein Zuhause',
bornOn: 'geboren am',
personality: 'Charakter',
personalityPlaceholder: 'Ein paar Sätze zum Charakter des Tieres …',
photos: 'Fotos',
noPhotos: 'Für dieses Tier sind keine Fotos hinterlegt.',
},
// Export / Paste-Paket
export: {
copyText: 'Text kopieren',
copied: 'Text in die Zwischenablage kopiert.',
copyFailed: 'Kopieren fehlgeschlagen.',
downloadPhotos: 'Fotos herunterladen',
zipping: 'Fotos werden gepackt …',
noPhotosSelected: 'Keine Fotos ausgewählt.',
zipFailed: 'Fotos konnten nicht gepackt werden.',
finish: 'Abgabe abschließen',
finishHint: 'Vertrag erstellen (folgt in einem späteren Schritt).',
},
// KI-Assistent (Backend folgt mit API-Schlüssel)
ai: {
improve: 'Text mit KI verbessern',
generating: 'KI erstellt den Text …',
hintsLabel: 'Hinweise für die KI (optional)',
notConfigured: 'KI-Funktion: API-Schlüssel noch nicht konfiguriert.',
},
},
// ── FEAT-2 (Oscar): Becken (Enclosures) ──
becken: {
title: 'Becken',