fix(multi): FDISK sync, order UX, Ausbildungen display, untracked items
FDISK sync: - fix(sync): strip 'KFZ-Führerschein / ' prefix from license class select option text before whitelist validation - fix(sync): fix navigateAndGetTableRows to pick date column with most matches (prevents sidebar tables from hijacking dateColIdx for Beförderungen) - fix(sync): input.value fallback now falls through to textContent when value is empty - feat(sync): expand Ausbildungen to capture Kursnummer, Kurz, Kurs (full name), Erfolgscode from FDISK table; add migration 086 External orders (Bestellungen): - fix(bestellungen): allow erhalten_menge editing in lieferung_pruefen status (resolves deadlock preventing order completion) - fix(bestellungen): show cost/received warnings for bestellt/teillieferung/lieferung_pruefen, not just when abgeschlossen is next - feat(bestellungen): rename status labels to Eingereicht, Genehmigt, Teilweise geliefert, Vollständig geliefert - fix(bestellungen): remove duplicate Bestelldatum from PDF export - feat(bestellungen): add catalog item autocomplete to creation form (auto-fills bezeichnung + artikelnummer) Internal orders (Ausruestungsanfrage): - fix(ausruestung): untracked items with zuweisung_typ='keine' now appear in Nicht-zugewiesen tab (frontend filter was too strict) - feat(ausruestung): load user-specific personal items when ordering for another user - feat(ausruestung): auto-set ist_ersatz=true for items from personal equipment list; add toggle for catalog/free-text items - feat(ausruestung): load item eigenschaften when personal item with artikel_id is checked Ausbildungen display: - feat(mitglieder): show kursname (full), kurs_kurzbezeichnung chip, erfolgscode chip (color-coded) per Ausbildung entry Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -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);
|
||||||
@@ -690,7 +690,7 @@ class MemberService {
|
|||||||
async getAusbildungen(userId: string): Promise<any[]> {
|
async getAusbildungen(userId: string): Promise<any[]> {
|
||||||
try {
|
try {
|
||||||
const result = await pool.query(
|
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
|
FROM ausbildung
|
||||||
WHERE user_id = $1
|
WHERE user_id = $1
|
||||||
ORDER BY kurs_datum DESC NULLS LAST, created_at DESC`,
|
ORDER BY kurs_datum DESC NULLS LAST, created_at DESC`,
|
||||||
|
|||||||
@@ -37,7 +37,7 @@ function formatOrderId(r: AusruestungAnfrage): string {
|
|||||||
// ── Helpers ──
|
// ── Helpers ──
|
||||||
|
|
||||||
function getUnassignedPositions(positions: AusruestungAnfragePosition[]): AusruestungAnfragePosition[] {
|
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'));
|
||||||
}
|
}
|
||||||
|
|
||||||
// ══════════════════════════════════════════════════════════════════════════════
|
// ══════════════════════════════════════════════════════════════════════════════
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { useState, useCallback, useRef } from 'react';
|
import { useState, useCallback, useRef, useEffect } from 'react';
|
||||||
import {
|
import {
|
||||||
Box, Typography, Paper, Button, TextField, IconButton,
|
Box, Typography, Paper, Button, TextField, IconButton,
|
||||||
Autocomplete, Divider, MenuItem, Checkbox, Chip,
|
Autocomplete, Divider, MenuItem, Checkbox, Chip, FormControlLabel, Switch,
|
||||||
} from '@mui/material';
|
} from '@mui/material';
|
||||||
import { ArrowBack, Add as AddIcon, Delete as DeleteIcon } from '@mui/icons-material';
|
import { ArrowBack, Add as AddIcon, Delete as DeleteIcon } from '@mui/icons-material';
|
||||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
@@ -82,6 +82,8 @@ export default function AusruestungsanfrageNeu() {
|
|||||||
const [catalogItems, setCatalogItems] = useState<AusruestungAnfrageFormItem[]>([{ bezeichnung: '', menge: 1 }]);
|
const [catalogItems, setCatalogItems] = useState<AusruestungAnfrageFormItem[]>([{ bezeichnung: '', menge: 1 }]);
|
||||||
const [freeItems, setFreeItems] = useState<{ bezeichnung: string; menge: number }[]>([]);
|
const [freeItems, setFreeItems] = useState<{ bezeichnung: string; menge: number }[]>([]);
|
||||||
const [assignedSelections, setAssignedSelections] = useState<Record<string, string>>({});
|
const [assignedSelections, setAssignedSelections] = useState<Record<string, string>>({});
|
||||||
|
const [catalogIstErsatz, setCatalogIstErsatz] = useState<Record<number, boolean>>({});
|
||||||
|
const [freeIstErsatz, setFreeIstErsatz] = useState<Record<number, boolean>>({});
|
||||||
|
|
||||||
// Eigenschaften state
|
// Eigenschaften state
|
||||||
const [itemEigenschaften, setItemEigenschaften] = useState<Record<number, AusruestungEigenschaft[]>>({});
|
const [itemEigenschaften, setItemEigenschaften] = useState<Record<number, AusruestungEigenschaft[]>>({});
|
||||||
@@ -101,12 +103,24 @@ export default function AusruestungsanfrageNeu() {
|
|||||||
enabled: canOrderForUser,
|
enabled: canOrderForUser,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Determine which user's personal items to load
|
||||||
|
const targetUserId = typeof fuerBenutzer === 'object' && fuerBenutzer ? fuerBenutzer.id : null;
|
||||||
|
|
||||||
const { data: myPersonalItems = [] } = useQuery({
|
const { data: myPersonalItems = [] } = useQuery({
|
||||||
queryKey: ['persoenliche-ausruestung', 'my-for-request'],
|
queryKey: ['persoenliche-ausruestung', targetUserId ? 'user-for-request' : 'my-for-request', targetUserId],
|
||||||
queryFn: () => personalEquipmentApi.getMy(),
|
queryFn: () => targetUserId ? personalEquipmentApi.getByUserId(targetUserId) : personalEquipmentApi.getMy(),
|
||||||
staleTime: 2 * 60 * 1000,
|
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 ──
|
// ── Mutations ──
|
||||||
const createMut = useMutation({
|
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 }[] }) =>
|
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)
|
const eigenschaften = Object.entries(vals)
|
||||||
.filter(([, wert]) => wert.trim())
|
.filter(([, wert]) => wert.trim())
|
||||||
.map(([eid, wert]) => ({ eigenschaft_id: Number(eid), wert }));
|
.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
|
// Free-text items
|
||||||
const freeValidItems = freeItems
|
const freeValidItems = freeItems
|
||||||
.filter(i => i.bezeichnung.trim())
|
.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];
|
const allItems = [...catalogValidItems, ...freeValidItems];
|
||||||
if (allItems.length === 0) return;
|
if (allItems.length === 0) return;
|
||||||
@@ -260,6 +274,11 @@ export default function AusruestungsanfrageNeu() {
|
|||||||
sx={{ width: 90 }}
|
sx={{ width: 90 }}
|
||||||
inputProps={{ min: 1 }}
|
inputProps={{ min: 1 }}
|
||||||
/>
|
/>
|
||||||
|
<FormControlLabel
|
||||||
|
control={<Switch size="small" checked={catalogIstErsatz[idx] || false} onChange={e => setCatalogIstErsatz(prev => ({ ...prev, [idx]: e.target.checked }))} />}
|
||||||
|
label="Ersatz"
|
||||||
|
sx={{ ml: 0, mr: 0 }}
|
||||||
|
/>
|
||||||
<IconButton size="small" onClick={() => setCatalogItems(prev => prev.filter((_, i) => i !== idx))} disabled={catalogItems.length <= 1}>
|
<IconButton size="small" onClick={() => setCatalogItems(prev => prev.filter((_, i) => i !== idx))} disabled={catalogItems.length <= 1}>
|
||||||
<DeleteIcon fontSize="small" />
|
<DeleteIcon fontSize="small" />
|
||||||
</IconButton>
|
</IconButton>
|
||||||
@@ -303,6 +322,11 @@ export default function AusruestungsanfrageNeu() {
|
|||||||
sx={{ width: 90 }}
|
sx={{ width: 90 }}
|
||||||
inputProps={{ min: 1 }}
|
inputProps={{ min: 1 }}
|
||||||
/>
|
/>
|
||||||
|
<FormControlLabel
|
||||||
|
control={<Switch size="small" checked={freeIstErsatz[idx] || false} onChange={e => setFreeIstErsatz(prev => ({ ...prev, [idx]: e.target.checked }))} />}
|
||||||
|
label="Ersatz"
|
||||||
|
sx={{ ml: 0, mr: 0 }}
|
||||||
|
/>
|
||||||
<IconButton size="small" onClick={() => setFreeItems(prev => prev.filter((_, i) => i !== idx))}>
|
<IconButton size="small" onClick={() => setFreeItems(prev => prev.filter((_, i) => i !== idx))}>
|
||||||
<DeleteIcon fontSize="small" />
|
<DeleteIcon fontSize="small" />
|
||||||
</IconButton>
|
</IconButton>
|
||||||
@@ -319,19 +343,28 @@ export default function AusruestungsanfrageNeu() {
|
|||||||
<Typography variant="body2" color="text.secondary">Keine zugewiesenen Gegenstände vorhanden.</Typography>
|
<Typography variant="body2" color="text.secondary">Keine zugewiesenen Gegenstände vorhanden.</Typography>
|
||||||
) : (
|
) : (
|
||||||
myPersonalItems.map((item) => (
|
myPersonalItems.map((item) => (
|
||||||
<Box key={item.id} sx={{ display: 'flex', gap: 1, alignItems: 'center', py: 0.5 }}>
|
<Box key={item.id}>
|
||||||
|
<Box sx={{ display: 'flex', gap: 1, alignItems: 'center', py: 0.5 }}>
|
||||||
<Checkbox
|
<Checkbox
|
||||||
size="small"
|
size="small"
|
||||||
checked={!!assignedSelections[item.id]}
|
checked={!!assignedSelections[item.id]}
|
||||||
onChange={(e) => {
|
onChange={(e) => {
|
||||||
if (e.target.checked) {
|
if (e.target.checked) {
|
||||||
setAssignedSelections(prev => ({ ...prev, [item.id]: item.zustand }));
|
setAssignedSelections(prev => ({ ...prev, [item.id]: item.zustand }));
|
||||||
|
if (item.artikel_id) loadEigenschaften(item.artikel_id);
|
||||||
} else {
|
} else {
|
||||||
setAssignedSelections(prev => { const n = { ...prev }; delete n[item.id]; return n; });
|
setAssignedSelections(prev => { const n = { ...prev }; delete n[item.id]; return n; });
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<Typography variant="body2" sx={{ flex: 1 }}>{item.bezeichnung}</Typography>
|
<Typography variant="body2" sx={{ flex: 1 }}>{item.bezeichnung}</Typography>
|
||||||
|
{item.eigenschaften && item.eigenschaften.length > 0 && (
|
||||||
|
<Box sx={{ display: 'flex', gap: 0.5, flexWrap: 'wrap' }}>
|
||||||
|
{item.eigenschaften.map(e => (
|
||||||
|
<Chip key={e.id} label={`${e.name}: ${e.wert}`} size="small" variant="outlined" />
|
||||||
|
))}
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
<Chip
|
<Chip
|
||||||
label={ZUSTAND_LABELS[item.zustand as PersoenlicheAusruestungZustand]}
|
label={ZUSTAND_LABELS[item.zustand as PersoenlicheAusruestungZustand]}
|
||||||
color={ZUSTAND_COLORS[item.zustand as PersoenlicheAusruestungZustand]}
|
color={ZUSTAND_COLORS[item.zustand as PersoenlicheAusruestungZustand]}
|
||||||
@@ -352,6 +385,7 @@ export default function AusruestungsanfrageNeu() {
|
|||||||
</TextField>
|
</TextField>
|
||||||
)}
|
)}
|
||||||
</Box>
|
</Box>
|
||||||
|
</Box>
|
||||||
))
|
))
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
@@ -28,7 +28,7 @@ interface PositionAssignment {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function getUnassignedPositions(positions: AusruestungAnfragePosition[]): AusruestungAnfragePosition[] {
|
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() {
|
export default function AusruestungsanfrageZuweisung() {
|
||||||
|
|||||||
@@ -487,7 +487,6 @@ export default function BestellungDetail() {
|
|||||||
curY += 5;
|
curY += 5;
|
||||||
row('Bezeichnung', bestellung.bezeichnung);
|
row('Bezeichnung', bestellung.bezeichnung);
|
||||||
row('Erstellt am', formatDate(bestellung.erstellt_am));
|
row('Erstellt am', formatDate(bestellung.erstellt_am));
|
||||||
if (bestellung.bestellt_am) row('Bestelldatum', formatDate(bestellung.bestellt_am));
|
|
||||||
curY += 5;
|
curY += 5;
|
||||||
|
|
||||||
// ── Place and date ──
|
// ── Place and date ──
|
||||||
@@ -807,10 +806,10 @@ export default function BestellungDetail() {
|
|||||||
{/* ── Status Action ── */}
|
{/* ── Status Action ── */}
|
||||||
{(canManageOrders || canCreate || canApprove) && (
|
{(canManageOrders || canCreate || canApprove) && (
|
||||||
<Box sx={{ mb: 3 }}>
|
<Box sx={{ mb: 3 }}>
|
||||||
{validTransitions.includes('abgeschlossen' as BestellungStatus) && !allCostsEntered && (
|
{(['bestellt', 'teillieferung', 'lieferung_pruefen'] as BestellungStatus[]).includes(bestellung.status) && !allCostsEntered && (
|
||||||
<Alert severity="warning" sx={{ mb: 2 }}>Nicht alle Positionen haben Kosten. Bitte Einzelpreise eintragen, bevor die Bestellung abgeschlossen wird.</Alert>
|
<Alert severity="warning" sx={{ mb: 2 }}>Nicht alle Positionen haben Kosten. Bitte Einzelpreise eintragen, bevor die Bestellung abgeschlossen wird.</Alert>
|
||||||
)}
|
)}
|
||||||
{validTransitions.includes('abgeschlossen' as BestellungStatus) && !allItemsReceived && (
|
{(['bestellt', 'teillieferung', 'lieferung_pruefen'] as BestellungStatus[]).includes(bestellung.status) && !allItemsReceived && (
|
||||||
<Alert severity="warning" sx={{ mb: 2 }}>Nicht alle Positionen wurden vollständig empfangen. Bitte Eingangsmenge prüfen, bevor die Bestellung abgeschlossen wird.</Alert>
|
<Alert severity="warning" sx={{ mb: 2 }}>Nicht alle Positionen wurden vollständig empfangen. Bitte Eingangsmenge prüfen, bevor die Bestellung abgeschlossen wird.</Alert>
|
||||||
)}
|
)}
|
||||||
<Box sx={{ display: 'flex', gap: 1, alignItems: 'center' }}>
|
<Box sx={{ display: 'flex', gap: 1, alignItems: 'center' }}>
|
||||||
@@ -954,7 +953,7 @@ export default function BestellungDetail() {
|
|||||||
{canManageOrders ? (
|
{canManageOrders ? (
|
||||||
<TextField size="small" type="number" sx={{ width: 70 }} value={p.erhalten_menge}
|
<TextField size="small" type="number" sx={{ width: 70 }} value={p.erhalten_menge}
|
||||||
inputProps={{ min: 0, max: p.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) })} />
|
onChange={(e) => updateReceived.mutate({ itemId: p.id, menge: Number(e.target.value) })} />
|
||||||
) : p.erhalten_menge}
|
) : p.erhalten_menge}
|
||||||
</TableCell>
|
</TableCell>
|
||||||
@@ -1026,7 +1025,7 @@ export default function BestellungDetail() {
|
|||||||
sx={{ width: 70 }}
|
sx={{ width: 70 }}
|
||||||
value={p.erhalten_menge}
|
value={p.erhalten_menge}
|
||||||
inputProps={{ min: 0, max: p.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) })}
|
onChange={(e) => updateReceived.mutate({ itemId: p.id, menge: Number(e.target.value) })}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
|
|||||||
@@ -19,7 +19,9 @@ import DashboardLayout from '../components/dashboard/DashboardLayout';
|
|||||||
import { PageHeader, FormLayout } from '../components/templates';
|
import { PageHeader, FormLayout } from '../components/templates';
|
||||||
import { useNotification } from '../contexts/NotificationContext';
|
import { useNotification } from '../contexts/NotificationContext';
|
||||||
import { bestellungApi } from '../services/bestellung';
|
import { bestellungApi } from '../services/bestellung';
|
||||||
|
import { ausruestungsanfrageApi } from '../services/ausruestungsanfrage';
|
||||||
import type { BestellungFormData, BestellpositionFormData, LieferantFormData, Lieferant } from '../types/bestellung.types';
|
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 emptyOrderForm: BestellungFormData = { bezeichnung: '', lieferant_id: undefined, besteller_id: '', notizen: '', positionen: [] };
|
||||||
const emptyVendorForm: LieferantFormData = { name: '', kontakt_name: '', email: '', telefon: '', adresse: '', website: '', notizen: '' };
|
const emptyVendorForm: LieferantFormData = { name: '', kontakt_name: '', email: '', telefon: '', adresse: '', website: '', notizen: '' };
|
||||||
@@ -45,6 +47,12 @@ export default function BestellungNeu() {
|
|||||||
queryFn: bestellungApi.getOrderUsers,
|
queryFn: bestellungApi.getOrderUsers,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const { data: katalogItems = [] } = useQuery({
|
||||||
|
queryKey: ['katalog'],
|
||||||
|
queryFn: () => ausruestungsanfrageApi.getItems({ aktiv: true }),
|
||||||
|
staleTime: 5 * 60 * 1000,
|
||||||
|
});
|
||||||
|
|
||||||
// ── Mutations ──
|
// ── Mutations ──
|
||||||
const createOrder = useMutation({
|
const createOrder = useMutation({
|
||||||
mutationFn: (data: BestellungFormData) => bestellungApi.createOrder(data),
|
mutationFn: (data: BestellungFormData) => bestellungApi.createOrder(data),
|
||||||
@@ -154,17 +162,40 @@ export default function BestellungNeu() {
|
|||||||
<Typography variant="subtitle2" sx={{ mt: 1 }}>Positionen</Typography>
|
<Typography variant="subtitle2" sx={{ mt: 1 }}>Positionen</Typography>
|
||||||
{(orderForm.positionen || []).map((pos, idx) => (
|
{(orderForm.positionen || []).map((pos, idx) => (
|
||||||
<Box key={idx} sx={{ display: 'flex', gap: 1, alignItems: 'center' }}>
|
<Box key={idx} sx={{ display: 'flex', gap: 1, alignItems: 'center' }}>
|
||||||
<TextField
|
<Autocomplete<AusruestungArtikel, false, false, true>
|
||||||
label="Bezeichnung"
|
freeSolo
|
||||||
size="small"
|
size="small"
|
||||||
required
|
options={katalogItems}
|
||||||
InputLabelProps={{ shrink: true }}
|
getOptionLabel={(o) => typeof o === 'string' ? o : o.bezeichnung}
|
||||||
value={pos.bezeichnung}
|
value={pos.bezeichnung || ''}
|
||||||
onChange={(e) => {
|
onChange={(_, v) => {
|
||||||
const next = [...(orderForm.positionen || [])];
|
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 }));
|
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) => <TextField {...params} label="Bezeichnung" size="small" required InputLabelProps={{ shrink: true }} />}
|
||||||
|
renderOption={(props, option) => (
|
||||||
|
<li {...props} key={option.id}>
|
||||||
|
<Box>
|
||||||
|
<Typography variant="body2">{option.bezeichnung}</Typography>
|
||||||
|
{option.kategorie_name && <Typography variant="caption" color="text.secondary">{option.kategorie_name}</Typography>}
|
||||||
|
</Box>
|
||||||
|
</li>
|
||||||
|
)}
|
||||||
|
isOptionEqualToValue={(o, v) => o.id === (typeof v === 'object' ? v.id : undefined)}
|
||||||
sx={{ flexGrow: 1 }}
|
sx={{ flexGrow: 1 }}
|
||||||
/>
|
/>
|
||||||
<TextField
|
<TextField
|
||||||
|
|||||||
@@ -1146,23 +1146,28 @@ function MitgliedDetail() {
|
|||||||
{a.kurs_datum ? new Date(a.kurs_datum).toLocaleDateString('de-AT') : '—'}
|
{a.kurs_datum ? new Date(a.kurs_datum).toLocaleDateString('de-AT') : '—'}
|
||||||
</Typography>
|
</Typography>
|
||||||
</Box>
|
</Box>
|
||||||
{(a.ort || a.status !== 'abgeschlossen') && (
|
<Box sx={{ display: 'flex', gap: 0.5, flexWrap: 'wrap', alignItems: 'center', mt: 0.25 }}>
|
||||||
<Box sx={{ display: 'flex', gap: 1, alignItems: 'center', mt: 0.25 }}>
|
{a.kurs_kurzbezeichnung && (
|
||||||
|
<Chip label={a.kurs_kurzbezeichnung} size="small" variant="outlined" />
|
||||||
|
)}
|
||||||
|
{a.erfolgscode && (
|
||||||
|
<Chip
|
||||||
|
label={a.erfolgscode}
|
||||||
|
size="small"
|
||||||
|
variant="outlined"
|
||||||
|
color={
|
||||||
|
/mit erfolg|bestanden/i.test(a.erfolgscode) ? 'success'
|
||||||
|
: /teilgenommen/i.test(a.erfolgscode) ? 'info'
|
||||||
|
: 'default'
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
{a.ort && (
|
{a.ort && (
|
||||||
<Typography variant="body2" color="text.secondary">
|
<Typography variant="body2" color="text.secondary">
|
||||||
{a.ort}
|
{a.ort}
|
||||||
</Typography>
|
</Typography>
|
||||||
)}
|
)}
|
||||||
{a.status !== 'abgeschlossen' && (
|
|
||||||
<Chip
|
|
||||||
label={a.status === 'in_bearbeitung' ? 'In Bearbeitung' : 'Abgelaufen'}
|
|
||||||
size="small"
|
|
||||||
color={a.status === 'abgelaufen' ? 'warning' : 'info'}
|
|
||||||
variant="outlined"
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</Box>
|
</Box>
|
||||||
)}
|
|
||||||
{a.ablaufdatum && (
|
{a.ablaufdatum && (
|
||||||
<Typography variant="caption" color="text.secondary">
|
<Typography variant="caption" color="text.secondary">
|
||||||
Gültig bis: {new Date(a.ablaufdatum).toLocaleDateString('de-AT')}
|
Gültig bis: {new Date(a.ablaufdatum).toLocaleDateString('de-AT')}
|
||||||
|
|||||||
@@ -101,7 +101,18 @@ export const ausruestungsanfrageApi = {
|
|||||||
fuer_benutzer_name?: string,
|
fuer_benutzer_name?: string,
|
||||||
assignedItems?: { persoenlich_id: string; neuer_zustand: string }[],
|
assignedItems?: { persoenlich_id: string; neuer_zustand: string }[],
|
||||||
): Promise<AusruestungAnfrageDetailResponse> => {
|
): Promise<AusruestungAnfrageDetailResponse> => {
|
||||||
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;
|
return r.data.data;
|
||||||
},
|
},
|
||||||
updateRequest: async (
|
updateRequest: async (
|
||||||
|
|||||||
@@ -32,11 +32,11 @@ export type BestellungStatus = 'entwurf' | 'wartet_auf_genehmigung' | 'bereit_zu
|
|||||||
|
|
||||||
export const BESTELLUNG_STATUS_LABELS: Record<BestellungStatus, string> = {
|
export const BESTELLUNG_STATUS_LABELS: Record<BestellungStatus, string> = {
|
||||||
entwurf: 'Entwurf',
|
entwurf: 'Entwurf',
|
||||||
wartet_auf_genehmigung: 'Wartet auf Genehmigung',
|
wartet_auf_genehmigung: 'Eingereicht',
|
||||||
bereit_zur_bestellung: 'Bereit zur Bestellung',
|
bereit_zur_bestellung: 'Genehmigt',
|
||||||
bestellt: 'Bestellt',
|
bestellt: 'Bestellt',
|
||||||
teillieferung: 'Teillieferung',
|
teillieferung: 'Teilweise geliefert',
|
||||||
lieferung_pruefen: 'Lieferung prüfen',
|
lieferung_pruefen: 'Vollständig geliefert',
|
||||||
abgeschlossen: 'Abgeschlossen',
|
abgeschlossen: 'Abgeschlossen',
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -238,4 +238,7 @@ export interface Ausbildung {
|
|||||||
bemerkung: string | null;
|
bemerkung: string | null;
|
||||||
status: string;
|
status: string;
|
||||||
created_at: string;
|
created_at: string;
|
||||||
|
kursnummer?: string | null;
|
||||||
|
kurs_kurzbezeichnung?: string | null;
|
||||||
|
erfolgscode?: string | null;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -243,17 +243,20 @@ export async function syncToDatabase(
|
|||||||
const userId = result.rows[0].user_id;
|
const userId = result.rows[0].user_id;
|
||||||
|
|
||||||
const upsertResult = await client.query<{ was_inserted: boolean }>(
|
const upsertResult = await client.query<{ was_inserted: boolean }>(
|
||||||
`INSERT INTO ausbildung (user_id, kursname, kurs_datum, ablaufdatum, ort, bemerkung, fdisk_sync_key)
|
`INSERT INTO ausbildung (user_id, kursname, kursnummer, kurs_kurzbezeichnung, erfolgscode, kurs_datum, ablaufdatum, ort, bemerkung, fdisk_sync_key)
|
||||||
VALUES ($1, $2, $3::date, $4::date, $5, $6, $7)
|
VALUES ($1, $2, $3, $4, $5, $6::date, $7::date, $8, $9, $10)
|
||||||
ON CONFLICT (user_id, fdisk_sync_key) DO UPDATE SET
|
ON CONFLICT (user_id, fdisk_sync_key) DO UPDATE SET
|
||||||
kursname = EXCLUDED.kursname,
|
kursname = EXCLUDED.kursname,
|
||||||
|
kursnummer = EXCLUDED.kursnummer,
|
||||||
|
kurs_kurzbezeichnung = EXCLUDED.kurs_kurzbezeichnung,
|
||||||
|
erfolgscode = EXCLUDED.erfolgscode,
|
||||||
kurs_datum = EXCLUDED.kurs_datum,
|
kurs_datum = EXCLUDED.kurs_datum,
|
||||||
ablaufdatum = EXCLUDED.ablaufdatum,
|
ablaufdatum = EXCLUDED.ablaufdatum,
|
||||||
ort = EXCLUDED.ort,
|
ort = EXCLUDED.ort,
|
||||||
bemerkung = EXCLUDED.bemerkung,
|
bemerkung = EXCLUDED.bemerkung,
|
||||||
updated_at = NOW()
|
updated_at = NOW()
|
||||||
RETURNING (xmax = 0) AS was_inserted`,
|
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) {
|
if (upsertResult.rows[0]?.was_inserted) {
|
||||||
|
|||||||
@@ -778,6 +778,9 @@ async function scrapeAusbildungenFromDetailPage(
|
|||||||
const results: Array<{
|
const results: Array<{
|
||||||
standesbuchNr: string;
|
standesbuchNr: string;
|
||||||
kursname: string | null;
|
kursname: string | null;
|
||||||
|
kursnummer: string | null;
|
||||||
|
kurzbezeichnung: string | null;
|
||||||
|
erfolgscode: string | null;
|
||||||
kursDatum: string | null;
|
kursDatum: string | null;
|
||||||
ablaufdatum: string | null;
|
ablaufdatum: string | null;
|
||||||
ort: string | null;
|
ort: string | null;
|
||||||
@@ -818,8 +821,11 @@ async function scrapeAusbildungenFromDetailPage(
|
|||||||
|
|
||||||
// Try to find column indices from headers
|
// Try to find column indices from headers
|
||||||
const hdr = bestHeaders.map(h => h.toLowerCase());
|
const hdr = bestHeaders.map(h => h.toLowerCase());
|
||||||
let kursnameIdx = hdr.findIndex(h => h.includes('kurs') || h.includes('ausbildung') || h.includes('bezeichnung'));
|
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 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 ablaufIdx = hdr.findIndex(h => h.includes('ablauf') || h.includes('gültig'));
|
||||||
let ortIdx = hdr.findIndex(h => h.includes('ort'));
|
let ortIdx = hdr.findIndex(h => h.includes('ort'));
|
||||||
let bemIdx = hdr.findIndex(h => h.includes('bem') || h.includes('info'));
|
let bemIdx = hdr.findIndex(h => h.includes('bem') || h.includes('info'));
|
||||||
@@ -866,11 +872,14 @@ async function scrapeAusbildungenFromDetailPage(
|
|||||||
// parseDate is not available inside evaluate; return raw values
|
// parseDate is not available inside evaluate; return raw values
|
||||||
results.push({
|
results.push({
|
||||||
standesbuchNr: stNr,
|
standesbuchNr: stNr,
|
||||||
|
kursnummer: (kursnummerIdx >= 0 ? row.cells[kursnummerIdx] : null)?.trim() || null,
|
||||||
|
kurzbezeichnung: (kurzIdx >= 0 ? row.cells[kurzIdx] : null)?.trim() || null,
|
||||||
kursname,
|
kursname,
|
||||||
kursDatum: rawDatum || null,
|
kursDatum: rawDatum || null,
|
||||||
ablaufdatum: rawAblauf || null,
|
ablaufdatum: rawAblauf || null,
|
||||||
ort: rawOrt,
|
ort: rawOrt,
|
||||||
bemerkung: rawBem,
|
bemerkung: rawBem,
|
||||||
|
erfolgscode: (erfolgscodeIdx >= 0 ? row.cells[erfolgscodeIdx] : null)?.trim() || null,
|
||||||
syncKey: `${stNr}::${kursname}::${rawDatum ?? ''}`,
|
syncKey: `${stNr}::${kursname}::${rawDatum ?? ''}`,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -884,6 +893,9 @@ async function scrapeAusbildungenFromDetailPage(
|
|||||||
return {
|
return {
|
||||||
standesbuchNr: a.standesbuchNr,
|
standesbuchNr: a.standesbuchNr,
|
||||||
kursname: a.kursname as string,
|
kursname: a.kursname as string,
|
||||||
|
kursnummer: a.kursnummer,
|
||||||
|
kurzbezeichnung: a.kurzbezeichnung,
|
||||||
|
erfolgscode: a.erfolgscode,
|
||||||
kursDatum,
|
kursDatum,
|
||||||
ablaufdatum: parseDate(a.ablaufdatum),
|
ablaufdatum: parseDate(a.ablaufdatum),
|
||||||
ort: a.ort,
|
ort: a.ort,
|
||||||
@@ -948,7 +960,7 @@ async function navigateAndGetTableRows(
|
|||||||
tableClass: cls,
|
tableClass: cls,
|
||||||
cells: tds.map(td => {
|
cells: tds.map(td => {
|
||||||
const input = td.querySelector('input[type="text"], input:not([type])') as HTMLInputElement | null;
|
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;
|
const sel = td.querySelector('select') as HTMLSelectElement | null;
|
||||||
if (sel) {
|
if (sel) {
|
||||||
const opt = sel.options[sel.selectedIndex];
|
const opt = sel.options[sel.selectedIndex];
|
||||||
@@ -975,17 +987,25 @@ async function navigateAndGetTableRows(
|
|||||||
cells: r.cells.map(c => c.replace(/\u00A0/g, ' ').trim()),
|
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}$/;
|
const datePattern = /^\d{2}\.\d{2}\.\d{4}$/;
|
||||||
let dateColIdx = -1;
|
const dateCountByCol: Record<number, number> = {};
|
||||||
for (const r of mapped) {
|
for (const r of mapped) {
|
||||||
for (let ci = 0; ci < r.cells.length; ci++) {
|
for (let ci = 0; ci < r.cells.length; ci++) {
|
||||||
if (datePattern.test(r.cells[ci] ?? '')) {
|
if (datePattern.test(r.cells[ci] ?? '')) {
|
||||||
dateColIdx = ci;
|
dateCountByCol[ci] = (dateCountByCol[ci] || 0) + 1;
|
||||||
break;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
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
|
const dataRows = dateColIdx >= 0
|
||||||
@@ -1296,8 +1316,10 @@ async function scrapeMemberFahrgenehmigungen(
|
|||||||
]);
|
]);
|
||||||
const results: FdiskFahrgenehmigung[] = [];
|
const results: FdiskFahrgenehmigung[] = [];
|
||||||
for (const row of rawRows) {
|
for (const row of rawRows) {
|
||||||
const klasse = cellText(row.klasse);
|
let klasse = cellText(row.klasse);
|
||||||
if (!klasse) continue;
|
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
|
// Validate klasse against whitelist — skip non-class data
|
||||||
if (!VALID_LICENSE_CLASSES.has(klasse.toUpperCase())) {
|
if (!VALID_LICENSE_CLASSES.has(klasse.toUpperCase())) {
|
||||||
log(` → Skipping invalid klasse: "${klasse}"`);
|
log(` → Skipping invalid klasse: "${klasse}"`);
|
||||||
|
|||||||
@@ -22,6 +22,9 @@ export interface FdiskMember {
|
|||||||
export interface FdiskAusbildung {
|
export interface FdiskAusbildung {
|
||||||
standesbuchNr: string;
|
standesbuchNr: string;
|
||||||
kursname: string;
|
kursname: string;
|
||||||
|
kursnummer: string | null;
|
||||||
|
kurzbezeichnung: string | null;
|
||||||
|
erfolgscode: string | null;
|
||||||
kursDatum: string | null;
|
kursDatum: string | null;
|
||||||
ablaufdatum: string | null;
|
ablaufdatum: string | null;
|
||||||
ort: string | null;
|
ort: string | null;
|
||||||
|
|||||||
Reference in New Issue
Block a user