Merge branch 'worktree-agent-a5f48334696288159'
# Conflicts: # gerbil-manager-web/e2e/mock-data.ts
This commit is contained in:
204
gerbil-manager-web/src/components/GerbilAcquisitionSection.tsx
Normal file
204
gerbil-manager-web/src/components/GerbilAcquisitionSection.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user