From 3ec4f660b021d2c8ddadf77dc17665b850b82192 Mon Sep 17 00:00:00 2001 From: Gulum Date: Sat, 6 Jun 2026 07:31:04 +0200 Subject: [PATCH] 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) --- gerbil-manager-web/src/App.tsx | 9 + gerbil-manager-web/src/api/client.ts | 1 + gerbil-manager-web/src/api/contacts.ts | 4 +- gerbil-manager-web/src/api/contracts.ts | 42 ++ gerbil-manager-web/src/api/settings.ts | 36 ++ gerbil-manager-web/src/api/types.ts | 4 +- .../src/components/AppShell.tsx | 3 + .../src/pages/EinstellungenPage.tsx | 100 ++++ .../src/pages/GerbilDetailPage.tsx | 6 + .../src/pages/KontaktDetailPage.tsx | 12 +- .../src/pages/KontaktFormPage.tsx | 45 +- gerbil-manager-web/src/pages/KontaktePage.tsx | 4 +- .../src/pages/VertraegeListPage.tsx | 123 +++++ .../src/pages/VertragWizardPage.tsx | 450 ++++++++++++++++++ .../src/pages/vertragWizard.css | 142 ++++++ gerbil-manager-web/src/strings/de.ts | 95 +++- 16 files changed, 1060 insertions(+), 16 deletions(-) create mode 100644 gerbil-manager-web/src/api/contracts.ts create mode 100644 gerbil-manager-web/src/api/settings.ts create mode 100644 gerbil-manager-web/src/pages/EinstellungenPage.tsx create mode 100644 gerbil-manager-web/src/pages/VertraegeListPage.tsx create mode 100644 gerbil-manager-web/src/pages/VertragWizardPage.tsx create mode 100644 gerbil-manager-web/src/pages/vertragWizard.css diff --git a/gerbil-manager-web/src/App.tsx b/gerbil-manager-web/src/App.tsx index 99b054f..d37967d 100644 --- a/gerbil-manager-web/src/App.tsx +++ b/gerbil-manager-web/src/App.tsx @@ -18,6 +18,9 @@ import WurfFormPage from './pages/WurfFormPage' import NotFoundPage from './pages/NotFoundPage' import StammbaumPage from './pages/StammbaumPage' import StatistikPage from './pages/StatistikPage' +import VertraegeListPage from './pages/VertraegeListPage' +import VertragWizardPage from './pages/VertragWizardPage' +import EinstellungenPage from './pages/EinstellungenPage' export default function App() { return ( @@ -54,6 +57,12 @@ export default function App() { } /> } /> } /> + {/* FEAT-13: Abgabeverträge + Einstellungen (Zuchtprofil) */} + + } /> + } /> + + } /> } /> diff --git a/gerbil-manager-web/src/api/client.ts b/gerbil-manager-web/src/api/client.ts index 8f6eab6..d72af1f 100644 --- a/gerbil-manager-web/src/api/client.ts +++ b/gerbil-manager-web/src/api/client.ts @@ -68,4 +68,5 @@ export const resources = { contacts: '/contacts', enclosures: '/enclosures', colorVarieties: '/color-varieties', + contracts: '/contracts', } as const diff --git a/gerbil-manager-web/src/api/contacts.ts b/gerbil-manager-web/src/api/contacts.ts index cbfa0be..72f4c9f 100644 --- a/gerbil-manager-web/src/api/contacts.ts +++ b/gerbil-manager-web/src/api/contacts.ts @@ -6,7 +6,9 @@ import type { Contact, Paged } from './types' /** Payload for POST /contacts. */ export interface CreateContact { name: string - contactInfo?: string | null + email?: string | null + phone?: string | null + address?: string | null notes?: string | null } diff --git a/gerbil-manager-web/src/api/contracts.ts b/gerbil-manager-web/src/api/contracts.ts new file mode 100644 index 0000000..e5d7295 --- /dev/null +++ b/gerbil-manager-web/src/api/contracts.ts @@ -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> { + return api.get>(`${resources.contracts}${toQueryString(query)}`) +} + +export function createContract(body: CreateSaleContract): Promise { + return api.post(resources.contracts, body) +} + +export function deleteContract(id: string): Promise { + return api.delete(`${resources.contracts}/${id}`) +} + +/** Absolute Download-URL der .docx (für / window.open). */ +export function contractFileUrl(contract: Pick): string { + return `${API_BASE_URL}${contract.url}` +} diff --git a/gerbil-manager-web/src/api/settings.ts b/gerbil-manager-web/src/api/settings.ts new file mode 100644 index 0000000..248c550 --- /dev/null +++ b/gerbil-manager-web/src/api/settings.ts @@ -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 { + return api.get('/settings/breeder-profile') +} + +export function putBreederProfile(profile: BreederProfile): Promise { + return api.put('/settings/breeder-profile', profile) +} diff --git a/gerbil-manager-web/src/api/types.ts b/gerbil-manager-web/src/api/types.ts index e9f62a4..a59aa3e 100644 --- a/gerbil-manager-web/src/api/types.ts +++ b/gerbil-manager-web/src/api/types.ts @@ -73,7 +73,9 @@ export interface Enclosure { export interface Contact { id: string name: string - contactInfo: string | null + email: string | null + phone: string | null + address: string | null notes: string | null } diff --git a/gerbil-manager-web/src/components/AppShell.tsx b/gerbil-manager-web/src/components/AppShell.tsx index dc45d86..58bf183 100644 --- a/gerbil-manager-web/src/components/AppShell.tsx +++ b/gerbil-manager-web/src/components/AppShell.tsx @@ -23,6 +23,9 @@ const SECONDARY: NavItem[] = [ { to: '/kontakte', label: de.nav.contacts, icon: '📇' }, { to: '/abgabe', label: de.nav.forSale, 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: '⚙️' }, ] const linkClass = ({ isActive }: { isActive: boolean }) => diff --git a/gerbil-manager-web/src/pages/EinstellungenPage.tsx b/gerbil-manager-web/src/pages/EinstellungenPage.tsx new file mode 100644 index 0000000..a340778 --- /dev/null +++ b/gerbil-manager-web/src/pages/EinstellungenPage.tsx @@ -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(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 = (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

{de.common.loading}

+ if (existing.error) { + return ( +
+

{t.title}

+
+ {existing.error} + +
+
+ ) + } + + const field = ( + key: keyof BreederProfile, + label: string, + hint?: string, + type: string = 'text', + ) => ( + + ) + + return ( +
+

{t.title}

+ +

{tz.title}

+

{tz.intro}

+ {!isBreederProfileComplete(form) &&
{tz.incompleteHint}
} + +
+ {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 &&
{mutation.error}
} + +
+ + {saved && {tz.saved}} +
+
+
+ ) +} diff --git a/gerbil-manager-web/src/pages/GerbilDetailPage.tsx b/gerbil-manager-web/src/pages/GerbilDetailPage.tsx index fbeae2b..4a52b27 100644 --- a/gerbil-manager-web/src/pages/GerbilDetailPage.tsx +++ b/gerbil-manager-web/src/pages/GerbilDetailPage.tsx @@ -113,6 +113,12 @@ export default function GerbilDetailPage() { {de.pages.stammbaum.openButton} + {/* FEAT-13: Abgabe abschließen — Vertrag-Assistent mit diesem Tier vorausgewählt. */} + {g.status !== 'Deceased' && g.status !== 'GivenAway' && ( + + {de.pages.vertraege.wizard.title} + + )} {t.detail.back} diff --git a/gerbil-manager-web/src/pages/KontaktDetailPage.tsx b/gerbil-manager-web/src/pages/KontaktDetailPage.tsx index d0b3103..fdc5318 100644 --- a/gerbil-manager-web/src/pages/KontaktDetailPage.tsx +++ b/gerbil-manager-web/src/pages/KontaktDetailPage.tsx @@ -97,8 +97,16 @@ export default function KontaktDetailPage() {
-
{t.fields.contactInfo}
-
{c.contactInfo ?? '—'}
+
{t.fields.email}
+
{c.email ?? '—'}
+
+
+
{t.fields.phone}
+
{c.phone ?? '—'}
+
+
+
{t.fields.address}
+
{c.address ?? '—'}
{t.fields.notes}
diff --git a/gerbil-manager-web/src/pages/KontaktFormPage.tsx b/gerbil-manager-web/src/pages/KontaktFormPage.tsx index 3aebe83..0480aa2 100644 --- a/gerbil-manager-web/src/pages/KontaktFormPage.tsx +++ b/gerbil-manager-web/src/pages/KontaktFormPage.tsx @@ -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() { + + + +
+
+
+

{t.title}

+

+ {totalCount} {t.countLabel} +

+
+
+ + {t.newButton} + +
+
+ + {removal.error &&
{removal.error}
} + + {items.length === 0 ? ( +

{t.empty}

+ ) : ( +
+ )} + + {totalPages > 1 && ( + + )} +
+ ) +} diff --git a/gerbil-manager-web/src/pages/VertragWizardPage.tsx b/gerbil-manager-web/src/pages/VertragWizardPage.tsx new file mode 100644 index 0000000..1434ffb --- /dev/null +++ b/gerbil-manager-web/src/pages/VertragWizardPage.tsx @@ -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=) und Kevins Abgabe-Gruppen (?tiere=); 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(0) + const [stepError, setStepError] = useState(null) + + /* ── Schritt 1: Abnehmer ── */ + const [contactId, setContactId] = useState(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>( + () => 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(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 ( +
+

{t.successTitle}

+

{t.successText}

+
+ + {t.downloadDocx} + + + {t.toList} + + +
+
+ ) + } + + const price = parsePrice(priceText) + + return ( +
+

{t.title}

+ + {/* Schritt-Anzeige */} +
    + {t.steps.map((label, i) => ( +
  1. + {i + 1} + {label} +
  2. + ))} +
+ + {stepError &&
{stepError}
} + + {/* ── Schritt 1: Abnehmer ── */} + {step === 0 && ( +
+

{t.pickContact}

+ {contacts.loading &&

{de.common.loading}

} + {contacts.error &&
{contacts.error}
} + {!contacts.loading && ( + <> + setContactSearch(e.target.value)} + /> + {filteredContacts.length === 0 ? ( +

{t.noContacts}

+ ) : ( +
    + {filteredContacts.map((c) => ( +
  • + +
  • + ))} +
+ )} + +
+ {t.orCreateNew} +
+ + + + + {contactCreation.error && ( +
{contactCreation.error}
+ )} + +
+
+ + )} +
+ )} + + {/* ── Schritt 2: Tiere ── */} + {step === 1 && ( +
+

{t.pickAnimals}

+

{t.pickAnimalsHint}

+ {animals.loading &&

{de.common.loading}

} + {animals.error &&
{animals.error}
} + {!animals.loading && animalItems.length === 0 &&

{t.noAnimals}

} +
    + {animalItems.map((g) => ( +
  • + +
  • + ))} +
+
+ )} + + {/* ── Schritt 3: Preis & Datum ── */} + {step === 2 && ( +
+ + + +
+ )} + + {/* ── Schritt 4: Zusammenfassung ── */} + {step === 3 && ( +
+

{t.summaryTitle}

+ {!profileComplete && ( +
+ {t.profileIncomplete} + + {t.profileLink} + +
+ )} +
+
+
{de.pages.vertraege.fields.contact}
+
{selectedContact?.name ?? '—'}
+
+
+
{de.pages.vertraege.fields.animals}
+
{selectedAnimals.map((g) => g.name).join(', ')}
+
+
+
{de.pages.vertraege.fields.price}
+
+ {price !== null + ? `${price.toLocaleString('de-DE', { minimumFractionDigits: 2 })} €` + : '—'} +
+
+
+
{de.pages.vertraege.fields.handoverDate}
+
{formatDate(handoverDate)}
+
+
+
{de.pages.vertraege.fields.contractDate}
+
{formatDate(contractDate || handoverDate)}
+
+
+ {creation.error &&
{creation.error}
} +
+ )} + + {/* ── Navigation ── */} +
+ {step > 0 ? ( + + ) : ( + + )} + {step < 3 ? ( + + ) : ( + + )} +
+
+ ) +} diff --git a/gerbil-manager-web/src/pages/vertragWizard.css b/gerbil-manager-web/src/pages/vertragWizard.css new file mode 100644 index 0000000..c017699 --- /dev/null +++ b/gerbil-manager-web/src/pages/vertragWizard.css @@ -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; +} diff --git a/gerbil-manager-web/src/strings/de.ts b/gerbil-manager-web/src/strings/de.ts index 81a0a36..997e49c 100644 --- a/gerbil-manager-web/src/strings/de.ts +++ b/gerbil-manager-web/src/strings/de.ts @@ -23,6 +23,9 @@ export const de = { more: 'Mehr', // FEAT-12a (Kevin): Abgabe forSale: 'Abgabe', + // FEAT-13 (Kelly): Verträge + Einstellungen + contracts: 'Verträge', + settings: 'Einstellungen', openMenu: 'Menü öffnen', closeMenu: 'Menü schließen', mainNavigation: 'Hauptnavigation', @@ -364,6 +367,90 @@ export const de = { losses: 'Verluste pro Jahr', 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) ── kontakte: { title: 'Kontakte', @@ -371,9 +458,13 @@ export const de = { empty: 'Keine Kontakte gefunden.', countLabel: 'Kontakte', 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: { name: 'Name', - contactInfo: 'Kontaktdaten', + email: 'E-Mail', + phone: 'Telefon', + address: 'Adresse', notes: 'Notizen', }, linked: { @@ -390,7 +481,7 @@ export const de = { form: { createTitle: 'Neuen Kontakt anlegen', editTitle: 'Kontakt bearbeiten', - contactInfoHint: 'Telefon, E-Mail oder Adresse — freies Format.', + addressHint: 'Straße Nr, PLZ Ort — so erscheint sie im Abgabevertrag.', save: 'Speichern', cancel: 'Abbrechen', saving: 'Speichern …',