feat(deploy): TrueNAS Custom-App + Auto-Deploy, plus aufgelaufene Arbeit
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:
@@ -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) */}
|
||||
|
||||
@@ -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[]
|
||||
|
||||
37
gerbil-manager-web/src/api/verified.ts
Normal file
37
gerbil-manager-web/src/api/verified.ts
Normal 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}`)
|
||||
}
|
||||
@@ -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')
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -41,6 +41,7 @@ export type { PhenotypeTokens } from './phenotype'
|
||||
export {
|
||||
farbschlagFor,
|
||||
genotypeToFarbschlag,
|
||||
formatVarietyName,
|
||||
representativeGenotype,
|
||||
farbschlagImageUrl,
|
||||
findVariety,
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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">
|
||||
|
||||
Binary file not shown.
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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))
|
||||
|
||||
115
gerbil-manager-web/src/pages/VerifizierteTierePage.tsx
Normal file
115
gerbil-manager-web/src/pages/VerifizierteTierePage.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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.',
|
||||
|
||||
Reference in New Issue
Block a user