feat(deploy): TrueNAS Custom-App + Auto-Deploy, plus aufgelaufene Arbeit
Some checks failed
CI / Backend Tests (.NET) (push) Successful in 1m11s
CI / Frontend Tests (Node/Vite) (push) Failing after 4m59s
CI / Docker Build & Push (push) Has been skipped
CI / Deploy auf TrueNAS (Custom App) (push) Has been skipped

Deployment:
- custom-app.compose.yaml: self-contained Compose fuer TrueNAS "Custom App"
  (absolute Host-Bind-Pfade, postgres:18, pull_policy always, Port 8090)
- scripts/truenas-deploy.sh: Host-Skript create/redeploy via midclt (App
  bleibt unter Apps sichtbar) inkl. Image-Pull + Health-Check
- ci.yml Deploy-Job: laeuft auf ubuntu-latest-Runner, kopiert Deploy-Dateien
  per SSH auf den NAS-Host und triggert truenas-deploy.sh (statt runs-on goldeye)
- compose.yaml/.env.example: postgres:18 (Locale-Match zur Quell-DB), Port 8090
- .gitignore: .agents/, tools/rag/, deploy/truenas/.env (Secrets/Scratch)

Aufgelaufene Feature-Arbeit (verified/Freeze, Migrationen, Import-Triage):
- GerbilOverride/VerifiedGerbil-Endpoints + GerbilSnapshotService + Tests
- EF-Migrationen (ShowInChronicle, Stillborn, BirthOrder, ManualFlag, DSGVO)
- Frontend VerifizierteTierePage + verified-API + e2e-Spec
- diverse Import-/Triage-Skripte und -Tests

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-07-19 09:19:11 +02:00
parent 84365bba7f
commit 45b8533f18
93 changed files with 20477 additions and 432 deletions

View File

@@ -414,6 +414,44 @@ export async function installMockApi(page: Page): Promise<MockDb> {
return json(route, 200, result)
}
// GEPRÜFTE TIERE: „vollständig korrekt"-Markierung / Schutz (/verified-gerbils).
if (path === '/verified-gerbils' && method === 'GET') {
return json(route, 200, [...db.verified].reverse())
}
const verMatch = path.match(/^\/verified-gerbils\/([^/]+)$/)
if (verMatch) {
const gid = decodeURIComponent(verMatch[1])
const vIdx = db.verified.findIndex((v) => v.gerbilId === gid)
if (method === 'GET') {
return vIdx >= 0 ? json(route, 200, db.verified[vIdx]) : json(route, 404, { title: 'Not Found' })
}
if (method === 'POST') {
const g = db.gerbils.find((x) => x.id === gid)
if (!g) return json(route, 404, { title: 'Not Found' })
const body = (request.postDataJSON() ?? {}) as { note?: string | null }
const row = {
gerbilId: gid,
entityName: g.name ?? null,
isVerified: true,
status: 'unchanged',
verifiedAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
note: body.note ?? null,
protectedFields: ['name', 'gender', 'dateOfBirth', 'genotype'],
importDiff: [],
}
if (vIdx >= 0) db.verified[vIdx] = row
else db.verified.push(row)
return json(route, 200, row)
}
if (method === 'DELETE') {
if (vIdx < 0) return json(route, 404, { title: 'Not Found' })
db.verified.splice(vIdx, 1)
return json(route, 204)
}
return json(route, 405)
}
// FEEDBACK: "Fehler melden" — POST persistiert, GET listet (neueste zuerst).
if (path === '/feedback') {
if (method === 'POST') {

View File

@@ -80,6 +80,8 @@ export interface MockDb {
namesConfigured: boolean
// FEEDBACK: "Fehler melden" — gesammelte Berichte (POST /feedback)
feedback: Record<string, unknown>[]
// GEPRÜFTE TIERE: „vollständig korrekt"-Markierungen / Schutz (/verified-gerbils)
verified: Record<string, unknown>[]
// RPRO3: RennmausPro-III-Import — feste Auswertung + Import-Ergebnis für die UI-Specs.
rpro3: {
analyze: Record<string, unknown>
@@ -222,10 +224,10 @@ export function seedDb(): MockDb {
]
const litters: Litter[] = [
{ id: 'w-zwillinge', name: 'Wurf Z', date: '2020-05-01', totalBorn: 4, expectedGoHomeDate: null, notes: null, fatherId: 'opa-w', motherId: 'oma-u' },
{ id: 'w-inzucht', name: 'Wurf I', date: '2021-06-01', totalBorn: 3, expectedGoHomeDate: null, notes: null, fatherId: 'zwilling-bock', motherId: 'zwilling-maus' },
{ id: 'w-zwillinge', name: 'Wurf Z', date: '2020-05-01', totalBorn: 4, expectedGoHomeDate: null, notes: null, fatherId: 'opa-w', motherId: 'oma-u', showInChronicle: true },
{ id: 'w-inzucht', name: 'Wurf I', date: '2021-06-01', totalBorn: 3, expectedGoHomeDate: null, notes: null, fatherId: 'zwilling-bock', motherId: 'zwilling-maus', showInChronicle: true },
{
id: 'w-kruemel', name: 'Wurf K', date: '2025-03-12', totalBorn: 5, expectedGoHomeDate: '2025-04-16', notes: null, fatherId: 'fridolin', motherId: 'luna', deathsWithin8Weeks: 1,
id: 'w-kruemel', name: 'Wurf K', date: '2025-03-12', totalBorn: 5, expectedGoHomeDate: '2025-04-16', notes: null, fatherId: 'fridolin', motherId: 'luna', deathsWithin8Weeks: 1, stillborn: 1, showInChronicle: true,
// NACHVERFOLGUNG: Datenherkunft des Wurfs (vom Import erzeugter JSON-String).
provenance: JSON.stringify({
sourceFiles: ['Wurfchronik Teil 1_page_0009.md', 'Wurfchronik Teil 1_page_0028.md'],
@@ -239,13 +241,16 @@ export function seedDb(): MockDb {
],
}),
},
{ id: 'w-fridolin', name: 'Wurf F', date: '2023-05-01', totalBorn: 4, expectedGoHomeDate: null, notes: null, fatherId: 'balu', motherId: 'maja' },
{ id: 'w-luna', name: 'Wurf L', date: '2023-08-15', totalBorn: 6, expectedGoHomeDate: null, notes: null, fatherId: 'karlsson', motherId: 'smilla' },
{ id: 'w-balu', name: 'Wurf B', date: '2021-04-20', totalBorn: 3, expectedGoHomeDate: null, notes: null, fatherId: 'anton', motherId: 'greta' },
{ id: 'w-fridolin', name: 'Wurf F', date: '2023-05-01', totalBorn: 4, expectedGoHomeDate: null, notes: null, fatherId: 'balu', motherId: 'maja', showInChronicle: true },
{ id: 'w-luna', name: 'Wurf L', date: '2023-08-15', totalBorn: 6, expectedGoHomeDate: null, notes: null, fatherId: 'karlsson', motherId: 'smilla', showInChronicle: true },
{ id: 'w-balu', name: 'Wurf B', date: '2021-04-20', totalBorn: 3, expectedGoHomeDate: null, notes: null, fatherId: 'anton', motherId: 'greta', showInChronicle: true },
// Anton ist auch Karlssons Vater -> gemeinsamer Vorfahre (Inzucht-Demo)
{ id: 'w-karlsson', name: 'Wurf C', date: '2022-02-02', totalBorn: 5, expectedGoHomeDate: null, notes: null, fatherId: 'anton', motherId: 'frieda' },
{ id: 'w-anton', name: 'Wurf A', date: '2019-07-07', totalBorn: 4, expectedGoHomeDate: null, notes: null, fatherId: 'emil', motherId: 'hilde' },
{ id: 'w-emil', name: 'Wurf E', date: '2017-03-03', totalBorn: 2, expectedGoHomeDate: null, notes: null, fatherId: 'max', motherId: null },
{ id: 'w-karlsson', name: 'Wurf C', date: '2022-02-02', totalBorn: 5, expectedGoHomeDate: null, notes: null, fatherId: 'anton', motherId: 'frieda', showInChronicle: true },
{ id: 'w-anton', name: 'Wurf A', date: '2019-07-07', totalBorn: 4, expectedGoHomeDate: null, notes: null, fatherId: 'emil', motherId: 'hilde', showInChronicle: true },
{ id: 'w-emil', name: 'Wurf E', date: '2017-03-03', totalBorn: 2, expectedGoHomeDate: null, notes: null, fatherId: 'max', motherId: null, showInChronicle: true },
// WURFCHRONIK-FILTER: dieser Wurf ist NICHT in der Wurfchronik (showInChronicle: false)
// -> taucht in der Wurf-Liste nicht auf, aber auf Fridolins Tier-Detailseite (parentLitters) weiterhin.
{ id: 'w-versteckt', name: 'Wurf V', date: '2024-02-02', totalBorn: 2, expectedGoHomeDate: null, notes: null, fatherId: 'fridolin', motherId: 'luna', showInChronicle: false },
]
const enclosures: Enclosure[] = [
@@ -426,6 +431,8 @@ export function seedDb(): MockDb {
],
saleAdConfigured: true,
namesConfigured: true,
// GEPRÜFTE TIERE: startet leer; die Verify-Specs markieren über POST.
verified: [],
// FEEDBACK-TICKETS: ein offenes + ein gelöstes Ticket (GET liefert neueste zuerst →
// db.feedback wird im Mock umgekehrt; das offene steht hier zuletzt = erscheint oben).
feedback: [

View File

@@ -0,0 +1,36 @@
/**
* GEPRÜFTE TIERE: „vollständig korrekt"-Markierung auf der Rennmausakte (Badge + Toggle)
* und die Übersichtsseite unter Hilfe. Mock-gebunden → skipUnlessMock.
*/
import { de, expect, gotoSection, acceptNextDialog, skipUnlessMock, test } from './fixtures'
const vt = de.verified
test.describe('Geprüfte Tiere', () => {
test('Tier als vollständig korrekt markieren und Markierung entfernen', async ({ page }) => {
skipUnlessMock()
await gotoSection(page, de.nav.gerbils)
// Erste Rennmaus aus der Liste öffnen.
await page.locator('.gerbil-card').first().click()
// Prüfstatus-Karte ist sichtbar und erklärt sich selbst.
await expect(page.getByRole('heading', { name: vt.sectionTitle })).toBeVisible()
// Markieren → Bestätigungs-Badge erscheint.
await page.getByRole('button', { name: vt.markAction }).click()
await expect(page.getByText(vt.verifiedBadge).first()).toBeVisible()
// Markierung entfernen (Klartext-Bestätigung akzeptieren) → Badge verschwindet.
acceptNextDialog(page)
await page.getByRole('button', { name: vt.unmarkAction }).click()
await expect(page.getByText(vt.verifiedBadge)).toHaveCount(0)
})
test('Übersichtsseite unter Hilfe erreichbar', async ({ page }) => {
skipUnlessMock()
await page.goto('/hilfe/verifizierte-tiere')
await expect(page.getByRole('heading', { name: vt.pageTitle })).toBeVisible()
await expect(page.getByText(vt.pageIntro1)).toBeVisible()
})
})

View File

@@ -21,6 +21,7 @@ import StatistikPage from './pages/StatistikPage'
import HilfePage from './pages/HilfePage'
import TicketsPage from './pages/TicketsPage'
import RennmausProImportPage from './pages/RennmausProImportPage'
import VerifizierteTierePage from './pages/VerifizierteTierePage'
import FarbkatalogPage from './pages/FarbkatalogPage'
import VertraegeListPage from './pages/VertraegeListPage'
import VertragWizardPage from './pages/VertragWizardPage'
@@ -83,6 +84,8 @@ export default function App() {
<Route path="tickets" element={<TicketsPage />} />
{/* RPRO3: RennmausPro-III-Backup-Import (Rubrik unter „Hilfe") */}
<Route path="rennmauspro-import" element={<RennmausProImportPage />} />
{/* GEPRÜFTE TIERE: „vollständig korrekt"-Übersicht + Import-Abweichungen */}
<Route path="verifizierte-tiere" element={<VerifizierteTierePage />} />
</Route>
<Route path="farbkatalog" element={<FarbkatalogPage />} />
{/* FEAT-13: Abgabeverträge + Einstellungen (Zuchtprofil) */}

View File

@@ -68,6 +68,11 @@ export interface Gerbil {
* null = manuell angelegtes Tier / keine Importdaten.
*/
provenance?: string | null
/**
* Reihenfolge des Jungtiers innerhalb seines Wurfs (aus der Wurfchronik, nach
* Geburtsgewicht). null = unbekannt → alphabetischer Fallback.
*/
birthOrder?: number | null
}
/**
@@ -191,6 +196,15 @@ export interface Litter {
motherId: string | null
/** LITTER-MORTALITY: pups that died within the first 8 weeks. */
deathsWithin8Weeks?: number | null
/** Totgeburten (tot geboren) — aus der xlsx-Wurfchronik importiert. */
stillborn?: number | null
/**
* WURFCHRONIK-FILTER: true = in der Wurfchronik (Wurf-Liste) sichtbar,
* false = ausgeblendet (z. B. fremde/pedigree-only Würfe). Server-seitig
* Gridify-filterbar (`showInChronicle==true`). Auf der Tier-Detailseite werden
* versteckte Würfe trotzdem gezeigt. Absent = behandeln wie true (Backend-Default).
*/
showInChronicle?: boolean
/**
* NACHVERFOLGUNG: Datenherkunft des Import-Eintrags als JSON-String (vom
* Python-Merge erzeugt; siehe {@link EntityProvenance}). null = manuell angelegt.
@@ -208,10 +222,44 @@ export interface CreateLitter {
fatherId?: string | null
motherId?: string | null
deathsWithin8Weeks?: number | null
stillborn?: number | null
/** WURFCHRONIK-FILTER: false = aus der Wurfchronik ausblenden (Default true). */
showInChronicle?: boolean
}
export type UpdateLitter = Partial<CreateLitter>
/**
* GEPRÜFTE TIERE: Status einer „vollständig korrekt"-Markierung / eines Schutzes
* relativ zum Rohimport. "unchanged" = Import stimmt mit dem geprüften Stand überein;
* "drifted" = Import weicht ab (siehe importDiff); "missing" = das Tier existiert im
* Import nicht mehr.
*/
export type VerifiedStatus = 'unchanged' | 'drifted' | 'missing'
/** Ein Feld-Unterschied zwischen geprüftem Stand (golden) und Rohimport. */
export interface SnapshotDiff {
path: string
goldenValue: string | null
importValue: string | null
}
/** Override-/Prüfstatus eines Tiers (Backend: GET /verified-gerbils[/{id}]). */
export interface VerifiedGerbil {
gerbilId: string
entityName: string | null
/** true = „vollständig korrekt" (zertifiziert + voller Freeze); false = „geschützt" (Bearbeitung, feldweise). */
isVerified: boolean
status: VerifiedStatus
verifiedAt: string | null
updatedAt: string
note: string | null
/** Eigene-Feld-Schlüssel, die gegen den Import eingefroren sind. */
protectedFields: string[]
/** Wo der Rohimport vom geprüften Stand abweicht (nur verifizierte Tiere). */
importDiff: SnapshotDiff[]
}
/** Paged list envelope returned by Gridify list endpoints. */
export interface Paged<T> {
items: T[]

View File

@@ -0,0 +1,37 @@
/**
* GEPRÜFTE TIERE: API-Client für die „vollständig korrekt"-Markierung + den Schutz
* manueller Änderungen (/verified-gerbils). Eine markierte Maus wird vom automatischen
* Import nicht mehr verändert (Freeze).
*/
import { api, ApiError } from './client'
import type { VerifiedGerbil } from './types'
const RESOURCE = '/verified-gerbils'
/** Alle geprüften/geschützten Tiere mit Drift-Status. */
export function listVerified(): Promise<VerifiedGerbil[]> {
return api.get<VerifiedGerbil[]>(RESOURCE)
}
/**
* Prüfstatus eines einzelnen Tiers. Gibt null zurück, wenn das Tier weder markiert
* noch geschützt ist (Backend liefert dann 404) — dann gibt es einfach keinen Status.
*/
export async function getVerifiedStatus(gerbilId: string): Promise<VerifiedGerbil | null> {
try {
return await api.get<VerifiedGerbil>(`${RESOURCE}/${gerbilId}`)
} catch (e) {
if (e instanceof ApiError && e.status === 404) return null
throw e
}
}
/** Als „vollständig korrekt" markieren (Freeze aller eigenen Felder). Optionaler Hinweis. */
export function verifyGerbil(gerbilId: string, note?: string | null): Promise<VerifiedGerbil> {
return api.post<VerifiedGerbil>(`${RESOURCE}/${gerbilId}`, { note: note ?? null })
}
/** Markierung/Schutz entfernen (nicht-destruktiv; nächster Import macht wieder import-getrieben). */
export function unverifyGerbil(gerbilId: string): Promise<void> {
return api.delete(`${RESOURCE}/${gerbilId}`)
}

View File

@@ -10,7 +10,10 @@ type NameParts = Pick<Gerbil, 'name' | 'originContactId' | 'originBreeder'>
/** Own-bred = no external origin recorded (no origin contact, no freetext breeder). */
export function isOwnBred(g: NameParts): boolean {
return !g.originContactId && !(g.originBreeder && g.originBreeder.trim())
if (g.originContactId) return false
const b = g.originBreeder?.trim().toLowerCase()
if (!b) return true
return b.includes('chaoten')
}
/**

View File

@@ -25,6 +25,7 @@ import {
makeGenotype,
resolveAllelePair,
toDisplayString,
fromDisplayString,
wildType,
type AllelePair,
type Genotype,
@@ -335,6 +336,20 @@ export function genotypeToFarbschlag(g: Genotype): string {
return farbschlagFor(g).name
}
export function formatVarietyName(baseName: string, genotypeStr: string | null | undefined): string {
if (!baseName) return ''
if (!genotypeStr?.trim()) return baseName
try {
const g = fromDisplayString(genotypeStr)
const modifiers: string[] = []
if (locusToken(g, 'Sp') === 'Sp') modifiers.push('Schecke')
if (locusToken(g, 'Re') === 'Re') modifiers.push('Rex')
return [baseName, ...modifiers].join(' ')
} catch {
return baseName
}
}
/**
* A representative full genotype for a catalog entry: each specified locus is
* homozygous for its token allele; unspecified loci take the wild-type allele.

View File

@@ -41,6 +41,7 @@ export type { PhenotypeTokens } from './phenotype'
export {
farbschlagFor,
genotypeToFarbschlag,
formatVarietyName,
representativeGenotype,
farbschlagImageUrl,
findVariety,

View File

@@ -15,6 +15,7 @@ import { useApi, useMutation } from '../hooks/useApi'
import { formatDate } from '../format/labels'
import { useToast } from '../components/toast'
import EnclosurePhotosSection from '../components/EnclosurePhotosSection'
import { formatVarietyName } from '../genetics'
/** true, wenn die nächste fällige Reinigung am/vor heute liegt. */
function isCleaningDue(nextCleaningDate: string | null): boolean {
@@ -201,7 +202,7 @@ export default function BeckenDetailPage() {
<Link to={`/rennmaeuse/${g.id}`} className="gerbil-card">
<span className="gerbil-card__name">{g.name}</span>
<span className="gerbil-card__meta">
{g.colorVarietyId ? (colorNameById.get(g.colorVarietyId) ?? '—') : '—'}
{g.colorVarietyId ? formatVarietyName(colorNameById.get(g.colorVarietyId) ?? '—', g.genotype) : '—'}
</span>
</Link>
</li>

View File

@@ -1,7 +1,8 @@
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 { getGerbil, updateGerbil, listGerbils } from '../api/gerbils'
import { getVerifiedStatus, verifyGerbil, unverifyGerbil } from '../api/verified'
import { listLitters as listLittersPaged } from '../api/litters'
import { listColorVarieties, listContacts, listEnclosures, listLitters } from '../api/lookups'
import { useApi, useMutation } from '../hooks/useApi'
@@ -10,6 +11,7 @@ import { ALL_TRAITS, TRAIT_CATEGORIES } from '../format/traits'
import {
fromDisplayString,
genotypeToFarbschlag,
formatVarietyName,
displayGenotypeSafe,
toDisplayString,
hasUnknown,
@@ -73,6 +75,10 @@ export default function GerbilDetailPage() {
const gerbil = useApi(() => getGerbil(id), [id])
const forSale = useMutation(() => updateGerbil(id, { status: 'ForSale' }))
// GEPRÜFTE TIERE: „vollständig korrekt"-Markierung / Schutz-Status dieses Tiers.
const verifiedStatus = useApi(() => getVerifiedStatus(id), [id])
const verify = useMutation(() => verifyGerbil(id))
const unverify = useMutation(() => unverifyGerbil(id))
// FEAT-14: Charakterbogen (persisted on the Gerbil).
const [charTraits, setCharTraits] = useState<string[]>([])
const [charNote, setCharNote] = useState('')
@@ -92,6 +98,51 @@ export default function GerbilDetailPage() {
[id],
)
const breedingParentIds = useMemo(() => {
const ids = new Set<string>()
for (const l of litters.data ?? []) {
if (l.fatherId) ids.add(l.fatherId)
if (l.motherId) ids.add(l.motherId)
}
return ids
}, [litters.data])
const offspring = useApi(
() => {
const litterIds = (parentLitters.data?.items ?? []).map((l) => l.id)
if (litterIds.length === 0) {
return Promise.resolve({ items: [], totalCount: 0, page: 1, pageSize: 100 })
}
const filter = litterIds.map((lid) => `litterId=${lid}`).join('|')
return listGerbils({ filter, pageSize: 200 })
},
[parentLitters.data],
)
// ZUCHTPARTNER: die jeweils anderen Elterntiere der eigenen Würfe (distinct).
const partnerIds = useMemo(() => {
const ids = new Set<string>()
for (const l of parentLitters.data?.items ?? []) {
const pid = l.fatherId === id ? l.motherId : l.fatherId
if (pid && pid !== id) ids.add(pid)
}
return [...ids]
}, [parentLitters.data, id])
const partners = useApi(
() =>
partnerIds.length === 0
? Promise.resolve({ items: [], totalCount: 0, page: 1, pageSize: 100 })
: listGerbils({ filter: partnerIds.map((pid) => `id=${pid}`).join('|'), pageSize: 100 }),
[partnerIds],
)
const breedingOffspring = useMemo(() => {
return (offspring.data?.items ?? []).filter(
(child) => child.status === 'Breeding' || breedingParentIds.has(child.id),
)
}, [offspring.data, breedingParentIds])
const colorName = useMemo(
() => new Map((colorVarieties.data ?? []).map((c) => [c.id, c.name])),
[colorVarieties.data],
@@ -179,7 +230,8 @@ export default function GerbilDetailPage() {
})()
const lookup = (map: Map<string, string>, key: string | null) => (key ? (map.get(key) ?? '—') : '—')
const storedColorName = g.colorVarietyId ? (colorName.get(g.colorVarietyId) ?? null) : null
const baseColorName = g.colorVarietyId ? (colorName.get(g.colorVarietyId) ?? null) : null
const storedColorName = baseColorName ? formatVarietyName(baseColorName, g.genotype) : null
// Geschwisterverpaarung: Vater und Mutter dieses Tiers sind Vollgeschwister —
// erkennbar an gemeinsamer litterId ODER gleichem Geburtsdatum (starkes Indiz:
@@ -229,6 +281,15 @@ export default function GerbilDetailPage() {
</div>
<div className="ak-heroover">
<h1 className="ak-name">{gerbilName(g) || de.pages.gerbils.nameless}</h1>
{g.isResident !== false && (parentLitters.data?.items.length ?? 0) > 0 && (
<span
className="ak-badge-extern"
style={{ color: '#5e8c5a' }}
title={t.detail.zuchttierBadgeTitle}
>
{t.detail.zuchttierBadge}
</span>
)}
<span className="ak-statusbadge" style={{ color: STATUS_COLORS[g.status] }}>
{statusLabel(g.status)}
</span>
@@ -237,6 +298,33 @@ export default function GerbilDetailPage() {
{de.pages.gerbils.externalBadge}
</span>
)}
{verifiedStatus.data?.isVerified && (
<span
className="ak-badge-extern"
style={{ color: '#5e8c5a' }}
title={de.verified.verifiedBadgeTitle}
>
{de.verified.verifiedBadge}
</span>
)}
{verifiedStatus.data && !verifiedStatus.data.isVerified && (
<span
className="ak-badge-extern"
style={{ color: '#5a7da8' }}
title={de.verified.protectedBadgeTitle}
>
{de.verified.protectedBadge}
</span>
)}
{verifiedStatus.data?.status === 'drifted' && (
<span
className="ak-badge-extern"
style={{ color: '#c2703d' }}
title={de.verified.driftBadgeTitle}
>
{de.verified.driftBadge}
</span>
)}
</div>
</div>
@@ -311,6 +399,82 @@ export default function GerbilDetailPage() {
</Link>
</div>
{/* GEPRÜFTE TIERE: Prüfstatus & Schutz — laienverständlich erklärt. */}
{(() => {
const vt = de.verified
const vs = verifiedStatus.data
const isVerified = vs?.isVerified === true
const isProtected = vs != null && vs.isVerified === false
const drifted = vs?.status === 'drifted'
const fieldLabel = (path: string) => vt.fieldLabels[path] ?? path
const runVerify = async () => {
const r = await verify.run()
if (r.ok) { toast.success(vt.markSuccess); verifiedStatus.reload(); gerbil.reload() }
else toast.error(r.error)
}
const runUnverify = async (confirmMsg: string) => {
if (!window.confirm(confirmMsg)) return
const r = await unverify.run()
if (r.ok) { toast.success(vt.unmarkSuccess); verifiedStatus.reload(); gerbil.reload() }
else toast.error(r.error)
}
return (
<section className="ak-card">
<h2 className="ak-h2">{vt.sectionTitle}</h2>
<p className="ak-empty">{isProtected ? vt.explainProtected : vt.explainIntro}</p>
<div className="ak-saverow">
{!isVerified && (
<button
type="button"
className="ak-btn primary"
disabled={verify.pending}
onClick={runVerify}
>
{verify.pending ? vt.marking : vt.markAction}
</button>
)}
{isVerified && (
<button
type="button"
className="ak-btn"
disabled={unverify.pending}
onClick={() => runUnverify(vt.unmarkConfirm)}
>
{vt.unmarkAction}
</button>
)}
{isProtected && (
<button
type="button"
className="ak-btn"
disabled={unverify.pending}
onClick={() => runUnverify(vt.releaseConfirm)}
>
{vt.releaseAction}
</button>
)}
</div>
{isVerified && !drifted && <p className="ak-empty">{vt.statusUnchanged}</p>}
{isVerified && drifted && vs!.importDiff.length > 0 && (
<>
<h3 className="ak-h2" style={{ fontSize: '1rem', marginTop: '0.8rem' }}>
{vt.driftHeading}
</h3>
<p className="ak-empty">{vt.driftHint}</p>
<dl className="ak-kvlist">
{vs!.importDiff.map((d) => (
<Kv key={d.path} label={fieldLabel(d.path)}>
<span>{d.goldenValue || '—'}</span>
<span className="muted"> {vt.colImport}: {d.importValue || '—'}</span>
</Kv>
))}
</dl>
</>
)}
</section>
)
})()}
<section className="ak-card">
<h2 className="ak-h2">{t.detail.masterData}</h2>
<dl className="ak-kvlist">
@@ -349,13 +513,18 @@ export default function GerbilDetailPage() {
{showEnclosure && (
<Kv label={t.fields.enclosure}>{lookup(enclosureName, g.enclosureId)}</Kv>
)}
<Kv label={t.fields.litter}>
{g.litterId && litterName.has(g.litterId) ? (
<Link to={`/wuerfe/${g.litterId}`}>{litterName.get(g.litterId)}</Link>
) : (
lookup(litterName, g.litterId)
)}
</Kv>
{/* Ticket 7dedec07: Die „Wurf"-Zeile nur zeigen, wenn das Tier einen eigenen
Geburtswurf in dieser Zucht hat. Zugekaufte/externe Zuchttiere (z. B. Blacky,
litterId=null) sind nicht hier geboren → keine Wurf-Zeile. */}
{g.litterId && (
<Kv label={t.fields.litter}>
{litterName.has(g.litterId) ? (
<Link to={`/wuerfe/${g.litterId}`}>{litterName.get(g.litterId)}</Link>
) : (
lookup(litterName, g.litterId)
)}
</Kv>
)}
{ownLitter && (
<>
<Kv label={t.detail.parentLittersRoleVater}>{parentLink(fatherId, father.data)}</Kv>
@@ -369,9 +538,19 @@ export default function GerbilDetailPage() {
g.originBreeder || '—'
)}
</Kv>
{g.status !== 'Deceased' && (
{(g.receiverContactId || g.status !== 'Deceased') && (
<Kv label={t.fields.receiver}>{lookup(contactName, g.receiverContactId)}</Kv>
)}
{g.isResident !== false && (partners.data?.items.length ?? 0) > 0 && (
<Kv label={t.detail.zuchtpartner}>
{(partners.data?.items ?? []).map((p, i) => (
<span key={p.id}>
{i > 0 && ', '}
<Link to={`/rennmaeuse/${p.id}`}>{p.name || de.pages.gerbils.nameless}</Link>
</span>
))}
</Kv>
)}
<Kv label={t.fields.notes}>{g.notes || '—'}</Kv>
</dl>
</section>
@@ -419,6 +598,19 @@ export default function GerbilDetailPage() {
{g.isResident === false && (
<section className="ak-card">
<p className="ak-empty">{t.detail.nonResidentNote}</p>
{(offspring.data?.items.length ?? 0) > 0 && (
<p className="ak-empty">
{t.detail.nonResidentOffspringLabel}{' '}
{(offspring.data?.items ?? []).map((child, i) => (
<span key={child.id}>
{i > 0 && ', '}
<Link to={`/rennmaeuse/${child.id}`}>
{child.name || de.pages.gerbils.nameless}
</Link>
</span>
))}
</p>
)}
</section>
)}
@@ -519,6 +711,11 @@ export default function GerbilDetailPage() {
{litter.totalBorn} {de.pages.litters.countLabel}
</span>
)}
{litter.showInChronicle === false && (
<span className="gerbil-card__meta muted">
{t.detail.parentLittersNotInChronicle}
</span>
)}
</Link>
</li>
))}
@@ -527,6 +724,42 @@ export default function GerbilDetailPage() {
</section>
)}
{g.isResident !== false && (parentLitters.data?.items.length ?? 0) > 0 && (
<section className="ak-card">
<h2 className="ak-h2">{t.detail.offspringInBreedingTitle}</h2>
{offspring.loading && <p className="muted">{de.common.loading}</p>}
{!offspring.loading && (breedingOffspring.length === 0) && (
<p className="ak-empty">{t.detail.offspringInBreedingEmpty}</p>
)}
{breedingOffspring.length > 0 && (
<ul className="card-list">
{breedingOffspring.map((child) => {
const childLitter = (parentLitters.data?.items ?? []).find(l => l.id === child.litterId);
const suffix = child.originBreeder ? ` (${child.originBreeder})` : '';
return (
<li key={child.id}>
<Link to={`/rennmaeuse/${child.id}`} className="gerbil-card">
<span className="gerbil-card__name">{child.name || de.pages.gerbils.nameless}</span>
<span className="badge badge--active">
{statusLabel(child.status)}
</span>
{childLitter && (
<span className="gerbil-card__meta">
{childLitter.name}
</span>
)}
<span className="gerbil-card__meta muted">
{child.gender === 'male' ? 'Bock' : child.gender === 'female' ? 'Weibchen' : 'Unbekannt'}{suffix}
</span>
</Link>
</li>
);
})}
</ul>
)}
</section>
)}
<section className="ak-card">
<h2 className="visually-hidden">{t.detail.moreData}</h2>
<div className="ak-tabs" role="tablist">

View File

@@ -239,6 +239,12 @@ export default function HilfePage() {
<span aria-hidden="true"> </span>
</Link>
{/* GEPRÜFTE TIERE: „vollständig korrekt"-Übersicht */}
<Link to="/hilfe/verifizierte-tiere" className="hilfe-tickets-link">
{de.verified.hilfeLink}
<span aria-hidden="true"> </span>
</Link>
<div className="hilfe-accordion">
{sections.map((s) => (
<details key={s.id} className="hilfe-section">

View File

@@ -14,6 +14,7 @@ import { condition } from '../api/gridify'
import { useApi, useMutation } from '../hooks/useApi'
import ProvenanceDialog from '../components/ProvenanceDialog'
import ReportErrorDialog from '../components/ReportErrorDialog'
import { formatVarietyName } from '../genetics'
export default function KontaktDetailPage() {
const t = de.pages.kontakte
@@ -164,7 +165,7 @@ export default function KontaktDetailPage() {
{g.name}
</span>
<span className="gerbil-card__meta">
{g.colorVarietyId ? (colorNameById.get(g.colorVarietyId) ?? '—') : '—'}
{g.colorVarietyId ? formatVarietyName(colorNameById.get(g.colorVarietyId) ?? '—', g.genotype) : '—'}
</span>
</Link>
</li>

View File

@@ -31,7 +31,7 @@ import { formatDate, genderLabel } from '../format/labels'
import GerbilIcon from '../components/GerbilIcon'
import ReportErrorDialog, { type ReportErrorContext } from '../components/ReportErrorDialog'
import { useToast } from '../components/toast'
import { UNKNOWN_FARBSCHLAG, fromDisplayString, genotypeToFarbschlag, displayGenotypeSafe } from '../genetics'
import { UNKNOWN_FARBSCHLAG, fromDisplayString, genotypeToFarbschlag, formatVarietyName, displayGenotypeSafe } from '../genetics'
import {
DEFAULT_GENERATIONS,
ancestorsAt,
@@ -206,7 +206,7 @@ export default function StammbaumPage() {
const farbschlagOf = useCallback(
(g: Gerbil): string | null => {
const byId = g.colorVarietyId ? colorNameById.get(g.colorVarietyId) : undefined
if (byId) return byId
if (byId) return formatVarietyName(byId, g.genotype)
if (g.genotype && g.genotype.trim()) {
try {
const name = genotypeToFarbschlag(fromDisplayString(g.genotype))

View File

@@ -15,7 +15,7 @@ import { listColorVarieties } from '../api/lookups'
import type { GridifyQuery } from '../api/gridify'
import type { Gerbil, Paged } from '../api/types'
import { useApi } from '../hooks/useApi'
import { UNKNOWN_FARBSCHLAG, fromDisplayString, genotypeToFarbschlag } from '../genetics'
import { UNKNOWN_FARBSCHLAG, fromDisplayString, genotypeToFarbschlag, formatVarietyName } from '../genetics'
import {
avgLitterSizePerYear,
farbschlagDistribution,
@@ -89,7 +89,7 @@ export default function StatistikPage() {
const farbschlagOf = useCallback(
(g: Gerbil): string | null => {
const byId = g.colorVarietyId ? colorNameById.get(g.colorVarietyId) : undefined
if (byId) return byId
if (byId) return formatVarietyName(byId, g.genotype)
if (g.genotype && g.genotype.trim()) {
try {
const name = genotypeToFarbschlag(fromDisplayString(g.genotype))

View File

@@ -0,0 +1,115 @@
/**
* GEPRÜFTE TIERE: konsolidierte Übersicht aller als „vollständig korrekt" markierten
* bzw. von Hand geänderten (geschützten) Mäuse — inkl. Abweichungen zum automatischen
* Import. Laienverständlich: erklärt oben, was „geprüft"/„geschützt"/„weicht ab" bedeutet.
*/
import { Link } from 'react-router-dom'
import { de } from '../strings/de'
import { listVerified } from '../api/verified'
import { useApi } from '../hooks/useApi'
import './hilfe.css'
const vt = de.verified
function statusLabel(status: string): string {
return vt.statusLabels[status] ?? status
}
export default function VerifizierteTierePage() {
const data = useApi(() => listVerified(), [])
const rows = data.data ?? []
const verifiedRows = rows.filter((r) => r.isVerified)
const protectedRows = rows.filter((r) => !r.isVerified)
const fieldLabel = (path: string) => vt.fieldLabels[path] ?? path
return (
<section className="page">
<h2>{vt.pageTitle}</h2>
<p className="hilfe-subtitle">{vt.pageSubtitle}</p>
<p>{vt.pageIntro1}</p>
<p className="hilfe-hint">{vt.pageIntro2}</p>
{data.loading && <p className="muted">{de.common.loading}</p>}
{data.error && <p className="error-text">{vt.loadError}</p>}
{!data.loading && !data.error && rows.length === 0 && (
<p className="muted">{vt.empty}</p>
)}
{verifiedRows.length > 0 && (
<>
<h3>{vt.sectionVerified}</h3>
<ul className="card-list">
{verifiedRows.map((r) => (
<li key={r.gerbilId}>
<div className="gerbil-card" style={{ display: 'block' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: '0.6rem', flexWrap: 'wrap' }}>
<Link to={`/rennmaeuse/${r.gerbilId}`} className="gerbil-card__name">
{r.entityName || de.pages.gerbils.nameless}
</Link>
<span
className="badge"
style={{
color:
r.status === 'drifted' ? '#c2703d' : r.status === 'missing' ? '#8a847b' : '#5e8c5a',
}}
>
{statusLabel(r.status)}
</span>
</div>
{r.status === 'drifted' && r.importDiff.length > 0 && (
<details style={{ marginTop: '0.5rem' }}>
<summary>{vt.driftHeading}</summary>
<p className="hilfe-hint">{vt.driftHint}</p>
<table className="drift-table">
<thead>
<tr>
<th>{vt.colField}</th>
<th>{vt.colGolden}</th>
<th>{vt.colImport}</th>
</tr>
</thead>
<tbody>
{r.importDiff.map((d) => (
<tr key={d.path}>
<td>{fieldLabel(d.path)}</td>
<td>{d.goldenValue || '—'}</td>
<td className="muted">{d.importValue || '—'}</td>
</tr>
))}
</tbody>
</table>
</details>
)}
</div>
</li>
))}
</ul>
</>
)}
{protectedRows.length > 0 && (
<>
<h3>{vt.sectionProtected}</h3>
<ul className="card-list">
{protectedRows.map((r) => (
<li key={r.gerbilId}>
<div className="gerbil-card" style={{ display: 'block' }}>
<Link to={`/rennmaeuse/${r.gerbilId}`} className="gerbil-card__name">
{r.entityName || de.pages.gerbils.nameless}
</Link>
{r.protectedFields.length > 0 && (
<p className="gerbil-card__meta muted" style={{ marginTop: '0.3rem' }}>
{vt.protectedFieldsLabel}{' '}
{r.protectedFields.map((f) => fieldLabel(f)).join(', ')}
</p>
)}
</div>
</li>
))}
</ul>
</>
)}
</section>
)
}

View File

@@ -14,9 +14,14 @@ import { useGerbilName } from '../components/breederSuffix'
const PAGE_SIZE = 20
type Tab = 'litters' | 'pairs'
type SortKey = 'dateDesc' | 'dateAsc'
type SortKey = 'dateDesc' | 'dateAsc' | 'nameAsc' | 'nameDesc'
// Trailing `id` → total order, so OFFSET pagination is stable for infinite scroll.
const SORT_ORDER_BY: Record<SortKey, string> = { dateDesc: 'date desc,id', dateAsc: 'date,id' }
const SORT_ORDER_BY: Record<SortKey, string> = {
dateDesc: 'date desc,id',
dateAsc: 'date,id',
nameAsc: 'name,id',
nameDesc: 'name desc,id',
}
function yearOptions(): number[] {
const current = new Date().getFullYear()
@@ -69,7 +74,10 @@ export default function WuerfeListPage() {
}, [gerbilById, gerbilName, t.detail.unknownParent])
// Litters tab (paged, filtered).
// WURFCHRONIK-FILTER: nur Würfe zeigen, die als "in der Wurfchronik" markiert sind
// (showInChronicle==true). Versteckte Würfe bleiben über die Tier-Detailseite sichtbar.
const filter = andFilter(
condition({ field: 'showInChronicle', op: '==', value: true }),
search.trim() && `(name=*${escapeGridifyValue(search)}/i|notes=*${escapeGridifyValue(search)}/i|litterLetter=*${escapeGridifyValue(search)}/i)`,
year && condition({ field: 'date', op: '>=', value: `${year}-01-01` }),
year && condition({ field: 'date', op: '<=', value: `${year}-12-31` }),
@@ -84,7 +92,10 @@ export default function WuerfeListPage() {
// Pairs tab: all litters, grouped client-side.
const allLitters = useApi(
() => (tab === 'pairs' ? listLitters({ page: 1, pageSize: 1000, orderBy: 'date desc' }) : Promise.resolve(null)),
() =>
tab === 'pairs'
? listLitters({ filter: 'showInChronicle==true', page: 1, pageSize: 1000, orderBy: 'date desc' })
: Promise.resolve(null),
[tab],
)
const pairs = useMemo(() => derivePairs(allLitters.data?.items ?? []), [allLitters.data])
@@ -170,6 +181,8 @@ export default function WuerfeListPage() {
<select value={sort} onChange={(e) => setSort(e.target.value as SortKey)}>
<option value="dateDesc">{t.sort.dateDesc}</option>
<option value="dateAsc">{t.sort.dateAsc}</option>
<option value="nameAsc">{t.sort.nameAsc}</option>
<option value="nameDesc">{t.sort.nameDesc}</option>
</select>
</label>
</FilterPanel>

View File

@@ -10,7 +10,7 @@ import { type Gerbil } from '../api/types'
import { useApi } from '../hooks/useApi'
import { formatDate, genderLabel } from '../format/labels'
import { isValidGenotype } from '../format/genotypeText'
import { breed, fromDisplayString, genotypeToFarbschlag, UNKNOWN_FARBSCHLAG, type BreedingResult } from '../genetics'
import { breed, fromDisplayString, genotypeToFarbschlag, formatVarietyName, UNKNOWN_FARBSCHLAG, type BreedingResult } from '../genetics'
import BreedingResultView from '../components/BreedingResultView'
import GerbilIcon from '../components/GerbilIcon'
import ProvenanceDialog from '../components/ProvenanceDialog'
@@ -18,6 +18,13 @@ import ReportErrorDialog from '../components/ReportErrorDialog'
import { useGerbilName } from '../components/breederSuffix'
import './wuerfe.css'
/** Addiert Tage zu einem ISO-Datum (YYYY-MM-DD) und gibt ISO zurück. */
function addDaysIso(iso: string, days: number): string {
const d = new Date(iso + 'T00:00:00')
d.setDate(d.getDate() + days)
return d.toISOString().slice(0, 10)
}
type JuvenileFields = (typeof de.pages.litters.detail.juvenileFields)
function JuvenileCard({ g, farbe, receiver, tJf }: {
@@ -115,7 +122,7 @@ export default function WurfDetailPage() {
(g: { colorVarietyId: string | null; genotype: string | null }): string | null => {
if (g.colorVarietyId) {
const name = colorNameById.get(g.colorVarietyId)
if (name) return name
if (name) return formatVarietyName(name, g.genotype)
}
if (g.genotype?.trim()) {
try {
@@ -191,6 +198,24 @@ export default function WurfDetailPage() {
<dd>{l.deathsWithin8Weeks}</dd>
</div>
)}
{l.stillborn != null && (
<div className="def-row">
<dt>{t.fields.stillborn}</dt>
<dd>{l.stillborn}</dd>
</div>
)}
{l.date && (
<>
<div className="def-row">
<dt>{t.fields.socializeDate}</dt>
<dd>{formatDate(addDaysIso(l.date, 56))}</dd>
</div>
<div className="def-row">
<dt>{t.fields.groupGoHomeDate}</dt>
<dd>{formatDate(addDaysIso(l.date, 70))}</dd>
</div>
</>
)}
<div className="def-row">
<dt>{t.fields.expectedGoHomeDate}</dt>
<dd>{formatDate(l.expectedGoHomeDate)}</dd>
@@ -216,7 +241,15 @@ export default function WurfDetailPage() {
{!juveniles.loading && registered === 0 && <p className="muted">{t.detail.noJuveniles}</p>}
{registered > 0 && (
<ul className="card-list">
{(juveniles.data?.items ?? []).map((g) => {
{[...(juveniles.data?.items ?? [])]
.sort((a, b) => {
// Wurfchronik-Reihenfolge (birthOrder) zuerst; ohne Angabe alphabetisch.
const ao = a.birthOrder ?? Number.POSITIVE_INFINITY
const bo = b.birthOrder ?? Number.POSITIVE_INFINITY
if (ao !== bo) return ao - bo
return (a.name || '').localeCompare(b.name || '', 'de')
})
.map((g) => {
const farbe = farbschlagOf(g)
const receiver = g.receiverContactId ? contactNameById.get(g.receiverContactId) ?? null : null
const tJf = t.detail.juvenileFields

View File

@@ -136,3 +136,21 @@
.hilfe-cta:hover {
background: var(--color-primary-dark, #1d4ed8);
}
/* GEPRÜFTE TIERE: Abweichungs-Tabelle (Rohimport ↔ geprüfter Stand). */
.drift-table {
width: 100%;
border-collapse: collapse;
margin-top: 0.4rem;
font-size: 0.9rem;
}
.drift-table th,
.drift-table td {
text-align: left;
padding: 0.3rem 0.5rem;
border-bottom: 1px solid var(--color-border, #e2e2e2);
vertical-align: top;
}
.drift-table th {
font-weight: 600;
}

View File

@@ -264,8 +264,10 @@
font-size: 16px;
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 10px;
min-width: 0;
overflow-wrap: anywhere;
}
.ak-kv-value a {
color: var(--color-accent);

View File

@@ -133,6 +133,12 @@ export const de = {
parentLittersEmpty: 'Keine Würfe als Elternteil erfasst.',
parentLittersRoleVater: 'Vater',
parentLittersRoleMutter: 'Mutter',
// WURFCHRONIK-FILTER: Wurf ist nicht in der Wurfchronik (showInChronicle=false),
// wird daher nur hier (am Elterntier) gezeigt, nicht in der Wurf-Liste.
parentLittersNotInChronicle: 'Nicht in der Wurfchronik',
offspringInBreedingTitle: 'Nachzucht in der Zucht',
offspringInBreedingEmpty: 'Keine Nachkommen dieses Tiers sind in die Zucht gegangen.',
// Hinweis: Eltern dieses Tiers sind Vollgeschwister (gleicher Wurf).
siblingPairing: 'Geschwisterverpaarung',
siblingPairingTitle:
@@ -141,6 +147,13 @@ export const de = {
// ausgeblendet, da das Tier nie im eigenen Bestand war.
nonResidentNote:
'Externes Tier — nur als Vorfahre erfasst. Würfe und Charakter werden nicht angezeigt, da es nie im eigenen Bestand war.',
// Vorfahren-Nachzucht-Zeile: statt Wurf-Karten nur ein Hinweis auf die Nachzucht.
nonResidentOffspringLabel: 'Nachzucht dieses Vorfahren:',
// Positives „Zuchttier“-Kennzeichen (Tier hatte eigene Würfe in der Zucht).
zuchttierBadge: '★ Zuchttier',
zuchttierBadgeTitle: 'Dieses Tier wurde bei dir in der Zucht eingesetzt (hatte eigene Würfe).',
// Zeile „Zuchtpartner“ in den Stammdaten (bisherige Verpaarungspartner).
zuchtpartner: 'Zuchtpartner',
},
// Formular (anlegen/bearbeiten)
form: {
@@ -151,9 +164,9 @@ export const de = {
ownBreeding: 'Eigene Zucht',
genotypeHint:
'Optional. Format z. B. „Aa CC Dd EE GG Pp Spsp rere“. Unbekannte Allele als „-“.',
// BESTAND-FILTER: Zugehörigkeit zum eigenen Bestand (sonst externe Ahne)
isResidentLabel: 'Gehört zum eigenen Bestand',
isResidentHint: 'Abwählen für externe Ahnen, die nur für den Stammbaum erfasst sind.',
// BESTAND-FILTER: Zuchtanwärter/in (eigenes Zuchttier) vs. reine Ahne
isResidentLabel: 'Zuchtanwärter/innen',
isResidentHint: 'Abwählen für reine Vorfahren, die nur für den Stammbaum erfasst sind und keine eigenen Zuchttiere waren.',
isDeafUnknown: 'Unbekannt',
isDeafYes: 'Ja',
isDeafNo: 'Nein',
@@ -218,6 +231,8 @@ export const de = {
sort: {
dateDesc: 'Wurfdatum (neueste)',
dateAsc: 'Wurfdatum (älteste)',
nameAsc: 'Alphabetisch (A-Z)',
nameDesc: 'Alphabetisch (Z-A)',
},
// Felder
fields: {
@@ -227,7 +242,10 @@ export const de = {
mother: 'Mutter',
totalBorn: 'Wurfstärke',
deathsWithin8Weeks: 'In den ersten 8 Wochen verstorben',
stillborn: 'Totgeburten',
expectedGoHomeDate: 'Voraussichtliches Abgabedatum',
socializeDate: 'Vergesellschaftung (ab 8 Wochen)',
groupGoHomeDate: 'Abgabe als Gruppe (ab 10 Wochen)',
notes: 'Notizen',
},
// Detail / Arbeitsbereich
@@ -1164,6 +1182,92 @@ export const de = {
button: 'Zum RennmausPro-Import',
},
},
// ── GEPRÜFTE TIERE: „vollständig korrekt"-Markierung + Schutz manueller Änderungen ──
// Ziel laienverständlich erklären: eine geprüfte Maus wird vom automatischen Import
// nicht mehr verändert; so wird der Bestand Tier für Tier sauber.
verified: {
// Karte auf der Rennmausakte
sectionTitle: 'Prüfstatus & Schutz',
explainIntro:
'Wenn bei dieser Maus alle Angaben stimmen, markiere sie als „vollständig korrekt". ' +
'Der automatische Import verändert sie dann nicht mehr — deine geprüften Angaben bleiben erhalten. ' +
'So wird dein Bestand nach und nach sauber, ohne dass der Import raten muss.',
explainProtected:
'Diese Maus enthält von dir geänderte Angaben. Diese sind vor dem Import geschützt und bleiben erhalten. ' +
'Felder, die du nicht angefasst hast, werden weiterhin automatisch aus dem Import verbessert.',
verifiedBadge: '✓ Vollständig korrekt',
verifiedBadgeTitle: 'Geprüft — der automatische Import verändert diese Maus nicht mehr.',
protectedBadge: '🔒 Geschützte Änderungen',
protectedBadgeTitle: 'Enthält von dir geänderte Angaben, die der Import nicht überschreibt.',
driftBadge: '⚠ Weicht vom Import ab',
driftBadgeTitle: 'Der automatische Import liefert für einige Felder andere Werte als dein geprüfter Stand.',
markAction: 'Als vollständig korrekt markieren',
unmarkAction: 'Markierung entfernen',
releaseAction: 'Schutz aufheben (wieder aus Import)',
marking: 'Wird gespeichert …',
markSuccess: 'Als vollständig korrekt markiert. Der Import verändert diese Maus nicht mehr.',
unmarkSuccess: 'Markierung entfernt. Beim nächsten Import wird die Maus wieder automatisch aktualisiert.',
error: 'Konnte nicht gespeichert werden.',
unmarkConfirm:
'Markierung wirklich entfernen? Die aktuellen Werte bleiben stehen; beim nächsten Import wird diese Maus wieder automatisch aktualisiert.',
releaseConfirm:
'Schutz wirklich aufheben? Beim nächsten Import wird diese Maus wieder komplett aus dem Import übernommen — deine Änderungen gehen dann verloren.',
statusUnchanged: 'Der Import stimmt mit deinem geprüften Stand überein.',
driftHeading: 'Abweichungen zum automatischen Import',
driftHint:
'So sieht es der Import gerade. Ist dein Stand richtig, kannst du das ignorieren — oder Julian passt den Import an. Deine geprüften Werte bleiben so oder so erhalten.',
colField: 'Feld',
colGolden: 'Dein geprüfter Stand',
colImport: 'Automatischer Import',
// Übersichtsseite (/hilfe/verifizierte-tiere)
pageTitle: 'Geprüfte Tiere',
pageSubtitle: 'Alle Mäuse, die du als „vollständig korrekt" markiert oder von Hand geändert hast.',
pageIntro1:
'Hier siehst du alle Mäuse, deren Angaben du geprüft und markiert hast. Diese werden vom automatischen Import nicht mehr verändert — dein Bestand wird so Tier für Tier sauber.',
pageIntro2:
'„Weicht vom Import ab" heißt: der Import liefert inzwischen andere Werte als dein geprüfter Stand — ein Hinweis, dass am Import noch etwas zu korrigieren ist. Deine Angaben bleiben trotzdem erhalten.',
empty: 'Noch keine Mäuse als vollständig korrekt markiert.',
loadError: 'Die geprüften Tiere konnten nicht geladen werden.',
sectionVerified: 'Vollständig korrekt',
sectionProtected: 'Geschützte Änderungen (von Hand bearbeitet)',
openAkte: 'Zur Akte',
protectedFieldsLabel: 'Geschützte Felder:',
warningsHeading: 'Hinweise',
statusLabels: {
unchanged: 'Unverändert',
drifted: 'Weicht vom Import ab',
missing: 'Im Import nicht mehr vorhanden',
} as Record<string, string>,
// Link auf der Hilfe-Seite
hilfeLink: 'Geprüfte Tiere (vollständig korrekt)',
// Feld-Bezeichnungen für Diff-/Schutz-Anzeige (Schlüssel = Backend-Feldnamen).
fieldLabels: {
name: 'Name',
gender: 'Geschlecht',
dateOfBirth: 'Geburtsdatum',
dateOfDeath: 'Todesdatum',
causeOfDeath: 'Todesursache',
goHomeDate: 'Abgabedatum',
genotype: 'Genotyp',
spottingType: 'Scheckung',
colorName: 'Farbschlag',
colorVarietyId: 'Farbschlag',
isResident: 'Zuchtanwärter/in',
isDeaf: 'Gehörlos',
isCastrated: 'Kastrat',
originBreeder: 'Herkunft (Zucht)',
originContact: 'Herkunft (Kontakt)',
originContactId: 'Herkunft (Kontakt)',
receiverContact: 'Abnehmer',
receiverContactId: 'Abnehmer',
notes: 'Notizen',
characterTraits: 'Charakter-Merkmale',
characterNote: 'Charakter-Notiz',
birthLitter: 'Geburtswurf',
father: 'Vater',
mother: 'Mutter',
} as Record<string, string>,
},
api: {
errors: {
network: 'Verbindung zum Server fehlgeschlagen. Bitte später erneut versuchen.',