Compare commits
8 Commits
feature/ge
...
feature/ca
| Author | SHA1 | Date | |
|---|---|---|---|
| 30ee7b6198 | |||
| 29207e41da | |||
| 7fe538dd5d | |||
| 90f998d189 | |||
| d5f4be76be | |||
| 433f9ee592 | |||
| 92dd614269 | |||
| 097a04cbd8 |
@@ -40,6 +40,25 @@ npm run build
|
||||
npm run preview
|
||||
```
|
||||
|
||||
## Farbschlag-Katalog (AR-5)
|
||||
|
||||
Der Katalog lebt in `src/genetics/catalog.ts` (Single Source of Truth).
|
||||
Nach jeder Änderung dort den Generator laufen lassen:
|
||||
|
||||
```bash
|
||||
npm run gen:catalog
|
||||
```
|
||||
|
||||
Erzeugt zwei Artefakte und committet beide:
|
||||
|
||||
| Datei | Notation | Verwendung |
|
||||
|---|---|---|
|
||||
| `src/genetics/colorVarietySeed.generated.json` | Klammer (`e[f]`, `c[chm]`) | UI-Dropdowns, Frontend-Suche |
|
||||
| `src/genetics/colorVarietySeed.backend.json` | Frozen symbols (`ef`, `cchm`) | EF-Seed-Migrationen (Pam, DATA-Lane) |
|
||||
|
||||
Der vitest-Drift-Guard (`catalog-drift.test.ts`) schlägt fehl, wenn
|
||||
`generated.json` nach einer Katalog-Änderung nicht aktualisiert wurde.
|
||||
|
||||
## E2E-Tests (QA-1, Playwright)
|
||||
|
||||
```bash
|
||||
@@ -56,4 +75,4 @@ npx playwright test --project=phone # nur Smartphone-Viewport (390px)
|
||||
überspringen sich selbst; die übrigen legen eigene Datensätze an.
|
||||
- Beide Viewports (Smartphone 390px, Laptop 1280px) laufen für jede Spec;
|
||||
alle Assertions prüfen die deutschen Oberflächentexte direkt aus
|
||||
`src/strings/de.ts`.
|
||||
`src/strings/de.ts`.
|
||||
|
||||
@@ -361,6 +361,26 @@ export async function installMockApi(page: Page): Promise<MockDb> {
|
||||
return json(route, 201, contract)
|
||||
}
|
||||
|
||||
// FEAT-NAMEGEN: /names/suggest
|
||||
if (path === '/names/suggest' && method === 'GET') {
|
||||
if (!db.namesConfigured) {
|
||||
return json(route, 503, { code: 'NamesKeyMissing', message: 'Kein API-Key konfiguriert' })
|
||||
}
|
||||
const letter = url.searchParams.get('letter')?.toUpperCase()
|
||||
const allSuggestions = [
|
||||
{ name: 'Fenrir', meaning: 'Wolf aus der Nordischen Mythologie', origin: 'Nordisch' },
|
||||
{ name: 'Freya', meaning: 'Göttin der Liebe und Fruchtbarkeit', origin: 'Nordisch' },
|
||||
{ name: 'Artemis', meaning: 'Göttin der Jagd und des Mondlichts', origin: 'Griech. Mythologie' },
|
||||
{ name: 'Kira', meaning: 'Strahlendes Licht', origin: 'Japanisch' },
|
||||
{ name: 'Luna', meaning: 'Mondgöttin', origin: 'Griech. Mythologie' },
|
||||
{ name: 'Baldur', meaning: 'Gott des Lichts und der Reinheit', origin: 'Nordisch' },
|
||||
]
|
||||
const result = letter
|
||||
? allSuggestions.filter((s) => s.name.startsWith(letter))
|
||||
: allSuggestions
|
||||
return json(route, 200, result)
|
||||
}
|
||||
|
||||
// Generische Kollektionen: /<resource> und /<resource>/<id>
|
||||
m = path.match(/^\/([a-z-]+)(?:\/([^/]+))?$/)
|
||||
const col = m ? collections[m[1]] : undefined
|
||||
|
||||
@@ -74,6 +74,8 @@ export interface MockDb {
|
||||
// ABGABE: Verträge + KI-Inserat-Flag
|
||||
contracts: MockContract[]
|
||||
saleAdConfigured: boolean
|
||||
// FEAT-NAMEGEN: Namensvorschläge — false = 503 NamesKeyMissing simulieren
|
||||
namesConfigured: boolean
|
||||
}
|
||||
|
||||
function gerbil(
|
||||
@@ -292,5 +294,6 @@ export function seedDb(): MockDb {
|
||||
mailConfigured: true,
|
||||
contracts: [],
|
||||
saleAdConfigured: true,
|
||||
namesConfigured: true,
|
||||
}
|
||||
}
|
||||
|
||||
59
gerbil-manager-web/e2e/namegen.spec.ts
Normal file
59
gerbil-manager-web/e2e/namegen.spec.ts
Normal file
@@ -0,0 +1,59 @@
|
||||
/** FEAT-NAMEGEN: UC-1 — 'Name vorschlagen'-Panel auf der GerbilFormPage. */
|
||||
import { de, expect, skipUnlessMock, test } from './fixtures'
|
||||
|
||||
const t = de.namegen
|
||||
const tf = de.pages.gerbils.form
|
||||
|
||||
test('Name-vorschlagen-Panel öffnet sich und zeigt Vorschläge (FEAT-NAMEGEN)', async ({ page }) => {
|
||||
skipUnlessMock()
|
||||
await page.goto('/rennmaeuse/neu')
|
||||
await expect(page.getByRole('heading', { name: tf.createTitle })).toBeVisible()
|
||||
|
||||
// Panel öffnen
|
||||
await page.getByRole('button', { name: t.button }).click()
|
||||
await expect(page.getByText(t.panelTitle)).toBeVisible()
|
||||
|
||||
// Vorschläge laden
|
||||
await page.getByRole('button', { name: t.loadButton }).click()
|
||||
// Fenrir ist im Mock immer dabei (kein Buchstabe-Filter)
|
||||
await expect(page.getByRole('button', { name: 'Fenrir' })).toBeVisible()
|
||||
await expect(page.getByText('Wolf aus der Nordischen Mythologie')).toBeVisible()
|
||||
})
|
||||
|
||||
test('Klick auf Vorschlag befüllt Namensfeld und schließt Panel (FEAT-NAMEGEN)', async ({ page }) => {
|
||||
skipUnlessMock()
|
||||
await page.goto('/rennmaeuse/neu')
|
||||
await page.getByRole('button', { name: t.button }).click()
|
||||
await page.getByRole('button', { name: t.loadButton }).click()
|
||||
await expect(page.getByRole('button', { name: 'Fenrir' })).toBeVisible()
|
||||
|
||||
// Klick auf Vorschlag 'Fenrir'
|
||||
await page.getByRole('button', { name: 'Fenrir' }).click()
|
||||
|
||||
// Panel geschlossen, Name-Feld befüllt
|
||||
await expect(page.getByText(t.panelTitle)).toBeHidden()
|
||||
await expect(page.getByLabel(`${de.pages.gerbils.fields.name} *`)).toHaveValue('Fenrir')
|
||||
})
|
||||
|
||||
test('Buchstabe-Filter schränkt Vorschläge ein (FEAT-NAMEGEN)', async ({ page }) => {
|
||||
skipUnlessMock()
|
||||
await page.goto('/rennmaeuse/neu')
|
||||
await page.getByRole('button', { name: t.button }).click()
|
||||
await page.getByLabel(t.letterLabel).fill('F')
|
||||
await page.getByRole('button', { name: t.loadButton }).click()
|
||||
|
||||
// Mock gibt nur Namen mit F zurück: Fenrir + Freya
|
||||
await expect(page.getByRole('button', { name: 'Fenrir' })).toBeVisible()
|
||||
await expect(page.getByRole('button', { name: 'Freya' })).toBeVisible()
|
||||
// Artemis (A) nicht sichtbar
|
||||
await expect(page.getByRole('button', { name: 'Artemis' })).toBeHidden()
|
||||
})
|
||||
|
||||
test('503 NamesKeyMissing zeigt freundlichen Hinweis (FEAT-NAMEGEN)', async ({ page, mockDb }) => {
|
||||
skipUnlessMock()
|
||||
if (mockDb) mockDb.namesConfigured = false
|
||||
await page.goto('/rennmaeuse/neu')
|
||||
await page.getByRole('button', { name: t.button }).click()
|
||||
await page.getByRole('button', { name: t.loadButton }).click()
|
||||
await expect(page.getByText(t.keyMissing)).toBeVisible()
|
||||
})
|
||||
@@ -172,3 +172,27 @@ test('Tier bearbeiten — isDeaf Tristate round-trip (FORM-FIELDS-1)', async ({
|
||||
await page.goto('/rennmaeuse/kruemel/bearbeiten')
|
||||
await expect(page.getByLabel(t.fields.isDeaf)).toHaveValue('true')
|
||||
})
|
||||
|
||||
test('Detailseite: Würfe als Elternteil zeigt verlinkten Wurf (ANIMAL-LITTERS)', async ({ page }) => {
|
||||
skipUnlessMock()
|
||||
// Fridolin ist fatherId von 'w-kruemel' (Wurf K)
|
||||
await page.goto('/rennmaeuse/fridolin')
|
||||
await expect(page.getByRole('heading', { name: 'Fridolin' })).toBeVisible()
|
||||
await expect(page.getByText(t.detail.parentLittersTitle)).toBeVisible()
|
||||
// Wurf K als Link sichtbar
|
||||
const litterLink = page.getByRole('link', { name: /Wurf K/ })
|
||||
await expect(litterLink).toBeVisible()
|
||||
// Vater-Badge sichtbar
|
||||
await expect(page.getByText(t.detail.parentLittersRoleVater)).toBeVisible()
|
||||
// Navigation zum Wurf funktioniert
|
||||
await litterLink.first().click()
|
||||
await expect(page.getByRole('heading', { name: 'Wurf K' })).toBeVisible()
|
||||
})
|
||||
|
||||
test('Detailseite: Keine Würfe als Elternteil zeigt Leer-Zustand (ANIMAL-LITTERS)', async ({ page }) => {
|
||||
skipUnlessMock()
|
||||
// Krümel hat keine Würfe als Elternteil (nur als Kind von w-kruemel)
|
||||
await page.goto('/rennmaeuse/kruemel')
|
||||
await expect(page.getByText(t.detail.parentLittersTitle)).toBeVisible()
|
||||
await expect(page.getByText(t.detail.parentLittersEmpty)).toBeVisible()
|
||||
})
|
||||
|
||||
49
gerbil-manager-web/gen-seed.mts
Normal file
49
gerbil-manager-web/gen-seed.mts
Normal file
@@ -0,0 +1,49 @@
|
||||
/**
|
||||
* Katalog-Generator — AR-5
|
||||
*
|
||||
* Erzeugt zwei Artefakte aus catalog.ts (Single Source of Truth):
|
||||
*
|
||||
* colorVarietySeed.generated.json — Display-Notation (Klammer: e[f]/c[chm]/c[h])
|
||||
* → Quelle für UI-Dropdowns, Frontend-Suche.
|
||||
*
|
||||
* colorVarietySeed.backend.json — Frozen internal symbols (ef/cchm/ch)
|
||||
* → Quelle für künftige EF-Seed-Migrationen (Pam).
|
||||
* NICHT in Bracket-Notation ändern — Backend-Parser
|
||||
* erwartet frozen symbols (CR-11-Matcher-Guardrail).
|
||||
*
|
||||
* Ausführen nach jeder Änderung an catalog.ts:
|
||||
* npm run gen:catalog
|
||||
*
|
||||
* Der vitest-Drift-Guard (catalog-drift.test.ts) schlägt fehl, wenn
|
||||
* generated.json veraltet ist — Fehler macht den fehlenden Generator-Lauf sichtbar.
|
||||
*/
|
||||
import { BASE_COLORS, CATALOG, representativeGenotype } from './src/genetics/catalog.ts'
|
||||
import { LOCUS_ORDER } from './src/genetics/loci.ts'
|
||||
import type { Genotype } from './src/genetics/genotype.ts'
|
||||
import { writeFileSync } from 'fs'
|
||||
|
||||
/** Internal (frozen) display string — concatenates canonical allele symbols without bracket mapping. */
|
||||
function toInternalString(g: Genotype): string {
|
||||
return LOCUS_ORDER.filter(
|
||||
(locus) => locus !== 'Sls' || !(g.Sls[0] === 'sl' && g.Sls[1] === 'sl'),
|
||||
)
|
||||
.map((locus) => (g[locus][0] + g[locus][1]).replace(/\?/g, '-'))
|
||||
.join(' ')
|
||||
}
|
||||
|
||||
// ── Display artefact (bracket notation) ─────────────────────────────────────
|
||||
const displayPath = './src/genetics/colorVarietySeed.generated.json'
|
||||
writeFileSync(displayPath, JSON.stringify(CATALOG, null, 2) + '\n')
|
||||
console.log(`[gen:catalog] display → ${displayPath} (${CATALOG.length} rows)`)
|
||||
|
||||
// ── Backend artefact (frozen internal symbols) ───────────────────────────────
|
||||
const backendSeed = BASE_COLORS.map((entry, i) => ({
|
||||
name: entry.name,
|
||||
...(entry.english !== undefined ? { english: entry.english } : {}),
|
||||
canonicalGenotype: toInternalString(representativeGenotype(entry)),
|
||||
sortOrder: i,
|
||||
...(entry.image !== undefined ? { image: entry.image } : {}),
|
||||
}))
|
||||
const backendPath = './src/genetics/colorVarietySeed.backend.json'
|
||||
writeFileSync(backendPath, JSON.stringify(backendSeed, null, 2) + '\n')
|
||||
console.log(`[gen:catalog] backend → ${backendPath} (${backendSeed.length} rows)`)
|
||||
@@ -10,7 +10,8 @@
|
||||
"preview": "vite preview",
|
||||
"test": "vitest run",
|
||||
"test:watch": "vitest",
|
||||
"e2e": "playwright test"
|
||||
"e2e": "playwright test",
|
||||
"gen:catalog": "npx tsx gen-seed.mts"
|
||||
},
|
||||
"dependencies": {
|
||||
"jszip": "^3.10.1",
|
||||
|
||||
73
gerbil-manager-web/src/api/__tests__/names.test.ts
Normal file
73
gerbil-manager-web/src/api/__tests__/names.test.ts
Normal file
@@ -0,0 +1,73 @@
|
||||
/** FEAT-NAMEGEN: Tests für buildSuggestPath + NAMEGEN_USAGES. */
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { buildSuggestPath, NAMEGEN_USAGES } from '../names'
|
||||
|
||||
describe('buildSuggestPath', () => {
|
||||
it('includes uppercased letter', () => {
|
||||
const path = buildSuggestPath({ letter: 'f', gender: 'female', usages: ['norn'] })
|
||||
expect(path).toContain('letter=F')
|
||||
})
|
||||
|
||||
it('omits letter when blank', () => {
|
||||
const path = buildSuggestPath({ letter: '', usages: ['norn'] })
|
||||
expect(path).not.toContain('letter=')
|
||||
})
|
||||
|
||||
it('omits letter when only whitespace', () => {
|
||||
const path = buildSuggestPath({ letter: ' ', usages: ['norn'] })
|
||||
expect(path).not.toContain('letter=')
|
||||
})
|
||||
|
||||
it('includes gender', () => {
|
||||
const path = buildSuggestPath({ gender: 'male', usages: ['norn'] })
|
||||
expect(path).toContain('gender=male')
|
||||
})
|
||||
|
||||
it('omits gender for empty string', () => {
|
||||
const path = buildSuggestPath({ gender: '', usages: ['norn'] })
|
||||
expect(path).not.toContain('gender=')
|
||||
})
|
||||
|
||||
it('joins multiple usages without encoding commas', () => {
|
||||
const path = buildSuggestPath({ usages: ['norn', 'mythg'] })
|
||||
expect(path).toContain('usages=norn,mythg')
|
||||
})
|
||||
|
||||
it('omits usages when array is empty', () => {
|
||||
const path = buildSuggestPath({ usages: [] })
|
||||
expect(path).not.toContain('usages=')
|
||||
})
|
||||
|
||||
it('defaults count to 6', () => {
|
||||
const path = buildSuggestPath({ usages: ['norn'] })
|
||||
expect(path).toContain('count=6')
|
||||
})
|
||||
|
||||
it('uses provided count', () => {
|
||||
const path = buildSuggestPath({ usages: ['norn'], count: 8 })
|
||||
expect(path).toContain('count=8')
|
||||
})
|
||||
|
||||
it('starts with /names/suggest', () => {
|
||||
const path = buildSuggestPath({ usages: ['norn'] })
|
||||
expect(path).toMatch(/^\/names\/suggest\?/)
|
||||
})
|
||||
})
|
||||
|
||||
describe('NAMEGEN_USAGES', () => {
|
||||
it('contains all 5 expected culture codes', () => {
|
||||
const codes = NAMEGEN_USAGES.map((u) => u.code)
|
||||
expect(codes).toContain('norn')
|
||||
expect(codes).toContain('japa')
|
||||
expect(codes).toContain('mythg')
|
||||
expect(codes).toContain('ger')
|
||||
expect(codes).toContain('arb')
|
||||
expect(codes).toHaveLength(5)
|
||||
})
|
||||
|
||||
it('every usage has a non-empty label', () => {
|
||||
for (const u of NAMEGEN_USAGES) {
|
||||
expect(u.label.length).toBeGreaterThan(0)
|
||||
}
|
||||
})
|
||||
})
|
||||
40
gerbil-manager-web/src/api/names.ts
Normal file
40
gerbil-manager-web/src/api/names.ts
Normal file
@@ -0,0 +1,40 @@
|
||||
/** FEAT-NAMEGEN: Namensvorschläge — Schnittstelle zum Backend /names/suggest. */
|
||||
import { api } from './client'
|
||||
|
||||
export interface NameSuggestion {
|
||||
name: string
|
||||
meaning: string
|
||||
origin: string
|
||||
}
|
||||
|
||||
export const NAMEGEN_USAGES = [
|
||||
{ code: 'norn', label: 'Nordisch' },
|
||||
{ code: 'japa', label: 'Japanisch' },
|
||||
{ code: 'mythg', label: 'Griech. Mythologie' },
|
||||
{ code: 'ger', label: 'Deutsch' },
|
||||
{ code: 'arb', label: 'Arabisch' },
|
||||
] as const
|
||||
|
||||
export type NamegenUsageCode = (typeof NAMEGEN_USAGES)[number]['code']
|
||||
|
||||
export interface SuggestNamesParams {
|
||||
letter?: string
|
||||
gender?: string
|
||||
usages: NamegenUsageCode[]
|
||||
count?: number
|
||||
}
|
||||
|
||||
/** Exported for unit tests — builds the query path without a network call. */
|
||||
export function buildSuggestPath(params: SuggestNamesParams): string {
|
||||
const parts: string[] = []
|
||||
const letter = params.letter?.trim().toUpperCase()
|
||||
if (letter) parts.push(`letter=${encodeURIComponent(letter)}`)
|
||||
if (params.gender && params.gender !== '') parts.push(`gender=${encodeURIComponent(params.gender)}`)
|
||||
if (params.usages.length > 0) parts.push(`usages=${params.usages.join(',')}`)
|
||||
parts.push(`count=${params.count ?? 6}`)
|
||||
return `/names/suggest?${parts.join('&')}`
|
||||
}
|
||||
|
||||
export function suggestNames(params: SuggestNamesParams): Promise<NameSuggestion[]> {
|
||||
return api.get<NameSuggestion[]>(buildSuggestPath(params))
|
||||
}
|
||||
114
gerbil-manager-web/src/components/NameSuggestPanel.css
Normal file
114
gerbil-manager-web/src/components/NameSuggestPanel.css
Normal file
@@ -0,0 +1,114 @@
|
||||
.namegen-name-row {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.namegen-name-row .input {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.namegen-panel {
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 0.5rem;
|
||||
padding: 1rem;
|
||||
margin-bottom: 1rem;
|
||||
background: var(--color-bg, #fff);
|
||||
}
|
||||
|
||||
.namegen-panel__header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 0.75rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.namegen-panel__filters {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 1rem;
|
||||
align-items: flex-start;
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
|
||||
.namegen-panel__letter {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
|
||||
.namegen-panel__letter-input {
|
||||
width: 5rem;
|
||||
}
|
||||
|
||||
.namegen-panel__usages {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
|
||||
.namegen-usages-grid {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.4rem 1rem;
|
||||
}
|
||||
|
||||
.namegen-usages-grid label {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.3rem;
|
||||
font-size: 0.9rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.namegen-suggestions {
|
||||
list-style: none;
|
||||
margin: 0.75rem 0 0;
|
||||
padding: 0;
|
||||
border-top: 1px solid var(--color-border);
|
||||
}
|
||||
|
||||
.namegen-suggestion {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 0.5rem;
|
||||
padding: 0.5rem 0;
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.namegen-suggestion:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.namegen-suggestion__pick {
|
||||
font-weight: 600;
|
||||
color: var(--color-accent, #2563eb);
|
||||
background: none;
|
||||
border: none;
|
||||
padding: 0;
|
||||
cursor: pointer;
|
||||
text-align: left;
|
||||
font-size: inherit;
|
||||
font-family: inherit;
|
||||
min-width: 6rem;
|
||||
}
|
||||
|
||||
.namegen-suggestion__pick:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.namegen-suggestion__meaning {
|
||||
flex: 1;
|
||||
font-size: 0.88rem;
|
||||
color: var(--color-text-muted);
|
||||
min-width: 8rem;
|
||||
}
|
||||
|
||||
.namegen-suggestion__origin {
|
||||
font-size: 0.85rem;
|
||||
color: var(--color-text-muted);
|
||||
white-space: nowrap;
|
||||
}
|
||||
132
gerbil-manager-web/src/components/NameSuggestPanel.tsx
Normal file
132
gerbil-manager-web/src/components/NameSuggestPanel.tsx
Normal file
@@ -0,0 +1,132 @@
|
||||
import { useState } from 'react'
|
||||
import { de } from '../strings/de'
|
||||
import { ApiError, errorCode } from '../api/client'
|
||||
import { NAMEGEN_USAGES, suggestNames, type NamegenUsageCode, type NameSuggestion } from '../api/names'
|
||||
import './NameSuggestPanel.css'
|
||||
|
||||
interface NameSuggestPanelProps {
|
||||
gender: string
|
||||
onPick: (name: string) => void
|
||||
onClose: () => void
|
||||
}
|
||||
|
||||
const ALL_CODES = NAMEGEN_USAGES.map((u) => u.code) as NamegenUsageCode[]
|
||||
|
||||
export default function NameSuggestPanel({ gender, onPick, onClose }: NameSuggestPanelProps) {
|
||||
const t = de.namegen
|
||||
const [letter, setLetter] = useState('')
|
||||
const [usages, setUsages] = useState<Set<NamegenUsageCode>>(new Set(ALL_CODES))
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [keyMissing, setKeyMissing] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [suggestions, setSuggestions] = useState<NameSuggestion[]>([])
|
||||
const [fetched, setFetched] = useState(false)
|
||||
|
||||
function toggleUsage(code: NamegenUsageCode) {
|
||||
setUsages((prev) => {
|
||||
const next = new Set(prev)
|
||||
if (next.has(code)) next.delete(code)
|
||||
else next.add(code)
|
||||
return next
|
||||
})
|
||||
}
|
||||
|
||||
async function load() {
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
setKeyMissing(false)
|
||||
setSuggestions([])
|
||||
try {
|
||||
const result = await suggestNames({
|
||||
letter: letter.trim() || undefined,
|
||||
gender: gender || undefined,
|
||||
usages: [...usages] as NamegenUsageCode[],
|
||||
count: 6,
|
||||
})
|
||||
setSuggestions(result)
|
||||
setFetched(true)
|
||||
} catch (err) {
|
||||
if (errorCode(err) === 'NamesKeyMissing') {
|
||||
setKeyMissing(true)
|
||||
} else {
|
||||
setError(err instanceof ApiError ? err.message : de.api.errors.unknown)
|
||||
}
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="namegen-panel" role="region" aria-label={t.panelTitle}>
|
||||
<div className="namegen-panel__header">
|
||||
<span>{t.panelTitle}</span>
|
||||
<button type="button" className="btn" onClick={onClose} aria-label={t.close}>
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="namegen-panel__filters">
|
||||
<div className="namegen-panel__letter">
|
||||
<label htmlFor="namegen-letter">{t.letterLabel}</label>
|
||||
<input
|
||||
id="namegen-letter"
|
||||
className="input namegen-panel__letter-input"
|
||||
value={letter}
|
||||
onChange={(e) => setLetter(e.target.value)}
|
||||
placeholder={t.letterPlaceholder}
|
||||
maxLength={1}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="namegen-panel__usages">
|
||||
<span>{t.usagesLabel}</span>
|
||||
<div className="namegen-usages-grid">
|
||||
{NAMEGEN_USAGES.map(({ code, label }) => (
|
||||
<label key={code}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={usages.has(code)}
|
||||
onChange={() => toggleUsage(code)}
|
||||
/>
|
||||
{label}
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn--primary"
|
||||
onClick={load}
|
||||
disabled={loading || usages.size === 0}
|
||||
>
|
||||
{loading ? t.loading : t.loadButton}
|
||||
</button>
|
||||
|
||||
{keyMissing && <p className="muted">{t.keyMissing}</p>}
|
||||
{error && <p className="error-text">{error}</p>}
|
||||
{fetched && !loading && !keyMissing && !error && suggestions.length === 0 && (
|
||||
<p className="muted">{t.empty}</p>
|
||||
)}
|
||||
|
||||
{suggestions.length > 0 && (
|
||||
<ul className="namegen-suggestions" aria-label={t.panelTitle}>
|
||||
{suggestions.map((s, i) => (
|
||||
<li key={`${s.name}-${i}`} className="namegen-suggestion">
|
||||
<button
|
||||
type="button"
|
||||
className="namegen-suggestion__pick"
|
||||
onClick={() => onPick(s.name)}
|
||||
>
|
||||
{s.name}
|
||||
</button>
|
||||
<span className="namegen-suggestion__meaning">{s.meaning}</span>
|
||||
<span className="namegen-suggestion__origin">{s.origin}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
/**
|
||||
* AR-5 Drift-Guard: colorVarietySeed.generated.json muss mit dem Live-Output
|
||||
* aus catalog.ts übereinstimmen.
|
||||
*
|
||||
* Schlägt dieser Test fehl, wurde catalog.ts verändert ohne danach
|
||||
* `npm run gen:catalog` auszuführen. Fix: `npm run gen:catalog` laufen lassen
|
||||
* und die geänderten JSON-Dateien committen.
|
||||
*/
|
||||
import { readFileSync } from 'fs'
|
||||
import { fileURLToPath } from 'url'
|
||||
import { dirname, join } from 'path'
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { CATALOG } from '../catalog'
|
||||
|
||||
const __dir = dirname(fileURLToPath(import.meta.url))
|
||||
|
||||
describe('AR-5 Catalog drift-guard', () => {
|
||||
it('colorVarietySeed.generated.json stimmt mit catalog.ts überein (sonst: npm run gen:catalog)', () => {
|
||||
const jsonPath = join(__dir, '..', 'colorVarietySeed.generated.json')
|
||||
const committed = JSON.parse(readFileSync(jsonPath, 'utf-8'))
|
||||
// CATALOG is readonly — deep equality against the plain parsed array is sufficient.
|
||||
expect(committed).toEqual(Array.from(CATALOG))
|
||||
})
|
||||
})
|
||||
426
gerbil-manager-web/src/genetics/colorVarietySeed.backend.json
Normal file
426
gerbil-manager-web/src/genetics/colorVarietySeed.backend.json
Normal file
@@ -0,0 +1,426 @@
|
||||
[
|
||||
{
|
||||
"name": "Pink Eyed White (PEW)",
|
||||
"english": "Pink Eyed White",
|
||||
"canonicalGenotype": "AA chch DD EE GG pp spsp rere",
|
||||
"sortOrder": 0,
|
||||
"image": "rotaugen-weiss-pew-d-sep-e-sep.jpg"
|
||||
},
|
||||
{
|
||||
"name": "Hermelin",
|
||||
"english": "Dark Tailed White",
|
||||
"canonicalGenotype": "aa chch DD EE GG PP spsp rere",
|
||||
"sortOrder": 1,
|
||||
"image": "hermelin.jpeg"
|
||||
},
|
||||
{
|
||||
"name": "Himalaya",
|
||||
"english": "Himalayan",
|
||||
"canonicalGenotype": "AA chch DD EE GG PP spsp rere",
|
||||
"sortOrder": 2,
|
||||
"image": "himalaya.jpg"
|
||||
},
|
||||
{
|
||||
"name": "Zobel",
|
||||
"english": "Sable",
|
||||
"canonicalGenotype": "aa cchmcchm DD EE gg PP spsp rere",
|
||||
"sortOrder": 3,
|
||||
"image": "zobel.jpeg"
|
||||
},
|
||||
{
|
||||
"name": "Rotaugenschimmel",
|
||||
"english": "Red-Eyed Roan",
|
||||
"canonicalGenotype": "AA CC DD efef GG pp spsp rere",
|
||||
"sortOrder": 4,
|
||||
"image": "rotaugen-schimmel.jpg"
|
||||
},
|
||||
{
|
||||
"name": "Agouti",
|
||||
"english": "Golden Agouti",
|
||||
"canonicalGenotype": "AA CC DD EE GG PP spsp rere",
|
||||
"sortOrder": 5,
|
||||
"image": "agouti-mit-erklaerung-der-genloci.JPG"
|
||||
},
|
||||
{
|
||||
"name": "Schwarz",
|
||||
"english": "Black",
|
||||
"canonicalGenotype": "aa CC DD EE GG PP spsp rere",
|
||||
"sortOrder": 6,
|
||||
"image": "schwarz.jpg"
|
||||
},
|
||||
{
|
||||
"name": "Silberagouti",
|
||||
"english": "Grey Agouti",
|
||||
"canonicalGenotype": "AA CC DD EE gg PP spsp rere",
|
||||
"sortOrder": 7,
|
||||
"image": "silberagouti.jpg"
|
||||
},
|
||||
{
|
||||
"name": "Anthrazit",
|
||||
"english": "Slate",
|
||||
"canonicalGenotype": "aa CC DD EE gg PP spsp rere",
|
||||
"sortOrder": 8,
|
||||
"image": "anthrazit.jpg"
|
||||
},
|
||||
{
|
||||
"name": "Algierfuchs",
|
||||
"english": "Dark-Eyed Honey",
|
||||
"canonicalGenotype": "AA CC DD ee GG PP spsp rere",
|
||||
"sortOrder": 9,
|
||||
"image": "algierfuchs.jpg"
|
||||
},
|
||||
{
|
||||
"name": "Blau",
|
||||
"english": "Blue",
|
||||
"canonicalGenotype": "aa CC dd EE GG PP spsp rere",
|
||||
"sortOrder": 10,
|
||||
"image": "blau-schwarz-dd.JPG"
|
||||
},
|
||||
{
|
||||
"name": "Gold",
|
||||
"english": "Argente Golden",
|
||||
"canonicalGenotype": "AA CC DD EE GG pp spsp rere",
|
||||
"sortOrder": 11,
|
||||
"image": "gold.jpg"
|
||||
},
|
||||
{
|
||||
"name": "Platin",
|
||||
"english": "Lilac",
|
||||
"canonicalGenotype": "aa CC DD EE GG pp spsp rere",
|
||||
"sortOrder": 12,
|
||||
"image": "platin.JPG"
|
||||
},
|
||||
{
|
||||
"name": "Goldfuchs",
|
||||
"english": "Yellow Fox",
|
||||
"canonicalGenotype": "AA CC DD ee GG pp spsp rere",
|
||||
"sortOrder": 13,
|
||||
"image": "goldfuchs.jpg"
|
||||
},
|
||||
{
|
||||
"name": "Rotfuchs",
|
||||
"english": "Argente Nutmeg",
|
||||
"canonicalGenotype": "aa CC DD ee GG pp spsp rere",
|
||||
"sortOrder": 14,
|
||||
"image": "rotfuchs.JPG"
|
||||
},
|
||||
{
|
||||
"name": "Dilute Gold",
|
||||
"english": "dd Argente Golden",
|
||||
"canonicalGenotype": "AA CC dd EE GG pp spsp rere",
|
||||
"sortOrder": 15,
|
||||
"image": "gold-dd.jpg"
|
||||
},
|
||||
{
|
||||
"name": "Dilute Platin",
|
||||
"english": "dd Lilac",
|
||||
"canonicalGenotype": "aa CC dd EE GG pp spsp rere",
|
||||
"sortOrder": 16,
|
||||
"image": "platin-dd.jpg"
|
||||
},
|
||||
{
|
||||
"name": "Altweiss (REW)",
|
||||
"canonicalGenotype": "aa CC DD EE gg pp spsp rere",
|
||||
"sortOrder": 17,
|
||||
"image": "altweiss-rew.jpeg"
|
||||
},
|
||||
{
|
||||
"name": "Apricot (Blassfuchs)",
|
||||
"canonicalGenotype": "AA CC DD ee gg pp spsp rere",
|
||||
"sortOrder": 18,
|
||||
"image": "apricot-blassfuchs.jpg"
|
||||
},
|
||||
{
|
||||
"name": "Blaufuchs",
|
||||
"canonicalGenotype": "aa CC DD ee gg PP spsp rere",
|
||||
"sortOrder": 19,
|
||||
"image": "blaufuchs.jpg"
|
||||
},
|
||||
{
|
||||
"name": "C-Separator",
|
||||
"canonicalGenotype": "aa CC DD ee gg pp spsp rere",
|
||||
"sortOrder": 20,
|
||||
"image": "c-separator.jpg"
|
||||
},
|
||||
{
|
||||
"name": "Elfenbein",
|
||||
"canonicalGenotype": "AA CC DD EE gg pp spsp rere",
|
||||
"sortOrder": 21,
|
||||
"image": "elfenbein.jpg"
|
||||
},
|
||||
{
|
||||
"name": "Kohlfuchs",
|
||||
"canonicalGenotype": "aa CC DD ee GG PP spsp rere",
|
||||
"sortOrder": 22,
|
||||
"image": "kohlfuchs.jpg"
|
||||
},
|
||||
{
|
||||
"name": "Polarfuchs",
|
||||
"canonicalGenotype": "AA CC DD ee gg PP spsp rere",
|
||||
"sortOrder": 23,
|
||||
"image": "polarfuchs.jpg"
|
||||
},
|
||||
{
|
||||
"name": "Saphir",
|
||||
"canonicalGenotype": "aa CC DD EE GG pp spsp rere",
|
||||
"sortOrder": 24,
|
||||
"image": "saphir.jpg"
|
||||
},
|
||||
{
|
||||
"name": "Orangeschimmel",
|
||||
"canonicalGenotype": "AA CC DD efef GG PP spsp rere",
|
||||
"sortOrder": 25,
|
||||
"image": "schimmel-orangeschimmel.jpg"
|
||||
},
|
||||
{
|
||||
"name": "Topas",
|
||||
"canonicalGenotype": "AA CC DD EE GG pp spsp rere",
|
||||
"sortOrder": 26,
|
||||
"image": "topas.jpg"
|
||||
},
|
||||
{
|
||||
"name": "Platin-Hell",
|
||||
"canonicalGenotype": "aa CC DD EE GG pp spsp rere",
|
||||
"sortOrder": 27,
|
||||
"image": "platin-hell.jpg"
|
||||
},
|
||||
{
|
||||
"name": "Dilute Agouti",
|
||||
"canonicalGenotype": "AA CC dd EE GG PP spsp rere",
|
||||
"sortOrder": 28,
|
||||
"image": "agouti-dd.jpg"
|
||||
},
|
||||
{
|
||||
"name": "Dilute Silberagouti",
|
||||
"canonicalGenotype": "AA CC dd EE gg PP spsp rere",
|
||||
"sortOrder": 29,
|
||||
"image": "silberagouti-dd.jpg"
|
||||
},
|
||||
{
|
||||
"name": "Dilute Kohlfuchs",
|
||||
"canonicalGenotype": "aa CC dd ee GG PP spsp rere",
|
||||
"sortOrder": 30,
|
||||
"image": "kohlfuchs-dd.jpg"
|
||||
},
|
||||
{
|
||||
"name": "Dilute Anthrazit",
|
||||
"canonicalGenotype": "aa CC dd EE gg PP spsp rere",
|
||||
"sortOrder": 31,
|
||||
"image": "anthrazit-dd.jpg"
|
||||
},
|
||||
{
|
||||
"name": "Dilute Algierfuchs",
|
||||
"canonicalGenotype": "AA CC dd ee GG PP spsp rere",
|
||||
"sortOrder": 32
|
||||
},
|
||||
{
|
||||
"name": "Dilute Goldfuchs",
|
||||
"canonicalGenotype": "AA CC dd ee GG pp spsp rere",
|
||||
"sortOrder": 33
|
||||
},
|
||||
{
|
||||
"name": "Dilute Rotfuchs",
|
||||
"canonicalGenotype": "aa CC dd ee GG pp spsp rere",
|
||||
"sortOrder": 34
|
||||
},
|
||||
{
|
||||
"name": "Dilute Polarfuchs",
|
||||
"canonicalGenotype": "AA CC dd ee gg PP spsp rere",
|
||||
"sortOrder": 35
|
||||
},
|
||||
{
|
||||
"name": "Silberschimmel",
|
||||
"canonicalGenotype": "AA CC DD efef gg PP spsp rere",
|
||||
"sortOrder": 36,
|
||||
"image": "silberschimmel.jpg"
|
||||
},
|
||||
{
|
||||
"name": "Polarfuchsschimmel",
|
||||
"canonicalGenotype": "AA CC DD efef gg PP spsp rere",
|
||||
"sortOrder": 37,
|
||||
"image": "polarfuchsschimmel.jpg"
|
||||
},
|
||||
{
|
||||
"name": "Algierfuchsschimmel",
|
||||
"canonicalGenotype": "AA CC DD efef GG PP spsp rere",
|
||||
"sortOrder": 38,
|
||||
"image": "algierfuchsschimmel.jpg"
|
||||
},
|
||||
{
|
||||
"name": "Kohlfuchsschimmel",
|
||||
"canonicalGenotype": "aa CC DD efef GG PP spsp rere",
|
||||
"sortOrder": 39,
|
||||
"image": "kohlfuchsschimmel.jpg"
|
||||
},
|
||||
{
|
||||
"name": "Blaufuchsschimmel",
|
||||
"canonicalGenotype": "aa CC DD efef gg PP spsp rere",
|
||||
"sortOrder": 40,
|
||||
"image": "blaufuchsschimmel.jpg"
|
||||
},
|
||||
{
|
||||
"name": "Kohlfuchs, hell",
|
||||
"canonicalGenotype": "aa CC DD ee GG PP spsp rere",
|
||||
"sortOrder": 41,
|
||||
"image": "kohlfuchs-hell.jpg"
|
||||
},
|
||||
{
|
||||
"name": "Goldfuchs, hell",
|
||||
"canonicalGenotype": "AA CC DD ee GG pp spsp rere",
|
||||
"sortOrder": 42,
|
||||
"image": "goldfuchs-hell.jpg"
|
||||
},
|
||||
{
|
||||
"name": "Goldfuchsschimmel",
|
||||
"canonicalGenotype": "AA CC DD efef GG pp spsp rere",
|
||||
"sortOrder": 43,
|
||||
"image": "goldfuchsschimmel.jpg"
|
||||
},
|
||||
{
|
||||
"name": "Gold-Hell",
|
||||
"canonicalGenotype": "AA CC DD EE GG pp spsp rere",
|
||||
"sortOrder": 44,
|
||||
"image": "gold-hell.jpg"
|
||||
},
|
||||
{
|
||||
"name": "Blaufuchs, hell",
|
||||
"canonicalGenotype": "aa CC DD ee gg PP spsp rere",
|
||||
"sortOrder": 45,
|
||||
"image": "blaufuchs-hell.jpeg"
|
||||
},
|
||||
{
|
||||
"name": "Rotfuchsschimmel",
|
||||
"canonicalGenotype": "aa CC DD efef GG pp spsp rere",
|
||||
"sortOrder": 46,
|
||||
"image": "rotfuchsschimmel.jpg"
|
||||
},
|
||||
{
|
||||
"name": "Polarfuchs, hell",
|
||||
"canonicalGenotype": "AA CC DD ee gg PP spsp rere",
|
||||
"sortOrder": 47,
|
||||
"image": "polarfuchs-hell.jpeg"
|
||||
},
|
||||
{
|
||||
"name": "Kohlfuchsschimmel, hell",
|
||||
"canonicalGenotype": "aa CC DD efef GG PP spsp rere",
|
||||
"sortOrder": 48,
|
||||
"image": "kohlfuchsschimmel-hell.jpg"
|
||||
},
|
||||
{
|
||||
"name": "Rotfuchs, hell",
|
||||
"canonicalGenotype": "aa CC DD ee GG pp spsp rere",
|
||||
"sortOrder": 49,
|
||||
"image": "rotfuchs-hell.jpg"
|
||||
},
|
||||
{
|
||||
"name": "Kohlfuchs-Hell",
|
||||
"canonicalGenotype": "aa CC DD ee GG PP spsp rere",
|
||||
"sortOrder": 50,
|
||||
"image": "kohlfuchs-hell-2.jpg"
|
||||
},
|
||||
{
|
||||
"name": "Algierfuchs, hell",
|
||||
"canonicalGenotype": "AA CC DD ee GG PP spsp rere",
|
||||
"sortOrder": 51,
|
||||
"image": "algierfuchs-hell.JPG"
|
||||
},
|
||||
{
|
||||
"name": "Dilute Topas",
|
||||
"canonicalGenotype": "AA CC dd EE GG pp spsp rere",
|
||||
"sortOrder": 52,
|
||||
"image": "topas-dd.jpg"
|
||||
},
|
||||
{
|
||||
"name": "Dilute Blaufuchs",
|
||||
"canonicalGenotype": "aa CC dd ee gg pp spsp rere",
|
||||
"sortOrder": 53,
|
||||
"image": "blaufuchs-dd.jpg"
|
||||
},
|
||||
{
|
||||
"name": "Marder",
|
||||
"canonicalGenotype": "aa cchmcchm DD EE GG PP spsp rere",
|
||||
"sortOrder": 54,
|
||||
"image": "marder.JPG"
|
||||
},
|
||||
{
|
||||
"name": "Siam",
|
||||
"canonicalGenotype": "aa cchmch DD EE GG PP spsp rere",
|
||||
"sortOrder": 55,
|
||||
"image": "siam-marder-hell.JPG"
|
||||
},
|
||||
{
|
||||
"name": "Zobel-Hell",
|
||||
"canonicalGenotype": "aa cchmch DD EE gg PP spsp rere",
|
||||
"sortOrder": 56,
|
||||
"image": "zobel-hell.jpg"
|
||||
},
|
||||
{
|
||||
"name": "CP-Agouti",
|
||||
"canonicalGenotype": "AA cchmcchm DD EE GG PP spsp rere",
|
||||
"sortOrder": 57,
|
||||
"image": "agouti-cp.jpg"
|
||||
},
|
||||
{
|
||||
"name": "CP-Agouti-Hell",
|
||||
"canonicalGenotype": "AA cchmch DD EE GG PP spsp rere",
|
||||
"sortOrder": 58
|
||||
},
|
||||
{
|
||||
"name": "CP-Silberagouti",
|
||||
"canonicalGenotype": "AA cchmcchm DD EE gg PP spsp rere",
|
||||
"sortOrder": 59,
|
||||
"image": "silberagouti-cp.JPG"
|
||||
},
|
||||
{
|
||||
"name": "CP-Silberagouti-Hell",
|
||||
"canonicalGenotype": "AA cchmch DD EE gg PP spsp rere",
|
||||
"sortOrder": 60
|
||||
},
|
||||
{
|
||||
"name": "CP-Algierfuchs",
|
||||
"canonicalGenotype": "AA cchmcchm DD ee GG PP spsp rere",
|
||||
"sortOrder": 61,
|
||||
"image": "algierfuchs-cp.jpg"
|
||||
},
|
||||
{
|
||||
"name": "CP-Algierfuchs-Hell",
|
||||
"canonicalGenotype": "AA cchmch DD ee GG PP spsp rere",
|
||||
"sortOrder": 62
|
||||
},
|
||||
{
|
||||
"name": "CP-Polarfuchs",
|
||||
"canonicalGenotype": "AA cchmcchm DD ee gg PP spsp rere",
|
||||
"sortOrder": 63,
|
||||
"image": "polarfuchs-cp.jpg"
|
||||
},
|
||||
{
|
||||
"name": "CP-Polarfuchs-Hell",
|
||||
"canonicalGenotype": "AA cchmch DD ee gg PP spsp rere",
|
||||
"sortOrder": 64
|
||||
},
|
||||
{
|
||||
"name": "CP-Fuchs",
|
||||
"canonicalGenotype": "AA cchmcchm dd ee GG PP spsp rere",
|
||||
"sortOrder": 65
|
||||
},
|
||||
{
|
||||
"name": "CP-Fuchs-Hell",
|
||||
"canonicalGenotype": "AA cchmch dd ee GG PP spsp rere",
|
||||
"sortOrder": 66
|
||||
},
|
||||
{
|
||||
"name": "CP-Blaufuchs",
|
||||
"canonicalGenotype": "AA cchmcchm dd ee gg PP spsp rere",
|
||||
"sortOrder": 67
|
||||
},
|
||||
{
|
||||
"name": "CP-Orangeschimmel",
|
||||
"canonicalGenotype": "AA cchmcchm DD efef GG PP spsp rere",
|
||||
"sortOrder": 68
|
||||
},
|
||||
{
|
||||
"name": "CP-Orangeschimmel-Hell",
|
||||
"canonicalGenotype": "AA cchmch DD efef GG PP spsp rere",
|
||||
"sortOrder": 69
|
||||
}
|
||||
]
|
||||
@@ -105,14 +105,14 @@
|
||||
"image": "rotfuchs.JPG"
|
||||
},
|
||||
{
|
||||
"name": "dd Gold",
|
||||
"name": "Dilute Gold",
|
||||
"english": "dd Argente Golden",
|
||||
"canonicalGenotype": "AA CC dd EE GG pp spsp rere",
|
||||
"sortOrder": 15,
|
||||
"image": "gold-dd.jpg"
|
||||
},
|
||||
{
|
||||
"name": "dd Platin",
|
||||
"name": "Dilute Platin",
|
||||
"english": "dd Lilac",
|
||||
"canonicalGenotype": "aa CC dd EE GG pp spsp rere",
|
||||
"sortOrder": 16,
|
||||
@@ -185,222 +185,242 @@
|
||||
"image": "platin-hell.jpg"
|
||||
},
|
||||
{
|
||||
"name": "Agouti dd",
|
||||
"name": "Dilute Agouti",
|
||||
"canonicalGenotype": "AA CC dd EE GG PP spsp rere",
|
||||
"sortOrder": 28,
|
||||
"image": "agouti-dd.jpg"
|
||||
},
|
||||
{
|
||||
"name": "Silberagouti dd",
|
||||
"name": "Dilute Silberagouti",
|
||||
"canonicalGenotype": "AA CC dd EE gg PP spsp rere",
|
||||
"sortOrder": 29,
|
||||
"image": "silberagouti-dd.jpg"
|
||||
},
|
||||
{
|
||||
"name": "Kohlfuchs dd",
|
||||
"name": "Dilute Kohlfuchs",
|
||||
"canonicalGenotype": "aa CC dd ee GG PP spsp rere",
|
||||
"sortOrder": 30,
|
||||
"image": "kohlfuchs-dd.jpg"
|
||||
},
|
||||
{
|
||||
"name": "Anthrazit dd",
|
||||
"name": "Dilute Anthrazit",
|
||||
"canonicalGenotype": "aa CC dd EE gg PP spsp rere",
|
||||
"sortOrder": 31,
|
||||
"image": "anthrazit-dd.jpg"
|
||||
},
|
||||
{
|
||||
"name": "Dilute Algierfuchs",
|
||||
"canonicalGenotype": "AA CC dd ee GG PP spsp rere",
|
||||
"sortOrder": 32
|
||||
},
|
||||
{
|
||||
"name": "Dilute Goldfuchs",
|
||||
"canonicalGenotype": "AA CC dd ee GG pp spsp rere",
|
||||
"sortOrder": 33
|
||||
},
|
||||
{
|
||||
"name": "Dilute Rotfuchs",
|
||||
"canonicalGenotype": "aa CC dd ee GG pp spsp rere",
|
||||
"sortOrder": 34
|
||||
},
|
||||
{
|
||||
"name": "Dilute Polarfuchs",
|
||||
"canonicalGenotype": "AA CC dd ee gg PP spsp rere",
|
||||
"sortOrder": 35
|
||||
},
|
||||
{
|
||||
"name": "Silberschimmel",
|
||||
"canonicalGenotype": "AA CC DD e[f]e[f] gg PP spsp rere",
|
||||
"sortOrder": 32,
|
||||
"sortOrder": 36,
|
||||
"image": "silberschimmel.jpg"
|
||||
},
|
||||
{
|
||||
"name": "Polarfuchsschimmel",
|
||||
"canonicalGenotype": "AA CC DD e[f]e[f] gg PP spsp rere",
|
||||
"sortOrder": 33,
|
||||
"sortOrder": 37,
|
||||
"image": "polarfuchsschimmel.jpg"
|
||||
},
|
||||
{
|
||||
"name": "Algierfuchsschimmel",
|
||||
"canonicalGenotype": "AA CC DD e[f]e[f] GG PP spsp rere",
|
||||
"sortOrder": 34,
|
||||
"sortOrder": 38,
|
||||
"image": "algierfuchsschimmel.jpg"
|
||||
},
|
||||
{
|
||||
"name": "Kohlfuchsschimmel",
|
||||
"canonicalGenotype": "aa CC DD e[f]e[f] GG PP spsp rere",
|
||||
"sortOrder": 35,
|
||||
"sortOrder": 39,
|
||||
"image": "kohlfuchsschimmel.jpg"
|
||||
},
|
||||
{
|
||||
"name": "Blaufuchsschimmel",
|
||||
"canonicalGenotype": "aa CC DD e[f]e[f] gg PP spsp rere",
|
||||
"sortOrder": 36,
|
||||
"sortOrder": 40,
|
||||
"image": "blaufuchsschimmel.jpg"
|
||||
},
|
||||
{
|
||||
"name": "Kohlfuchs, hell",
|
||||
"canonicalGenotype": "aa CC DD ee GG PP spsp rere",
|
||||
"sortOrder": 37,
|
||||
"sortOrder": 41,
|
||||
"image": "kohlfuchs-hell.jpg"
|
||||
},
|
||||
{
|
||||
"name": "Goldfuchs, hell",
|
||||
"canonicalGenotype": "AA CC DD ee GG pp spsp rere",
|
||||
"sortOrder": 38,
|
||||
"sortOrder": 42,
|
||||
"image": "goldfuchs-hell.jpg"
|
||||
},
|
||||
{
|
||||
"name": "Goldfuchsschimmel",
|
||||
"canonicalGenotype": "AA CC DD e[f]e[f] GG pp spsp rere",
|
||||
"sortOrder": 39,
|
||||
"sortOrder": 43,
|
||||
"image": "goldfuchsschimmel.jpg"
|
||||
},
|
||||
{
|
||||
"name": "Gold-Hell",
|
||||
"canonicalGenotype": "AA CC DD EE GG pp spsp rere",
|
||||
"sortOrder": 40,
|
||||
"sortOrder": 44,
|
||||
"image": "gold-hell.jpg"
|
||||
},
|
||||
{
|
||||
"name": "Blaufuchs, hell",
|
||||
"canonicalGenotype": "aa CC DD ee gg PP spsp rere",
|
||||
"sortOrder": 41,
|
||||
"sortOrder": 45,
|
||||
"image": "blaufuchs-hell.jpeg"
|
||||
},
|
||||
{
|
||||
"name": "Rotfuchsschimmel",
|
||||
"canonicalGenotype": "aa CC DD e[f]e[f] GG pp spsp rere",
|
||||
"sortOrder": 42,
|
||||
"sortOrder": 46,
|
||||
"image": "rotfuchsschimmel.jpg"
|
||||
},
|
||||
{
|
||||
"name": "Polarfuchs, hell",
|
||||
"canonicalGenotype": "AA CC DD ee gg PP spsp rere",
|
||||
"sortOrder": 43,
|
||||
"sortOrder": 47,
|
||||
"image": "polarfuchs-hell.jpeg"
|
||||
},
|
||||
{
|
||||
"name": "Kohlfuchsschimmel, hell",
|
||||
"canonicalGenotype": "aa CC DD e[f]e[f] GG PP spsp rere",
|
||||
"sortOrder": 44,
|
||||
"sortOrder": 48,
|
||||
"image": "kohlfuchsschimmel-hell.jpg"
|
||||
},
|
||||
{
|
||||
"name": "Rotfuchs, hell",
|
||||
"canonicalGenotype": "aa CC DD ee GG pp spsp rere",
|
||||
"sortOrder": 45,
|
||||
"sortOrder": 49,
|
||||
"image": "rotfuchs-hell.jpg"
|
||||
},
|
||||
{
|
||||
"name": "Kohlfuchs-Hell",
|
||||
"canonicalGenotype": "aa CC DD ee GG PP spsp rere",
|
||||
"sortOrder": 46,
|
||||
"sortOrder": 50,
|
||||
"image": "kohlfuchs-hell-2.jpg"
|
||||
},
|
||||
{
|
||||
"name": "Algierfuchs, hell",
|
||||
"canonicalGenotype": "AA CC DD ee GG PP spsp rere",
|
||||
"sortOrder": 47,
|
||||
"sortOrder": 51,
|
||||
"image": "algierfuchs-hell.JPG"
|
||||
},
|
||||
{
|
||||
"name": "Topas dd",
|
||||
"name": "Dilute Topas",
|
||||
"canonicalGenotype": "AA CC dd EE GG pp spsp rere",
|
||||
"sortOrder": 48,
|
||||
"sortOrder": 52,
|
||||
"image": "topas-dd.jpg"
|
||||
},
|
||||
{
|
||||
"name": "Blaufuchs dd",
|
||||
"name": "Dilute Blaufuchs",
|
||||
"canonicalGenotype": "aa CC dd ee gg pp spsp rere",
|
||||
"sortOrder": 49,
|
||||
"sortOrder": 53,
|
||||
"image": "blaufuchs-dd.jpg"
|
||||
},
|
||||
{
|
||||
"name": "Marder",
|
||||
"canonicalGenotype": "aa c[chm]c[chm] DD EE GG PP spsp rere",
|
||||
"sortOrder": 50,
|
||||
"sortOrder": 54,
|
||||
"image": "marder.JPG"
|
||||
},
|
||||
{
|
||||
"name": "Siam",
|
||||
"canonicalGenotype": "aa c[chm]c[h] DD EE GG PP spsp rere",
|
||||
"sortOrder": 51,
|
||||
"sortOrder": 55,
|
||||
"image": "siam-marder-hell.JPG"
|
||||
},
|
||||
{
|
||||
"name": "Zobel-Hell",
|
||||
"canonicalGenotype": "aa c[chm]c[h] DD EE gg PP spsp rere",
|
||||
"sortOrder": 52,
|
||||
"sortOrder": 56,
|
||||
"image": "zobel-hell.jpg"
|
||||
},
|
||||
{
|
||||
"name": "CP-Agouti",
|
||||
"canonicalGenotype": "AA c[chm]c[chm] DD EE GG PP spsp rere",
|
||||
"sortOrder": 53,
|
||||
"sortOrder": 57,
|
||||
"image": "agouti-cp.jpg"
|
||||
},
|
||||
{
|
||||
"name": "CP-Agouti-Hell",
|
||||
"canonicalGenotype": "AA c[chm]c[h] DD EE GG PP spsp rere",
|
||||
"sortOrder": 54
|
||||
"sortOrder": 58
|
||||
},
|
||||
{
|
||||
"name": "CP-Silberagouti",
|
||||
"canonicalGenotype": "AA c[chm]c[chm] DD EE gg PP spsp rere",
|
||||
"sortOrder": 55,
|
||||
"sortOrder": 59,
|
||||
"image": "silberagouti-cp.JPG"
|
||||
},
|
||||
{
|
||||
"name": "CP-Silberagouti-Hell",
|
||||
"canonicalGenotype": "AA c[chm]c[h] DD EE gg PP spsp rere",
|
||||
"sortOrder": 56
|
||||
"sortOrder": 60
|
||||
},
|
||||
{
|
||||
"name": "CP-Algierfuchs",
|
||||
"canonicalGenotype": "AA c[chm]c[chm] DD ee GG PP spsp rere",
|
||||
"sortOrder": 57,
|
||||
"sortOrder": 61,
|
||||
"image": "algierfuchs-cp.jpg"
|
||||
},
|
||||
{
|
||||
"name": "CP-Algierfuchs-Hell",
|
||||
"canonicalGenotype": "AA c[chm]c[h] DD ee GG PP spsp rere",
|
||||
"sortOrder": 58
|
||||
"sortOrder": 62
|
||||
},
|
||||
{
|
||||
"name": "CP-Polarfuchs",
|
||||
"canonicalGenotype": "AA c[chm]c[chm] DD ee gg PP spsp rere",
|
||||
"sortOrder": 59,
|
||||
"sortOrder": 63,
|
||||
"image": "polarfuchs-cp.jpg"
|
||||
},
|
||||
{
|
||||
"name": "CP-Polarfuchs-Hell",
|
||||
"canonicalGenotype": "AA c[chm]c[h] DD ee gg PP spsp rere",
|
||||
"sortOrder": 60
|
||||
"sortOrder": 64
|
||||
},
|
||||
{
|
||||
"name": "CP-Fuchs",
|
||||
"canonicalGenotype": "AA c[chm]c[chm] dd ee GG PP spsp rere",
|
||||
"sortOrder": 61
|
||||
"sortOrder": 65
|
||||
},
|
||||
{
|
||||
"name": "CP-Fuchs-Hell",
|
||||
"canonicalGenotype": "AA c[chm]c[h] dd ee GG PP spsp rere",
|
||||
"sortOrder": 62
|
||||
"sortOrder": 66
|
||||
},
|
||||
{
|
||||
"name": "CP-Blaufuchs",
|
||||
"canonicalGenotype": "AA c[chm]c[chm] dd ee gg PP spsp rere",
|
||||
"sortOrder": 63
|
||||
"sortOrder": 67
|
||||
},
|
||||
{
|
||||
"name": "CP-Orangeschimmel",
|
||||
"canonicalGenotype": "AA c[chm]c[chm] DD e[f]e[f] GG PP spsp rere",
|
||||
"sortOrder": 64
|
||||
"sortOrder": 68
|
||||
},
|
||||
{
|
||||
"name": "CP-Orangeschimmel-Hell",
|
||||
"canonicalGenotype": "AA c[chm]c[h] DD e[f]e[f] GG PP spsp rere",
|
||||
"sortOrder": 65
|
||||
"sortOrder": 69
|
||||
}
|
||||
]
|
||||
|
||||
@@ -2,6 +2,7 @@ import { useMemo, useState, type ReactNode } from 'react'
|
||||
import { Link, useParams } from 'react-router-dom'
|
||||
import { de } from '../strings/de'
|
||||
import { getGerbil, updateGerbil } from '../api/gerbils'
|
||||
import { listLitters as listLittersPaged } from '../api/litters'
|
||||
import { listColorVarieties, listContacts, listEnclosures, listLitters } from '../api/lookups'
|
||||
import { useApi, useMutation } from '../hooks/useApi'
|
||||
import { formatDate, genderLabel, statusLabel } from '../format/labels'
|
||||
@@ -55,6 +56,10 @@ export default function GerbilDetailPage() {
|
||||
const enclosures = useApi(() => listEnclosures(), [])
|
||||
const contacts = useApi(() => listContacts(), [])
|
||||
const litters = useApi(() => listLitters(), [])
|
||||
const parentLitters = useApi(
|
||||
() => listLittersPaged({ filter: `fatherId=${id}|motherId=${id}`, orderBy: 'date desc', pageSize: 50 }),
|
||||
[id],
|
||||
)
|
||||
|
||||
const colorName = useMemo(
|
||||
() => new Map((colorVarieties.data ?? []).map((c) => [c.id, c.name])),
|
||||
@@ -237,6 +242,32 @@ export default function GerbilDetailPage() {
|
||||
{saveCharacter.error && <span className="error-text">{saveCharacter.error}</span>}
|
||||
</div>
|
||||
|
||||
<h3>{t.detail.parentLittersTitle}</h3>
|
||||
{parentLitters.loading && <p className="muted">{de.common.loading}</p>}
|
||||
{!parentLitters.loading && (parentLitters.data?.items.length ?? 0) === 0 && (
|
||||
<p className="muted">{t.detail.parentLittersEmpty}</p>
|
||||
)}
|
||||
{(parentLitters.data?.items.length ?? 0) > 0 && (
|
||||
<ul className="card-list">
|
||||
{(parentLitters.data?.items ?? []).map((litter) => (
|
||||
<li key={litter.id}>
|
||||
<Link to={`/wuerfe/${litter.id}`} className="gerbil-card">
|
||||
<span className="gerbil-card__name">{litter.name}</span>
|
||||
<span className="badge badge--active">
|
||||
{litter.fatherId === id ? t.detail.parentLittersRoleVater : t.detail.parentLittersRoleMutter}
|
||||
</span>
|
||||
<span className="gerbil-card__meta">{formatDate(litter.date)}</span>
|
||||
{litter.totalBorn !== null && (
|
||||
<span className="gerbil-card__meta">
|
||||
{litter.totalBorn} {de.pages.litters.countLabel}
|
||||
</span>
|
||||
)}
|
||||
</Link>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
|
||||
<h3 className="visually-hidden">{t.detail.moreData}</h3>
|
||||
<div className="tabs" role="tablist">
|
||||
{(['photos', 'health', 'weight'] as const).map((key) => (
|
||||
|
||||
@@ -8,6 +8,8 @@ import { useApi, useMutation } from '../hooks/useApi'
|
||||
import { genderLabel, statusLabel } from '../format/labels'
|
||||
import { fromDisplayString } from '../genetics'
|
||||
import FarbschlagImage from '../components/FarbschlagImage'
|
||||
import NameSuggestPanel from '../components/NameSuggestPanel'
|
||||
import '../components/NameSuggestPanel.css'
|
||||
|
||||
interface FormState {
|
||||
name: string
|
||||
@@ -111,6 +113,7 @@ export default function GerbilFormPage() {
|
||||
const [form, setForm] = useState<FormState>(EMPTY)
|
||||
const [errors, setErrors] = useState<Partial<Record<keyof FormState, string>>>({})
|
||||
const [initializedFor, setInitializedFor] = useState<string | null>(null)
|
||||
const [showNameSuggest, setShowNameSuggest] = useState(false)
|
||||
|
||||
const existing = useApi(() => (id ? getGerbil(id) : Promise.resolve(null)), [id])
|
||||
const colorVarieties = useApi(() => listColorVarieties(), [])
|
||||
@@ -197,16 +200,33 @@ export default function GerbilFormPage() {
|
||||
<h2>{isEdit ? t.form.editTitle : t.form.createTitle}</h2>
|
||||
|
||||
<form className="form" onSubmit={onSubmit} noValidate>
|
||||
<label className="field">
|
||||
<span>{t.fields.name} *</span>
|
||||
<input
|
||||
className="input"
|
||||
value={form.name}
|
||||
onChange={(e) => set('name', e.target.value)}
|
||||
aria-invalid={Boolean(errors.name)}
|
||||
/>
|
||||
<div className="field">
|
||||
<label htmlFor="gerbil-name">{t.fields.name} *</label>
|
||||
<div className="namegen-name-row">
|
||||
<input
|
||||
id="gerbil-name"
|
||||
className="input"
|
||||
value={form.name}
|
||||
onChange={(e) => set('name', e.target.value)}
|
||||
aria-invalid={Boolean(errors.name)}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className="btn"
|
||||
onClick={() => setShowNameSuggest((v) => !v)}
|
||||
>
|
||||
{de.namegen.button}
|
||||
</button>
|
||||
</div>
|
||||
{errors.name && <small className="error-text">{errors.name}</small>}
|
||||
</label>
|
||||
</div>
|
||||
{showNameSuggest && (
|
||||
<NameSuggestPanel
|
||||
gender={form.gender}
|
||||
onPick={(name) => { set('name', name); setShowNameSuggest(false) }}
|
||||
onClose={() => setShowNameSuggest(false)}
|
||||
/>
|
||||
)}
|
||||
|
||||
<label className="field">
|
||||
<span>{t.fields.gender} *</span>
|
||||
|
||||
@@ -105,6 +105,11 @@ export const de = {
|
||||
tabPlaceholder: 'Dieser Bereich entsteht in einem späteren Schritt.',
|
||||
notFound: 'Diese Rennmaus wurde nicht gefunden.',
|
||||
moreData: 'Weitere Daten',
|
||||
// ANIMAL-LITTERS: Würfe als Elternteil
|
||||
parentLittersTitle: 'Würfe als Elternteil',
|
||||
parentLittersEmpty: 'Keine Würfe als Elternteil erfasst.',
|
||||
parentLittersRoleVater: 'Vater',
|
||||
parentLittersRoleMutter: 'Mutter',
|
||||
},
|
||||
// Formular (anlegen/bearbeiten)
|
||||
form: {
|
||||
@@ -813,6 +818,19 @@ export const de = {
|
||||
resetButton: 'Filter zurücksetzen',
|
||||
closeButton: 'Schließen',
|
||||
},
|
||||
// ── FEAT-NAMEGEN: Namensvorschläge (KI-gestützt, UC-1 Einzeltier) ──
|
||||
namegen: {
|
||||
button: 'Name vorschlagen',
|
||||
panelTitle: 'Namensvorschläge',
|
||||
letterLabel: 'Anfangsbuchstabe',
|
||||
letterPlaceholder: 'z. B. A',
|
||||
usagesLabel: 'Herkunftskultur',
|
||||
loadButton: 'Vorschläge laden',
|
||||
loading: 'Lade Vorschläge …',
|
||||
empty: 'Keine Vorschläge — andere Einstellungen versuchen.',
|
||||
keyMissing: 'Namensvorschläge benötigen einen API-Key — bitte in den Einstellungen konfigurieren.',
|
||||
close: 'Schließen',
|
||||
},
|
||||
} as const
|
||||
|
||||
export type Strings = typeof de
|
||||
|
||||
Reference in New Issue
Block a user