feat(import): resolve color variety ID mappings mismatch, support unnamed parents in virtual litters, and implement SaleContract updates

This commit is contained in:
2026-06-21 21:57:40 +02:00
parent b83e96f552
commit e460cd5905
29 changed files with 3783 additions and 67 deletions

View File

@@ -22,6 +22,8 @@ export interface CreateSaleContract {
price: number
handoverDate: DateOnlyString
contractDate?: DateOnlyString | null
/** Optionales Vertragsfoto je Tier: GerbilId → GerbilPhoto-Id. */
animalPhotos?: Record<string, string>
}
export function listContracts(query: GridifyQuery): Promise<Paged<SaleContract>> {
@@ -40,3 +42,8 @@ export function deleteContract(id: string): Promise<void> {
export function contractFileUrl(contract: Pick<SaleContract, 'url'>): string {
return `${API_BASE_URL}${contract.url}`
}
/** Absolute Download-URL des druckfertigen PDF. */
export function contractPdfUrl(contract: Pick<SaleContract, 'id'>): string {
return `${API_BASE_URL}/contracts/${contract.id}/pdf`
}

View File

@@ -2,7 +2,7 @@
import { useMemo } from 'react'
import { Link } from 'react-router-dom'
import { de } from '../strings/de'
import { contractFileUrl, deleteContract, listContracts } from '../api/contracts'
import { contractFileUrl, contractPdfUrl, deleteContract, listContracts } from '../api/contracts'
import { listContactsPaged } from '../api/contacts'
import { useApi, useMutation } from '../hooks/useApi'
import { useInfiniteList, useInfiniteSentinel } from '../hooks/useInfiniteList'
@@ -81,6 +81,9 @@ export default function VertraegeListPage() {
{t.fields.handoverDate} {formatDate(c.handoverDate)}
</span>
<span className="head-actions">
<a className="btn btn--primary" href={contractPdfUrl(c)}>
{t.downloadPdf}
</a>
<a className="btn" href={contractFileUrl(c)}>
{t.download}
</a>

View File

@@ -8,7 +8,7 @@
* serverseitig in EINER Transaktion: .docx erzeugen + speichern, Vertrag
* anlegen, Tiere auf „Abgegeben“ stellen (Abnehmer + Abgabedatum).
*/
import { useMemo, useState } from 'react'
import { useEffect, useMemo, useState } from 'react'
import { Link, useNavigate, useSearchParams } from 'react-router-dom'
import { de } from '../strings/de'
import { listGerbils } from '../api/gerbils'
@@ -16,6 +16,7 @@ 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'
@@ -110,6 +111,11 @@ export default function VertragWizardPage() {
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())
@@ -127,6 +133,12 @@ export default function VertragWizardPage() {
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() {
@@ -339,6 +351,13 @@ export default function VertragWizardPage() {
.join(' · ')}
</span>
</label>
{selectedIds.has(g.id) && (
<ContractPhotoPicker
gerbilId={g.id}
chosen={animalPhotos[g.id]}
onChange={(photoId) => setAnimalPhoto(g.id, photoId)}
/>
)}
</li>
))}
</ul>
@@ -450,3 +469,57 @@ export default function VertragWizardPage() {
</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>
)
}

View File

@@ -140,3 +140,50 @@
grid-row: 1 / span 2;
align-self: center;
}
/* ── Vertragsfoto-Auswahl je Tier (Schritt „Tiere“) ── */
.contract-photopick {
margin: 0.5rem 0 0.25rem 1.9rem;
display: flex;
flex-direction: column;
gap: 0.4rem;
}
.contract-photopick__label {
font-size: 0.8rem;
color: var(--color-muted, #8c7f6e);
}
.contract-photopick__thumbs {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
}
.contract-photopick__thumb {
width: 64px;
height: 64px;
padding: 0;
border: 2px solid var(--color-border, #d9ccb4);
border-radius: 10px;
background: var(--color-surface, #fff);
overflow: hidden;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
transition: border-color 0.15s ease, box-shadow 0.15s ease;
}
.contract-photopick__thumb img {
width: 100%;
height: 100%;
object-fit: cover;
display: block;
}
.contract-photopick__thumb--none {
font-size: 0.72rem;
color: var(--color-muted, #8c7f6e);
text-align: center;
line-height: 1.1;
}
.contract-photopick__thumb.is-selected {
border-color: var(--color-accent, #a85f2e);
box-shadow: 0 0 0 2px color-mix(in srgb, var(--color-accent, #a85f2e) 30%, transparent);
}

View File

@@ -66,6 +66,9 @@ export const de = {
// DESIGN (Rennmaus Filter): kurze Variante für den Toggle-Chip.
showExternalShort: 'Externe Ahnen',
showExternalHint: 'Tiere aus fremden Zuchten, die nur für den Stammbaum erfasst sind.',
// BESTAND-FILTER: Tiere ohne Geburtsdatum ausblenden (Toggle-Chip neben „Externe Ahnen“).
hideUndatedShort: 'Mit Geburtsdatum',
hideUndatedHint: 'Tiere ohne hinterlegtes Geburtsdatum ausblenden.',
},
// Sortier-Optionen
sort: {
@@ -451,7 +454,8 @@ export const de = {
newButton: 'Neuer Vertrag',
empty: 'Noch keine Verträge — erstelle den ersten über „Neuer Vertrag“ oder den Abgabe-Bereich.',
countText: (n: number) => (n === 1 ? '1 Vertrag' : `${n} Verträge`),
download: 'Herunterladen',
download: 'Word',
downloadPdf: 'PDF',
delete: 'Löschen',
confirmDelete:
'Vertrag wirklich löschen? Die Word-Datei wird mit entfernt; der Status der Tiere bleibt unverändert.',
@@ -483,6 +487,8 @@ export const de = {
pickAnimalsHint: 'Nur lebende, nicht abgegebene Tiere werden angezeigt.',
noAnimals: 'Keine abgebbaren Tiere gefunden.',
animalsRequired: 'Bitte mindestens ein Tier auswählen.',
photoLabel: 'Foto für den Vertrag',
photoNone: 'Kein Foto',
// Schritt 3
priceLabel: 'Kaufpreis (€)',
pricePlaceholder: 'z. B. 72,00',