526 lines
18 KiB
TypeScript
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>
|
|
)
|
|
}
|