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:
100
gerbil-manager-web/src/pages/EinstellungenPage.tsx
Normal file
100
gerbil-manager-web/src/pages/EinstellungenPage.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -113,6 +113,12 @@ export default function GerbilDetailPage() {
|
||||
<Link to={`/rennmaeuse/${g.id}/stammbaum`} className="btn">
|
||||
{de.pages.stammbaum.openButton}
|
||||
</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">
|
||||
{t.detail.back}
|
||||
</Link>
|
||||
|
||||
@@ -97,8 +97,16 @@ export default function KontaktDetailPage() {
|
||||
|
||||
<dl className="def-list">
|
||||
<div className="def-row">
|
||||
<dt>{t.fields.contactInfo}</dt>
|
||||
<dd>{c.contactInfo ?? '—'}</dd>
|
||||
<dt>{t.fields.email}</dt>
|
||||
<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 className="def-row">
|
||||
<dt>{t.fields.notes}</dt>
|
||||
|
||||
@@ -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 { Link, useNavigate, useParams } from 'react-router-dom'
|
||||
import { de } from '../strings/de'
|
||||
@@ -7,11 +8,13 @@ import { useApi, useMutation } from '../hooks/useApi'
|
||||
|
||||
interface FormState {
|
||||
name: string
|
||||
contactInfo: string
|
||||
email: string
|
||||
phone: string
|
||||
address: string
|
||||
notes: string
|
||||
}
|
||||
|
||||
const EMPTY: FormState = { name: '', contactInfo: '', notes: '' }
|
||||
const EMPTY: FormState = { name: '', email: '', phone: '', address: '', notes: '' }
|
||||
|
||||
/** "" -> null, sonst der Wert. */
|
||||
const nn = (s: string): string | null => (s.trim() === '' ? null : s)
|
||||
@@ -33,7 +36,9 @@ export default function KontaktFormPage() {
|
||||
setInitializedFor(existing.data.id)
|
||||
setForm({
|
||||
name: existing.data.name,
|
||||
contactInfo: existing.data.contactInfo ?? '',
|
||||
email: existing.data.email ?? '',
|
||||
phone: existing.data.phone ?? '',
|
||||
address: existing.data.address ?? '',
|
||||
notes: existing.data.notes ?? '',
|
||||
})
|
||||
}
|
||||
@@ -54,7 +59,9 @@ export default function KontaktFormPage() {
|
||||
setErrors({})
|
||||
const result = await mutation.run({
|
||||
name: form.name.trim(),
|
||||
contactInfo: nn(form.contactInfo),
|
||||
email: nn(form.email),
|
||||
phone: nn(form.phone),
|
||||
address: nn(form.address),
|
||||
notes: nn(form.notes),
|
||||
})
|
||||
if (result.ok) navigate(`/kontakte/${result.value.id}`)
|
||||
@@ -80,13 +87,33 @@ export default function KontaktFormPage() {
|
||||
</label>
|
||||
|
||||
<label className="field">
|
||||
<span>{t.fields.contactInfo}</span>
|
||||
<span>{t.fields.email}</span>
|
||||
<input
|
||||
className="input"
|
||||
value={form.contactInfo}
|
||||
onChange={(e) => set('contactInfo', e.target.value)}
|
||||
type="email"
|
||||
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 className="field">
|
||||
|
||||
@@ -74,7 +74,9 @@ export default function KontaktePage() {
|
||||
<li key={c.id}>
|
||||
<Link to={`/kontakte/${c.id}`} className="gerbil-card">
|
||||
<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>
|
||||
</li>
|
||||
))}
|
||||
|
||||
123
gerbil-manager-web/src/pages/VertraegeListPage.tsx
Normal file
123
gerbil-manager-web/src/pages/VertraegeListPage.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
450
gerbil-manager-web/src/pages/VertragWizardPage.tsx
Normal file
450
gerbil-manager-web/src/pages/VertragWizardPage.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
142
gerbil-manager-web/src/pages/vertragWizard.css
Normal file
142
gerbil-manager-web/src/pages/vertragWizard.css
Normal 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;
|
||||
}
|
||||
Reference in New Issue
Block a user