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:
@@ -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">
|
||||
|
||||
Reference in New Issue
Block a user