FEAT-13: Abgabe wizard (/vertraege/neu, 4 steps, ?tiere= prefill), Vertraege list w/ download+delete, /einstellungen Zuchtprofil form, contracts/settings API clients, nav entries, detail-page entry point + replace orphaned contactInfo with email/phone/address across Kontakt pages

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-06 07:31:04 +02:00
parent 0c05a5acf1
commit 9c3bcfc9f9
16 changed files with 1063 additions and 16 deletions

View File

@@ -19,6 +19,9 @@ import NotFoundPage from './pages/NotFoundPage'
import StammbaumPage from './pages/StammbaumPage' import StammbaumPage from './pages/StammbaumPage'
import StatistikPage from './pages/StatistikPage' import StatistikPage from './pages/StatistikPage'
import HilfePage from './pages/HilfePage' import HilfePage from './pages/HilfePage'
import VertraegeListPage from './pages/VertraegeListPage'
import VertragWizardPage from './pages/VertragWizardPage'
import EinstellungenPage from './pages/EinstellungenPage'
export default function App() { export default function App() {
return ( return (
@@ -55,7 +58,16 @@ export default function App() {
<Route path="genetik" element={<GenetikPage />} /> <Route path="genetik" element={<GenetikPage />} />
<Route path="abgabe" element={<AbgabePage />} /> <Route path="abgabe" element={<AbgabePage />} />
<Route path="statistik" element={<StatistikPage />} /> <Route path="statistik" element={<StatistikPage />} />
<<<<<<< HEAD
<Route path="hilfe" element={<HilfePage />} /> <Route path="hilfe" element={<HilfePage />} />
=======
{/* FEAT-13: Abgabeverträge + Einstellungen (Zuchtprofil) */}
<Route path="vertraege">
<Route index element={<VertraegeListPage />} />
<Route path="neu" element={<VertragWizardPage />} />
</Route>
<Route path="einstellungen" element={<EinstellungenPage />} />
>>>>>>> 3ec4f66 (FEAT-13: Abgabe wizard (/vertraege/neu, 4 steps, ?tiere= prefill), Vertraege list w/ download+delete, /einstellungen Zuchtprofil form, contracts/settings API clients, nav entries, detail-page entry point + replace orphaned contactInfo with email/phone/address across Kontakt pages)
<Route path="*" element={<NotFoundPage />} /> <Route path="*" element={<NotFoundPage />} />
</Route> </Route>
</Routes> </Routes>

View File

@@ -69,4 +69,5 @@ export const resources = {
contacts: '/contacts', contacts: '/contacts',
enclosures: '/enclosures', enclosures: '/enclosures',
colorVarieties: '/color-varieties', colorVarieties: '/color-varieties',
contracts: '/contracts',
} as const } as const

View File

@@ -6,7 +6,9 @@ import type { Contact, Paged } from './types'
/** Payload for POST /contacts. */ /** Payload for POST /contacts. */
export interface CreateContact { export interface CreateContact {
name: string name: string
contactInfo?: string | null email?: string | null
phone?: string | null
address?: string | null
notes?: string | null notes?: string | null
} }

View File

@@ -0,0 +1,42 @@
/** FEAT-13: Abgabeverträge (/contracts). */
import { API_BASE_URL, api, resources } from './client'
import { toQueryString, type GridifyQuery } from './gridify'
import type { DateOnlyString, Paged } from './types'
export interface SaleContract {
id: string
contactId: string
price: number
handoverDate: DateOnlyString
contractDate: DateOnlyString
fileName: string
createdAt: string
gerbilIds: string[]
/** API-relativer Download-Pfad ("/contracts/{id}/file"). */
url: string
}
export interface CreateSaleContract {
contactId: string
gerbilIds: string[]
price: number
handoverDate: DateOnlyString
contractDate?: DateOnlyString | null
}
export function listContracts(query: GridifyQuery): Promise<Paged<SaleContract>> {
return api.get<Paged<SaleContract>>(`${resources.contracts}${toQueryString(query)}`)
}
export function createContract(body: CreateSaleContract): Promise<SaleContract> {
return api.post<SaleContract>(resources.contracts, body)
}
export function deleteContract(id: string): Promise<void> {
return api.delete(`${resources.contracts}/${id}`)
}
/** Absolute Download-URL der .docx (für <a href> / window.open). */
export function contractFileUrl(contract: Pick<SaleContract, 'url'>): string {
return `${API_BASE_URL}${contract.url}`
}

View File

@@ -0,0 +1,36 @@
/** FEAT-13: Zuchtprofil (/settings/breeder-profile) — Verkäufer-Block der Verträge. */
import { api } from './client'
export interface BreederProfile {
zuchtName: string
name: string
address: string
phone: string
email: string
homepage: string
/** Ort der Unterschriftszeile („{Ort}, den {Datum}“). */
city: string
}
export const EMPTY_BREEDER_PROFILE: BreederProfile = {
zuchtName: '',
name: '',
address: '',
phone: '',
email: '',
homepage: '',
city: '',
}
/** Pflichtangaben für einen brauchbaren Vertrag (Hinweis-Logik der UI). */
export function isBreederProfileComplete(p: BreederProfile): boolean {
return [p.name, p.address, p.city].every((v) => v.trim().length > 0)
}
export function getBreederProfile(): Promise<BreederProfile> {
return api.get<BreederProfile>('/settings/breeder-profile')
}
export function putBreederProfile(profile: BreederProfile): Promise<void> {
return api.put<void>('/settings/breeder-profile', profile)
}

View File

@@ -73,7 +73,9 @@ export interface Enclosure {
export interface Contact { export interface Contact {
id: string id: string
name: string name: string
contactInfo: string | null email: string | null
phone: string | null
address: string | null
notes: string | null notes: string | null
} }

View File

@@ -23,6 +23,9 @@ const SECONDARY: NavItem[] = [
{ to: '/kontakte', label: de.nav.contacts, icon: '📇' }, { to: '/kontakte', label: de.nav.contacts, icon: '📇' },
{ to: '/abgabe', label: de.nav.forSale, icon: '🏡' }, { to: '/abgabe', label: de.nav.forSale, icon: '🏡' },
{ to: '/statistik', label: de.nav.statistics, icon: '📊' }, { to: '/statistik', label: de.nav.statistics, icon: '📊' },
// FEAT-13: Abgabeverträge + Zuchtprofil
{ to: '/vertraege', label: de.nav.contracts, icon: '📄' },
{ to: '/einstellungen', label: de.nav.settings, icon: '⚙️' },
{ to: '/hilfe', label: de.nav.help, icon: '❓' }, { to: '/hilfe', label: de.nav.help, icon: '❓' },
] ]

View File

@@ -0,0 +1,100 @@
/**
* FEAT-13: Einstellungen — Zuchtprofil (Verkäufer-Block der Abgabeverträge).
* Einzelzeilen-Settings im Backend (/settings/breeder-profile); die Züchterin
* pflegt ihre Daten hier statt in einer JSON-Datei.
*/
import { useState, type FormEvent } from 'react'
import { de } from '../strings/de'
import {
EMPTY_BREEDER_PROFILE,
getBreederProfile,
isBreederProfileComplete,
putBreederProfile,
type BreederProfile,
} from '../api/settings'
import { useApi, useMutation } from '../hooks/useApi'
export default function EinstellungenPage() {
const t = de.pages.einstellungen
const tz = t.zuchtprofil
const [form, setForm] = useState<BreederProfile>(EMPTY_BREEDER_PROFILE)
const [initialized, setInitialized] = useState(false)
const [saved, setSaved] = useState(false)
const existing = useApi(() => getBreederProfile(), [])
if (existing.data && !initialized) {
setInitialized(true)
setForm(existing.data)
}
const set = <K extends keyof BreederProfile>(key: K, value: string) => {
setSaved(false)
setForm((f) => ({ ...f, [key]: value }))
}
const mutation = useMutation((profile: BreederProfile) => putBreederProfile(profile))
async function onSubmit(e: FormEvent) {
e.preventDefault()
const result = await mutation.run(form)
if (result.ok) setSaved(true)
}
if (existing.loading) return <p className="muted">{de.common.loading}</p>
if (existing.error) {
return (
<section className="page">
<h2>{t.title}</h2>
<div className="alert alert--error">
<span>{existing.error}</span>
<button type="button" className="btn" onClick={existing.reload}>
{de.common.retry}
</button>
</div>
</section>
)
}
const field = (
key: keyof BreederProfile,
label: string,
hint?: string,
type: string = 'text',
) => (
<label className="field">
<span>{label}</span>
<input className="input" type={type} value={form[key]} onChange={(e) => set(key, e.target.value)} />
{hint && <small className="muted">{hint}</small>}
</label>
)
return (
<section className="page">
<h2>{t.title}</h2>
<h3>{tz.title}</h3>
<p className="muted">{tz.intro}</p>
{!isBreederProfileComplete(form) && <div className="alert alert--error">{tz.incompleteHint}</div>}
<form className="form" onSubmit={onSubmit} noValidate>
{field('zuchtName', tz.fields.zuchtName)}
{field('name', tz.fields.name, tz.fields.nameHint)}
{field('address', tz.fields.address, tz.fields.addressHint)}
{field('city', tz.fields.city)}
{field('phone', tz.fields.phone, undefined, 'tel')}
{field('email', tz.fields.email, undefined, 'email')}
{field('homepage', tz.fields.homepage, undefined, 'url')}
{mutation.error && <div className="alert alert--error">{mutation.error}</div>}
<div className="form-actions">
<button type="submit" className="btn btn--primary" disabled={mutation.pending}>
{mutation.pending ? tz.saving : tz.save}
</button>
{saved && <span className="muted">{tz.saved}</span>}
</div>
</form>
</section>
)
}

View File

@@ -113,6 +113,12 @@ export default function GerbilDetailPage() {
<Link to={`/rennmaeuse/${g.id}/stammbaum`} className="btn"> <Link to={`/rennmaeuse/${g.id}/stammbaum`} className="btn">
{de.pages.stammbaum.openButton} {de.pages.stammbaum.openButton}
</Link> </Link>
{/* FEAT-13: Abgabe abschließen — Vertrag-Assistent mit diesem Tier vorausgewählt. */}
{g.status !== 'Deceased' && g.status !== 'GivenAway' && (
<Link to={`/vertraege/neu?tiere=${g.id}`} className="btn">
{de.pages.vertraege.wizard.title}
</Link>
)}
<Link to="/rennmaeuse" className="btn"> <Link to="/rennmaeuse" className="btn">
{t.detail.back} {t.detail.back}
</Link> </Link>

View File

@@ -97,8 +97,16 @@ export default function KontaktDetailPage() {
<dl className="def-list"> <dl className="def-list">
<div className="def-row"> <div className="def-row">
<dt>{t.fields.contactInfo}</dt> <dt>{t.fields.email}</dt>
<dd>{c.contactInfo ?? '—'}</dd> <dd>{c.email ?? '—'}</dd>
</div>
<div className="def-row">
<dt>{t.fields.phone}</dt>
<dd>{c.phone ?? '—'}</dd>
</div>
<div className="def-row">
<dt>{t.fields.address}</dt>
<dd>{c.address ?? '—'}</dd>
</div> </div>
<div className="def-row"> <div className="def-row">
<dt>{t.fields.notes}</dt> <dt>{t.fields.notes}</dt>

View File

@@ -1,4 +1,5 @@
/** FEAT-2: Kontakt anlegen/bearbeiten — Name (Pflicht), Kontaktdaten, Notizen. */ /** FEAT-2: Kontakt anlegen/bearbeiten — Name (Pflicht), E-Mail/Telefon/Adresse, Notizen.
* (FEAT-13: strukturierte Felder statt Freitext-Kontaktdaten — Käufer-Block der Verträge.) */
import { useState, type FormEvent } from 'react' import { useState, type FormEvent } from 'react'
import { Link, useNavigate, useParams } from 'react-router-dom' import { Link, useNavigate, useParams } from 'react-router-dom'
import { de } from '../strings/de' import { de } from '../strings/de'
@@ -7,11 +8,13 @@ import { useApi, useMutation } from '../hooks/useApi'
interface FormState { interface FormState {
name: string name: string
contactInfo: string email: string
phone: string
address: string
notes: string notes: string
} }
const EMPTY: FormState = { name: '', contactInfo: '', notes: '' } const EMPTY: FormState = { name: '', email: '', phone: '', address: '', notes: '' }
/** "" -> null, sonst der Wert. */ /** "" -> null, sonst der Wert. */
const nn = (s: string): string | null => (s.trim() === '' ? null : s) const nn = (s: string): string | null => (s.trim() === '' ? null : s)
@@ -33,7 +36,9 @@ export default function KontaktFormPage() {
setInitializedFor(existing.data.id) setInitializedFor(existing.data.id)
setForm({ setForm({
name: existing.data.name, name: existing.data.name,
contactInfo: existing.data.contactInfo ?? '', email: existing.data.email ?? '',
phone: existing.data.phone ?? '',
address: existing.data.address ?? '',
notes: existing.data.notes ?? '', notes: existing.data.notes ?? '',
}) })
} }
@@ -54,7 +59,9 @@ export default function KontaktFormPage() {
setErrors({}) setErrors({})
const result = await mutation.run({ const result = await mutation.run({
name: form.name.trim(), name: form.name.trim(),
contactInfo: nn(form.contactInfo), email: nn(form.email),
phone: nn(form.phone),
address: nn(form.address),
notes: nn(form.notes), notes: nn(form.notes),
}) })
if (result.ok) navigate(`/kontakte/${result.value.id}`) if (result.ok) navigate(`/kontakte/${result.value.id}`)
@@ -80,13 +87,33 @@ export default function KontaktFormPage() {
</label> </label>
<label className="field"> <label className="field">
<span>{t.fields.contactInfo}</span> <span>{t.fields.email}</span>
<input <input
className="input" className="input"
value={form.contactInfo} type="email"
onChange={(e) => set('contactInfo', e.target.value)} value={form.email}
onChange={(e) => set('email', e.target.value)}
/> />
<small className="muted">{t.form.contactInfoHint}</small> </label>
<label className="field">
<span>{t.fields.phone}</span>
<input
className="input"
type="tel"
value={form.phone}
onChange={(e) => set('phone', e.target.value)}
/>
</label>
<label className="field">
<span>{t.fields.address}</span>
<input
className="input"
value={form.address}
onChange={(e) => set('address', e.target.value)}
/>
<small className="muted">{t.form.addressHint}</small>
</label> </label>
<label className="field"> <label className="field">

View File

@@ -74,7 +74,9 @@ export default function KontaktePage() {
<li key={c.id}> <li key={c.id}>
<Link to={`/kontakte/${c.id}`} className="gerbil-card"> <Link to={`/kontakte/${c.id}`} className="gerbil-card">
<span className="gerbil-card__name">{c.name}</span> <span className="gerbil-card__name">{c.name}</span>
<span className="gerbil-card__meta">{c.contactInfo ?? ''}</span> <span className="gerbil-card__meta">
{[c.phone, c.email].filter(Boolean).join(' · ')}
</span>
</Link> </Link>
</li> </li>
))} ))}

View File

@@ -0,0 +1,123 @@
/** FEAT-13: Abgabeverträge — Liste mit Download/Löschen (/vertraege). */
import { useMemo, useState } from 'react'
import { Link } from 'react-router-dom'
import { de } from '../strings/de'
import { contractFileUrl, deleteContract, listContracts } from '../api/contracts'
import { listContactsPaged } from '../api/contacts'
import { useApi, useMutation } from '../hooks/useApi'
import { formatDate } from '../format/labels'
import './vertragWizard.css'
const PAGE_SIZE = 20
function formatPrice(price: number): string {
return `${price.toLocaleString('de-DE', { minimumFractionDigits: 2, maximumFractionDigits: 2 })} €`
}
export default function VertraegeListPage() {
const t = de.pages.vertraege
const [page, setPage] = useState(1)
const contracts = useApi(
() => listContracts({ page, pageSize: PAGE_SIZE, orderBy: 'createdAt desc' }),
[page],
)
const contacts = useApi(() => listContactsPaged({ page: 1, pageSize: 1000, orderBy: 'name' }), [])
const contactName = useMemo(
() => new Map((contacts.data?.items ?? []).map((c) => [c.id, c.name])),
[contacts.data],
)
const removal = useMutation((id: string) => deleteContract(id))
async function onDelete(id: string) {
if (!window.confirm(t.confirmDelete)) return
const result = await removal.run(id)
if (result.ok) contracts.reload()
}
if (contracts.loading) return <p className="muted">{de.common.loading}</p>
if (contracts.error || !contracts.data) {
return (
<section className="page">
<h2>{t.title}</h2>
<div className="alert alert--error">
<span>{contracts.error}</span>
<button type="button" className="btn" onClick={contracts.reload}>
{de.common.retry}
</button>
</div>
</section>
)
}
const { items, totalCount } = contracts.data
const totalPages = Math.max(1, Math.ceil(totalCount / PAGE_SIZE))
return (
<section className="page">
<header className="page-head">
<div>
<h2>{t.title}</h2>
<p className="muted">
{totalCount} {t.countLabel}
</p>
</div>
<div className="head-actions">
<Link to="/vertraege/neu" className="btn btn--primary">
{t.newButton}
</Link>
</div>
</header>
{removal.error && <div className="alert alert--error">{removal.error}</div>}
{items.length === 0 ? (
<p className="muted">{t.empty}</p>
) : (
<ul className="card-list">
{items.map((c) => (
<li key={c.id} className="gerbil-card vertrag-card">
<span className="gerbil-card__name">{contactName.get(c.contactId) ?? '—'}</span>
<span className="gerbil-card__meta">
{t.animalsCount(c.gerbilIds.length)} · {formatPrice(c.price)} ·{' '}
{t.fields.handoverDate} {formatDate(c.handoverDate)}
</span>
<span className="head-actions">
<a className="btn" href={contractFileUrl(c)}>
{t.download}
</a>
<button
type="button"
className="btn btn--danger"
onClick={() => onDelete(c.id)}
disabled={removal.pending}
>
{t.delete}
</button>
</span>
</li>
))}
</ul>
)}
{totalPages > 1 && (
<nav className="pager" aria-label="Seitennavigation">
<button type="button" className="btn" disabled={page <= 1} onClick={() => setPage(page - 1)}>
{de.common.previous}
</button>
<span>
{de.common.page} {page} {de.common.of} {totalPages}
</span>
<button
type="button"
className="btn"
disabled={page >= totalPages}
onClick={() => setPage(page + 1)}
>
{de.common.next}
</button>
</nav>
)}
</section>
)
}

View File

@@ -0,0 +1,450 @@
/**
* FEAT-13: „Abgabe abschließen“ — Assistent (/vertraege/neu).
*
* Schritte: Abnehmer wählen/anlegen → Tiere bestätigen → Preis & Datum →
* Zusammenfassung + Vertrag erzeugen. Einstiege: Tier-Detail
* (?tiere=<id>) und Kevins Abgabe-Gruppen (?tiere=<id1,id2,…>); die
* Vorauswahl kommt aus dem Query-Parameter. POST /contracts erledigt
* serverseitig in EINER Transaktion: .docx erzeugen + speichern, Vertrag
* anlegen, Tiere auf „Abgegeben“ stellen (Abnehmer + Abgabedatum).
*/
import { useMemo, useState } from 'react'
import { Link, useNavigate, useSearchParams } from 'react-router-dom'
import { de } from '../strings/de'
import { listGerbils } from '../api/gerbils'
import { createContact, listContactsPaged } from '../api/contacts'
import { contractFileUrl, createContract, type SaleContract } from '../api/contracts'
import { getBreederProfile, isBreederProfileComplete } from '../api/settings'
import { listColorVarieties } from '../api/lookups'
import { useApi, useMutation } from '../hooks/useApi'
import { formatDate, genderLabel } from '../format/labels'
import './vertragWizard.css'
type Step = 0 | 1 | 2 | 3
/** "72,00" / "72.5" / "72" → Zahl; null bei Unfug. */
function parsePrice(input: string): number | null {
const normalized = input.trim().replace(/\./g, '').replace(',', '.')
if (normalized === '' || !/^\d+(\.\d{1,2})?$/.test(normalized)) return null
return Number(normalized)
}
function todayIso(): string {
return new Date().toISOString().slice(0, 10)
}
export default function VertragWizardPage() {
const t = de.pages.vertraege.wizard
const navigate = useNavigate()
const [params] = useSearchParams()
const [step, setStep] = useState<Step>(0)
const [stepError, setStepError] = useState<string | null>(null)
/* ── Schritt 1: Abnehmer ── */
const [contactId, setContactId] = useState<string | null>(null)
const [contactSearch, setContactSearch] = useState('')
const [newContact, setNewContact] = useState({ name: '', email: '', phone: '', address: '' })
const contacts = useApi(() => listContactsPaged({ page: 1, pageSize: 1000, orderBy: 'name' }), [])
const contactItems = useMemo(() => contacts.data?.items ?? [], [contacts.data])
const filteredContacts = useMemo(() => {
const needle = contactSearch.trim().toLowerCase()
return needle === ''
? contactItems
: contactItems.filter((c) => c.name.toLowerCase().includes(needle))
}, [contactItems, contactSearch])
const selectedContact = contactItems.find((c) => c.id === contactId) ?? null
const contactCreation = useMutation(() =>
createContact({
name: newContact.name.trim(),
email: newContact.email.trim() || null,
phone: newContact.phone.trim() || null,
address: newContact.address.trim() || null,
}),
)
async function onCreateContact() {
if (newContact.name.trim() === '') {
setStepError(t.contactNameRequired)
return
}
setStepError(null)
const result = await contactCreation.run()
if (result.ok) {
contacts.reload()
setContactId(result.value.id)
setNewContact({ name: '', email: '', phone: '', address: '' })
}
}
/* ── Schritt 2: Tiere (lebend, nicht abgegeben; Vorauswahl aus ?tiere=) ── */
const [selectedIds, setSelectedIds] = useState<Set<string>>(
() => new Set((params.get('tiere') ?? '').split(',').filter(Boolean)),
)
const animals = useApi(
() =>
listGerbils({
page: 1,
pageSize: 1000,
orderBy: 'name',
filter: 'status!=Deceased,status!=GivenAway',
}),
[],
)
const colorVarieties = useApi(() => listColorVarieties(), [])
const colorName = useMemo(
() => new Map((colorVarieties.data ?? []).map((c) => [c.id, c.name])),
[colorVarieties.data],
)
const animalItems = animals.data?.items ?? []
const selectedAnimals = animalItems.filter((g) => selectedIds.has(g.id))
const toggleAnimal = (id: string) =>
setSelectedIds((ids) => {
const next = new Set(ids)
if (next.has(id)) next.delete(id)
else next.add(id)
return next
})
/* ── Schritt 3: Preis & Datum ── */
const [priceText, setPriceText] = useState('')
const [handoverDate, setHandoverDate] = useState(todayIso())
const [contractDate, setContractDate] = useState('')
/* ── Schritt 4: Zusammenfassung + Erzeugen ── */
const profile = useApi(() => getBreederProfile(), [])
const profileComplete = profile.data ? isBreederProfileComplete(profile.data) : true
const [created, setCreated] = useState<SaleContract | null>(null)
const creation = useMutation(() =>
createContract({
contactId: contactId!,
gerbilIds: [...selectedIds],
price: parsePrice(priceText)!,
handoverDate,
contractDate: contractDate || null,
}),
)
async function onGenerate() {
const result = await creation.run()
if (result.ok) setCreated(result.value)
}
/* ── Navigation mit Schritt-Validierung ── */
function goNext() {
if (step === 0 && !contactId) {
setStepError(t.pickContact)
return
}
if (step === 1 && selectedIds.size === 0) {
setStepError(t.animalsRequired)
return
}
if (step === 2) {
if (parsePrice(priceText) === null) {
setStepError(t.priceInvalid)
return
}
if (!handoverDate) {
setStepError(t.handoverLabel)
return
}
}
setStepError(null)
setStep((s) => Math.min(3, s + 1) as Step)
}
function goBack() {
setStepError(null)
setStep((s) => Math.max(0, s - 1) as Step)
}
/* ── Erfolgsansicht ── */
if (created) {
return (
<section className="page wizard">
<h2>{t.successTitle}</h2>
<p>{t.successText}</p>
<div className="wizard-success-actions">
<a className="btn btn--primary" href={contractFileUrl(created)}>
{t.downloadDocx}
</a>
<Link to="/vertraege" className="btn">
{t.toList}
</Link>
<button
type="button"
className="btn"
onClick={() => {
setCreated(null)
setSelectedIds(new Set())
setPriceText('')
setStep(0)
animals.reload()
}}
>
{t.anotherOne}
</button>
</div>
</section>
)
}
const price = parsePrice(priceText)
return (
<section className="page wizard">
<h2>{t.title}</h2>
{/* Schritt-Anzeige */}
<ol className="wizard-steps">
{t.steps.map((label, i) => (
<li
key={label}
className={
i === step ? 'wizard-step wizard-step--active' : i < step ? 'wizard-step wizard-step--done' : 'wizard-step'
}
aria-current={i === step ? 'step' : undefined}
>
<span className="wizard-step__number">{i + 1}</span>
<span className="wizard-step__label">{label}</span>
</li>
))}
</ol>
{stepError && <div className="alert alert--error">{stepError}</div>}
{/* ── Schritt 1: Abnehmer ── */}
{step === 0 && (
<div className="wizard-panel">
<h3>{t.pickContact}</h3>
{contacts.loading && <p className="muted">{de.common.loading}</p>}
{contacts.error && <div className="alert alert--error">{contacts.error}</div>}
{!contacts.loading && (
<>
<input
className="input"
type="search"
placeholder={t.searchContact}
value={contactSearch}
onChange={(e) => setContactSearch(e.target.value)}
/>
{filteredContacts.length === 0 ? (
<p className="muted">{t.noContacts}</p>
) : (
<ul className="wizard-pick-list">
{filteredContacts.map((c) => (
<li key={c.id}>
<label className="wizard-pick">
<input
type="radio"
name="abnehmer"
checked={contactId === c.id}
onChange={() => setContactId(c.id)}
/>
<span className="wizard-pick__name">{c.name}</span>
<span className="wizard-pick__meta">
{[c.phone, c.email].filter(Boolean).join(' · ')}
</span>
</label>
</li>
))}
</ul>
)}
<details className="wizard-newcontact">
<summary>{t.orCreateNew}</summary>
<div className="form">
<label className="field">
<span>{de.pages.kontakte.fields.name} *</span>
<input
className="input"
value={newContact.name}
onChange={(e) => setNewContact({ ...newContact, name: e.target.value })}
/>
</label>
<label className="field">
<span>{de.pages.kontakte.fields.address}</span>
<input
className="input"
value={newContact.address}
onChange={(e) => setNewContact({ ...newContact, address: e.target.value })}
/>
<small className="muted">{de.pages.kontakte.form.addressHint}</small>
</label>
<label className="field">
<span>{de.pages.kontakte.fields.phone}</span>
<input
className="input"
type="tel"
value={newContact.phone}
onChange={(e) => setNewContact({ ...newContact, phone: e.target.value })}
/>
</label>
<label className="field">
<span>{de.pages.kontakte.fields.email}</span>
<input
className="input"
type="email"
value={newContact.email}
onChange={(e) => setNewContact({ ...newContact, email: e.target.value })}
/>
</label>
{contactCreation.error && (
<div className="alert alert--error">{contactCreation.error}</div>
)}
<button
type="button"
className="btn"
onClick={onCreateContact}
disabled={contactCreation.pending}
>
{t.createContact}
</button>
</div>
</details>
</>
)}
</div>
)}
{/* ── Schritt 2: Tiere ── */}
{step === 1 && (
<div className="wizard-panel">
<h3>{t.pickAnimals}</h3>
<p className="muted">{t.pickAnimalsHint}</p>
{animals.loading && <p className="muted">{de.common.loading}</p>}
{animals.error && <div className="alert alert--error">{animals.error}</div>}
{!animals.loading && animalItems.length === 0 && <p className="muted">{t.noAnimals}</p>}
<ul className="wizard-pick-list">
{animalItems.map((g) => (
<li key={g.id}>
<label className="wizard-pick">
<input
type="checkbox"
checked={selectedIds.has(g.id)}
onChange={() => toggleAnimal(g.id)}
/>
<span className="wizard-pick__name">{g.name}</span>
<span className="wizard-pick__meta">
{[
genderLabel(g.gender),
g.colorVarietyId ? colorName.get(g.colorVarietyId) : null,
g.dateOfBirth ? `* ${formatDate(g.dateOfBirth)}` : null,
]
.filter(Boolean)
.join(' · ')}
</span>
</label>
</li>
))}
</ul>
</div>
)}
{/* ── Schritt 3: Preis & Datum ── */}
{step === 2 && (
<div className="wizard-panel form">
<label className="field">
<span>{t.priceLabel}</span>
<input
className="input"
inputMode="decimal"
placeholder={t.pricePlaceholder}
value={priceText}
onChange={(e) => setPriceText(e.target.value)}
/>
</label>
<label className="field">
<span>{t.handoverLabel}</span>
<input
className="input"
type="date"
value={handoverDate}
onChange={(e) => setHandoverDate(e.target.value)}
/>
</label>
<label className="field">
<span>{t.contractDateLabel}</span>
<input
className="input"
type="date"
value={contractDate}
onChange={(e) => setContractDate(e.target.value)}
/>
</label>
</div>
)}
{/* ── Schritt 4: Zusammenfassung ── */}
{step === 3 && (
<div className="wizard-panel">
<h3>{t.summaryTitle}</h3>
{!profileComplete && (
<div className="alert alert--error">
<span>{t.profileIncomplete}</span>
<Link to="/einstellungen" className="btn">
{t.profileLink}
</Link>
</div>
)}
<dl className="def-list">
<div className="def-row">
<dt>{de.pages.vertraege.fields.contact}</dt>
<dd>{selectedContact?.name ?? '—'}</dd>
</div>
<div className="def-row">
<dt>{de.pages.vertraege.fields.animals}</dt>
<dd>{selectedAnimals.map((g) => g.name).join(', ')}</dd>
</div>
<div className="def-row">
<dt>{de.pages.vertraege.fields.price}</dt>
<dd>
{price !== null
? `${price.toLocaleString('de-DE', { minimumFractionDigits: 2 })} €`
: '—'}
</dd>
</div>
<div className="def-row">
<dt>{de.pages.vertraege.fields.handoverDate}</dt>
<dd>{formatDate(handoverDate)}</dd>
</div>
<div className="def-row">
<dt>{de.pages.vertraege.fields.contractDate}</dt>
<dd>{formatDate(contractDate || handoverDate)}</dd>
</div>
</dl>
{creation.error && <div className="alert alert--error">{creation.error}</div>}
</div>
)}
{/* ── Navigation ── */}
<div className="form-actions wizard-actions">
{step > 0 ? (
<button type="button" className="btn" onClick={goBack}>
{t.back}
</button>
) : (
<button type="button" className="btn" onClick={() => navigate(-1)}>
{t.cancel}
</button>
)}
{step < 3 ? (
<button type="button" className="btn btn--primary" onClick={goNext}>
{t.next}
</button>
) : (
<button
type="button"
className="btn btn--primary"
onClick={onGenerate}
disabled={creation.pending}
>
{creation.pending ? t.generating : t.generate}
</button>
)}
</div>
</section>
)
}

View File

@@ -0,0 +1,142 @@
/* FEAT-13: Abgabe-Assistent + Vertragsliste — seitenspezifische Stile
(Standing-Rule-2-Muster: eigene Datei statt index.css). */
.wizard {
max-width: 40rem;
}
/* ── Schritt-Anzeige ── */
.wizard-steps {
list-style: none;
display: flex;
gap: 0.25rem;
margin: 0.75rem 0 1rem;
padding: 0;
}
.wizard-step {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
gap: 0.25rem;
padding: 0.4rem 0.25rem;
border-bottom: 3px solid var(--color-border);
color: var(--color-text-muted);
font-size: 0.72rem;
text-align: center;
}
.wizard-step--active {
border-bottom-color: var(--color-accent);
color: var(--color-accent);
font-weight: 600;
}
.wizard-step--done {
border-bottom-color: var(--color-accent-soft);
}
.wizard-step__number {
display: inline-flex;
align-items: center;
justify-content: center;
width: 1.6rem;
height: 1.6rem;
border-radius: 50%;
background: var(--color-accent-soft);
color: var(--color-accent);
font-weight: 600;
}
.wizard-step--active .wizard-step__number {
background: var(--color-accent);
color: #fff;
}
/* ── Auswahllisten (Kontakte / Tiere) ── */
.wizard-panel {
margin: 0.5rem 0 1rem;
}
.wizard-pick-list {
list-style: none;
margin: 0.75rem 0 0;
padding: 0;
display: flex;
flex-direction: column;
gap: 0.4rem;
max-height: 22rem;
overflow-y: auto;
}
.wizard-pick {
display: grid;
grid-template-columns: auto 1fr;
gap: 0.2rem 0.6rem;
align-items: center;
padding: 0.6rem 0.8rem;
border: 1px solid var(--color-border);
border-radius: 0.6rem;
background: var(--color-surface);
cursor: pointer;
min-height: 44px;
}
.wizard-pick:hover {
background: var(--color-accent-soft);
}
.wizard-pick input {
grid-row: span 2;
width: 1.1rem;
height: 1.1rem;
}
.wizard-pick__name {
font-weight: 600;
}
.wizard-pick__meta {
grid-column: 2;
font-size: 0.8rem;
color: var(--color-text-muted);
}
.wizard-newcontact {
margin-top: 1rem;
}
.wizard-newcontact summary {
cursor: pointer;
color: var(--color-accent);
min-height: 44px;
display: flex;
align-items: center;
}
.wizard-actions {
justify-content: space-between;
}
.wizard-success-actions {
display: flex;
flex-direction: column;
gap: 0.75rem;
max-width: 22rem;
margin-top: 1rem;
}
/* ── Vertragsliste ── */
.vertrag-card {
grid-template-columns: 1fr auto;
}
.vertrag-card .head-actions {
grid-column: 2;
grid-row: 1 / span 2;
align-self: center;
}

View File

@@ -23,6 +23,9 @@ export const de = {
more: 'Mehr', more: 'Mehr',
// FEAT-12a (Kevin): Abgabe // FEAT-12a (Kevin): Abgabe
forSale: 'Abgabe', forSale: 'Abgabe',
// FEAT-13 (Kelly): Verträge + Einstellungen
contracts: 'Verträge',
settings: 'Einstellungen',
openMenu: 'Menü öffnen', openMenu: 'Menü öffnen',
closeMenu: 'Menü schließen', closeMenu: 'Menü schließen',
mainNavigation: 'Hauptnavigation', mainNavigation: 'Hauptnavigation',
@@ -366,6 +369,90 @@ export const de = {
losses: 'Verluste pro Jahr', losses: 'Verluste pro Jahr',
lossesHint: 'Verstorbene Tiere nach Todesjahr.', lossesHint: 'Verstorbene Tiere nach Todesjahr.',
}, },
// ── FEAT-13 (Kelly): Abgabeverträge ──
vertraege: {
title: 'Abgabeverträge',
newButton: 'Neuer Vertrag',
empty: 'Noch keine Verträge — erstelle den ersten über „Neuer Vertrag“ oder den Abgabe-Bereich.',
countLabel: 'Verträge',
download: 'Herunterladen',
delete: 'Löschen',
confirmDelete:
'Vertrag wirklich löschen? Die Word-Datei wird mit entfernt; der Status der Tiere bleibt unverändert.',
animalsCount: (n: number) => (n === 1 ? '1 Tier' : `${n} Tiere`),
fields: {
contact: 'Abnehmer',
animals: 'Tiere',
price: 'Kaufpreis',
handoverDate: 'Übergabedatum',
contractDate: 'Vertragsdatum',
createdAt: 'Erstellt',
},
// Assistent (/vertraege/neu)
wizard: {
title: 'Abgabe abschließen',
steps: ['Abnehmer', 'Tiere', 'Preis & Datum', 'Vertrag'],
back: 'Zurück',
next: 'Weiter',
cancel: 'Abbrechen',
// Schritt 1
pickContact: 'Abnehmer auswählen',
searchContact: 'Name suchen …',
noContacts: 'Keine Kontakte gefunden.',
orCreateNew: 'Oder neuen Kontakt anlegen',
createContact: 'Kontakt anlegen und auswählen',
contactNameRequired: 'Bitte einen Namen für den Kontakt eingeben.',
// Schritt 2
pickAnimals: 'Tiere bestätigen',
pickAnimalsHint: 'Nur lebende, nicht abgegebene Tiere werden angezeigt.',
noAnimals: 'Keine abgebbaren Tiere gefunden.',
animalsRequired: 'Bitte mindestens ein Tier auswählen.',
// Schritt 3
priceLabel: 'Kaufpreis (€)',
pricePlaceholder: 'z. B. 72,00',
priceInvalid: 'Bitte einen gültigen Preis eingeben (z. B. 72,00).',
handoverLabel: 'Übergabedatum',
contractDateLabel: 'Vertragsdatum (optional, Standard = Übergabedatum)',
// Schritt 4
summaryTitle: 'Zusammenfassung',
profileIncomplete:
'Das Zuchtprofil ist unvollständig — Name, Adresse und Ort erscheinen im Vertrag. Jetzt unter Einstellungen ergänzen?',
profileLink: 'Zu den Einstellungen',
generate: 'Vertrag erzeugen',
generating: 'Vertrag wird erzeugt …',
successTitle: 'Vertrag erstellt!',
successText:
'Die Tiere wurden als „Abgegeben“ markiert (Abnehmer und Abgabedatum gesetzt).',
downloadDocx: 'Vertrag herunterladen (.docx)',
toList: 'Zur Vertragsliste',
anotherOne: 'Weiteren Vertrag erstellen',
},
},
// ── FEAT-13 (Kelly): Einstellungen (Zuchtprofil) ──
einstellungen: {
title: 'Einstellungen',
zuchtprofil: {
title: 'Zuchtprofil',
intro:
'Diese Angaben erscheinen als Verkäufer-Block in jedem Abgabevertrag.',
incompleteHint:
'Noch unvollständig: Name, Adresse und Ort werden für den Vertrag benötigt.',
fields: {
zuchtName: 'Zuchtname',
name: 'Vor- und Nachname',
nameHint: 'Mit Anrede, z. B. „Frau Erika Muster“.',
address: 'Adresse',
addressHint: 'Straße Nr, PLZ Ort.',
phone: 'Telefon',
email: 'E-Mail',
homepage: 'Homepage',
city: 'Ort (Unterschriftszeile)',
},
save: 'Speichern',
saving: 'Speichern …',
saved: 'Gespeichert.',
},
},
// ── FEAT-2 (Oscar): Kontakte (Contacts — Herkunft/Abnehmer) ── // ── FEAT-2 (Oscar): Kontakte (Contacts — Herkunft/Abnehmer) ──
kontakte: { kontakte: {
title: 'Kontakte', title: 'Kontakte',
@@ -373,9 +460,13 @@ export const de = {
empty: 'Keine Kontakte gefunden.', empty: 'Keine Kontakte gefunden.',
countLabel: 'Kontakte', countLabel: 'Kontakte',
searchPlaceholder: 'Name suchen …', searchPlaceholder: 'Name suchen …',
// FEAT-13: contactInfo (Freitext) wurde durch strukturierte Felder ersetzt
// (DATA-2-Schema: email/phone/address) — gebraucht für den Käufer-Block der Verträge.
fields: { fields: {
name: 'Name', name: 'Name',
contactInfo: 'Kontaktdaten', email: 'E-Mail',
phone: 'Telefon',
address: 'Adresse',
notes: 'Notizen', notes: 'Notizen',
}, },
linked: { linked: {
@@ -392,7 +483,7 @@ export const de = {
form: { form: {
createTitle: 'Neuen Kontakt anlegen', createTitle: 'Neuen Kontakt anlegen',
editTitle: 'Kontakt bearbeiten', editTitle: 'Kontakt bearbeiten',
contactInfoHint: 'Telefon, E-Mail oder Adresse — freies Format.', addressHint: 'Straße Nr, PLZ Ort — so erscheint sie im Abgabevertrag.',
save: 'Speichern', save: 'Speichern',
cancel: 'Abbrechen', cancel: 'Abbrechen',
saving: 'Speichern …', saving: 'Speichern …',