Merge branch 'worktree-agent-a36d202be311b9289'

# Conflicts:
#	GerbilManagerWebAPI/ApplicationContext.cs
#	GerbilManagerWebAPI/Program.cs
#	gerbil-manager-web/e2e/mock-data.ts
This commit is contained in:
2026-06-22 22:57:16 +02:00
18 changed files with 3056 additions and 0 deletions

View File

@@ -577,6 +577,68 @@ export async function installMockApi(page: Page): Promise<MockDb> {
return json(route, 405)
}
// ABGABE-STATUS: /reservations — Reservierungs-/Abgabe-Status (verfügbar → reserviert → abgegeben).
if (path === '/reservations') {
const allowed = ['verfuegbar', 'reserviert', 'abgegeben']
const normStatus = (s: unknown) =>
typeof s === 'string' && allowed.includes(s.trim().toLowerCase()) ? s.trim().toLowerCase() : 'verfuegbar'
if (method === 'POST') {
const body = request.postDataJSON() as Row
if (!body.gerbilId) return json(route, 400, 'GerbilId ist erforderlich.')
const nowIso = new Date().toISOString()
const created = {
id: newId('reservation'),
gerbilId: body.gerbilId,
gerbilName: body.gerbilName ?? null,
status: normStatus(body.status),
reservedForContactId: body.reservedForContactId ?? null,
contactName: body.contactName ?? null,
appointmentDate: body.appointmentDate ?? null,
price: body.price ?? null,
note: body.note ?? null,
handedOverDate: body.handedOverDate ?? null,
createdAt: nowIso,
updatedAt: nowIso,
}
db.reservations.push(created)
return json(route, 201, created)
}
if (method === 'GET') {
const gid = url.searchParams.get('gerbilId')
const rows = gid ? db.reservations.filter((r) => r.gerbilId === gid) : [...db.reservations]
return json(route, 200, rows.slice().reverse())
}
return json(route, 405)
}
const resm = path.match(/^\/reservations\/([^/]+)$/)
if (resm) {
const allowed = ['verfuegbar', 'reserviert', 'abgegeben']
const normStatus = (s: unknown) =>
typeof s === 'string' && allowed.includes(s.trim().toLowerCase()) ? s.trim().toLowerCase() : 'verfuegbar'
const rid = decodeURIComponent(resm[1])
const idx = db.reservations.findIndex((r) => r.id === rid)
if (idx < 0) return json(route, 404, { title: 'Not Found' })
if (method === 'PUT') {
const body = request.postDataJSON() as Row
const row = db.reservations[idx]
if (typeof body.status === 'string') row.status = normStatus(body.status)
if ('gerbilName' in body) row.gerbilName = body.gerbilName ?? null
if ('contactName' in body) row.contactName = body.contactName ?? null
if ('note' in body) row.note = body.note ?? null
if ('reservedForContactId' in body) row.reservedForContactId = body.reservedForContactId ?? null
if ('appointmentDate' in body) row.appointmentDate = body.appointmentDate ?? null
if ('price' in body) row.price = body.price ?? null
if ('handedOverDate' in body) row.handedOverDate = body.handedOverDate ?? null
row.updatedAt = new Date().toISOString()
return json(route, 200, row)
}
if (method === 'DELETE') {
db.reservations.splice(idx, 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

@@ -87,6 +87,8 @@ export interface MockDb {
}
// ERWERB: Erwerb/Kauf je Tier (Kaufdatum, Preis, Notiz) — /acquisitions
acquisitions: Record<string, unknown>[]
// ABGABE-STATUS: Reservierungs-/Abgabe-Status (POST/PUT/DELETE /reservations)
reservations: Record<string, unknown>[]
}
function gerbil(
@@ -544,5 +546,50 @@ export function seedDb(): MockDb {
},
},
acquisitions: [],
// ABGABE-STATUS: eine reservierte + eine abgegebene + eine verfügbare Vormerkung.
reservations: [
{
id: 'res-reserviert',
gerbilId: 'sale-balu',
gerbilName: 'Balu Abgabe',
status: 'reserviert',
reservedForContactId: 'con-huber',
contactName: 'Familie Huber',
appointmentDate: '2026-07-01T00:00:00Z',
price: 25,
note: 'Käfig wird mitgebracht.',
handedOverDate: null,
createdAt: '2026-06-15T09:00:00Z',
updatedAt: '2026-06-15T09:00:00Z',
},
{
id: 'res-abgegeben',
gerbilId: 'pup-abgabe',
gerbilName: 'Pippa',
status: 'abgegeben',
reservedForContactId: 'con-huber',
contactName: 'Familie Huber',
appointmentDate: null,
price: 20,
note: null,
handedOverDate: '2025-05-01T00:00:00Z',
createdAt: '2025-04-20T09:00:00Z',
updatedAt: '2025-05-01T09:00:00Z',
},
{
id: 'res-verfuegbar',
gerbilId: 'sale-benny',
gerbilName: 'Benny Abgabe',
status: 'verfuegbar',
reservedForContactId: null,
contactName: null,
appointmentDate: null,
price: null,
note: null,
handedOverDate: null,
createdAt: '2026-06-16T09:00:00Z',
updatedAt: '2026-06-16T09:00:00Z',
},
],
}
}

View File

@@ -0,0 +1,78 @@
/**
* ABGABE-STATUS: Reservierungs-/Abgabe-Status-Seite (verfügbar → reserviert → abgegeben).
* Erreichbar über das Menü („Reservierungen"). Liste mit Status-Chips, Tier-/Kontakt-Links,
* Anlegen, Status-Wechsel und Löschen.
*/
import { de, expect, gotoSection, skipUnlessMock, acceptNextDialog, test } from './fixtures'
const tr = de.pages.reservierungen
test.describe('Reservierungen', () => {
test.beforeEach(() => skipUnlessMock())
test('über das Menü erreichbar, zeigt die Seed-Reservierungen', async ({ page }) => {
await gotoSection(page, de.nav.reservations)
await expect(page.getByRole('heading', { name: tr.title })).toBeVisible()
// Reservierte + abgegebene + verfügbare Vormerkung sichtbar.
await expect(page.getByText('Balu Abgabe')).toBeVisible()
await expect(page.getByText('Pippa')).toBeVisible()
await expect(page.getByText('Benny Abgabe')).toBeVisible()
})
test('Status-Filter filtern korrekt (Anzahl-Badges stimmen)', async ({ page }) => {
await page.goto('/reservierungen')
const filters = page.locator('.reservierungen-filter')
await filters.filter({ hasText: tr.filters.reserviert }).click()
await expect(page.getByText('Balu Abgabe')).toBeVisible()
await expect(page.getByText('Benny Abgabe')).toHaveCount(0)
await filters.filter({ hasText: tr.filters.abgegeben }).click()
await expect(page.getByText('Pippa')).toBeVisible()
await expect(page.getByText('Balu Abgabe')).toHaveCount(0)
})
test('Tier- und Kontakt-Bezüge sind verlinkt', async ({ page }) => {
await page.goto('/reservierungen')
const card = page.locator('.reservation-card').filter({ hasText: 'Balu Abgabe' })
await expect(card.getByRole('link', { name: 'Balu Abgabe' })).toHaveAttribute('href', /\/rennmaeuse\/sale-balu/)
await expect(card.getByRole('link', { name: 'Familie Huber' })).toHaveAttribute('href', /\/kontakte\/con-huber/)
})
test('Status wechseln: verfügbares Tier reservieren', async ({ page }) => {
await page.goto('/reservierungen')
const card = page.locator('.reservation-card').filter({ hasText: 'Benny Abgabe' })
await expect(card.locator('.reservation-badge--verfuegbar')).toBeVisible()
await card.getByRole('button', { name: tr.actions.markReserved }).click()
await expect(
page.locator('.reservation-card').filter({ hasText: 'Benny Abgabe' }).locator('.reservation-badge--reserviert'),
).toBeVisible()
})
test('neue Reservierung anlegen', async ({ page }) => {
await page.goto('/reservierungen')
await page.getByRole('button', { name: tr.newButton }).click()
const form = page.locator('.reservation-form')
await expect(form).toBeVisible()
// Ein Tier auswählen (Krümel ist in den Seed-Daten vorhanden).
await form.getByLabel(tr.form.animal).selectOption({ label: 'Krümel' })
await form.getByRole('button', { name: tr.form.save }).click()
// Die neue Reservierung erscheint in der Liste.
await expect(page.locator('.reservation-card').filter({ hasText: 'Krümel' })).toBeVisible()
})
test('Reservierung löschen', async ({ page }) => {
await page.goto('/reservierungen')
const card = page.locator('.reservation-card').filter({ hasText: 'Benny Abgabe' })
await expect(card).toBeVisible()
acceptNextDialog(page)
await card.getByRole('button', { name: tr.actions.delete }).click()
await expect(page.locator('.reservation-card').filter({ hasText: 'Benny Abgabe' })).toHaveCount(0)
})
})

View File

@@ -8,6 +8,7 @@ import GerbilDetailPage from './pages/GerbilDetailPage'
import GerbilFormPage from './pages/GerbilFormPage'
import GenetikPage from './pages/GenetikPage'
import AbgabePage from './pages/AbgabePage'
import ReservierungenPage from './pages/ReservierungenPage'
import KontaktePage from './pages/KontaktePage'
import KontaktDetailPage from './pages/KontaktDetailPage'
import KontaktFormPage from './pages/KontaktFormPage'
@@ -71,6 +72,8 @@ export default function App() {
</Route>
<Route path="genetik" element={<GenetikPage />} />
<Route path="abgabe" element={<AbgabePage />} />
{/* ABGABE-STATUS: Reservierungs-/Abgabe-Status (verfügbar → reserviert → abgegeben) */}
<Route path="reservierungen" element={<ReservierungenPage />} />
<Route path="statistik" element={<StatistikPage />} />
<Route path="hilfe">
<Route index element={<HilfePage />} />

View File

@@ -0,0 +1,66 @@
/**
* ABGABE-STATUS: API client for the reservation/sale-status pipeline (/reservations).
* Status verfügbar → reserviert → abgegeben. This is the informal pre-handover status —
* separate from the legal Vertrag (SaleContract).
*/
import { api } from './client'
const RESOURCE = '/reservations'
/** Canonical status values (match the backend contract, case-sensitive). */
export type ReservationStatus = 'verfuegbar' | 'reserviert' | 'abgegeben'
export interface SaleReservation {
id: string
gerbilId: string
gerbilName: string | null
status: ReservationStatus
reservedForContactId: string | null
contactName: string | null
/** ISO-8601 appointment date or null. */
appointmentDate: string | null
price: number | null
note: string | null
/** ISO-8601 hand-over date or null. */
handedOverDate: string | null
createdAt: string
updatedAt: string
}
/** Payload for POST /reservations. */
export interface CreateReservation {
gerbilId: string
gerbilName?: string | null
status?: ReservationStatus
reservedForContactId?: string | null
contactName?: string | null
appointmentDate?: string | null
price?: number | null
note?: string | null
handedOverDate?: string | null
}
/** Payload for PUT /reservations/{id}. */
export type UpdateReservation = Partial<Omit<CreateReservation, 'gerbilId'>>
/** List all reservations, newest-updated first. */
export function listReservations(): Promise<SaleReservation[]> {
return api.get<SaleReservation[]>(RESOURCE)
}
/** List the reservation status rows for one animal. */
export function listReservationsForGerbil(gerbilId: string): Promise<SaleReservation[]> {
return api.get<SaleReservation[]>(`${RESOURCE}?gerbilId=${encodeURIComponent(gerbilId)}`)
}
export function createReservation(body: CreateReservation): Promise<SaleReservation> {
return api.post<SaleReservation>(RESOURCE, body)
}
export function updateReservation(id: string, body: UpdateReservation): Promise<SaleReservation> {
return api.put<SaleReservation>(`${RESOURCE}/${id}`, body)
}
export function deleteReservation(id: string): Promise<void> {
return api.delete(`${RESOURCE}/${id}`)
}

View File

@@ -24,6 +24,8 @@ const SECONDARY: NavItem[] = [
{ to: '/gehege', label: de.nav.enclosures, icon: '🏜️' },
{ to: '/kontakte', label: de.nav.contacts, icon: '📇' },
{ to: '/abgabe', label: de.nav.forSale, icon: '🏡' },
// ABGABE-STATUS: Reservierungs-/Abgabe-Status
{ to: '/reservierungen', label: de.nav.reservations, icon: '🔖' },
// INBOX-1: Anfragen-Posteingang
{ to: '/anfragen', label: de.nav.requests, icon: '📨' },
{ to: '/statistik', label: de.nav.statistics, icon: '📊' },

View File

@@ -0,0 +1,417 @@
/**
* ABGABE-STATUS: Reservierungs-/Abgabe-Status-Verwaltung.
* Liste der (vor allem abzugebenden) Tiere mit Status verfügbar → reserviert → abgegeben,
* inkl. Reservierung für einen Kontakt, Termin, Preis und Notiz. Tier- und Kontakt-Bezüge
* sind als Links hinterlegt. Dieser Status ist die Vormerkung VOR dem rechtlichen Vertrag
* (Verträge-Seite) — er ersetzt ihn nicht.
*/
import { useMemo, useState } from 'react'
import { Link } from 'react-router-dom'
import { de } from '../strings/de'
import { listGerbils } from '../api/gerbils'
import { listContactsPaged } from '../api/contacts'
import {
createReservation,
deleteReservation,
listReservations,
updateReservation,
type ReservationStatus,
type SaleReservation,
} from '../api/reservations'
import { useApi, useMutation } from '../hooks/useApi'
import type { Contact, Gerbil } from '../api/types'
import './reservierungen.css'
const STATUSES: ReservationStatus[] = ['verfuegbar', 'reserviert', 'abgegeben']
type FilterKey = 'all' | ReservationStatus
/** ISO-Datetime → "TT.MM.JJJJ"; leer/ungültig → Strich. */
function dateOnly(iso: string | null): string {
if (!iso) return de.pages.reservierungen.fields.none
const date = new Date(iso)
if (Number.isNaN(date.getTime())) return iso
return date.toLocaleDateString('de-DE', { day: '2-digit', month: '2-digit', year: 'numeric' })
}
/** ISO-Datetime → Wert für <input type="date"> ("YYYY-MM-DD"). */
function toDateInput(iso: string | null): string {
if (!iso) return ''
const date = new Date(iso)
if (Number.isNaN(date.getTime())) return ''
return date.toISOString().slice(0, 10)
}
/** <input type="date">-Wert ("YYYY-MM-DD") → ISO-Datetime oder null. */
function fromDateInput(value: string): string | null {
if (!value) return null
return new Date(`${value}T00:00:00Z`).toISOString()
}
interface FormState {
id: string | null
gerbilId: string
status: ReservationStatus
reservedForContactId: string
appointmentDate: string
price: string
note: string
handedOverDate: string
}
const emptyForm: FormState = {
id: null,
gerbilId: '',
status: 'reserviert',
reservedForContactId: '',
appointmentDate: '',
price: '',
note: '',
handedOverDate: '',
}
export default function ReservierungenPage() {
const t = de.pages.reservierungen
const reservations = useApi(() => listReservations(), [])
const gerbils = useApi(
() => listGerbils({ orderBy: 'name', page: 1, pageSize: 1000 }),
[],
)
const contacts = useApi(
() => listContactsPaged({ orderBy: 'name', page: 1, pageSize: 1000 }),
[],
)
const createM = useMutation(createReservation)
const updateM = useMutation(updateReservation)
const deleteM = useMutation(deleteReservation)
const [filter, setFilter] = useState<FilterKey>('all')
const [form, setForm] = useState<FormState | null>(null)
const [formError, setFormError] = useState<string | null>(null)
const gerbilById = useMemo(
() => new Map((gerbils.data?.items ?? []).map((g: Gerbil) => [g.id, g])),
[gerbils.data],
)
const contactById = useMemo(
() => new Map((contacts.data?.items ?? []).map((c: Contact) => [c.id, c])),
[contacts.data],
)
const rows = useMemo(() => {
const all = reservations.data ?? []
return filter === 'all' ? all : all.filter((r) => r.status === filter)
}, [reservations.data, filter])
const counts = useMemo(() => {
const all = reservations.data ?? []
const c: Record<FilterKey, number> = { all: all.length, verfuegbar: 0, reserviert: 0, abgegeben: 0 }
for (const r of all) c[r.status] += 1
return c
}, [reservations.data])
function gerbilName(r: SaleReservation): string {
return gerbilById.get(r.gerbilId)?.name ?? r.gerbilName ?? t.fields.none
}
function contactName(r: SaleReservation): string {
if (!r.reservedForContactId) return t.fields.none
return contactById.get(r.reservedForContactId)?.name ?? r.contactName ?? t.fields.none
}
function openCreate() {
setFormError(null)
setForm({ ...emptyForm })
}
function openEdit(r: SaleReservation) {
setFormError(null)
setForm({
id: r.id,
gerbilId: r.gerbilId,
status: r.status,
reservedForContactId: r.reservedForContactId ?? '',
appointmentDate: toDateInput(r.appointmentDate),
price: r.price != null ? String(r.price) : '',
note: r.note ?? '',
handedOverDate: toDateInput(r.handedOverDate),
})
}
async function saveForm() {
if (!form) return
if (!form.gerbilId) {
setFormError(t.form.chooseAnimal)
return
}
const g = gerbilById.get(form.gerbilId)
const c = form.reservedForContactId ? contactById.get(form.reservedForContactId) : undefined
const priceNum = form.price.trim() === '' ? null : Number(form.price.replace(',', '.'))
const payload = {
status: form.status,
reservedForContactId: form.reservedForContactId || null,
contactName: c?.name ?? null,
appointmentDate: fromDateInput(form.appointmentDate),
price: priceNum != null && !Number.isNaN(priceNum) ? priceNum : null,
note: form.note.trim() || null,
handedOverDate: fromDateInput(form.handedOverDate),
}
const result = form.id
? await updateM.run(form.id, payload)
: await createM.run({ gerbilId: form.gerbilId, gerbilName: g?.name ?? null, ...payload })
if (result.ok) {
setForm(null)
reservations.reload()
} else {
setFormError(t.form.saveError)
}
}
async function quickStatus(r: SaleReservation, status: ReservationStatus) {
const patch: Parameters<typeof updateReservation>[1] = { status }
if (status === 'abgegeben' && !r.handedOverDate) patch.handedOverDate = new Date().toISOString()
const result = await updateM.run(r.id, patch)
if (result.ok) reservations.reload()
}
async function remove(r: SaleReservation) {
if (!window.confirm(t.actions.confirmDelete)) return
const result = await deleteM.run(r.id)
if (result.ok) reservations.reload()
}
if (reservations.loading) return <p className="muted">{de.common.loading}</p>
if (reservations.error) return <p className="error">{t.loadError}</p>
return (
<section className="page reservierungen">
<h2>{t.title}</h2>
<p className="muted">{t.subtitle}</p>
<p className="muted reservierungen__hint">{t.contractHint}</p>
<div className="reservierungen__toolbar">
<div className="reservierungen__filters" role="group" aria-label={t.fields.status}>
{(['all', ...STATUSES] as FilterKey[]).map((key) => (
<button
key={key}
type="button"
className={
filter === key
? 'reservierungen-filter reservierungen-filter--active'
: 'reservierungen-filter'
}
onClick={() => setFilter(key)}
>
{t.filters[key]}
<span className="reservierungen-filter__count">{counts[key]}</span>
</button>
))}
</div>
<button type="button" className="btn btn--primary" onClick={openCreate}>
{t.newButton}
</button>
</div>
{form && (
<ReservationForm
t={t}
form={form}
setForm={setForm}
gerbils={gerbils.data?.items ?? []}
contacts={contacts.data?.items ?? []}
error={formError}
pending={createM.pending || updateM.pending}
onSave={saveForm}
onCancel={() => setForm(null)}
/>
)}
{rows.length === 0 ? (
<p className="muted">{t.empty}</p>
) : (
<ul className="reservierungen__list">
{rows.map((r) => (
<li key={r.id} className={`reservation-card reservation-card--${r.status}`}>
<div className="reservation-card__head">
<Link to={`/rennmaeuse/${r.gerbilId}`} className="reservation-card__animal">
{gerbilName(r)}
</Link>
<span className={`reservation-badge reservation-badge--${r.status}`}>
{t.status[r.status]}
</span>
</div>
<dl className="reservation-card__meta">
<div>
<dt>{t.fields.reservedFor}</dt>
<dd>
{r.reservedForContactId ? (
<Link to={`/kontakte/${r.reservedForContactId}`}>{contactName(r)}</Link>
) : (
contactName(r)
)}
</dd>
</div>
<div>
<dt>{t.fields.appointment}</dt>
<dd>{dateOnly(r.appointmentDate)}</dd>
</div>
<div>
<dt>{t.fields.price}</dt>
<dd>{r.price != null ? `${r.price.toFixed(2)}` : t.fields.none}</dd>
</div>
<div>
<dt>{t.fields.handedOver}</dt>
<dd>{dateOnly(r.handedOverDate)}</dd>
</div>
</dl>
{r.note && <p className="reservation-card__note">{r.note}</p>}
<div className="reservation-card__actions">
{r.status !== 'reserviert' && (
<button type="button" className="btn" onClick={() => quickStatus(r, 'reserviert')}>
{t.actions.markReserved}
</button>
)}
{r.status !== 'abgegeben' && (
<button type="button" className="btn" onClick={() => quickStatus(r, 'abgegeben')}>
{t.actions.markHandedOver}
</button>
)}
{r.status !== 'verfuegbar' && (
<button type="button" className="btn" onClick={() => quickStatus(r, 'verfuegbar')}>
{t.actions.markAvailable}
</button>
)}
<button type="button" className="btn" onClick={() => openEdit(r)}>
{t.actions.edit}
</button>
<button type="button" className="btn btn--danger" onClick={() => remove(r)}>
{t.actions.delete}
</button>
</div>
</li>
))}
</ul>
)}
</section>
)
}
interface FormProps {
t: typeof de.pages.reservierungen
form: FormState
setForm: (f: FormState) => void
gerbils: Gerbil[]
contacts: Contact[]
error: string | null
pending: boolean
onSave: () => void
onCancel: () => void
}
function ReservationForm({ t, form, setForm, gerbils, contacts, error, pending, onSave, onCancel }: FormProps) {
const isEdit = form.id != null
return (
<form
className="reservation-form"
onSubmit={(e) => {
e.preventDefault()
onSave()
}}
>
<h3>{isEdit ? t.form.editTitle : t.form.addTitle}</h3>
<label>
{t.form.animal}
<select
value={form.gerbilId}
disabled={isEdit}
onChange={(e) => setForm({ ...form, gerbilId: e.target.value })}
>
<option value="">{t.form.animalPlaceholder}</option>
{gerbils.map((g) => (
<option key={g.id} value={g.id}>
{g.name}
</option>
))}
</select>
</label>
<label>
{t.form.status}
<select
value={form.status}
onChange={(e) => setForm({ ...form, status: e.target.value as ReservationStatus })}
>
{STATUSES.map((s) => (
<option key={s} value={s}>
{t.status[s]}
</option>
))}
</select>
</label>
<label>
{t.form.reservedFor}
<select
value={form.reservedForContactId}
onChange={(e) => setForm({ ...form, reservedForContactId: e.target.value })}
>
<option value="">{t.form.contactNone}</option>
{contacts.map((c) => (
<option key={c.id} value={c.id}>
{c.name}
</option>
))}
</select>
</label>
<label>
{t.form.appointment}
<input
type="date"
value={form.appointmentDate}
onChange={(e) => setForm({ ...form, appointmentDate: e.target.value })}
/>
</label>
<label>
{t.form.price}
<input
type="number"
step="0.01"
min="0"
value={form.price}
onChange={(e) => setForm({ ...form, price: e.target.value })}
/>
</label>
<label>
{t.form.handedOver}
<input
type="date"
value={form.handedOverDate}
onChange={(e) => setForm({ ...form, handedOverDate: e.target.value })}
/>
</label>
<label className="reservation-form__note">
{t.form.note}
<textarea
value={form.note}
rows={2}
onChange={(e) => setForm({ ...form, note: e.target.value })}
/>
</label>
{error && <p className="error">{error}</p>}
<div className="reservation-form__actions">
<button type="submit" className="btn btn--primary" disabled={pending}>
{t.form.save}
</button>
<button type="button" className="btn" onClick={onCancel}>
{t.form.cancel}
</button>
</div>
</form>
)
}

View File

@@ -0,0 +1,172 @@
/* ABGABE-STATUS: Reservierungs-/Abgabe-Status-Seite. */
.reservierungen__hint {
font-size: 0.85rem;
}
.reservierungen__toolbar {
display: flex;
flex-wrap: wrap;
gap: 0.75rem;
align-items: center;
justify-content: space-between;
margin: 1rem 0;
}
.reservierungen__filters {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
}
.reservierungen-filter {
display: inline-flex;
align-items: center;
gap: 0.4rem;
padding: 0.35rem 0.75rem;
border: 1px solid var(--border, #ccc);
border-radius: 999px;
background: transparent;
cursor: pointer;
font: inherit;
}
.reservierungen-filter--active {
background: var(--accent, #3b6ea5);
color: #fff;
border-color: var(--accent, #3b6ea5);
}
.reservierungen-filter__count {
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 1.4em;
padding: 0 0.35em;
border-radius: 999px;
background: rgba(0, 0, 0, 0.12);
font-size: 0.8em;
}
.reservierungen-filter--active .reservierungen-filter__count {
background: rgba(255, 255, 255, 0.3);
}
.reservierungen__list {
list-style: none;
margin: 0;
padding: 0;
display: grid;
gap: 0.75rem;
}
.reservation-card {
border: 1px solid var(--border, #ddd);
border-left: 4px solid var(--border, #ddd);
border-radius: 8px;
padding: 0.85rem 1rem;
background: var(--surface, #fff);
}
.reservation-card--verfuegbar {
border-left-color: #2e7d32;
}
.reservation-card--reserviert {
border-left-color: #ef6c00;
}
.reservation-card--abgegeben {
border-left-color: #6d6d6d;
}
.reservation-card__head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.5rem;
}
.reservation-card__animal {
font-weight: 600;
font-size: 1.05rem;
}
.reservation-badge {
display: inline-block;
padding: 0.2rem 0.6rem;
border-radius: 999px;
font-size: 0.8rem;
font-weight: 600;
color: #fff;
}
.reservation-badge--verfuegbar {
background: #2e7d32;
}
.reservation-badge--reserviert {
background: #ef6c00;
}
.reservation-badge--abgegeben {
background: #6d6d6d;
}
.reservation-card__meta {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
gap: 0.5rem 1rem;
margin: 0.6rem 0 0;
}
.reservation-card__meta div {
display: flex;
flex-direction: column;
}
.reservation-card__meta dt {
font-size: 0.75rem;
color: var(--muted, #777);
}
.reservation-card__meta dd {
margin: 0;
}
.reservation-card__note {
margin: 0.5rem 0 0;
font-style: italic;
color: var(--muted, #555);
}
.reservation-card__actions {
display: flex;
flex-wrap: wrap;
gap: 0.4rem;
margin-top: 0.75rem;
}
.reservation-form {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
gap: 0.75rem;
border: 1px solid var(--border, #ddd);
border-radius: 8px;
padding: 1rem;
margin-bottom: 1rem;
background: var(--surface-alt, #fafafa);
}
.reservation-form h3 {
grid-column: 1 / -1;
margin: 0;
}
.reservation-form label {
display: flex;
flex-direction: column;
gap: 0.25rem;
font-size: 0.85rem;
}
.reservation-form__note {
grid-column: 1 / -1;
}
.reservation-form .error {
grid-column: 1 / -1;
}
.reservation-form__actions {
grid-column: 1 / -1;
display: flex;
gap: 0.5rem;
}

View File

@@ -22,6 +22,8 @@ export const de = {
more: 'Mehr',
// FEAT-12a (Kevin): Abgabe
forSale: 'Abgabe',
// ABGABE-STATUS: Reservierungs-/Abgabe-Status (verfügbar → reserviert → abgegeben)
reservations: 'Reservierungen',
// FEAT-13 (Kelly): Verträge + Einstellungen
contracts: 'Verträge',
settings: 'Einstellungen',
@@ -362,6 +364,69 @@ export const de = {
notConfigured: 'KI-Funktion: API-Schlüssel noch nicht konfiguriert.',
},
},
// ── ABGABE-STATUS: Reservierungs-/Abgabe-Status (verfügbar → reserviert → abgegeben) ──
reservierungen: {
title: 'Reservierungen',
subtitle: 'Reservierungs- und Abgabe-Status der Abgabetiere verwalten.',
loadError: 'Reservierungen konnten nicht geladen werden.',
empty: 'Noch keine Reservierungen erfasst.',
newButton: 'Neue Reservierung',
// Status-Werte (Anzeige).
status: {
verfuegbar: 'Verfügbar',
reserviert: 'Reserviert',
abgegeben: 'Abgegeben',
},
// Filter-Chips nach Status.
filters: {
all: 'Alle',
verfuegbar: 'Verfügbar',
reserviert: 'Reserviert',
abgegeben: 'Abgegeben',
},
// Spalten / Feld-Beschriftungen.
fields: {
animal: 'Tier',
status: 'Status',
reservedFor: 'Reserviert für',
appointment: 'Termin',
price: 'Preis',
note: 'Notiz',
handedOver: 'Abgegeben am',
none: '—',
},
// Formular (Anlegen / Bearbeiten).
form: {
addTitle: 'Reservierung anlegen',
editTitle: 'Reservierung bearbeiten',
animal: 'Tier',
animalPlaceholder: 'Tier auswählen …',
status: 'Status',
reservedFor: 'Reserviert für (Kontakt)',
contactNone: '— niemand —',
appointment: 'Termin',
price: 'Preis (€)',
note: 'Notiz',
handedOver: 'Abgegeben am',
save: 'Speichern',
cancel: 'Abbrechen',
saveError: 'Speichern fehlgeschlagen.',
chooseAnimal: 'Bitte ein Tier auswählen.',
},
// Aktionen pro Zeile.
actions: {
edit: 'Bearbeiten',
delete: 'Löschen',
markReserved: 'Reservieren',
markHandedOver: 'Als abgegeben markieren',
markAvailable: 'Wieder freigeben',
confirmDelete: 'Diese Reservierung wirklich löschen?',
deleteError: 'Löschen fehlgeschlagen.',
},
// Hinweis: Status ≠ Vertrag.
contractHint:
'Hinweis: Der Status ist eine Vormerkung. Die rechtliche Abgabe erfolgt weiterhin über einen Vertrag.',
},
// ── FEAT-2 (Oscar): Gehege (Enclosures) — GEHEGE-RENAME: Becken → Gehege ──
becken: {
title: 'Gehege',