Files
GerbilManager/gerbil-manager-web/src/pages/VertragWizardPage.tsx

526 lines
18 KiB
TypeScript

/**
* 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 { 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<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 ?? []).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<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
})
/* Vertragsfoto je Tier: GerbilId → PhotoId ('' = bewusst kein Foto). */
const [animalPhotos, setAnimalPhotos] = useState<Record<string, string>>({})
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<SaleContract | null>(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 (
<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 || de.pages.gerbils.nameless}</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>
{selectedIds.has(g.id) && (
<ContractPhotoPicker
gerbilId={g.id}
chosen={animalPhotos[g.id]}
onChange={(photoId) => setAnimalPhoto(g.id, photoId)}
/>
)}
</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 || de.pages.gerbils.nameless).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>
)
}
/**
* 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 (
<div className="contract-photopick">
<span className="contract-photopick__label">{t.photoLabel}</span>
<div className="contract-photopick__thumbs">
<button
type="button"
className={`contract-photopick__thumb contract-photopick__thumb--none${chosen === '' ? ' is-selected' : ''}`}
onClick={() => onChange('')}
>
{t.photoNone}
</button>
{list.map((p) => (
<button
key={p.id}
type="button"
className={`contract-photopick__thumb${chosen === p.id ? ' is-selected' : ''}`}
onClick={() => onChange(p.id)}
>
<img src={photoSrc(p)} alt={p.caption ?? ''} />
</button>
))}
</div>
</div>
)
}