Merge branch 'worktree-agent-a5f48334696288159'

# Conflicts:
#	gerbil-manager-web/e2e/mock-data.ts
This commit is contained in:
2026-06-22 22:54:50 +02:00
16 changed files with 2360 additions and 0 deletions

View File

@@ -0,0 +1,45 @@
/** ERWERB: Erwerb/Kauf je Tier — Sektion in der Rennmausakte (anlegen / bearbeiten / löschen). */
import { de, expect, skipUnlessMock, test } from './fixtures'
const t = de.pages.tierTabs.acquisition
test('Tierakte: Erwerb erfassen, bearbeiten und löschen', async ({ page, mockDb }) => {
skipUnlessMock()
await page.goto('/rennmaeuse/kruemel')
const section = page.locator('section.ak-card', { hasText: t.sectionTitle })
await expect(section).toBeVisible()
await expect(section).toContainText(t.empty)
// Anlegen: Formular öffnen, Felder füllen, speichern.
await section.getByRole('button', { name: t.addButton }).click()
await section.getByLabel(t.fields.date).fill('2025-03-14')
await section.getByLabel(t.fields.price).fill('25.50')
await section.getByLabel(t.fields.note).fill('Auf der Börse gekauft.')
await section.getByRole('button', { name: t.addButton }).click()
// Eintrag erscheint in der Liste, im Mock gespeichert.
await expect(section).toContainText('25,50')
await expect(section).toContainText('Auf der Börse gekauft.')
expect(mockDb).not.toBeNull()
expect(mockDb!.acquisitions.length).toBe(1)
expect(mockDb!.acquisitions[0]).toMatchObject({
gerbilId: 'kruemel',
date: '2025-03-14',
price: 25.5,
note: 'Auf der Börse gekauft.',
})
// Bearbeiten: Preis ändern.
await section.getByRole('button', { name: t.edit }).click()
await section.getByLabel(t.fields.price).fill('30')
await section.getByRole('button', { name: t.saveButton }).click()
await expect(section).toContainText('30,00')
expect(mockDb!.acquisitions[0]).toMatchObject({ price: 30 })
// Löschen (window.confirm bestätigen).
page.once('dialog', (d) => d.accept())
await section.getByRole('button', { name: t.delete }).click()
await expect(section).toContainText(t.empty)
expect(mockDb!.acquisitions.length).toBe(0)
})

View File

@@ -533,6 +533,50 @@ export async function installMockApi(page: Page): Promise<MockDb> {
return json(route, 200, db.rpro3.execute)
}
// ERWERB: Erwerb/Kauf je Tier — GET ?gerbilId= (Array, kein Gridify-Paging),
// POST/PUT/DELETE. Vor den generischen Kollektionen, weil GET kein {items}-Objekt
// liefert und nach gerbilId statt Gridify-filter selektiert.
const acqMatch = path.match(/^\/acquisitions(?:\/([^/]+))?$/)
if (acqMatch) {
const acqId = acqMatch[1] ? decodeURIComponent(acqMatch[1]) : null
if (!acqId) {
if (method === 'GET') {
const gid = url.searchParams.get('gerbilId')
const rows = db.acquisitions.filter((a) => !gid || a.gerbilId === gid)
return json(route, 200, [...rows].reverse())
}
if (method === 'POST') {
const created = {
id: newId('acq'),
sourceContactId: null,
date: null,
price: null,
note: null,
...(request.postDataJSON() as Row),
createdAt: new Date().toISOString(),
}
db.acquisitions.push(created)
return json(route, 201, created)
}
return json(route, 405)
}
const ai = db.acquisitions.findIndex((a) => a.id === acqId)
if (method === 'GET') {
return ai >= 0 ? json(route, 200, db.acquisitions[ai]) : json(route, 404, { title: 'Not Found' })
}
if (method === 'PUT') {
if (ai < 0) return json(route, 404, { title: 'Not Found' })
Object.assign(db.acquisitions[ai], request.postDataJSON() as Row)
return json(route, 204)
}
if (method === 'DELETE') {
if (ai < 0) return json(route, 404, { title: 'Not Found' })
db.acquisitions.splice(ai, 1)
return json(route, 204)
}
return json(route, 405)
}
// Generische Kollektionen: /<resource> und /<resource>/<id>
m = path.match(/^\/([a-z-]+)(?:\/([^/]+))?$/)
const col = m ? collections[m[1]] : undefined

View File

@@ -85,6 +85,8 @@ export interface MockDb {
analyze: Record<string, unknown>
execute: Record<string, unknown>
}
// ERWERB: Erwerb/Kauf je Tier (Kaufdatum, Preis, Notiz) — /acquisitions
acquisitions: Record<string, unknown>[]
}
function gerbil(
@@ -541,5 +543,6 @@ export function seedDb(): MockDb {
message: 'Import erfolgreich: 3694 Tiere, 1678 Würfe, 508 Kontakte.',
},
},
acquisitions: [],
}
}

View File

@@ -0,0 +1,45 @@
/**
* ERWERB: typed API client for the acquisition (AcquisitionRecord) resource.
* Captures when/how an animal was acquired + its price. Decoupled from the gerbil
* (loose nullable ids, no FK) so rows survive the import re-ingest wipe.
*/
import { api } from './client'
import type { DateOnlyString } from './types'
const RESOURCE = '/acquisitions'
export interface Acquisition {
id: string
gerbilId: string | null
sourceContactId: string | null
date: DateOnlyString | null
price: number | null
note: string | null
createdAt: string
}
/** Payload for POST/PUT /acquisitions. */
export interface AcquisitionInput {
gerbilId?: string | null
sourceContactId?: string | null
date?: DateOnlyString | null
price?: number | null
note?: string | null
}
/** All acquisition records for one animal (newest acquisition date first). */
export function listAcquisitions(gerbilId: string): Promise<Acquisition[]> {
return api.get<Acquisition[]>(`${RESOURCE}?gerbilId=${encodeURIComponent(gerbilId)}`)
}
export function createAcquisition(body: AcquisitionInput): Promise<Acquisition> {
return api.post<Acquisition>(RESOURCE, body)
}
export function updateAcquisition(id: string, body: AcquisitionInput): Promise<void> {
return api.put<void>(`${RESOURCE}/${id}`, body)
}
export function deleteAcquisition(id: string): Promise<void> {
return api.delete(`${RESOURCE}/${id}`)
}

View File

@@ -0,0 +1,204 @@
/**
* ERWERB: compact, self-contained acquisition section for the Rennmausakte.
* Shows when/at what price an animal was acquired (purchase date, price, note),
* with inline add / edit / delete. Backed by /acquisitions (loose nullable ids,
* no FK) so rows survive the import re-ingest wipe.
*
* Embedded with a single line in GerbilDetailPage.
*/
import { useState, type FormEvent } from 'react'
import { de } from '../strings/de'
import {
createAcquisition,
deleteAcquisition,
listAcquisitions,
updateAcquisition,
type Acquisition,
} from '../api/acquisitions'
import { useApi, useMutation } from '../hooks/useApi'
import { formatDate } from '../format/labels'
function formatPrice(price: number): string {
return `${price.toLocaleString('de-DE', { minimumFractionDigits: 2, maximumFractionDigits: 2 })} €`
}
export default function GerbilAcquisitionSection({ gerbilId }: { gerbilId: string }) {
const t = de.pages.tierTabs.acquisition
const records = useApi(() => listAcquisitions(gerbilId), [gerbilId])
const [editing, setEditing] = useState<string | null>(null) // null = none, '' = new
const [date, setDate] = useState('')
const [price, setPrice] = useState('')
const [note, setNote] = useState('')
const [error, setError] = useState<string | null>(null)
const save = useMutation((id: string | '') => {
const body = {
gerbilId,
date: date || null,
price: price.trim() === '' ? null : Number(price),
note: note.trim() || null,
}
return id === '' ? createAcquisition(body) : updateAcquisition(id, body)
})
const removal = useMutation((id: string) => deleteAcquisition(id))
function startNew() {
setEditing('')
setDate('')
setPrice('')
setNote('')
setError(null)
}
function startEdit(a: Acquisition) {
setEditing(a.id)
setDate(a.date ?? '')
setPrice(a.price === null ? '' : String(a.price))
setNote(a.note ?? '')
setError(null)
}
function cancel() {
setEditing(null)
setError(null)
}
async function onSubmit(e: FormEvent) {
e.preventDefault()
const trimmedNote = note.trim()
if (!date && price.trim() === '' && trimmedNote === '') {
setError(t.validation.empty)
return
}
if (price.trim() !== '') {
const value = Number(price)
if (Number.isNaN(value) || value < 0 || value > 9999) {
setError(t.validation.priceRange)
return
}
}
setError(null)
const r = await save.run(editing ?? '')
if (r.ok) {
setEditing(null)
records.reload()
}
}
async function onDelete(id: string) {
if (!window.confirm(t.deleteConfirm)) return
await removal.run(id)
records.reload()
}
const items = records.data ?? []
return (
<section className="ak-card">
<h2 className="ak-h2">{t.sectionTitle}</h2>
<p className="ak-empty" style={{ marginTop: 0 }}>
{t.intro}
</p>
{records.loading && <p className="muted">{de.common.loading}</p>}
{records.error && (
<div className="alert alert--error">
<span>{records.error}</span>
<button type="button" className="btn" onClick={records.reload}>
{de.common.retry}
</button>
</div>
)}
{removal.error && <div className="alert alert--error">{removal.error}</div>}
{!records.loading && !records.error && items.length === 0 && editing === null && (
<p className="ak-empty">{t.empty}</p>
)}
{items.length > 0 && (
<ul className="card-list">
{items.map((a) => (
<li key={a.id} className="record-card record-card--row">
<span className="record-card__date">{a.date ? formatDate(a.date) : t.noDate}</span>
<strong>{a.price === null ? t.noPrice : formatPrice(a.price)}</strong>
{a.note && <span className="muted">{a.note}</span>}
<button
type="button"
className="btn"
onClick={() => startEdit(a)}
disabled={editing !== null}
>
{t.edit}
</button>
<button
type="button"
className="btn btn--danger"
onClick={() => onDelete(a.id)}
disabled={removal.pending || editing !== null}
>
{t.delete}
</button>
</li>
))}
</ul>
)}
{editing !== null ? (
<form className="weight-quick-add" onSubmit={onSubmit} noValidate>
<h4>{editing === '' ? t.addTitle : t.editTitle}</h4>
<div className="weight-quick-add__row">
<label className="field">
<span>{t.fields.date}</span>
<input
type="date"
className="input"
value={date}
onChange={(e) => setDate(e.target.value)}
/>
</label>
<label className="field">
<span>{t.fields.price}</span>
<input
type="number"
inputMode="decimal"
min={0}
max={9999}
step={0.01}
className="input"
value={price}
placeholder="0,00"
onChange={(e) => setPrice(e.target.value)}
/>
</label>
<label className="field">
<span>{t.fields.note}</span>
<input
type="text"
className="input"
value={note}
onChange={(e) => setNote(e.target.value)}
/>
</label>
</div>
<div className="ak-saverow">
<button type="submit" className="ak-btn primary" disabled={save.pending}>
{save.pending ? t.saving : editing === '' ? t.addButton : t.saveButton}
</button>
<button type="button" className="ak-btn" onClick={cancel} disabled={save.pending}>
{t.cancel}
</button>
</div>
{error && <small className="error-text">{error}</small>}
{save.error && <div className="alert alert--error">{save.error}</div>}
</form>
) : (
<div className="ak-saverow">
<button type="button" className="ak-btn primary" onClick={startNew}>
{t.addButton}
</button>
</div>
)}
</section>
)
}

View File

@@ -10,6 +10,7 @@ import { ALL_TRAITS, TRAIT_CATEGORIES } from '../format/traits'
import { fromDisplayString, genotypeToFarbschlag, displayGenotypeSafe } from '../genetics'
import type { Gender, GerbilStatus } from '../api/types'
import FarbschlagImage from '../components/FarbschlagImage'
import GerbilAcquisitionSection from '../components/GerbilAcquisitionSection'
import GerbilHealthTab from '../components/GerbilHealthTab'
import GerbilPhotosTab from '../components/GerbilPhotosTab'
import GerbilProfilePhoto from '../components/GerbilProfilePhoto'
@@ -336,6 +337,8 @@ export default function GerbilDetailPage() {
</dl>
</section>
<GerbilAcquisitionSection gerbilId={g.id} />
<section className="ak-card">
<h2 className="ak-h2">{t.detail.genetics}</h2>
{geno ? (

View File

@@ -758,6 +758,32 @@ export const de = {
fileRequired: 'Bitte zuerst ein Foto auswählen.',
},
},
// ERWERB: Erwerb/Kauf je Tier (Kaufdatum, Preis, Notiz) — eigene Sektion in der Akte.
acquisition: {
sectionTitle: 'Erwerb',
intro: 'Wann und zu welchem Preis dieses Tier erworben wurde.',
addTitle: 'Erwerb erfassen',
editTitle: 'Erwerb bearbeiten',
fields: {
date: 'Kaufdatum',
price: 'Preis (€)',
note: 'Notiz',
},
addButton: 'Hinzufügen',
saveButton: 'Speichern',
saving: 'Speichern …',
cancel: 'Abbrechen',
edit: 'Bearbeiten',
delete: 'Löschen',
empty: 'Noch keine Erwerbsdaten erfasst.',
noDate: 'Ohne Datum',
noPrice: '—',
deleteConfirm: 'Diesen Erwerbseintrag wirklich löschen?',
validation: {
empty: 'Bitte mindestens Kaufdatum, Preis oder Notiz angeben.',
priceRange: 'Bitte einen plausiblen Preis (0 bis 9999 €) angeben.',
},
},
},
// ── EXPORT-1 (Oscar): Datenexport (Karte auf /einstellungen) ──
datenexport: {