feat(abgabe): Reservierungs-/Abgabe-Status (verfügbar/reserviert/abgegeben)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -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'
|
||||
@@ -69,6 +70,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" element={<HilfePage />} />
|
||||
<Route path="farbkatalog" element={<FarbkatalogPage />} />
|
||||
|
||||
66
gerbil-manager-web/src/api/reservations.ts
Normal file
66
gerbil-manager-web/src/api/reservations.ts
Normal 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}`)
|
||||
}
|
||||
@@ -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: '📊' },
|
||||
|
||||
417
gerbil-manager-web/src/pages/ReservierungenPage.tsx
Normal file
417
gerbil-manager-web/src/pages/ReservierungenPage.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
172
gerbil-manager-web/src/pages/reservierungen.css
Normal file
172
gerbil-manager-web/src/pages/reservierungen.css
Normal 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;
|
||||
}
|
||||
@@ -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',
|
||||
@@ -358,6 +360,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',
|
||||
|
||||
Reference in New Issue
Block a user