diff --git a/gerbil-manager-web/src/App.tsx b/gerbil-manager-web/src/App.tsx index f11a4b0..a880715 100644 --- a/gerbil-manager-web/src/App.tsx +++ b/gerbil-manager-web/src/App.tsx @@ -19,6 +19,9 @@ import NotFoundPage from './pages/NotFoundPage' import StammbaumPage from './pages/StammbaumPage' import StatistikPage from './pages/StatistikPage' import HilfePage from './pages/HilfePage' +import VertraegeListPage from './pages/VertraegeListPage' +import VertragWizardPage from './pages/VertragWizardPage' +import EinstellungenPage from './pages/EinstellungenPage' export default function App() { return ( @@ -55,7 +58,16 @@ export default function App() { } /> } /> } /> +<<<<<<< HEAD } /> +======= + {/* FEAT-13: Abgabeverträge + Einstellungen (Zuchtprofil) */} + + } /> + } /> + + } /> +>>>>>>> 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) } /> diff --git a/gerbil-manager-web/src/api/client.ts b/gerbil-manager-web/src/api/client.ts index 08fc51d..90498e0 100644 --- a/gerbil-manager-web/src/api/client.ts +++ b/gerbil-manager-web/src/api/client.ts @@ -69,4 +69,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 3a0859d..05d13fe 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: '⚙️' }, { to: '/hilfe', label: de.nav.help, icon: '❓' }, ] 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} + + {de.common.retry} + + + + ) + } + + const field = ( + key: keyof BreederProfile, + label: string, + hint?: string, + type: string = 'text', + ) => ( + + {label} + set(key, e.target.value)} /> + {hint && {hint}} + + ) + + 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}} + + + + {mutation.pending ? tz.saving : tz.save} + + {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.fields.contactInfo} + {t.fields.email} set('contactInfo', e.target.value)} + type="email" + value={form.email} + onChange={(e) => set('email', e.target.value)} /> - {t.form.contactInfoHint} + + + + {t.fields.phone} + set('phone', e.target.value)} + /> + + + + {t.fields.address} + set('address', e.target.value)} + /> + {t.form.addressHint} diff --git a/gerbil-manager-web/src/pages/KontaktePage.tsx b/gerbil-manager-web/src/pages/KontaktePage.tsx index a897126..6512ef9 100644 --- a/gerbil-manager-web/src/pages/KontaktePage.tsx +++ b/gerbil-manager-web/src/pages/KontaktePage.tsx @@ -74,7 +74,9 @@ export default function KontaktePage() { {c.name} - {c.contactInfo ?? ''} + + {[c.phone, c.email].filter(Boolean).join(' · ')} + ))} diff --git a/gerbil-manager-web/src/pages/VertraegeListPage.tsx b/gerbil-manager-web/src/pages/VertraegeListPage.tsx new file mode 100644 index 0000000..1238da9 --- /dev/null +++ b/gerbil-manager-web/src/pages/VertraegeListPage.tsx @@ -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 {de.common.loading} + if (contracts.error || !contracts.data) { + return ( + + {t.title} + + {contracts.error} + + {de.common.retry} + + + + ) + } + + const { items, totalCount } = contracts.data + const totalPages = Math.max(1, Math.ceil(totalCount / PAGE_SIZE)) + + return ( + + + + {t.title} + + {totalCount} {t.countLabel} + + + + + {t.newButton} + + + + + {removal.error && {removal.error}} + + {items.length === 0 ? ( + {t.empty} + ) : ( + + {items.map((c) => ( + + {contactName.get(c.contactId) ?? '—'} + + {t.animalsCount(c.gerbilIds.length)} · {formatPrice(c.price)} ·{' '} + {t.fields.handoverDate} {formatDate(c.handoverDate)} + + + + {t.download} + + onDelete(c.id)} + disabled={removal.pending} + > + {t.delete} + + + + ))} + + )} + + {totalPages > 1 && ( + + setPage(page - 1)}> + {de.common.previous} + + + {de.common.page} {page} {de.common.of} {totalPages} + + = totalPages} + onClick={() => setPage(page + 1)} + > + {de.common.next} + + + )} + + ) +} 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} + + { + setCreated(null) + setSelectedIds(new Set()) + setPriceText('') + setStep(0) + animals.reload() + }} + > + {t.anotherOne} + + + + ) + } + + const price = parsePrice(priceText) + + return ( + + {t.title} + + {/* Schritt-Anzeige */} + + {t.steps.map((label, i) => ( + + {i + 1} + {label} + + ))} + + + {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) => ( + + + setContactId(c.id)} + /> + {c.name} + + {[c.phone, c.email].filter(Boolean).join(' · ')} + + + + ))} + + )} + + + {t.orCreateNew} + + + {de.pages.kontakte.fields.name} * + setNewContact({ ...newContact, name: e.target.value })} + /> + + + {de.pages.kontakte.fields.address} + setNewContact({ ...newContact, address: e.target.value })} + /> + {de.pages.kontakte.form.addressHint} + + + {de.pages.kontakte.fields.phone} + setNewContact({ ...newContact, phone: e.target.value })} + /> + + + {de.pages.kontakte.fields.email} + setNewContact({ ...newContact, email: e.target.value })} + /> + + {contactCreation.error && ( + {contactCreation.error} + )} + + {t.createContact} + + + + > + )} + + )} + + {/* ── 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) => ( + + + toggleAnimal(g.id)} + /> + {g.name} + + {[ + genderLabel(g.gender), + g.colorVarietyId ? colorName.get(g.colorVarietyId) : null, + g.dateOfBirth ? `* ${formatDate(g.dateOfBirth)}` : null, + ] + .filter(Boolean) + .join(' · ')} + + + + ))} + + + )} + + {/* ── Schritt 3: Preis & Datum ── */} + {step === 2 && ( + + + {t.priceLabel} + setPriceText(e.target.value)} + /> + + + {t.handoverLabel} + setHandoverDate(e.target.value)} + /> + + + {t.contractDateLabel} + setContractDate(e.target.value)} + /> + + + )} + + {/* ── 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 ? ( + + {t.back} + + ) : ( + navigate(-1)}> + {t.cancel} + + )} + {step < 3 ? ( + + {t.next} + + ) : ( + + {creation.pending ? t.generating : t.generate} + + )} + + + ) +} 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 6dfed61..0957f33 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', @@ -366,6 +369,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', @@ -373,9 +460,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: { @@ -392,7 +483,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 …',
{de.common.loading}
{tz.intro}
+ {totalCount} {t.countLabel} +
{t.empty}
{t.successText}
{t.noContacts}
{t.pickAnimalsHint}
{t.noAnimals}