Compare commits
15 Commits
ddc343a96b
...
feature/ge
| Author | SHA1 | Date | |
|---|---|---|---|
| d5c7b77e7e | |||
| bff25b4683 | |||
| aa1d22cec7 | |||
| f7e97ad7a2 | |||
| 7d552d33cd | |||
| 4d7333b714 | |||
| 62b07597f5 | |||
| 5ce35af813 | |||
| 23b6dad303 | |||
| e9eef5cce6 | |||
| 299c038449 | |||
| ecd7ad2658 | |||
| 6de56ab112 | |||
| 40f3ce250d | |||
| 29e9c4cd64 |
3
.gitignore
vendored
3
.gitignore
vendored
@@ -137,3 +137,6 @@ GerbilManagerWebAPI/photo-storage/
|
||||
|
||||
# AR-3: Data Protection key ring (dev-only ephemeral keys) — never commit
|
||||
GerbilManagerWebAPI/.data-protection-keys/
|
||||
|
||||
# node_modules — never commit, installed locally per npm install
|
||||
node_modules/
|
||||
|
||||
@@ -21,7 +21,7 @@ namespace GerbilManagerWebAPI.Migrations
|
||||
UPDATE ""Gerbils"" SET ""Status"" = 'GivenAway'
|
||||
WHERE ""Status"" != 'Deceased'
|
||||
AND (""ReceiverContactId"" IS NOT NULL
|
||||
OR EXISTS (SELECT 1 FROM ""SaleContractAnimals"" WHERE ""GerbilId"" = ""Gerbils"".""Id""));");
|
||||
OR EXISTS (SELECT 1 FROM ""SaleContractAnimal"" WHERE ""GerbilId"" = ""Gerbils"".""Id""));");
|
||||
|
||||
// 4) Age > 7 years (presumed dead) → Deceased, but NOT overriding GivenAway.
|
||||
migrationBuilder.Sql(@"
|
||||
|
||||
@@ -64,7 +64,7 @@ _Quelldatei = die Stammbaum-Datei(en) in `C:\Users\gulum\dev\Sttammbäume\`, in
|
||||
**B) Gencode-Konflikt** (+ ggf. Sterbedatum):
|
||||
| Tier | Konflikt — was stimmt? | Quelldatei(en) |
|
||||
|---|---|---|
|
||||
| Milon v.d. K.C. (*27.11.2014) | A-Locus: **Aa** ↔ **aa** | Stammbaum von **South Dakota**.xlsx · von **Tennessee**.xlsx |
|
||||
| ~~Milon v.d. K.C. (*27.11.2014)~~ ✅ | A-Locus **Aa** (erledigt — Julian 2026-06-07: Aa gewinnt gg. aa) | — |
|
||||
| ~~Percy of little runners (*16.12.2017)~~ ✅ | P-Locus **Pp** (erledigt) | — |
|
||||
| ~~Iwana of little runners (*02.10.2018)~~ ✅ | P-Locus **Pp** (erledigt) | — |
|
||||
| ~~Sokrates v.d. K.C. (*14.12.2015)~~ ✅ | **D-** + Sterbedatum **20.05.2019** (erledigt) | — |
|
||||
@@ -1,4 +1,4 @@
|
||||
/** LITTER-JUVENILE-DETAILS: Jungtier-Felder (Farbschlag/Abgabe/Verstorben) im Wurf-Detail. */
|
||||
/** LITTER-JUVENILE-DETAILS + JUVENILE-AVATARS: Jungtier-Felder und Foto-Avatare im Wurf-Detail. */
|
||||
import { de, expect, skipUnlessMock, test } from './fixtures'
|
||||
|
||||
const t = de.pages.litters
|
||||
@@ -28,3 +28,18 @@ test('Jungtier-Liste zeigt Farbschlag, Abgabedatum, Abnehmer, Todesdatum und Tod
|
||||
await expect(bennyCard).toContainText(t.detail.juvenileFields.dateOfDeath)
|
||||
await expect(bennyCard).toContainText(`${t.detail.juvenileFields.causeOfDeath}: Altersschwäche`)
|
||||
})
|
||||
|
||||
test('JUVENILE-AVATARS: Pippa zeigt Foto-Avatar, Benny zeigt GerbilIcon', async ({ page }) => {
|
||||
skipUnlessMock()
|
||||
await page.goto('/wuerfe/w-kruemel')
|
||||
await expect(page.getByRole('heading', { name: 'Wurf K' })).toBeVisible()
|
||||
|
||||
// Pippa hat profilePhotoUrl → <img.juvenile-photo__avatar> sichtbar
|
||||
const pippaCard = page.getByRole('link', { name: /Pippa/ })
|
||||
await expect(pippaCard.locator('.juvenile-photo__avatar')).toBeVisible()
|
||||
|
||||
// Benny hat kein Foto → kein img, SVG-Icon (GerbilIcon) sichtbar
|
||||
const bennyCard = page.getByRole('link', { name: /Benny/ })
|
||||
await expect(bennyCard.locator('.juvenile-photo__avatar')).not.toBeVisible()
|
||||
await expect(bennyCard.locator('svg')).toBeVisible()
|
||||
})
|
||||
|
||||
46
gerbil-manager-web/e2e/litter-mortality.spec.ts
Normal file
46
gerbil-manager-web/e2e/litter-mortality.spec.ts
Normal file
@@ -0,0 +1,46 @@
|
||||
/** LITTER-MORTALITY: deathsWithin8Weeks im Wurf-Formular und -Detail. */
|
||||
import { de, expect, skipUnlessMock, test, uniqueName } from './fixtures'
|
||||
|
||||
const t = de.pages.litters
|
||||
|
||||
test('Wurf-Detail zeigt Frühverluste wenn gesetzt', async ({ page }) => {
|
||||
skipUnlessMock()
|
||||
await page.goto('/wuerfe/w-kruemel')
|
||||
await expect(page.getByRole('heading', { name: 'Wurf K' })).toBeVisible()
|
||||
// Frühverluste-Zeile im def-list: Label + Wert '1'
|
||||
const mortalityRow = page.locator('.def-row', {
|
||||
has: page.locator('dt', { hasText: t.fields.deathsWithin8Weeks }),
|
||||
})
|
||||
await expect(mortalityRow).toBeVisible()
|
||||
await expect(mortalityRow.locator('dd')).toHaveText('1')
|
||||
})
|
||||
|
||||
test('Wurf-Formular speichert Frühverluste und zeigt sie im Detail', async ({ page }) => {
|
||||
skipUnlessMock()
|
||||
const name = uniqueName('WurfM')
|
||||
await page.goto('/wuerfe/neu')
|
||||
await page.getByLabel(`${t.fields.name} *`).fill(name)
|
||||
await page.getByLabel(`${t.fields.date} *`).fill('2026-01-10')
|
||||
|
||||
// Frühverluste eingeben
|
||||
await page.getByLabel(t.fields.deathsWithin8Weeks).fill('2')
|
||||
await expect(page.getByText(t.validation.deathsExceedTotalBorn)).not.toBeVisible()
|
||||
|
||||
await page.getByRole('button', { name: t.form.save, exact: true }).click()
|
||||
await expect(page.getByRole('heading', { name })).toBeVisible()
|
||||
// Frühverluste-Zeile im Detail
|
||||
const mortalityRow = page.locator('.def-row', {
|
||||
has: page.locator('dt', { hasText: t.fields.deathsWithin8Weeks }),
|
||||
})
|
||||
await expect(mortalityRow).toBeVisible()
|
||||
await expect(mortalityRow.locator('dd')).toHaveText('2')
|
||||
})
|
||||
|
||||
test('Frühverluste > Wurfstärke zeigt Warnmeldung', async ({ page }) => {
|
||||
skipUnlessMock()
|
||||
await page.goto('/wuerfe/neu')
|
||||
|
||||
await page.getByLabel(t.fields.totalBorn).fill('3')
|
||||
await page.getByLabel(t.fields.deathsWithin8Weeks).fill('5')
|
||||
await expect(page.getByText(t.validation.deathsExceedTotalBorn)).toBeVisible()
|
||||
})
|
||||
@@ -91,7 +91,7 @@ function gerbil(
|
||||
id,
|
||||
name,
|
||||
gender,
|
||||
status: 'Active',
|
||||
status: 'Breeding',
|
||||
dateOfBirth,
|
||||
dateOfDeath: null,
|
||||
causeOfDeath: null,
|
||||
@@ -153,11 +153,13 @@ export function seedDb(): MockDb {
|
||||
notes: 'neugieriger Entdecker',
|
||||
},
|
||||
// LITTER-JUVENILE-DETAILS: Jungtiere für Wurf K mit Zusatzinfos.
|
||||
// JUVENILE-AVATARS: Pippa hat profilePhotoUrl (1×1-PNG data-URI) → Avatar-Img sichtbar.
|
||||
{
|
||||
...gerbil('pup-abgabe', 'Pippa', 'female', '2025-03-12', 'w-kruemel', 'cv-gold'),
|
||||
status: 'GivenAway' as const,
|
||||
goHomeDate: '2025-05-01',
|
||||
receiverContactId: 'con-huber',
|
||||
profilePhotoUrl: 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==',
|
||||
},
|
||||
{
|
||||
...gerbil('pup-verstorben', 'Benny', 'male', '2025-03-12', 'w-kruemel', null),
|
||||
@@ -170,7 +172,7 @@ export function seedDb(): MockDb {
|
||||
]
|
||||
|
||||
const litters: Litter[] = [
|
||||
{ id: 'w-kruemel', name: 'Wurf K', date: '2025-03-12', totalBorn: 5, expectedGoHomeDate: '2025-04-16', notes: null, fatherId: 'fridolin', motherId: 'luna' },
|
||||
{ id: 'w-kruemel', name: 'Wurf K', date: '2025-03-12', totalBorn: 5, expectedGoHomeDate: '2025-04-16', notes: null, fatherId: 'fridolin', motherId: 'luna', deathsWithin8Weeks: 1 },
|
||||
{ id: 'w-fridolin', name: 'Wurf F', date: '2023-05-01', totalBorn: 4, expectedGoHomeDate: null, notes: null, fatherId: 'balu', motherId: 'maja' },
|
||||
{ id: 'w-luna', name: 'Wurf L', date: '2023-08-15', totalBorn: 6, expectedGoHomeDate: null, notes: null, fatherId: 'karlsson', motherId: 'smilla' },
|
||||
{ id: 'w-balu', name: 'Wurf B', date: '2021-04-20', totalBorn: 3, expectedGoHomeDate: null, notes: null, fatherId: 'anton', motherId: 'greta' },
|
||||
|
||||
58
gerbil-manager-web/e2e/status-model.spec.ts
Normal file
58
gerbil-manager-web/e2e/status-model.spec.ts
Normal file
@@ -0,0 +1,58 @@
|
||||
/** STATUS-MODEL: Zucht/Liebhaber wählbar; Verstorben/Abgegeben abgeleitet (read-only). */
|
||||
import { de, expect, skipUnlessMock, test, uniqueName } from './fixtures'
|
||||
|
||||
const t = de.pages.gerbils
|
||||
|
||||
test('Tier-Formular zeigt nur wählbare Statuswerte im Dropdown', async ({ page }) => {
|
||||
skipUnlessMock()
|
||||
await page.goto('/rennmaeuse/neu')
|
||||
const statusField = page.locator('label.field', {
|
||||
has: page.locator(`span:text-is("${t.fields.status}")`),
|
||||
})
|
||||
// Zucht (Breeding) ist vorausgewählt
|
||||
await expect(statusField.locator('select')).toHaveValue('Breeding')
|
||||
// Optionen: nur Zucht, Liebhaber, Abzugeben (NICHT Verstorben/Abgegeben)
|
||||
const opts = statusField.locator('select option')
|
||||
await expect(opts.filter({ hasText: t.statusLabels.Breeding })).toHaveCount(1)
|
||||
await expect(opts.filter({ hasText: t.statusLabels.Pet })).toHaveCount(1)
|
||||
await expect(opts.filter({ hasText: t.statusLabels.ForSale })).toHaveCount(1)
|
||||
await expect(opts.filter({ hasText: t.statusLabels.Deceased })).toHaveCount(0)
|
||||
await expect(opts.filter({ hasText: t.statusLabels.GivenAway })).toHaveCount(0)
|
||||
})
|
||||
|
||||
test('Tier-Formular zeigt abgeleiteten Status Verstorben als Text + Hinweis', async ({ page }) => {
|
||||
skipUnlessMock()
|
||||
// Willi ist Deceased
|
||||
await page.goto('/rennmaeuse/willi/bearbeiten')
|
||||
// Status-Select ist NICHT vorhanden (abgeleitet)
|
||||
const statusField = page.locator('.field', {
|
||||
has: page.locator(`span:text-is("${t.fields.status}")`),
|
||||
})
|
||||
await expect(statusField).toBeVisible()
|
||||
await expect(statusField.locator('select')).toHaveCount(0)
|
||||
await expect(statusField).toContainText(t.statusLabels.Deceased)
|
||||
await expect(statusField).toContainText(t.form.statusDerivedHint.Deceased)
|
||||
})
|
||||
|
||||
test('Tiere-Liste zeigt Zucht als Standard-Status-Filter', async ({ page }) => {
|
||||
skipUnlessMock()
|
||||
await page.goto('/rennmaeuse')
|
||||
const statusSelect = page
|
||||
.locator('label.field', { has: page.locator(`span:text-is("${t.filters.status}")`) })
|
||||
.locator('select')
|
||||
await expect(statusSelect).toHaveValue('Breeding')
|
||||
})
|
||||
|
||||
test('Neues Tier anlegen mit Status Liebhaber', async ({ page }) => {
|
||||
skipUnlessMock()
|
||||
const name = uniqueName('Tier')
|
||||
await page.goto('/rennmaeuse/neu')
|
||||
await page.getByLabel(`${t.fields.name} *`).fill(name)
|
||||
await page.getByLabel(`${t.fields.gender} *`).selectOption({ label: t.genderLabels.male })
|
||||
const statusField = page.locator('label.field', {
|
||||
has: page.locator(`span:text-is("${t.fields.status}")`),
|
||||
})
|
||||
await statusField.locator('select').selectOption({ label: t.statusLabels.Pet })
|
||||
await page.getByRole('button', { name: t.form.save, exact: true }).click()
|
||||
await expect(page.getByRole('heading', { name })).toBeVisible()
|
||||
})
|
||||
@@ -10,9 +10,11 @@
|
||||
export type Gender = 'unknown' | 'male' | 'female'
|
||||
export const GENDERS: Gender[] = ['unknown', 'male', 'female']
|
||||
|
||||
/** Gerbil.Status — C# enum { Active, Deceased, GivenAway, ForSale }. */
|
||||
export type GerbilStatus = 'Active' | 'Deceased' | 'GivenAway' | 'ForSale'
|
||||
export const GERBIL_STATUSES: GerbilStatus[] = ['Active', 'ForSale', 'Deceased', 'GivenAway']
|
||||
/** Gerbil.Status — C# enum { Breeding, Pet, Deceased, GivenAway, ForSale }. STATUS-MODEL: Active→Breeding (gated on Pam feature/status-model). */
|
||||
export type GerbilStatus = 'Breeding' | 'Pet' | 'Deceased' | 'GivenAway' | 'ForSale'
|
||||
export const GERBIL_STATUSES: GerbilStatus[] = ['Breeding', 'Pet', 'ForSale', 'Deceased', 'GivenAway']
|
||||
/** Statuses the user can pick freely in the form; Deceased+GivenAway are derived by the backend. */
|
||||
export const SELECTABLE_STATUSES: GerbilStatus[] = ['Breeding', 'Pet', 'ForSale']
|
||||
|
||||
/** ISO date string "YYYY-MM-DD" (maps to C# DateOnly). */
|
||||
export type DateOnlyString = string
|
||||
@@ -124,6 +126,8 @@ export interface Litter {
|
||||
notes?: string | null
|
||||
fatherId: string | null
|
||||
motherId: string | null
|
||||
/** LITTER-MORTALITY: pups that died within the first 8 weeks. */
|
||||
deathsWithin8Weeks?: number | null
|
||||
}
|
||||
|
||||
/** Payload for POST /litters. */
|
||||
@@ -135,6 +139,7 @@ export interface CreateLitter {
|
||||
notes?: string | null
|
||||
fatherId?: string | null
|
||||
motherId?: string | null
|
||||
deathsWithin8Weeks?: number | null
|
||||
}
|
||||
|
||||
export type UpdateLitter = Partial<CreateLitter>
|
||||
|
||||
@@ -21,15 +21,17 @@ export default function GerbilIcon({ size = '1em', style, className }: Props) {
|
||||
aria-label="Rennmaus"
|
||||
>
|
||||
{/* body — sitting upright */}
|
||||
<path d="M40 14c6 0 11 5 11 12 0 4-2 7-2 11 0 10-6 16-15 16-8 0-14-6-14-15 0-13 9-24 20-24z" />
|
||||
{/* ear */}
|
||||
<circle cx="46" cy="15" r="5" />
|
||||
{/* hind paw */}
|
||||
<ellipse cx="24" cy="52" rx="9" ry="3.5" />
|
||||
{/* long curved tail with tuft */}
|
||||
<path d="M16 51c-7 1-13-2-15-9-1-4 0-8 3-10 1 3 1 6 3 8 3 3 7 4 11 4z" />
|
||||
<path d="M 34 23 C 38 20, 44 21, 46 25 C 47 27, 45 30, 41 31 C 38 32, 40 38, 40 42 C 40 48, 36 51, 31 52 C 25 52, 21 47, 21 40 C 21 31, 27 25, 34 23 Z" />
|
||||
{/* ears */}
|
||||
<path d="M 36 22 Q 33 13 39 11 Q 42 13 39 21 Z" />
|
||||
<path d="M 32 23 Q 30 15 35 13 Q 37 15 35 22 Z" opacity="0.6" />
|
||||
{/* front paw */}
|
||||
<path d="M30 45c3 0 5 1 5 4-3 1-6 1-7-2 0-1 1-2 2-2z" />
|
||||
<path d="M 41 33 Q 46 33 46 36 Q 44 38 41 35 Z" />
|
||||
{/* hind paw */}
|
||||
<path d="M 21 49 C 19 53, 23 55, 30 55 C 33 55, 34 52, 30 50 Z" />
|
||||
{/* tail and tuft */}
|
||||
<path d="M 21 47 C 14 51, 9 46, 8 41 C 7 35, 11 27, 16 22 C 17 21, 18 22, 17 23 C 13 28, 10 35, 11 40 C 12 44, 15 47, 21 44 Z" />
|
||||
<path d="M 16 22 C 16 16, 13 11, 16 7 C 20 3, 22 10, 19 16 C 18 19, 17 21, 16 22 Z" />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -3,7 +3,7 @@ import { Link, useNavigate, useParams, useSearchParams } from 'react-router-dom'
|
||||
import { de } from '../strings/de'
|
||||
import { createGerbil, getGerbil, updateGerbil } from '../api/gerbils'
|
||||
import { listColorVarieties, listContacts, listEnclosures, listLitters } from '../api/lookups'
|
||||
import { GENDERS, GERBIL_STATUSES, type CreateGerbil, type Gender, type GerbilStatus } from '../api/types'
|
||||
import { GENDERS, SELECTABLE_STATUSES, type CreateGerbil, type Gender, type GerbilStatus } from '../api/types'
|
||||
import { useApi, useMutation } from '../hooks/useApi'
|
||||
import { genderLabel, statusLabel } from '../format/labels'
|
||||
import { fromDisplayString, genotypeToFarbschlag, UNKNOWN_FARBSCHLAG } from '../genetics'
|
||||
@@ -34,7 +34,7 @@ interface FormState {
|
||||
const EMPTY: FormState = {
|
||||
name: '',
|
||||
gender: '',
|
||||
status: 'Active',
|
||||
status: 'Breeding',
|
||||
dateOfBirth: '',
|
||||
dateOfDeath: '',
|
||||
causeOfDeath: '',
|
||||
@@ -245,16 +245,29 @@ export default function GerbilFormPage() {
|
||||
{errors.gender && <small className="error-text">{errors.gender}</small>}
|
||||
</label>
|
||||
|
||||
{/* STATUS-MODEL: Deceased/GivenAway are derived by the backend — show as read-only with hint */}
|
||||
{form.status === 'Deceased' || form.status === 'GivenAway' ? (
|
||||
<div className="field">
|
||||
<span>{t.fields.status}</span>
|
||||
<span className="muted">
|
||||
{statusLabel(form.status)}
|
||||
<small className="muted" style={{ marginLeft: '0.5rem' }}>
|
||||
({t.form.statusDerivedHint[form.status]})
|
||||
</small>
|
||||
</span>
|
||||
</div>
|
||||
) : (
|
||||
<label className="field">
|
||||
<span>{t.fields.status}</span>
|
||||
<select value={form.status} onChange={(e) => set('status', e.target.value as GerbilStatus)}>
|
||||
{GERBIL_STATUSES.map((s) => (
|
||||
{SELECTABLE_STATUSES.map((s) => (
|
||||
<option key={s} value={s}>
|
||||
{statusLabel(s)}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
)}
|
||||
|
||||
<label className="field">
|
||||
<span>{t.fields.dateOfBirth}</span>
|
||||
|
||||
@@ -29,7 +29,7 @@ const SORT_ORDER_BY: Record<SortKey, string> = {
|
||||
export default function GerbilsPage() {
|
||||
const t = de.pages.gerbils
|
||||
const [search, setSearch] = useState('')
|
||||
const [status, setStatus] = useState<GerbilStatus | ''>('Active')
|
||||
const [status, setStatus] = useState<GerbilStatus | ''>('Breeding')
|
||||
const [gender, setGender] = useState<Gender | ''>('')
|
||||
const [colorVarietyId, setColorVarietyId] = useState('')
|
||||
const [originBreeder, setOriginBreeder] = useState('')
|
||||
@@ -64,7 +64,7 @@ export default function GerbilsPage() {
|
||||
|
||||
const resetFilters = () => {
|
||||
setSearch('')
|
||||
setStatus('Active')
|
||||
setStatus('Breeding')
|
||||
setGender('')
|
||||
setColorVarietyId('')
|
||||
setOriginBreeder('')
|
||||
@@ -85,7 +85,7 @@ export default function GerbilsPage() {
|
||||
|
||||
// UX-MOBILE-1: count non-default filter values for the badge.
|
||||
const activeFilterCount =
|
||||
(status !== 'Active' ? 1 : 0) +
|
||||
(status !== 'Breeding' ? 1 : 0) +
|
||||
(gender !== '' ? 1 : 0) +
|
||||
(colorVarietyId !== '' ? 1 : 0) +
|
||||
(originBreeder !== '' ? 1 : 0) +
|
||||
|
||||
@@ -381,14 +381,13 @@ function PedigreeCard({
|
||||
const g = node.gerbil
|
||||
const chip = farbschlag ? chipColorFor(farbschlag) : null
|
||||
const dob = g.dateOfBirth ? formatDate(g.dateOfBirth) : null
|
||||
const [imgError, setImgError] = useState(false)
|
||||
useEffect(() => setImgError(false), [g.id])
|
||||
const photoUrl =
|
||||
!imgError && g.profilePhotoUrl
|
||||
const [failedUrl, setFailedUrl] = useState<string | null>(null)
|
||||
const rawUrl = g.profilePhotoUrl
|
||||
? /^(https?:|data:|\/\/)/.test(g.profilePhotoUrl)
|
||||
? g.profilePhotoUrl
|
||||
: `${API_BASE_URL}${g.profilePhotoUrl}`
|
||||
: null
|
||||
const photoUrl = rawUrl !== failedUrl ? rawUrl : null
|
||||
return (
|
||||
<div
|
||||
className={isRoot ? 'pedigree-card pedigree-card--root' : 'pedigree-card'}
|
||||
@@ -402,7 +401,7 @@ function PedigreeCard({
|
||||
src={photoUrl}
|
||||
alt={g.name || ''}
|
||||
className="pedigree-card__avatar"
|
||||
onError={() => setImgError(true)}
|
||||
onError={() => setFailedUrl(rawUrl)}
|
||||
/>
|
||||
) : (
|
||||
<GerbilIcon size="1.6rem" aria-hidden="true" />
|
||||
|
||||
@@ -1,15 +1,62 @@
|
||||
import { useCallback, useMemo } from 'react'
|
||||
import { useCallback, useMemo, useState } from 'react'
|
||||
import { Link, useParams } from 'react-router-dom'
|
||||
import { de } from '../strings/de'
|
||||
import { getLitter } from '../api/litters'
|
||||
import { getGerbil, listGerbils } from '../api/gerbils'
|
||||
import { listColorVarieties, listContacts } from '../api/lookups'
|
||||
import { API_BASE_URL } from '../api/client'
|
||||
import { condition } from '../api/gridify'
|
||||
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 BreedingResultView from '../components/BreedingResultView'
|
||||
import GerbilIcon from '../components/GerbilIcon'
|
||||
import './wuerfe.css'
|
||||
|
||||
type JuvenileFields = (typeof de.pages.litters.detail.juvenileFields)
|
||||
|
||||
function JuvenileCard({ g, farbe, receiver, tJf }: {
|
||||
g: Gerbil
|
||||
farbe: string | null
|
||||
receiver: string | null
|
||||
tJf: JuvenileFields
|
||||
}) {
|
||||
const [failedUrl, setFailedUrl] = useState<string | null>(null)
|
||||
const rawUrl = g.profilePhotoUrl
|
||||
? /^(https?:|data:|\/\/)/.test(g.profilePhotoUrl)
|
||||
? g.profilePhotoUrl
|
||||
: `${API_BASE_URL}${g.profilePhotoUrl}`
|
||||
: null
|
||||
const photoUrl = rawUrl !== failedUrl ? rawUrl : null
|
||||
|
||||
return (
|
||||
<Link to={`/rennmaeuse/${g.id}`} className="gerbil-card gerbil-card--with-photo">
|
||||
<div className="juvenile-photo">
|
||||
{photoUrl ? (
|
||||
<img
|
||||
src={photoUrl}
|
||||
alt={g.name || ''}
|
||||
className="juvenile-photo__avatar"
|
||||
onError={() => setFailedUrl(rawUrl)}
|
||||
/>
|
||||
) : (
|
||||
<GerbilIcon size="1.4rem" aria-hidden="true" />
|
||||
)}
|
||||
</div>
|
||||
<div className="juvenile-body">
|
||||
<span className="gerbil-card__name">{g.name}</span>
|
||||
<span className="gerbil-card__meta">{genderLabel(g.gender)}</span>
|
||||
{farbe && <span className="gerbil-card__meta">{tJf.colorVariety}: {farbe}</span>}
|
||||
{g.goHomeDate && <span className="gerbil-card__meta">{tJf.goHomeDate}: {formatDate(g.goHomeDate)}</span>}
|
||||
{receiver && <span className="gerbil-card__meta">{tJf.receiver}: {receiver}</span>}
|
||||
{g.dateOfDeath && <span className="gerbil-card__meta">{tJf.dateOfDeath}: {formatDate(g.dateOfDeath)}</span>}
|
||||
{g.causeOfDeath && <span className="gerbil-card__meta">{tJf.causeOfDeath}: {g.causeOfDeath}</span>}
|
||||
</div>
|
||||
</Link>
|
||||
)
|
||||
}
|
||||
|
||||
export default function WurfDetailPage() {
|
||||
const t = de.pages.litters
|
||||
@@ -125,6 +172,12 @@ export default function WurfDetailPage() {
|
||||
{l.totalBorn != null ? ` / ${l.totalBorn}` : ''}
|
||||
</dd>
|
||||
</div>
|
||||
{l.deathsWithin8Weeks != null && (
|
||||
<div className="def-row">
|
||||
<dt>{t.fields.deathsWithin8Weeks}</dt>
|
||||
<dd>{l.deathsWithin8Weeks}</dd>
|
||||
</div>
|
||||
)}
|
||||
<div className="def-row">
|
||||
<dt>{t.fields.expectedGoHomeDate}</dt>
|
||||
<dd>{formatDate(l.expectedGoHomeDate)}</dd>
|
||||
@@ -152,19 +205,11 @@ export default function WurfDetailPage() {
|
||||
<ul className="card-list">
|
||||
{(juveniles.data?.items ?? []).map((g) => {
|
||||
const farbe = farbschlagOf(g)
|
||||
const receiver = g.receiverContactId ? contactNameById.get(g.receiverContactId) : null
|
||||
const receiver = g.receiverContactId ? contactNameById.get(g.receiverContactId) ?? null : null
|
||||
const tJf = t.detail.juvenileFields
|
||||
return (
|
||||
<li key={g.id}>
|
||||
<Link to={`/rennmaeuse/${g.id}`} className="gerbil-card">
|
||||
<span className="gerbil-card__name">{g.name}</span>
|
||||
<span className="gerbil-card__meta">{genderLabel(g.gender)}</span>
|
||||
{farbe && <span className="gerbil-card__meta">{tJf.colorVariety}: {farbe}</span>}
|
||||
{g.goHomeDate && <span className="gerbil-card__meta">{tJf.goHomeDate}: {formatDate(g.goHomeDate)}</span>}
|
||||
{receiver && <span className="gerbil-card__meta">{tJf.receiver}: {receiver}</span>}
|
||||
{g.dateOfDeath && <span className="gerbil-card__meta">{tJf.dateOfDeath}: {formatDate(g.dateOfDeath)}</span>}
|
||||
{g.causeOfDeath && <span className="gerbil-card__meta">{tJf.causeOfDeath}: {g.causeOfDeath}</span>}
|
||||
</Link>
|
||||
<JuvenileCard g={g} farbe={farbe} receiver={receiver} tJf={tJf} />
|
||||
</li>
|
||||
)
|
||||
})}
|
||||
|
||||
@@ -16,6 +16,7 @@ interface FormState {
|
||||
motherId: string
|
||||
motherName: string
|
||||
totalBorn: string
|
||||
deathsWithin8Weeks: string
|
||||
expectedGoHomeDate: string
|
||||
notes: string
|
||||
}
|
||||
@@ -28,6 +29,7 @@ const EMPTY: FormState = {
|
||||
motherId: '',
|
||||
motherName: '',
|
||||
totalBorn: '',
|
||||
deathsWithin8Weeks: '',
|
||||
expectedGoHomeDate: '',
|
||||
notes: '',
|
||||
}
|
||||
@@ -75,6 +77,7 @@ export default function WurfFormPage() {
|
||||
motherId: l.motherId ?? '',
|
||||
motherName: l.motherId ? (nameById.get(l.motherId) || de.pages.gerbils.nameless) : '',
|
||||
totalBorn: l.totalBorn != null ? String(l.totalBorn) : '',
|
||||
deathsWithin8Weeks: l.deathsWithin8Weeks != null ? String(l.deathsWithin8Weeks) : '',
|
||||
expectedGoHomeDate: l.expectedGoHomeDate ?? '',
|
||||
notes: l.notes ?? '',
|
||||
})
|
||||
@@ -109,12 +112,14 @@ export default function WurfFormPage() {
|
||||
e.preventDefault()
|
||||
if (!validate()) return
|
||||
const totalBornNum = form.totalBorn.trim() === '' ? null : Number(form.totalBorn)
|
||||
const deathsNum = form.deathsWithin8Weeks.trim() === '' ? null : Number(form.deathsWithin8Weeks)
|
||||
const body: CreateLitter = {
|
||||
name: form.name.trim(),
|
||||
date: form.date,
|
||||
fatherId: nn(form.fatherId),
|
||||
motherId: nn(form.motherId),
|
||||
totalBorn: totalBornNum != null && Number.isFinite(totalBornNum) ? totalBornNum : null,
|
||||
deathsWithin8Weeks: deathsNum != null && Number.isFinite(deathsNum) ? deathsNum : null,
|
||||
expectedGoHomeDate: nn(form.expectedGoHomeDate),
|
||||
notes: nn(form.notes),
|
||||
}
|
||||
@@ -215,6 +220,26 @@ export default function WurfFormPage() {
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label className="field">
|
||||
<span>{t.fields.deathsWithin8Weeks}</span>
|
||||
<input
|
||||
type="number"
|
||||
min="0"
|
||||
className="input"
|
||||
value={form.deathsWithin8Weeks}
|
||||
onChange={(e) => set('deathsWithin8Weeks', e.target.value)}
|
||||
/>
|
||||
{(() => {
|
||||
const d = Number(form.deathsWithin8Weeks)
|
||||
const tb = Number(form.totalBorn)
|
||||
return form.deathsWithin8Weeks.trim() !== '' &&
|
||||
form.totalBorn.trim() !== '' &&
|
||||
d > tb ? (
|
||||
<small className="error-text">{t.validation.deathsExceedTotalBorn}</small>
|
||||
) : null
|
||||
})()}
|
||||
</label>
|
||||
|
||||
<label className="field">
|
||||
<span>{t.fields.expectedGoHomeDate}</span>
|
||||
<input
|
||||
|
||||
34
gerbil-manager-web/src/pages/wuerfe.css
Normal file
34
gerbil-manager-web/src/pages/wuerfe.css
Normal file
@@ -0,0 +1,34 @@
|
||||
/* JUVENILE-AVATARS: photo avatar to the left of each juvenile card. */
|
||||
.gerbil-card--with-photo {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.juvenile-body {
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.2rem;
|
||||
}
|
||||
|
||||
.juvenile-photo {
|
||||
flex: none;
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
border-radius: 50%;
|
||||
overflow: hidden;
|
||||
background: var(--color-accent-soft);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
align-self: center;
|
||||
}
|
||||
|
||||
.juvenile-photo__avatar {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
display: block;
|
||||
}
|
||||
@@ -22,7 +22,7 @@ function makeGerbil(id: string, name: string, litterId: string | null = null): G
|
||||
id,
|
||||
name,
|
||||
gender: 'unknown',
|
||||
status: 'Active',
|
||||
status: 'Breeding',
|
||||
dateOfBirth: null,
|
||||
dateOfDeath: null,
|
||||
causeOfDeath: null,
|
||||
|
||||
@@ -27,7 +27,7 @@ function makeGerbil(over: Partial<Gerbil> & { id: string }): Gerbil {
|
||||
return {
|
||||
name: over.id,
|
||||
gender: 'unknown',
|
||||
status: 'Active' as GerbilStatus,
|
||||
status: 'Breeding' as GerbilStatus,
|
||||
dateOfBirth: null,
|
||||
dateOfDeath: null,
|
||||
causeOfDeath: null,
|
||||
|
||||
@@ -83,7 +83,7 @@ export function farbschlagDistribution(
|
||||
): NameCount[] {
|
||||
const byName = new Map<string | null, number>()
|
||||
for (const g of gerbils) {
|
||||
if (g.status !== 'Active') continue
|
||||
if (g.status !== 'Breeding' && g.status !== 'Pet') continue
|
||||
const name = resolveName(g)
|
||||
byName.set(name, (byName.get(name) ?? 0) + 1)
|
||||
}
|
||||
|
||||
@@ -124,6 +124,11 @@ export const de = {
|
||||
isDeafUnknown: 'Unbekannt',
|
||||
isDeafYes: 'Ja',
|
||||
isDeafNo: 'Nein',
|
||||
// STATUS-MODEL: Hinweis für abgeleitete Statuswerte
|
||||
statusDerivedHint: {
|
||||
Deceased: 'Wird automatisch gesetzt wenn Todesdatum erfasst.',
|
||||
GivenAway: 'Wird automatisch gesetzt nach Abgabe.',
|
||||
},
|
||||
save: 'Speichern',
|
||||
cancel: 'Abbrechen',
|
||||
saving: 'Speichern …',
|
||||
@@ -141,8 +146,10 @@ export const de = {
|
||||
male: 'Männlich',
|
||||
female: 'Weiblich',
|
||||
},
|
||||
// STATUS-MODEL: Breeding/Pet ersetzen Active; Deceased+GivenAway = abgeleitet (Backend)
|
||||
statusLabels: {
|
||||
Active: 'Aktiv',
|
||||
Breeding: 'Zucht',
|
||||
Pet: 'Liebhaber',
|
||||
Deceased: 'Verstorben',
|
||||
GivenAway: 'Abgegeben',
|
||||
ForSale: 'Zur Abgabe',
|
||||
@@ -172,6 +179,7 @@ export const de = {
|
||||
father: 'Vater',
|
||||
mother: 'Mutter',
|
||||
totalBorn: 'Wurfstärke',
|
||||
deathsWithin8Weeks: 'In den ersten 8 Wochen verstorben',
|
||||
expectedGoHomeDate: 'Voraussichtliches Abgabedatum',
|
||||
notes: 'Notizen',
|
||||
},
|
||||
@@ -212,6 +220,7 @@ export const de = {
|
||||
nameRequired: 'Bitte eine Bezeichnung eingeben.',
|
||||
dateRequired: 'Bitte ein Wurfdatum angeben.',
|
||||
invalidParentGender: 'Der Vater muss männlich und die Mutter weiblich sein.',
|
||||
deathsExceedTotalBorn: 'Die Anzahl der Frühverluste darf die Wurfstärke nicht überschreiten.',
|
||||
},
|
||||
// Zuchtpaar-Übersicht
|
||||
pairs: {
|
||||
|
||||
@@ -102,9 +102,9 @@
|
||||
{
|
||||
"name": "Skarlett von den Kleinen Chaoten",
|
||||
"dob": "14.07.2013",
|
||||
"decision": "death date = 17.04.2016 (the '2018' variant was wrong — it had leaked as '/ +2018' into the genotype field; parse-leak already fixed in IMPORT-POLISH)",
|
||||
"dateOfDeath": "17.04.2016",
|
||||
"source": "Julian 2026-06-07 — HUMANQUESTION D6"
|
||||
"decision": "birth 2013 + 4 years = death year 2017, no exact date → year-only convention (01.01.2017). Previous entry (17.04.2016) was wrong.",
|
||||
"dateOfDeath": "01.01.2017",
|
||||
"source": "Julian 2026-06-07 — HUMANQUESTION D7 (final Skarlett resolution)"
|
||||
},
|
||||
{
|
||||
"name": "Kazu von den Kleinen Chaoten",
|
||||
@@ -166,10 +166,10 @@
|
||||
{
|
||||
"name": "Max von Privat",
|
||||
"dob": "01.02.2013",
|
||||
"decision": "Genotyp von der Züchterin; Todesjahr 2014 (Jahr-only — Konvention mit Julian zu klären, deathYear NICHT als dateOfDeath gesetzt)",
|
||||
"decision": "Genotyp von der Züchterin bestätigt (D-=D-, P=PP); Todesjahr 2014 (kein genaues Datum → Jahr-only-Konvention 01.01.2014). Reject 4 on-file-Varianten: 04.02.2016 / 04.03.2016 / 2014-raw / 30.12.2015.",
|
||||
"genotype": "aa c[chm]c[chm] D- EE GG PP spsp",
|
||||
"_deathYear_pending": 2014,
|
||||
"source": "Julian/Züchterin 2026-06-07 — HUMANQUESTION D7 (Jahr-only-Tod offen)"
|
||||
"dateOfDeath": "01.01.2014",
|
||||
"source": "Julian/Züchterin 2026-06-07 — HUMANQUESTION D7 (resolved)"
|
||||
},
|
||||
{
|
||||
"name": "Isa of Golden Lights",
|
||||
@@ -198,6 +198,21 @@
|
||||
"decision": "Sterbedatum von der Züchterin; bestätigt = 'von PZ Karl' (nicht 'Sunny Sky of Fiomi')",
|
||||
"dateOfDeath": "30.04.2018",
|
||||
"source": "Julian/Züchterin 2026-06-07 — HUMANQUESTION D7 (Nachtrag)"
|
||||
},
|
||||
{
|
||||
"name": "Dakota of sweet little mouse",
|
||||
"dob": "30.01.2015",
|
||||
"decision": "Genotyp von Julian/Züchterin: A-Locus=Aa, P-Locus=pp, Sp-Locus=Spsp (offene Loci); übrige bestätigt.",
|
||||
"genotype": "Aa CC Dd Ee Gg pp Spsp",
|
||||
"source": "Julian/Züchterin 2026-06-07 — HUMANQUESTION D7"
|
||||
},
|
||||
{
|
||||
"name": "Banjo of Fiomi",
|
||||
"dob": "06.07.2015",
|
||||
"decision": "E-Locus Korrektur: Julian — 'Goldfuchs Starkschecke, nicht Gold Starkschecke' → E-Locus muss ee sein, nicht E-. Extract hatte AA CC DD E- Gg pp Spsp [WP]; korrigiert zu ee (Goldfuchs-Definition). Sohn von Dakota of sweet little mouse.",
|
||||
"genotype": "AA CC DD ee Gg pp Spsp",
|
||||
"farbschlag": "Goldfuchs Starkschecke",
|
||||
"source": "Julian 2026-06-07 — HUMANQUESTION D7 (Sohn-Korrektur, nicht ursprüngliche D7-Liste)"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
32
tools/wipe-import-data.sql
Normal file
32
tools/wipe-import-data.sql
Normal file
@@ -0,0 +1,32 @@
|
||||
-- WIPE-IMPORT-DATA: truncate only import-derived tables, preserve seeded data.
|
||||
--
|
||||
-- Scope (god decision 2026-06-07):
|
||||
-- WIPE: GerbilPhotos, WeightRecords, HealthRecords, Gerbils, Litters
|
||||
-- KEEP: ColorVarieties (migration-seeded, import matches against these)
|
||||
-- BreederSettings, Sites, Pages (CMS), Contacts, Enclosures,
|
||||
-- SaleContracts, Requests, MailSettings
|
||||
--
|
||||
-- Run ONLY after pg_dump backup. FK order: dependents first.
|
||||
-- Usage (with Aspire Postgres running):
|
||||
-- docker exec -i <postgres-container> psql -U postgres gerbilmanager < tools/wipe-import-data.sql
|
||||
-- OR connect via psql and \i this file.
|
||||
|
||||
BEGIN;
|
||||
|
||||
-- 1. leaf dependents
|
||||
TRUNCATE TABLE "GerbilPhotos" CASCADE;
|
||||
TRUNCATE TABLE "WeightRecords" CASCADE;
|
||||
TRUNCATE TABLE "HealthRecords" CASCADE;
|
||||
|
||||
-- 2. main import tables (Gerbils before Litters: Gerbil.LitterId FK)
|
||||
TRUNCATE TABLE "Gerbils" CASCADE;
|
||||
TRUNCATE TABLE "Litters" CASCADE;
|
||||
|
||||
COMMIT;
|
||||
|
||||
-- Verify (expected: 0 rows each)
|
||||
SELECT 'GerbilPhotos' AS tbl, COUNT(*) FROM "GerbilPhotos"
|
||||
UNION ALL SELECT 'WeightRecords', COUNT(*) FROM "WeightRecords"
|
||||
UNION ALL SELECT 'HealthRecords', COUNT(*) FROM "HealthRecords"
|
||||
UNION ALL SELECT 'Gerbils', COUNT(*) FROM "Gerbils"
|
||||
UNION ALL SELECT 'Litters', COUNT(*) FROM "Litters";
|
||||
Reference in New Issue
Block a user