feat(import): RennmausPro-III-Backup-Importer (analyze+execute, Dedup, Fotos)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-22 22:43:19 +02:00
parent 5e14124322
commit 3ad276637c
19 changed files with 2620 additions and 0 deletions

View File

@@ -416,6 +416,15 @@ export async function installMockApi(page: Page): Promise<MockDb> {
return json(route, 405)
}
// RPRO3: RennmausPro-III-Backup-Import (multipart-Upload). Mock liefert eine feste
// Auswertung bzw. ein Import-Ergebnis — keine echte Datei-Verarbeitung.
if (path === '/import/rpro3/analyze' && method === 'POST') {
return json(route, 200, db.rpro3.analyze)
}
if (path === '/import/rpro3/execute' && method === 'POST') {
return json(route, 200, db.rpro3.execute)
}
// Generische Kollektionen: /<resource> und /<resource>/<id>
m = path.match(/^\/([a-z-]+)(?:\/([^/]+))?$/)
const col = m ? collections[m[1]] : undefined

View File

@@ -78,6 +78,11 @@ export interface MockDb {
namesConfigured: boolean
// FEEDBACK: "Fehler melden" — gesammelte Berichte (POST /feedback)
feedback: Record<string, unknown>[]
// RPRO3: RennmausPro-III-Import — feste Auswertung + Import-Ergebnis für die UI-Specs.
rpro3: {
analyze: Record<string, unknown>
execute: Record<string, unknown>
}
}
function gerbil(
@@ -376,5 +381,54 @@ export function seedDb(): MockDb {
saleAdConfigured: true,
namesConfigured: true,
feedback: [],
rpro3: {
analyze: {
counts: {
ownAnimals: 303,
externalRaw: 7427,
externalAfterDedup: 3391,
litters: 127,
contacts: 750,
genotypes: 7732,
duplicatesMerged: 4036,
mergeClusters: 782,
},
newVsCurrentNew: 280,
newVsCurrentExisting: 23,
topMerges: [
{ name: 'Alice', recordCount: 30, dob: '2010-07-19', farbe: 'Schimmel', origin: 'Clan of Kleine Füchse' },
{ name: 'BlackEye', recordCount: 28, dob: '', farbe: 'Silberschimmel', origin: 'Clan of Desert' },
],
ambiguousNames: [
{
name: 'Merlin',
bareCount: 0,
variants: [
{ count: 4, dob: '2007-08-22', farbe: 'Blaufuchs', origin: 'Clan of the Yankees', isOwn: false },
{ count: 3, dob: '—', farbe: 'Platin-Schecke', origin: '—', isOwn: false },
],
},
{
name: 'Bella',
bareCount: 0,
variants: [
{ count: 2, dob: '2012-06-21', farbe: 'Blaufuchs', origin: 'Clan of Sunset Glow', isOwn: false },
{ count: 1, dob: '2013-03-20', farbe: 'Algierfuchs, hell', origin: 'eigene Zucht', isOwn: true },
],
},
],
photosProvided: false,
photoFilesAvailable: 0,
},
execute: {
gerbilsImported: 3694,
littersImported: 1678,
contactsImported: 508,
healthRecordsImported: 39,
weightRecordsImported: 229,
photosImported: 0,
message: 'Import erfolgreich: 3694 Tiere, 1678 Würfe, 508 Kontakte.',
},
},
}
}

View File

@@ -0,0 +1,49 @@
/** RPRO3: RennmausPro-III-Import-Rubrik unter „Hilfe" — Auswerten → Import durchführen. */
import { de, expect, skipUnlessMock, test } from './fixtures'
const t = de.pages.rpro3Import
const h = de.hilfe
test('Hilfe verlinkt auf den RennmausPro-Import', async ({ page }) => {
skipUnlessMock()
await page.goto('/hilfe')
// Den Abschnitt aufklappen und dem Link folgen.
await page.getByText(h.sections.rpro3Import).click()
await page.getByRole('link', { name: new RegExp(h.rpro3Link.button) }).click()
await expect(page).toHaveURL(/\/hilfe\/rennmauspro-import$/)
await expect(page.getByRole('heading', { name: t.title })).toBeVisible()
})
test('Auswerten zeigt die Übersicht, danach importiert "Import durchführen"', async ({ page }) => {
skipUnlessMock()
await page.goto('/hilfe/rennmauspro-import')
// Auswerten ohne Datei ist deaktiviert.
const analyzeBtn = page.getByRole('button', { name: t.analyzeButton })
await expect(analyzeBtn).toBeDisabled()
// Eine (Pseudo-).backup-Datei auswählen.
await page.getByLabel(t.backupLabel).setInputFiles({
name: 'RennmausPro.backup',
mimeType: 'application/octet-stream',
buffer: Buffer.from('PK fake-zip'),
})
await expect(analyzeBtn).toBeEnabled()
await analyzeBtn.click()
// Auswertung erscheint (Zählungen + Abgleich).
await expect(page.getByRole('heading', { name: t.step2Title })).toBeVisible()
await expect(page.getByText(t.counts.ownAnimals)).toBeVisible()
await expect(page.getByText('303')).toBeVisible()
await expect(page.getByText(t.newCount(280))).toBeVisible()
// Mehrdeutige Namen sind aufklappbar.
await page.getByText(t.ambiguousTitle).click()
await expect(page.getByText('Merlin')).toBeVisible()
// Import durchführen.
await page.getByRole('button', { name: t.executeButton }).click()
await expect(page.getByRole('heading', { name: t.successTitle })).toBeVisible()
await expect(page.getByText('3694')).toBeVisible()
await expect(page.getByText(t.resultCounts.gerbils)).toBeVisible()
})

View File

@@ -18,6 +18,7 @@ import NotFoundPage from './pages/NotFoundPage'
import StammbaumPage from './pages/StammbaumPage'
import StatistikPage from './pages/StatistikPage'
import HilfePage from './pages/HilfePage'
import RennmausProImportPage from './pages/RennmausProImportPage'
import FarbkatalogPage from './pages/FarbkatalogPage'
import VertraegeListPage from './pages/VertraegeListPage'
import VertragWizardPage from './pages/VertragWizardPage'
@@ -71,6 +72,8 @@ export default function App() {
<Route path="abgabe" element={<AbgabePage />} />
<Route path="statistik" element={<StatistikPage />} />
<Route path="hilfe" element={<HilfePage />} />
{/* RPRO3: RennmausPro-III-Backup-Import (Rubrik unter „Hilfe") */}
<Route path="hilfe/rennmauspro-import" element={<RennmausProImportPage />} />
<Route path="farbkatalog" element={<FarbkatalogPage />} />
{/* FEAT-13: Abgabeverträge + Einstellungen (Zuchtprofil) */}
<Route path="vertraege">

View File

@@ -0,0 +1,103 @@
/**
* RPRO3: API-Client für den RennmausPro-III-Backup-Import.
* POST /import/rpro3/analyze -> multipart 'backup' (+ optional 'images') -> Auswertung (kein Schreiben)
* POST /import/rpro3/execute -> derselbe Upload -> idempotenter Import
*
* Upload via FormData (nicht api.post — das erzwingt application/json); der Browser setzt
* den multipart-Boundary-Header selbst.
*/
import { API_BASE_URL, ApiError } from './client'
import { de } from '../strings/de'
export interface Rpro3Counts {
ownAnimals: number
externalRaw: number
externalAfterDedup: number
litters: number
contacts: number
genotypes: number
duplicatesMerged: number
mergeClusters: number
}
export interface Rpro3AmbiguousVariant {
count: number
dob: string
farbe: string
origin: string
isOwn: boolean
}
export interface Rpro3AmbiguousName {
name: string
variants: Rpro3AmbiguousVariant[]
bareCount: number
}
export interface Rpro3MergeSample {
name: string
recordCount: number
dob: string
farbe: string
origin: string
}
export interface Rpro3AnalyzeResult {
counts: Rpro3Counts
newVsCurrentNew: number
newVsCurrentExisting: number
topMerges: Rpro3MergeSample[]
ambiguousNames: Rpro3AmbiguousName[]
photosProvided: boolean
photoFilesAvailable: number
}
export interface Rpro3ExecuteResult {
gerbilsImported: number
littersImported: number
contactsImported: number
healthRecordsImported: number
weightRecordsImported: number
photosImported: number
message: string
}
async function postUpload<T>(path: string, backup: File, images: File | null): Promise<T> {
const form = new FormData()
form.append('backup', backup)
if (images) form.append('images', images)
let response: Response
try {
response = await fetch(`${API_BASE_URL}${path}`, { method: 'POST', body: form })
} catch {
throw new ApiError(de.api.errors.network, null, path)
}
if (!response.ok) {
// 400 trägt eine deutsche Klartext-Fehlermeldung (z. B. „keine _rpro3.db gefunden").
let body: unknown
let text: string | null = null
try {
text = await response.clone().text()
body = text ? JSON.parse(text) : undefined
} catch {
body = undefined
}
const message =
response.status === 400 && text
? text.replace(/^"|"$/g, '')
: response.status >= 500
? de.api.errors.server
: de.api.errors.unknown
throw new ApiError(message, response.status, path, body)
}
return (await response.json()) as T
}
export function analyzeRpro3(backup: File, images: File | null): Promise<Rpro3AnalyzeResult> {
return postUpload<Rpro3AnalyzeResult>('/import/rpro3/analyze', backup, images)
}
export function executeRpro3(backup: File, images: File | null): Promise<Rpro3ExecuteResult> {
return postUpload<Rpro3ExecuteResult>('/import/rpro3/execute', backup, images)
}

View File

@@ -3,6 +3,7 @@
* Accordion via <details>/<summary> — kein JS-State nötig, barrierefrei, mobiltauglich.
* Texte referenzieren nur Bezeichnungen aus de.ts (bleiben bei Umbenennung korrekt).
*/
import { Link } from 'react-router-dom'
import { de } from '../strings/de'
import './hilfe.css'
@@ -210,6 +211,20 @@ const sections: Section[] = [
</>
),
},
{
id: 'rpro3-import',
title: t.sections.rpro3Import,
content: (
<>
<p>{t.rpro3Link.text}</p>
<p>
<Link to="/hilfe/rennmauspro-import" className="hilfe-cta">
{t.rpro3Link.button}
</Link>
</p>
</>
),
},
]
export default function HilfePage() {

View File

@@ -0,0 +1,255 @@
/**
* RPRO3: Rubrik unter „Hilfe" für den RennmausPro-III-Backup-Import.
* Laienverständlicher 3-Schritt-Ablauf:
* 1) .backup (+ optional _bilder.zip) auswählen → „Auswerten" (schreibt nichts)
* 2) Auswertung: Zählungen, Dubletten, mehrdeutige Namen, neu/vorhanden
* 3) „Import durchführen" → Ergebnis
* Alle Texte aus de.ts.
*/
import { useState } from 'react'
import { Link } from 'react-router-dom'
import { de } from '../strings/de'
import { ApiError } from '../api/client'
import {
analyzeRpro3,
executeRpro3,
type Rpro3AnalyzeResult,
type Rpro3ExecuteResult,
} from '../api/rpro3'
import './rpro3-import.css'
const t = de.pages.rpro3Import
type Phase = 'select' | 'analyzing' | 'analyzed' | 'executing' | 'done'
export default function RennmausProImportPage() {
const [backup, setBackup] = useState<File | null>(null)
const [images, setImages] = useState<File | null>(null)
const [phase, setPhase] = useState<Phase>('select')
const [analysis, setAnalysis] = useState<Rpro3AnalyzeResult | null>(null)
const [result, setResult] = useState<Rpro3ExecuteResult | null>(null)
const [error, setError] = useState<string | null>(null)
function reset() {
setBackup(null)
setImages(null)
setPhase('select')
setAnalysis(null)
setResult(null)
setError(null)
}
async function handleAnalyze() {
if (!backup) {
setError(t.noBackup)
return
}
setError(null)
setPhase('analyzing')
try {
const res = await analyzeRpro3(backup, images)
setAnalysis(res)
setPhase('analyzed')
} catch (err) {
setError(err instanceof ApiError ? err.message : de.api.errors.unknown)
setPhase('select')
}
}
async function handleExecute() {
if (!backup) return
setError(null)
setPhase('executing')
try {
const res = await executeRpro3(backup, images)
setResult(res)
setPhase('done')
} catch (err) {
setError(err instanceof ApiError ? err.message : de.api.errors.unknown)
setPhase('analyzed')
}
}
return (
<section className="page rpro3-page">
<h2>{t.title}</h2>
<p className="rpro3-subtitle">{t.subtitle}</p>
<p className="rpro3-intro">{t.intro}</p>
{error && (
<div className="rpro3-error" role="alert">
<strong>{t.errorTitle}:</strong> {error}
</div>
)}
{/* Schritt 1: Dateien auswählen */}
{phase !== 'done' && (
<div className="rpro3-card">
<h3>{t.step1Title}</h3>
<div className="rpro3-field">
<label htmlFor="rpro3-backup">{t.backupLabel}</label>
<input
id="rpro3-backup"
type="file"
accept=".backup,.zip,.db"
disabled={phase === 'analyzing' || phase === 'executing'}
onChange={(e) => setBackup(e.target.files?.[0] ?? null)}
/>
<small>{t.backupHint}</small>
</div>
<div className="rpro3-field">
<label htmlFor="rpro3-images">{t.imagesLabel}</label>
<input
id="rpro3-images"
type="file"
accept=".zip"
disabled={phase === 'analyzing' || phase === 'executing'}
onChange={(e) => setImages(e.target.files?.[0] ?? null)}
/>
<small>{t.imagesHint}</small>
</div>
<button
type="button"
className="rpro3-btn rpro3-btn-primary"
disabled={!backup || phase === 'analyzing' || phase === 'executing'}
onClick={handleAnalyze}
>
{phase === 'analyzing' ? t.analyzing : t.analyzeButton}
</button>
</div>
)}
{/* Schritt 2: Auswertung */}
{analysis && phase !== 'done' && (
<AnalysisView analysis={analysis} />
)}
{/* Schritt 3: Import durchführen */}
{analysis && (phase === 'analyzed' || phase === 'executing') && (
<div className="rpro3-card">
<h3>{t.step3Title}</h3>
<p className="rpro3-warning">{t.executeWarning}</p>
<button
type="button"
className="rpro3-btn rpro3-btn-primary"
disabled={phase === 'executing'}
onClick={handleExecute}
>
{phase === 'executing' ? t.executing : t.executeButton}
</button>
</div>
)}
{/* Ergebnis */}
{result && phase === 'done' && (
<div className="rpro3-card rpro3-success">
<h3>{t.successTitle}</h3>
<ul className="rpro3-result-list">
<li><strong>{result.gerbilsImported}</strong> {t.resultCounts.gerbils}</li>
<li><strong>{result.littersImported}</strong> {t.resultCounts.litters}</li>
<li><strong>{result.contactsImported}</strong> {t.resultCounts.contacts}</li>
<li><strong>{result.healthRecordsImported}</strong> {t.resultCounts.health}</li>
<li><strong>{result.weightRecordsImported}</strong> {t.resultCounts.weights}</li>
<li><strong>{result.photosImported}</strong> {t.resultCounts.photos}</li>
</ul>
<div className="rpro3-actions">
<Link to="/rennmaeuse" className="rpro3-btn">{de.nav.gerbils}</Link>
<button type="button" className="rpro3-btn" onClick={reset}>{t.restart}</button>
</div>
</div>
)}
</section>
)
}
function AnalysisView({ analysis }: { analysis: Rpro3AnalyzeResult }) {
const c = analysis.counts
return (
<div className="rpro3-card">
<h3>{t.step2Title}</h3>
<h4>{t.overviewTitle}</h4>
<dl className="rpro3-counts">
<Count label={t.counts.ownAnimals} value={c.ownAnimals} />
<Count label={t.counts.externalAfterDedup} value={c.externalAfterDedup} />
<Count label={t.counts.litters} value={c.litters} />
<Count label={t.counts.contacts} value={c.contacts} />
<Count label={t.counts.genotypes} value={c.genotypes} />
<Count label={t.counts.duplicatesMerged} value={c.duplicatesMerged} />
</dl>
<p className="rpro3-hint">{t.dedupExplain}</p>
<h4>{t.newVsExistingTitle}</h4>
<p>
<span className="rpro3-badge rpro3-badge-new">{t.newCount(analysis.newVsCurrentNew)}</span>{' '}
<span className="rpro3-badge">{t.existingCount(analysis.newVsCurrentExisting)}</span>
</p>
<p className="rpro3-photos-note">
{analysis.photosProvided ? t.photosNote(analysis.photoFilesAvailable) : t.photosNoneNote}
</p>
{analysis.topMerges.length > 0 && (
<details className="rpro3-details">
<summary>{t.topMergesTitle}</summary>
<div className="rpro3-table-wrap">
<table className="rpro3-table">
<thead>
<tr>
<th>{t.topMergesCol.name}</th>
<th>{t.topMergesCol.count}</th>
<th>{t.topMergesCol.dob}</th>
<th>{t.topMergesCol.farbe}</th>
<th>{t.topMergesCol.origin}</th>
</tr>
</thead>
<tbody>
{analysis.topMerges.map((m, i) => (
<tr key={i}>
<td>{m.name}</td>
<td>{m.recordCount}</td>
<td>{m.dob || '—'}</td>
<td>{m.farbe || '—'}</td>
<td>{m.origin || '—'}</td>
</tr>
))}
</tbody>
</table>
</div>
</details>
)}
{analysis.ambiguousNames.length > 0 && (
<details className="rpro3-details">
<summary>{t.ambiguousTitle}</summary>
<p className="rpro3-hint">{t.ambiguousIntro}</p>
<ul className="rpro3-ambiguous">
{analysis.ambiguousNames.map((a, i) => (
<li key={i}>
<strong>{a.name}</strong>
<ul>
{a.variants.map((v, j) => (
<li key={j}>
{v.isOwn && <span className="rpro3-star" title={t.ambiguousOwn}> </span>}
{t.ambiguousVariant(v.count, v.dob, v.farbe, v.origin)}
</li>
))}
</ul>
</li>
))}
</ul>
</details>
)}
</div>
)
}
function Count({ label, value }: { label: string; value: number }) {
return (
<div className="rpro3-count">
<dt>{label}</dt>
<dd>{value}</dd>
</div>
)
}

View File

@@ -103,3 +103,17 @@
.hilfe-warning {
color: var(--color-warning-text, #92400e);
}
.hilfe-cta {
display: inline-block;
padding: 0.55rem 1rem;
border-radius: 6px;
background: var(--color-primary, #2563eb);
color: #fff;
text-decoration: none;
font-weight: 600;
}
.hilfe-cta:hover {
background: var(--color-primary-dark, #1d4ed8);
}

View File

@@ -0,0 +1,235 @@
.rpro3-page {
max-width: 760px;
}
.rpro3-subtitle {
color: var(--color-muted, #666);
margin-bottom: 0.75rem;
}
.rpro3-intro {
line-height: 1.6;
margin-bottom: 1.5rem;
}
.rpro3-card {
border: 1px solid var(--color-border, #ddd);
border-radius: 8px;
background: var(--color-surface, #fff);
padding: 1.25rem;
margin-bottom: 1.25rem;
}
.rpro3-card h3 {
margin: 0 0 1rem;
font-size: 1.1rem;
}
.rpro3-card h4 {
margin: 1.25rem 0 0.5rem;
font-size: 1rem;
}
.rpro3-field {
margin-bottom: 1rem;
}
.rpro3-field label {
display: block;
font-weight: 600;
margin-bottom: 0.35rem;
}
.rpro3-field input[type='file'] {
display: block;
width: 100%;
max-width: 100%;
}
.rpro3-field small {
display: block;
color: var(--color-muted, #666);
margin-top: 0.25rem;
}
.rpro3-btn {
display: inline-block;
padding: 0.6rem 1.1rem;
border: 1px solid var(--color-border, #ccc);
border-radius: 6px;
background: var(--color-surface, #fff);
color: inherit;
font-size: 1rem;
cursor: pointer;
text-decoration: none;
}
.rpro3-btn:hover:not(:disabled) {
background: var(--color-hover, #f5f5f5);
}
.rpro3-btn-primary {
background: var(--color-primary, #2563eb);
border-color: var(--color-primary, #2563eb);
color: #fff;
}
.rpro3-btn-primary:hover:not(:disabled) {
background: var(--color-primary-dark, #1d4ed8);
}
.rpro3-btn:disabled {
opacity: 0.55;
cursor: not-allowed;
}
.rpro3-actions {
display: flex;
gap: 0.75rem;
flex-wrap: wrap;
margin-top: 1rem;
}
.rpro3-error {
background: var(--color-danger-bg, #fef2f2);
border-left: 3px solid var(--color-danger, #dc2626);
padding: 0.75rem 1rem;
border-radius: 0 4px 4px 0;
margin-bottom: 1.25rem;
}
.rpro3-warning {
background: var(--color-warning-bg, #fffbeb);
border-left: 3px solid var(--color-warning, #f59e0b);
padding: 0.6rem 0.9rem;
border-radius: 0 4px 4px 0;
line-height: 1.55;
margin-bottom: 1rem;
}
.rpro3-hint {
background: var(--color-info-bg, #eff6ff);
border-left: 3px solid var(--color-info, #3b82f6);
padding: 0.5rem 0.75rem;
border-radius: 0 4px 4px 0;
font-size: 0.93rem;
line-height: 1.55;
}
.rpro3-photos-note {
color: var(--color-muted, #666);
font-size: 0.93rem;
}
/* Zählungen als Kachel-Grid */
.rpro3-counts {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(170px, 1fr));
gap: 0.75rem;
margin: 0.5rem 0 0.5rem;
}
.rpro3-count {
border: 1px solid var(--color-border, #eee);
border-radius: 6px;
padding: 0.6rem 0.75rem;
background: var(--color-hover, #fafafa);
}
.rpro3-count dt {
font-size: 0.85rem;
color: var(--color-muted, #666);
}
.rpro3-count dd {
margin: 0.2rem 0 0;
font-size: 1.5rem;
font-weight: 700;
}
.rpro3-badge {
display: inline-block;
padding: 0.25rem 0.6rem;
border-radius: 999px;
background: var(--color-hover, #f1f5f9);
font-size: 0.92rem;
}
.rpro3-badge-new {
background: var(--color-info-bg, #eff6ff);
color: var(--color-info, #1d4ed8);
font-weight: 600;
}
.rpro3-details {
margin-top: 1rem;
border: 1px solid var(--color-border, #eee);
border-radius: 6px;
padding: 0.5rem 0.75rem;
}
.rpro3-details summary {
cursor: pointer;
font-weight: 600;
}
.rpro3-table-wrap {
overflow-x: auto;
margin-top: 0.75rem;
}
.rpro3-table {
border-collapse: collapse;
width: 100%;
font-size: 0.9rem;
}
.rpro3-table th,
.rpro3-table td {
border: 1px solid var(--color-border, #eee);
padding: 0.35rem 0.55rem;
text-align: left;
white-space: nowrap;
}
.rpro3-table th {
background: var(--color-hover, #f5f5f5);
}
.rpro3-ambiguous {
margin: 0.75rem 0 0 1rem;
line-height: 1.5;
}
.rpro3-ambiguous > li {
margin-bottom: 0.6rem;
}
.rpro3-ambiguous ul {
margin: 0.2rem 0 0 1rem;
font-size: 0.92rem;
color: var(--color-muted, #555);
}
.rpro3-star {
color: var(--color-primary, #2563eb);
}
.rpro3-success {
border-color: var(--color-success, #16a34a);
}
.rpro3-result-list {
list-style: none;
padding: 0;
margin: 0;
display: grid;
grid-template-columns: repeat(auto-fill, minmax(180px, 1fr));
gap: 0.5rem;
}
.rpro3-result-list li {
background: var(--color-hover, #f6f6f6);
border-radius: 6px;
padding: 0.5rem 0.75rem;
}

View File

@@ -820,6 +820,65 @@ export const de = {
},
},
},
// ── RPRO3: RennmausPro-III-Backup-Import (Rubrik unter „Hilfe") ──
rpro3Import: {
title: 'RennmausPro-Import',
subtitle:
'Daten aus deiner alten RennmausPro-III-Software übernehmen — Tiere, Würfe, Stammbäume, Kontakte und Genotypen.',
intro:
'Lade hier deine RennmausPro-Sicherungsdatei hoch (die Datei endet auf „.backup"). Optional kannst du zusätzlich das zugehörige Bilder-Archiv (endet auf „_bilder.zip") hochladen. Zuerst wird die Datei nur ausgewertet — es wird noch nichts gespeichert. Du siehst dann eine Übersicht und entscheidest selbst, ob du den Import durchführst.',
step1Title: '1. Dateien auswählen',
backupLabel: 'RennmausPro-Sicherung (.backup)',
backupHint: 'Pflicht. Die Datei aus deiner RennmausPro-III-Software.',
imagesLabel: 'Bilder-Archiv (_bilder.zip)',
imagesHint: 'Optional. Wird gebraucht, um die Tierfotos zuzuordnen.',
analyzeButton: 'Auswerten',
analyzing: 'Datei wird ausgewertet …',
noBackup: 'Bitte zuerst eine RennmausPro-Sicherung (.backup) auswählen.',
step2Title: '2. Auswertung',
overviewTitle: 'Was steckt in der Datei?',
counts: {
ownAnimals: 'Eigene Tiere',
externalAfterDedup: 'Externe Ahnen (nach Bereinigung)',
externalRaw: 'Externe Ahnen (roh, vor Bereinigung)',
litters: 'Würfe',
contacts: 'Kontakte (Züchter & Abnehmer)',
genotypes: 'Hinterlegte Genotypen',
duplicatesMerged: 'Erkannte Dubletten (zusammengelegt)',
},
newVsExistingTitle: 'Abgleich mit deinem aktuellen Bestand',
newCount: (n: number) => `${n} Tiere wären neu`,
existingCount: (n: number) => `${n} Tiere sind vermutlich schon vorhanden`,
dedupExplain:
'RennmausPro hat beim Import vorhandene Tiere nicht erkannt — dadurch kommt dasselbe Tier oft mehrfach vor. Diese Dubletten werden automatisch anhand von Name, Geburtsdatum, Farbe und Herkunft zusammengelegt.',
topMergesTitle: 'Größte automatische Zusammenlegungen',
topMergesCol: { name: 'Tier', count: 'Datensätze', dob: 'Geburtsdatum', farbe: 'Farbe', origin: 'Herkunft' },
ambiguousTitle: 'Mehrdeutige Namen — bitte später prüfen',
ambiguousIntro:
'Bei diesen Namen gibt es mehrere unterschiedliche Tiere. Sie werden vorsichtshalber NICHT automatisch zusammengelegt, damit nichts falsch verknüpft wird. Du kannst sie nach dem Import in der Tierliste prüfen.',
ambiguousVariant: (count: number, dob: string, farbe: string, origin: string) =>
`${count}× · ${dob} · ${farbe} · ${origin}`,
ambiguousOwn: 'eigenes Tier',
ambiguousMore: (n: number) => `… und ${n} weitere mehrdeutige Namen.`,
photosNote: (n: number) => `Bilder-Archiv erkannt: ${n} Bilddateien werden den Tieren zugeordnet.`,
photosNoneNote: 'Kein Bilder-Archiv hochgeladen — Tiere werden ohne Fotos importiert.',
step3Title: '3. Import durchführen',
executeWarning:
'Beim Import werden die Tiere, Würfe, Kontakte und Genotypen aus RennmausPro übernommen. Ein erneuter Import aktualisiert dieselben Daten (es entstehen keine Dubletten). Deine manuell angelegten Tiere bleiben unberührt.',
executeButton: 'Import jetzt durchführen',
executing: 'Import läuft … das kann einen Moment dauern.',
successTitle: 'Import abgeschlossen',
resultCounts: {
gerbils: 'Tiere importiert',
litters: 'Würfe importiert',
contacts: 'Kontakte importiert',
health: 'Gesundheitseinträge',
weights: 'Gewichtseinträge',
photos: 'Fotos',
},
errorTitle: 'Es ist ein Fehler aufgetreten',
restart: 'Neue Datei auswählen',
},
},
// ── HELP-1: In-App-Anleitung ──
hilfe: {
@@ -838,6 +897,12 @@ export const de = {
einstellungen: 'Zuchtprofil (Einstellungen)',
statistik: 'Statistik',
datensicherung: 'Datensicherung',
rpro3Import: 'Daten aus RennmausPro übernehmen',
},
// Verweis auf die eigene RennmausPro-Import-Rubrik.
rpro3Link: {
text: 'Hast du früher mit der Software „RennmausPro III" gearbeitet? Du kannst deine alten Daten (Tiere, Würfe, Stammbäume, Kontakte) bequem übernehmen.',
button: 'Zum RennmausPro-Import',
},
},
api: {