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

@@ -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">