/** * 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 { useEffect, 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 { listGerbilPhotos, photoSrc, profilePhoto } from '../api/photos' 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 ?? []).filter((c) => c.isReceiver), [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, isReceiver: true, isBreeder: false, }), ) 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 }) /* Vertragsfoto je Tier: GerbilId → PhotoId ('' = bewusst kein Foto). */ const [animalPhotos, setAnimalPhotos] = useState>({}) const setAnimalPhoto = (gerbilId: string, photoId: string) => setAnimalPhotos((m) => ({ ...m, [gerbilId]: photoId })) /* ── 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, // nur ausgewählte Tiere mit tatsächlich gewähltem Foto übermitteln animalPhotos: Object.fromEntries( [...selectedIds] .map((id) => [id, animalPhotos[id]] as const) .filter(([, pid]) => Boolean(pid)), ), }), ) 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) => (
  • {selectedIds.has(g.id) && ( setAnimalPhoto(g.id, photoId)} /> )}
  • ))}
)} {/* ── 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 || de.pages.gerbils.nameless).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 ? ( ) : ( )}
) } /** * Foto-Auswahl je Tier für den Vertrag. Lädt die Tierfotos lazy; Standard ist * das Profilfoto (erstes nach SortOrder). '' = bewusst „Kein Foto“. * Rendert nichts, wenn das Tier keine Fotos hat. */ function ContractPhotoPicker({ gerbilId, chosen, onChange, }: { gerbilId: string chosen: string | undefined onChange: (photoId: string) => void }) { const t = de.pages.vertraege.wizard const photos = useApi(() => listGerbilPhotos(gerbilId), [gerbilId]) const list = useMemo(() => photos.data ?? [], [photos.data]) // Sobald Fotos da sind und noch nichts entschieden ist: Profilfoto vorauswählen. useEffect(() => { if (list.length > 0 && chosen === undefined) { const p = profilePhoto(list) if (p) onChange(p.id) } }, [list, chosen, onChange]) if (photos.loading || list.length === 0) return null return (
{t.photoLabel}
{list.map((p) => ( ))}
) }