diff --git a/backend/src/database/migrations/086_extend_ausbildung_columns.sql b/backend/src/database/migrations/086_extend_ausbildung_columns.sql new file mode 100644 index 0000000..d15769f --- /dev/null +++ b/backend/src/database/migrations/086_extend_ausbildung_columns.sql @@ -0,0 +1,6 @@ +-- Add Kursnummer, Kurzbezeichnung, and Erfolgscode columns to ausbildung table +-- These fields are scraped from FDISK's 5-column Ausbildungen detail page +ALTER TABLE ausbildung + ADD COLUMN IF NOT EXISTS kursnummer VARCHAR(32), + ADD COLUMN IF NOT EXISTS kurs_kurzbezeichnung VARCHAR(32), + ADD COLUMN IF NOT EXISTS erfolgscode VARCHAR(64); diff --git a/backend/src/services/member.service.ts b/backend/src/services/member.service.ts index 2f482d7..c330d42 100644 --- a/backend/src/services/member.service.ts +++ b/backend/src/services/member.service.ts @@ -690,7 +690,7 @@ class MemberService { async getAusbildungen(userId: string): Promise { try { const result = await pool.query( - `SELECT id, kursname, kurs_datum, ablaufdatum, ort, bemerkung, status, created_at + `SELECT id, kursname, kursnummer, kurs_kurzbezeichnung, erfolgscode, kurs_datum, ablaufdatum, ort, bemerkung, status, created_at FROM ausbildung WHERE user_id = $1 ORDER BY kurs_datum DESC NULLS LAST, created_at DESC`, diff --git a/frontend/src/pages/AusruestungsanfrageDetail.tsx b/frontend/src/pages/AusruestungsanfrageDetail.tsx index 71bf6a5..8a6e355 100644 --- a/frontend/src/pages/AusruestungsanfrageDetail.tsx +++ b/frontend/src/pages/AusruestungsanfrageDetail.tsx @@ -37,7 +37,7 @@ function formatOrderId(r: AusruestungAnfrage): string { // ── Helpers ── function getUnassignedPositions(positions: AusruestungAnfragePosition[]): AusruestungAnfragePosition[] { - return positions.filter((p) => p.geliefert && !p.zuweisung_typ); + return positions.filter((p) => p.geliefert && (!p.zuweisung_typ || p.zuweisung_typ === 'keine')); } // ══════════════════════════════════════════════════════════════════════════════ diff --git a/frontend/src/pages/AusruestungsanfrageNeu.tsx b/frontend/src/pages/AusruestungsanfrageNeu.tsx index 893a2c4..8599ac6 100644 --- a/frontend/src/pages/AusruestungsanfrageNeu.tsx +++ b/frontend/src/pages/AusruestungsanfrageNeu.tsx @@ -1,7 +1,7 @@ -import { useState, useCallback, useRef } from 'react'; +import { useState, useCallback, useRef, useEffect } from 'react'; import { Box, Typography, Paper, Button, TextField, IconButton, - Autocomplete, Divider, MenuItem, Checkbox, Chip, + Autocomplete, Divider, MenuItem, Checkbox, Chip, FormControlLabel, Switch, } from '@mui/material'; import { ArrowBack, Add as AddIcon, Delete as DeleteIcon } from '@mui/icons-material'; import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; @@ -82,6 +82,8 @@ export default function AusruestungsanfrageNeu() { const [catalogItems, setCatalogItems] = useState([{ bezeichnung: '', menge: 1 }]); const [freeItems, setFreeItems] = useState<{ bezeichnung: string; menge: number }[]>([]); const [assignedSelections, setAssignedSelections] = useState>({}); + const [catalogIstErsatz, setCatalogIstErsatz] = useState>({}); + const [freeIstErsatz, setFreeIstErsatz] = useState>({}); // Eigenschaften state const [itemEigenschaften, setItemEigenschaften] = useState>({}); @@ -101,12 +103,24 @@ export default function AusruestungsanfrageNeu() { enabled: canOrderForUser, }); + // Determine which user's personal items to load + const targetUserId = typeof fuerBenutzer === 'object' && fuerBenutzer ? fuerBenutzer.id : null; + const { data: myPersonalItems = [] } = useQuery({ - queryKey: ['persoenliche-ausruestung', 'my-for-request'], - queryFn: () => personalEquipmentApi.getMy(), + queryKey: ['persoenliche-ausruestung', targetUserId ? 'user-for-request' : 'my-for-request', targetUserId], + queryFn: () => targetUserId ? personalEquipmentApi.getByUserId(targetUserId) : personalEquipmentApi.getMy(), staleTime: 2 * 60 * 1000, }); + // Clear assigned selections when switching user + const prevTargetUserRef = useRef(targetUserId); + useEffect(() => { + if (prevTargetUserRef.current !== targetUserId) { + setAssignedSelections({}); + prevTargetUserRef.current = targetUserId; + } + }, [targetUserId]); + // ── Mutations ── const createMut = useMutation({ mutationFn: (args: { items: AusruestungAnfrageFormItem[]; notizen?: string; bezeichnung?: string; fuer_benutzer_id?: string; fuer_benutzer_name?: string; assignedItems?: { persoenlich_id: string; neuer_zustand: string }[] }) => @@ -140,13 +154,13 @@ export default function AusruestungsanfrageNeu() { const eigenschaften = Object.entries(vals) .filter(([, wert]) => wert.trim()) .map(([eid, wert]) => ({ eigenschaft_id: Number(eid), wert })); - return { ...item, eigenschaften: eigenschaften.length > 0 ? eigenschaften : undefined }; + return { ...item, eigenschaften: eigenschaften.length > 0 ? eigenschaften : undefined, ist_ersatz: catalogIstErsatz[idx] || false }; }); // Free-text items const freeValidItems = freeItems .filter(i => i.bezeichnung.trim()) - .map(i => ({ bezeichnung: i.bezeichnung, menge: i.menge })); + .map((i, idx) => ({ bezeichnung: i.bezeichnung, menge: i.menge, ist_ersatz: freeIstErsatz[idx] || false })); const allItems = [...catalogValidItems, ...freeValidItems]; if (allItems.length === 0) return; @@ -260,6 +274,11 @@ export default function AusruestungsanfrageNeu() { sx={{ width: 90 }} inputProps={{ min: 1 }} /> + setCatalogIstErsatz(prev => ({ ...prev, [idx]: e.target.checked }))} />} + label="Ersatz" + sx={{ ml: 0, mr: 0 }} + /> setCatalogItems(prev => prev.filter((_, i) => i !== idx))} disabled={catalogItems.length <= 1}> @@ -303,6 +322,11 @@ export default function AusruestungsanfrageNeu() { sx={{ width: 90 }} inputProps={{ min: 1 }} /> + setFreeIstErsatz(prev => ({ ...prev, [idx]: e.target.checked }))} />} + label="Ersatz" + sx={{ ml: 0, mr: 0 }} + /> setFreeItems(prev => prev.filter((_, i) => i !== idx))}> @@ -319,19 +343,28 @@ export default function AusruestungsanfrageNeu() { Keine zugewiesenen Gegenstände vorhanden. ) : ( myPersonalItems.map((item) => ( - + + { if (e.target.checked) { setAssignedSelections(prev => ({ ...prev, [item.id]: item.zustand })); + if (item.artikel_id) loadEigenschaften(item.artikel_id); } else { setAssignedSelections(prev => { const n = { ...prev }; delete n[item.id]; return n; }); } }} /> {item.bezeichnung} + {item.eigenschaften && item.eigenschaften.length > 0 && ( + + {item.eigenschaften.map(e => ( + + ))} + + )} )} + )) )} diff --git a/frontend/src/pages/AusruestungsanfrageZuweisung.tsx b/frontend/src/pages/AusruestungsanfrageZuweisung.tsx index 7e5dda7..e15ea7d 100644 --- a/frontend/src/pages/AusruestungsanfrageZuweisung.tsx +++ b/frontend/src/pages/AusruestungsanfrageZuweisung.tsx @@ -28,7 +28,7 @@ interface PositionAssignment { } function getUnassignedPositions(positions: AusruestungAnfragePosition[]): AusruestungAnfragePosition[] { - return positions.filter((p) => p.geliefert && !p.zuweisung_typ); + return positions.filter((p) => p.geliefert && (!p.zuweisung_typ || p.zuweisung_typ === 'keine')); } export default function AusruestungsanfrageZuweisung() { diff --git a/frontend/src/pages/BestellungDetail.tsx b/frontend/src/pages/BestellungDetail.tsx index 901d458..ce00204 100644 --- a/frontend/src/pages/BestellungDetail.tsx +++ b/frontend/src/pages/BestellungDetail.tsx @@ -487,7 +487,6 @@ export default function BestellungDetail() { curY += 5; row('Bezeichnung', bestellung.bezeichnung); row('Erstellt am', formatDate(bestellung.erstellt_am)); - if (bestellung.bestellt_am) row('Bestelldatum', formatDate(bestellung.bestellt_am)); curY += 5; // ── Place and date ── @@ -807,10 +806,10 @@ export default function BestellungDetail() { {/* ── Status Action ── */} {(canManageOrders || canCreate || canApprove) && ( - {validTransitions.includes('abgeschlossen' as BestellungStatus) && !allCostsEntered && ( + {(['bestellt', 'teillieferung', 'lieferung_pruefen'] as BestellungStatus[]).includes(bestellung.status) && !allCostsEntered && ( Nicht alle Positionen haben Kosten. Bitte Einzelpreise eintragen, bevor die Bestellung abgeschlossen wird. )} - {validTransitions.includes('abgeschlossen' as BestellungStatus) && !allItemsReceived && ( + {(['bestellt', 'teillieferung', 'lieferung_pruefen'] as BestellungStatus[]).includes(bestellung.status) && !allItemsReceived && ( Nicht alle Positionen wurden vollständig empfangen. Bitte Eingangsmenge prüfen, bevor die Bestellung abgeschlossen wird. )} @@ -954,7 +953,7 @@ export default function BestellungDetail() { {canManageOrders ? ( updateReceived.mutate({ itemId: p.id, menge: Number(e.target.value) })} /> ) : p.erhalten_menge} @@ -1026,7 +1025,7 @@ export default function BestellungDetail() { sx={{ width: 70 }} value={p.erhalten_menge} inputProps={{ min: 0, max: p.menge }} - disabled={bestellung.status !== 'bestellt' && bestellung.status !== 'teillieferung'} + disabled={!['bestellt', 'teillieferung', 'lieferung_pruefen'].includes(bestellung.status)} onChange={(e) => updateReceived.mutate({ itemId: p.id, menge: Number(e.target.value) })} /> ) : ( diff --git a/frontend/src/pages/BestellungNeu.tsx b/frontend/src/pages/BestellungNeu.tsx index 3c2a75c..5893115 100644 --- a/frontend/src/pages/BestellungNeu.tsx +++ b/frontend/src/pages/BestellungNeu.tsx @@ -19,7 +19,9 @@ import DashboardLayout from '../components/dashboard/DashboardLayout'; import { PageHeader, FormLayout } from '../components/templates'; import { useNotification } from '../contexts/NotificationContext'; import { bestellungApi } from '../services/bestellung'; +import { ausruestungsanfrageApi } from '../services/ausruestungsanfrage'; import type { BestellungFormData, BestellpositionFormData, LieferantFormData, Lieferant } from '../types/bestellung.types'; +import type { AusruestungArtikel } from '../types/ausruestungsanfrage.types'; const emptyOrderForm: BestellungFormData = { bezeichnung: '', lieferant_id: undefined, besteller_id: '', notizen: '', positionen: [] }; const emptyVendorForm: LieferantFormData = { name: '', kontakt_name: '', email: '', telefon: '', adresse: '', website: '', notizen: '' }; @@ -45,6 +47,12 @@ export default function BestellungNeu() { queryFn: bestellungApi.getOrderUsers, }); + const { data: katalogItems = [] } = useQuery({ + queryKey: ['katalog'], + queryFn: () => ausruestungsanfrageApi.getItems({ aktiv: true }), + staleTime: 5 * 60 * 1000, + }); + // ── Mutations ── const createOrder = useMutation({ mutationFn: (data: BestellungFormData) => bestellungApi.createOrder(data), @@ -154,17 +162,40 @@ export default function BestellungNeu() { Positionen {(orderForm.positionen || []).map((pos, idx) => ( - + freeSolo size="small" - required - InputLabelProps={{ shrink: true }} - value={pos.bezeichnung} - onChange={(e) => { + options={katalogItems} + getOptionLabel={(o) => typeof o === 'string' ? o : o.bezeichnung} + value={pos.bezeichnung || ''} + onChange={(_, v) => { const next = [...(orderForm.positionen || [])]; - next[idx] = { ...next[idx], bezeichnung: e.target.value }; + if (v && typeof v !== 'string') { + next[idx] = { ...next[idx], bezeichnung: v.bezeichnung, artikel_id: v.id }; + } else if (typeof v === 'string') { + next[idx] = { ...next[idx], bezeichnung: v, artikel_id: undefined }; + } else { + next[idx] = { ...next[idx], bezeichnung: '', artikel_id: undefined }; + } setOrderForm((f) => ({ ...f, positionen: next })); }} + onInputChange={(_, val, reason) => { + if (reason === 'input') { + const next = [...(orderForm.positionen || [])]; + next[idx] = { ...next[idx], bezeichnung: val, artikel_id: undefined }; + setOrderForm((f) => ({ ...f, positionen: next })); + } + }} + renderInput={(params) => } + renderOption={(props, option) => ( +
  • + + {option.bezeichnung} + {option.kategorie_name && {option.kategorie_name}} + +
  • + )} + isOptionEqualToValue={(o, v) => o.id === (typeof v === 'object' ? v.id : undefined)} sx={{ flexGrow: 1 }} />
    - {(a.ort || a.status !== 'abgeschlossen') && ( - - {a.ort && ( - - {a.ort} - - )} - {a.status !== 'abgeschlossen' && ( - - )} - - )} + + {a.kurs_kurzbezeichnung && ( + + )} + {a.erfolgscode && ( + + )} + {a.ort && ( + + {a.ort} + + )} + {a.ablaufdatum && ( Gültig bis: {new Date(a.ablaufdatum).toLocaleDateString('de-AT')} diff --git a/frontend/src/services/ausruestungsanfrage.ts b/frontend/src/services/ausruestungsanfrage.ts index 8bb3b3d..754245d 100644 --- a/frontend/src/services/ausruestungsanfrage.ts +++ b/frontend/src/services/ausruestungsanfrage.ts @@ -101,7 +101,18 @@ export const ausruestungsanfrageApi = { fuer_benutzer_name?: string, assignedItems?: { persoenlich_id: string; neuer_zustand: string }[], ): Promise => { - const r = await api.post('/api/ausruestungsanfragen/requests', { items, notizen, bezeichnung, fuer_benutzer_id, fuer_benutzer_name, assignedItems }); + // Merge assigned personal items into the items array with ist_ersatz: true + const allItems = [ + ...items, + ...(assignedItems ?? []).map(a => ({ + bezeichnung: '', // backend resolves from persoenlich_id + menge: 1, + persoenlich_id: a.persoenlich_id, + neuer_zustand: a.neuer_zustand, + ist_ersatz: true, + })), + ]; + const r = await api.post('/api/ausruestungsanfragen/requests', { items: allItems, notizen, bezeichnung, fuer_benutzer_id, fuer_benutzer_name }); return r.data.data; }, updateRequest: async ( diff --git a/frontend/src/types/bestellung.types.ts b/frontend/src/types/bestellung.types.ts index 61bc65f..7ef149c 100644 --- a/frontend/src/types/bestellung.types.ts +++ b/frontend/src/types/bestellung.types.ts @@ -32,11 +32,11 @@ export type BestellungStatus = 'entwurf' | 'wartet_auf_genehmigung' | 'bereit_zu export const BESTELLUNG_STATUS_LABELS: Record = { entwurf: 'Entwurf', - wartet_auf_genehmigung: 'Wartet auf Genehmigung', - bereit_zur_bestellung: 'Bereit zur Bestellung', + wartet_auf_genehmigung: 'Eingereicht', + bereit_zur_bestellung: 'Genehmigt', bestellt: 'Bestellt', - teillieferung: 'Teillieferung', - lieferung_pruefen: 'Lieferung prüfen', + teillieferung: 'Teilweise geliefert', + lieferung_pruefen: 'Vollständig geliefert', abgeschlossen: 'Abgeschlossen', }; diff --git a/frontend/src/types/member.types.ts b/frontend/src/types/member.types.ts index 6af7473..9724e4c 100644 --- a/frontend/src/types/member.types.ts +++ b/frontend/src/types/member.types.ts @@ -238,4 +238,7 @@ export interface Ausbildung { bemerkung: string | null; status: string; created_at: string; + kursnummer?: string | null; + kurs_kurzbezeichnung?: string | null; + erfolgscode?: string | null; } diff --git a/sync/src/db.ts b/sync/src/db.ts index 47b095c..379b66d 100644 --- a/sync/src/db.ts +++ b/sync/src/db.ts @@ -243,17 +243,20 @@ export async function syncToDatabase( const userId = result.rows[0].user_id; const upsertResult = await client.query<{ was_inserted: boolean }>( - `INSERT INTO ausbildung (user_id, kursname, kurs_datum, ablaufdatum, ort, bemerkung, fdisk_sync_key) - VALUES ($1, $2, $3::date, $4::date, $5, $6, $7) + `INSERT INTO ausbildung (user_id, kursname, kursnummer, kurs_kurzbezeichnung, erfolgscode, kurs_datum, ablaufdatum, ort, bemerkung, fdisk_sync_key) + VALUES ($1, $2, $3, $4, $5, $6::date, $7::date, $8, $9, $10) ON CONFLICT (user_id, fdisk_sync_key) DO UPDATE SET - kursname = EXCLUDED.kursname, - kurs_datum = EXCLUDED.kurs_datum, - ablaufdatum = EXCLUDED.ablaufdatum, - ort = EXCLUDED.ort, - bemerkung = EXCLUDED.bemerkung, - updated_at = NOW() + kursname = EXCLUDED.kursname, + kursnummer = EXCLUDED.kursnummer, + kurs_kurzbezeichnung = EXCLUDED.kurs_kurzbezeichnung, + erfolgscode = EXCLUDED.erfolgscode, + kurs_datum = EXCLUDED.kurs_datum, + ablaufdatum = EXCLUDED.ablaufdatum, + ort = EXCLUDED.ort, + bemerkung = EXCLUDED.bemerkung, + updated_at = NOW() RETURNING (xmax = 0) AS was_inserted`, - [userId, ausb.kursname, ausb.kursDatum, ausb.ablaufdatum, ausb.ort, ausb.bemerkung, ausb.syncKey] + [userId, ausb.kursname, ausb.kursnummer, ausb.kurzbezeichnung, ausb.erfolgscode, ausb.kursDatum, ausb.ablaufdatum, ausb.ort, ausb.bemerkung, ausb.syncKey] ); if (upsertResult.rows[0]?.was_inserted) { diff --git a/sync/src/scraper.ts b/sync/src/scraper.ts index 18cb03c..ddd833c 100644 --- a/sync/src/scraper.ts +++ b/sync/src/scraper.ts @@ -778,6 +778,9 @@ async function scrapeAusbildungenFromDetailPage( const results: Array<{ standesbuchNr: string; kursname: string | null; + kursnummer: string | null; + kurzbezeichnung: string | null; + erfolgscode: string | null; kursDatum: string | null; ablaufdatum: string | null; ort: string | null; @@ -818,11 +821,14 @@ async function scrapeAusbildungenFromDetailPage( // Try to find column indices from headers const hdr = bestHeaders.map(h => h.toLowerCase()); - let kursnameIdx = hdr.findIndex(h => h.includes('kurs') || h.includes('ausbildung') || h.includes('bezeichnung')); - let datumIdx = hdr.findIndex(h => h.includes('datum') || h.includes('abschluss')); - let ablaufIdx = hdr.findIndex(h => h.includes('ablauf') || h.includes('gültig')); - let ortIdx = hdr.findIndex(h => h.includes('ort')); - let bemIdx = hdr.findIndex(h => h.includes('bem') || h.includes('info')); + let kursnummerIdx = hdr.findIndex(h => h.includes('nummer')); + let kurzIdx = hdr.findIndex(h => h === 'kurz' || (h.includes('kurz') && !h.includes('name'))); + let kursnameIdx = hdr.findIndex(h => h === 'kurs' || h.includes('ausbildung') || h.includes('bezeichnung')); + let datumIdx = hdr.findIndex(h => h.includes('datum') || h.includes('abschluss')); + let erfolgscodeIdx = hdr.findIndex(h => h.includes('erfolg') || h.includes('code')); + let ablaufIdx = hdr.findIndex(h => h.includes('ablauf') || h.includes('gültig')); + let ortIdx = hdr.findIndex(h => h.includes('ort')); + let bemIdx = hdr.findIndex(h => h.includes('bem') || h.includes('info')); // If headers didn't help, scan data for date-like columns and text columns if (kursnameIdx === -1 && bestRows.length > 0) { @@ -866,11 +872,14 @@ async function scrapeAusbildungenFromDetailPage( // parseDate is not available inside evaluate; return raw values results.push({ standesbuchNr: stNr, + kursnummer: (kursnummerIdx >= 0 ? row.cells[kursnummerIdx] : null)?.trim() || null, + kurzbezeichnung: (kurzIdx >= 0 ? row.cells[kurzIdx] : null)?.trim() || null, kursname, kursDatum: rawDatum || null, ablaufdatum: rawAblauf || null, ort: rawOrt, bemerkung: rawBem, + erfolgscode: (erfolgscodeIdx >= 0 ? row.cells[erfolgscodeIdx] : null)?.trim() || null, syncKey: `${stNr}::${kursname}::${rawDatum ?? ''}`, }); } @@ -884,6 +893,9 @@ async function scrapeAusbildungenFromDetailPage( return { standesbuchNr: a.standesbuchNr, kursname: a.kursname as string, + kursnummer: a.kursnummer, + kurzbezeichnung: a.kurzbezeichnung, + erfolgscode: a.erfolgscode, kursDatum, ablaufdatum: parseDate(a.ablaufdatum), ort: a.ort, @@ -948,7 +960,7 @@ async function navigateAndGetTableRows( tableClass: cls, cells: tds.map(td => { const input = td.querySelector('input[type="text"], input:not([type])') as HTMLInputElement | null; - if (input) return input.value?.trim() ?? ''; + if (input && input.value?.trim()) return input.value.trim(); const sel = td.querySelector('select') as HTMLSelectElement | null; if (sel) { const opt = sel.options[sel.selectedIndex]; @@ -975,17 +987,25 @@ async function navigateAndGetTableRows( cells: r.cells.map(c => c.replace(/\u00A0/g, ' ').trim()), })); - // Find date column dynamically: look for a DD.MM.YYYY pattern in any column + // Find date column dynamically: count date matches per column across ALL rows + // and pick the column with the MOST matches (avoids picking stray date in nav tables) const datePattern = /^\d{2}\.\d{2}\.\d{4}$/; - let dateColIdx = -1; + const dateCountByCol: Record = {}; for (const r of mapped) { for (let ci = 0; ci < r.cells.length; ci++) { if (datePattern.test(r.cells[ci] ?? '')) { - dateColIdx = ci; - break; + dateCountByCol[ci] = (dateCountByCol[ci] || 0) + 1; } } - if (dateColIdx >= 0) break; + } + let dateColIdx = -1; + let maxCount = 0; + for (const [col, count] of Object.entries(dateCountByCol)) { + const colNum = Number(col); + if (count > maxCount || (count === maxCount && (dateColIdx === -1 || colNum < dateColIdx))) { + dateColIdx = colNum; + maxCount = count; + } } const dataRows = dateColIdx >= 0 @@ -1296,8 +1316,10 @@ async function scrapeMemberFahrgenehmigungen( ]); const results: FdiskFahrgenehmigung[] = []; for (const row of rawRows) { - const klasse = cellText(row.klasse); + let klasse = cellText(row.klasse); if (!klasse) continue; + // FDISK select option text includes prefix "KFZ-Führerschein / B" — extract just the class code + if (klasse.includes(' / ')) klasse = klasse.split(' / ').pop()!.trim(); // Validate klasse against whitelist — skip non-class data if (!VALID_LICENSE_CLASSES.has(klasse.toUpperCase())) { log(` → Skipping invalid klasse: "${klasse}"`); diff --git a/sync/src/types.ts b/sync/src/types.ts index 064836b..50592f0 100644 --- a/sync/src/types.ts +++ b/sync/src/types.ts @@ -22,6 +22,9 @@ export interface FdiskMember { export interface FdiskAusbildung { standesbuchNr: string; kursname: string; + kursnummer: string | null; + kurzbezeichnung: string | null; + erfolgscode: string | null; kursDatum: string | null; ablaufdatum: string | null; ort: string | null;