feat(ausruestung): catalog-driven item tracking, im_haus in overview, order quantity override, fix stale queries

This commit is contained in:
Matthias Hochmeister
2026-04-15 10:20:36 +02:00
parent 633a75cb0b
commit 279cc03b6b
10 changed files with 195 additions and 114 deletions

View File

@@ -324,7 +324,13 @@ async function getRequests(filters?: { status?: string; anfrager_id?: string })
COALESCE(u2.given_name || ' ' || u2.family_name, u2.name) AS bearbeitet_von_name, COALESCE(u2.given_name || ' ' || u2.family_name, u2.name) AS bearbeitet_von_name,
a.fuer_benutzer_name, a.fuer_benutzer_name,
(SELECT COUNT(*)::int FROM ausruestung_anfrage_positionen p WHERE p.anfrage_id = a.id) AS positionen_count, (SELECT COUNT(*)::int FROM ausruestung_anfrage_positionen p WHERE p.anfrage_id = a.id) AS positionen_count,
(SELECT COUNT(*)::int FROM ausruestung_anfrage_positionen p WHERE p.anfrage_id = a.id AND p.geliefert) AS geliefert_count (SELECT COUNT(*)::int FROM ausruestung_anfrage_positionen p WHERE p.anfrage_id = a.id AND p.geliefert) AS geliefert_count,
EXISTS(
SELECT 1 FROM ausruestung_anfrage_bestellung ab
JOIN bestellungen b ON b.id = ab.bestellung_id
WHERE ab.anfrage_id = a.id
AND b.status IN ('lieferung_pruefen', 'abgeschlossen')
) AS im_haus
FROM ausruestung_anfragen a FROM ausruestung_anfragen a
LEFT JOIN users u ON u.id = a.anfrager_id LEFT JOIN users u ON u.id = a.anfrager_id
LEFT JOIN users u2 ON u2.id = a.bearbeitet_von LEFT JOIN users u2 ON u2.id = a.bearbeitet_von
@@ -339,7 +345,13 @@ async function getMyRequests(userId: string) {
const result = await pool.query( const result = await pool.query(
`SELECT a.*, `SELECT a.*,
(SELECT COUNT(*)::int FROM ausruestung_anfrage_positionen p WHERE p.anfrage_id = a.id) AS positionen_count, (SELECT COUNT(*)::int FROM ausruestung_anfrage_positionen p WHERE p.anfrage_id = a.id) AS positionen_count,
(SELECT COUNT(*)::int FROM ausruestung_anfrage_positionen p WHERE p.anfrage_id = a.id AND p.geliefert) AS geliefert_count (SELECT COUNT(*)::int FROM ausruestung_anfrage_positionen p WHERE p.anfrage_id = a.id AND p.geliefert) AS geliefert_count,
EXISTS(
SELECT 1 FROM ausruestung_anfrage_bestellung ab
JOIN bestellungen b ON b.id = ab.bestellung_id
WHERE ab.anfrage_id = a.id
AND b.status IN ('lieferung_pruefen', 'abgeschlossen')
) AS im_haus
FROM ausruestung_anfragen a FROM ausruestung_anfragen a
WHERE a.anfrager_id = $1 WHERE a.anfrager_id = $1
ORDER BY a.erstellt_am DESC`, ORDER BY a.erstellt_am DESC`,
@@ -1100,7 +1112,7 @@ async function getUnassignedPositions() {
FROM ausruestung_anfrage_positionen p FROM ausruestung_anfrage_positionen p
JOIN ausruestung_anfragen a ON a.id = p.anfrage_id JOIN ausruestung_anfragen a ON a.id = p.anfrage_id
LEFT JOIN users u ON u.id = a.anfrager_id LEFT JOIN users u ON u.id = a.anfrager_id
WHERE p.geliefert = true AND p.zuweisung_typ IS NULL WHERE p.geliefert = true AND (p.zuweisung_typ IS NULL OR p.zuweisung_typ = 'keine')
ORDER BY a.bestell_jahr DESC NULLS LAST, a.bestell_nummer DESC NULLS LAST, p.bezeichnung ORDER BY a.bestell_jahr DESC NULLS LAST, a.bestell_nummer DESC NULLS LAST, p.bezeichnung
`); `);
return result.rows; return result.rows;

View File

@@ -8,7 +8,7 @@ import App from './App';
const queryClient = new QueryClient({ const queryClient = new QueryClient({
defaultOptions: { defaultOptions: {
queries: { queries: {
staleTime: 5 * 60 * 1000, // 5 minutes staleTime: 30 * 1000, // 30 seconds
gcTime: 10 * 60 * 1000, // keep cache 10 minutes gcTime: 10 * 60 * 1000, // keep cache 10 minutes
retry: 1, retry: 1,
refetchOnWindowFocus: false, // prevent refetch on every tab switch refetchOnWindowFocus: false, // prevent refetch on every tab switch

View File

@@ -96,6 +96,7 @@ function MeineAnfragenTab() {
<TableCell>Anfrage ID</TableCell> <TableCell>Anfrage ID</TableCell>
<TableCell>Bezeichnung</TableCell> <TableCell>Bezeichnung</TableCell>
<TableCell>Status</TableCell> <TableCell>Status</TableCell>
<TableCell>Im Haus</TableCell>
<TableCell>Positionen</TableCell> <TableCell>Positionen</TableCell>
<TableCell>Geliefert</TableCell> <TableCell>Geliefert</TableCell>
<TableCell>Erstellt am</TableCell> <TableCell>Erstellt am</TableCell>
@@ -107,6 +108,7 @@ function MeineAnfragenTab() {
<TableCell>{formatOrderId(r)}</TableCell> <TableCell>{formatOrderId(r)}</TableCell>
<TableCell>{r.bezeichnung || '-'}</TableCell> <TableCell>{r.bezeichnung || '-'}</TableCell>
<TableCell><Chip label={AUSRUESTUNG_STATUS_LABELS[r.status]} color={AUSRUESTUNG_STATUS_COLORS[r.status]} size="small" /></TableCell> <TableCell><Chip label={AUSRUESTUNG_STATUS_LABELS[r.status]} color={AUSRUESTUNG_STATUS_COLORS[r.status]} size="small" /></TableCell>
<TableCell>{r.im_haus ? <Chip label="Im Haus" color="success" size="small" /> : null}</TableCell>
<TableCell>{r.positionen_count ?? r.items_count ?? '-'}</TableCell> <TableCell>{r.positionen_count ?? r.items_count ?? '-'}</TableCell>
<TableCell> <TableCell>
{r.positionen_count != null && r.positionen_count > 0 {r.positionen_count != null && r.positionen_count > 0
@@ -204,6 +206,7 @@ function AlleAnfragenTab() {
<TableCell>Bezeichnung</TableCell> <TableCell>Bezeichnung</TableCell>
<TableCell>Anfrage für</TableCell> <TableCell>Anfrage für</TableCell>
<TableCell>Status</TableCell> <TableCell>Status</TableCell>
<TableCell>Im Haus</TableCell>
<TableCell>Positionen</TableCell> <TableCell>Positionen</TableCell>
<TableCell>Geliefert</TableCell> <TableCell>Geliefert</TableCell>
<TableCell>Erstellt am</TableCell> <TableCell>Erstellt am</TableCell>
@@ -216,6 +219,7 @@ function AlleAnfragenTab() {
<TableCell>{r.bezeichnung || '-'}</TableCell> <TableCell>{r.bezeichnung || '-'}</TableCell>
<TableCell>{r.fuer_benutzer_name || r.anfrager_name || r.anfrager_id}</TableCell> <TableCell>{r.fuer_benutzer_name || r.anfrager_name || r.anfrager_id}</TableCell>
<TableCell><Chip label={AUSRUESTUNG_STATUS_LABELS[r.status]} color={AUSRUESTUNG_STATUS_COLORS[r.status]} size="small" /></TableCell> <TableCell><Chip label={AUSRUESTUNG_STATUS_LABELS[r.status]} color={AUSRUESTUNG_STATUS_COLORS[r.status]} size="small" /></TableCell>
<TableCell>{r.im_haus ? <Chip label="Im Haus" color="success" size="small" /> : null}</TableCell>
<TableCell>{r.positionen_count ?? r.items_count ?? '-'}</TableCell> <TableCell>{r.positionen_count ?? r.items_count ?? '-'}</TableCell>
<TableCell> <TableCell>
{r.positionen_count != null && r.positionen_count > 0 {r.positionen_count != null && r.positionen_count > 0

View File

@@ -84,6 +84,7 @@ export default function AusruestungsanfrageZuBestellung() {
// ── State ── // ── State ──
const [assignments, setAssignments] = useState<Record<number, VendorAssignment | null>>({}); const [assignments, setAssignments] = useState<Record<number, VendorAssignment | null>>({});
const [orderNames, setOrderNames] = useState<Record<number, string>>({}); const [orderNames, setOrderNames] = useState<Record<number, string>>({});
const [quantities, setQuantities] = useState<Record<number, number>>({});
// New vendor dialog // New vendor dialog
const [newVendorDialog, setNewVendorDialog] = useState(false); const [newVendorDialog, setNewVendorDialog] = useState(false);
@@ -153,6 +154,8 @@ export default function AusruestungsanfrageZuBestellung() {
showSuccess(`${result.created_bestellungen.length} Bestellung(en) erfolgreich erstellt`); showSuccess(`${result.created_bestellungen.length} Bestellung(en) erfolgreich erstellt`);
queryClient.invalidateQueries({ queryKey: ['ausruestungsanfrage', requestId] }); queryClient.invalidateQueries({ queryKey: ['ausruestungsanfrage', requestId] });
navigate('/bestellungen'); navigate('/bestellungen');
queryClient.invalidateQueries({ queryKey: ['ausruestungsanfrage'] });
queryClient.invalidateQueries({ queryKey: ['bestellungen'] });
}, },
onError: () => showError('Bestellungen konnten nicht erstellt werden'), onError: () => showError('Bestellungen konnten nicht erstellt werden'),
}); });
@@ -191,7 +194,7 @@ export default function AusruestungsanfrageZuBestellung() {
positionen: g.positionen.map(p => ({ positionen: g.positionen.map(p => ({
position_id: p.id, position_id: p.id,
bezeichnung: p.bezeichnung, bezeichnung: p.bezeichnung,
menge: p.menge, menge: quantities[p.id] ?? p.menge,
einheit: p.einheit, einheit: p.einheit,
notizen: p.notizen, notizen: p.notizen,
artikel_id: p.artikel_id, artikel_id: p.artikel_id,
@@ -281,6 +284,7 @@ export default function AusruestungsanfrageZuBestellung() {
<TableRow> <TableRow>
<TableCell>Artikel</TableCell> <TableCell>Artikel</TableCell>
<TableCell align="right" sx={{ width: 80 }}>Menge</TableCell> <TableCell align="right" sx={{ width: 80 }}>Menge</TableCell>
<TableCell align="right" sx={{ width: 140 }}>Bestellmenge</TableCell>
<TableCell sx={{ width: 320 }}>Lieferant</TableCell> <TableCell sx={{ width: 320 }}>Lieferant</TableCell>
</TableRow> </TableRow>
</TableHead> </TableHead>
@@ -301,6 +305,20 @@ export default function AusruestungsanfrageZuBestellung() {
<TableCell align="right"> <TableCell align="right">
<Typography variant="body2">{pos.menge} {pos.einheit ?? 'Stk'}</Typography> <Typography variant="body2">{pos.menge} {pos.einheit ?? 'Stk'}</Typography>
</TableCell> </TableCell>
<TableCell align="right">
<TextField
type="number"
size="small"
sx={{ width: 80 }}
value={quantities[pos.id] ?? pos.menge}
onChange={e => {
const val = Math.min(pos.menge, Math.max(1, Number(e.target.value)));
setQuantities(prev => ({ ...prev, [pos.id]: val }));
}}
inputProps={{ min: 1, max: pos.menge }}
helperText={pos.menge > 1 ? `max ${pos.menge}` : undefined}
/>
</TableCell>
<TableCell> <TableCell>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5 }}> <Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5 }}>
<Autocomplete<Lieferant> <Autocomplete<Lieferant>

View File

@@ -12,6 +12,7 @@ import { PageHeader } from '../components/templates';
import { useNotification } from '../contexts/NotificationContext'; import { useNotification } from '../contexts/NotificationContext';
import { usePermissionContext } from '../contexts/PermissionContext'; import { usePermissionContext } from '../contexts/PermissionContext';
import { ausruestungsanfrageApi } from '../services/ausruestungsanfrage'; import { ausruestungsanfrageApi } from '../services/ausruestungsanfrage';
import { personalEquipmentApi } from '../services/personalEquipment';
import { vehiclesApi } from '../services/vehicles'; import { vehiclesApi } from '../services/vehicles';
import { membersService } from '../services/members'; import { membersService } from '../services/members';
import type { AusruestungAnfragePosition } from '../types/ausruestungsanfrage.types'; import type { AusruestungAnfragePosition } from '../types/ausruestungsanfrage.types';
@@ -24,8 +25,6 @@ interface PositionAssignment {
standort?: string; standort?: string;
userId?: string; userId?: string;
benutzerName?: string; benutzerName?: string;
groesse?: string;
kategorie?: string;
} }
function getUnassignedPositions(positions: AusruestungAnfragePosition[]): AusruestungAnfragePosition[] { function getUnassignedPositions(positions: AusruestungAnfragePosition[]): AusruestungAnfragePosition[] {
@@ -92,6 +91,12 @@ export default function AusruestungsanfrageZuweisung() {
name: v.bezeichnung ?? v.kurzname, name: v.bezeichnung ?? v.kurzname,
})); }));
const { data: allPersonalItems = [] } = useQuery({
queryKey: ['persoenliche-ausruestung', 'all-for-count'],
queryFn: () => personalEquipmentApi.getAll(),
staleTime: 2 * 60 * 1000,
});
const [submitting, setSubmitting] = useState(false); const [submitting, setSubmitting] = useState(false);
const updateAssignment = (posId: number, patch: Partial<PositionAssignment>) => { const updateAssignment = (posId: number, patch: Partial<PositionAssignment>) => {
@@ -137,12 +142,12 @@ export default function AusruestungsanfrageZuweisung() {
standort: a.typ === 'ausruestung' ? a.standort : undefined, standort: a.typ === 'ausruestung' ? a.standort : undefined,
userId: a.typ === 'persoenlich' ? a.userId : undefined, userId: a.typ === 'persoenlich' ? a.userId : undefined,
benutzerName: a.typ === 'persoenlich' ? (a.benutzerName || anfrage.fuer_benutzer_name || anfrage.anfrager_name) : undefined, benutzerName: a.typ === 'persoenlich' ? (a.benutzerName || anfrage.fuer_benutzer_name || anfrage.anfrager_name) : undefined,
groesse: a.typ === 'persoenlich' ? a.groesse : undefined,
kategorie: a.typ === 'persoenlich' ? a.kategorie : undefined,
})); }));
await ausruestungsanfrageApi.assignItems(anfrageId, payload); await ausruestungsanfrageApi.assignItems(anfrageId, payload);
showSuccess('Gegenstände zugewiesen'); showSuccess('Gegenstände zugewiesen');
navigate(`/ausruestungsanfrage/${id}`); navigate(`/ausruestungsanfrage/${id}`);
queryClient.invalidateQueries({ queryKey: ['persoenliche-ausruestung'] });
queryClient.invalidateQueries({ queryKey: ['ausruestungsanfrage'] });
} catch { } catch {
showError('Fehler beim Zuweisen'); showError('Fehler beim Zuweisen');
} finally { } finally {
@@ -233,11 +238,10 @@ export default function AusruestungsanfrageZuweisung() {
size="small" size="small"
onChange={(_e, val) => val && updateAssignment(pos.id, { typ: val })} onChange={(_e, val) => val && updateAssignment(pos.id, { typ: val })}
sx={{ mb: 1.5 }} sx={{ mb: 1.5 }}
disabled={!pos.artikel_id}
> >
<ToggleButton value="ausruestung">Ausrüstung</ToggleButton> <ToggleButton value="ausruestung" disabled={!pos.artikel_id}>Ausrüstung</ToggleButton>
<ToggleButton value="persoenlich">Persönlich</ToggleButton> <ToggleButton value="persoenlich" disabled={!pos.artikel_id}>Persönlich</ToggleButton>
<ToggleButton value="keine">Nicht erfassen</ToggleButton> <ToggleButton value="keine">Nicht verfolgt</ToggleButton>
</ToggleButtonGroup> </ToggleButtonGroup>
{a.typ === 'ausruestung' && ( {a.typ === 'ausruestung' && (
@@ -262,7 +266,7 @@ export default function AusruestungsanfrageZuweisung() {
)} )}
{a.typ === 'persoenlich' && ( {a.typ === 'persoenlich' && (
<Box sx={{ display: 'flex', gap: 1.5, flexWrap: 'wrap' }}> <Box sx={{ display: 'flex', gap: 1.5, flexWrap: 'wrap', alignItems: 'center' }}>
<Autocomplete <Autocomplete
size="small" size="small"
options={memberOptions} options={memberOptions}
@@ -278,20 +282,19 @@ export default function AusruestungsanfrageZuweisung() {
)} )}
sx={{ minWidth: 200, flex: 1 }} sx={{ minWidth: 200, flex: 1 }}
/> />
<TextField {(() => {
size="small" if (!a.userId || !pos.artikel_id) return null;
label="Größe" const count = allPersonalItems.filter(i => i.user_id === a.userId && i.artikel_id === pos.artikel_id).length;
value={a.groesse ?? ''} if (count === 0) return null;
onChange={(e) => updateAssignment(pos.id, { groesse: e.target.value })} return <Typography variant="caption" color="text.secondary">Hat bereits {count} Stk.</Typography>;
sx={{ minWidth: 100 }} })()}
/> {pos.eigenschaften && pos.eigenschaften.length > 0 && (
<TextField <Box sx={{ display: 'flex', gap: 0.5, flexWrap: 'wrap', mb: 1 }}>
size="small" {pos.eigenschaften.map(e => (
label="Kategorie" <Chip key={e.eigenschaft_id} label={`${e.eigenschaft_name}: ${e.wert}`} size="small" variant="outlined" />
value={a.kategorie ?? ''} ))}
onChange={(e) => updateAssignment(pos.id, { kategorie: e.target.value })} </Box>
sx={{ minWidth: 140 }} )}
/>
</Box> </Box>
)} )}
</Box> </Box>

View File

@@ -214,6 +214,7 @@ export default function BestellungDetail() {
mutationFn: ({ status, force }: { status: string; force?: boolean }) => bestellungApi.updateStatus(orderId, status, force), mutationFn: ({ status, force }: { status: string; force?: boolean }) => bestellungApi.updateStatus(orderId, status, force),
onSuccess: () => { onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['bestellung', orderId] }); queryClient.invalidateQueries({ queryKey: ['bestellung', orderId] });
queryClient.invalidateQueries({ queryKey: ['bestellungen'] });
showSuccess('Status aktualisiert'); showSuccess('Status aktualisiert');
setStatusConfirmTarget(null); setStatusConfirmTarget(null);
setStatusForce(false); setStatusForce(false);
@@ -225,6 +226,7 @@ export default function BestellungDetail() {
mutationFn: (data: BestellpositionFormData) => bestellungApi.addLineItem(orderId, data), mutationFn: (data: BestellpositionFormData) => bestellungApi.addLineItem(orderId, data),
onSuccess: () => { onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['bestellung', orderId] }); queryClient.invalidateQueries({ queryKey: ['bestellung', orderId] });
queryClient.invalidateQueries({ queryKey: ['bestellungen'] });
setNewItem({ ...emptyItem }); setNewItem({ ...emptyItem });
setSelectedKatalogItem(null); setSelectedKatalogItem(null);
setKatalogEigenschaften([]); setKatalogEigenschaften([]);
@@ -238,6 +240,7 @@ export default function BestellungDetail() {
mutationFn: (itemId: number) => bestellungApi.deleteLineItem(itemId), mutationFn: (itemId: number) => bestellungApi.deleteLineItem(itemId),
onSuccess: () => { onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['bestellung', orderId] }); queryClient.invalidateQueries({ queryKey: ['bestellung', orderId] });
queryClient.invalidateQueries({ queryKey: ['bestellungen'] });
setDeleteItemTarget(null); setDeleteItemTarget(null);
showSuccess('Position gelöscht'); showSuccess('Position gelöscht');
}, },
@@ -249,6 +252,7 @@ export default function BestellungDetail() {
bestellungApi.updateReceivedQty(itemId, menge), bestellungApi.updateReceivedQty(itemId, menge),
onSuccess: () => { onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['bestellung', orderId] }); queryClient.invalidateQueries({ queryKey: ['bestellung', orderId] });
queryClient.invalidateQueries({ queryKey: ['bestellungen'] });
}, },
onError: () => showError('Fehler beim Aktualisieren'), onError: () => showError('Fehler beim Aktualisieren'),
}); });
@@ -257,6 +261,7 @@ export default function BestellungDetail() {
mutationFn: (file: File) => bestellungApi.uploadFile(orderId, file), mutationFn: (file: File) => bestellungApi.uploadFile(orderId, file),
onSuccess: () => { onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['bestellung', orderId] }); queryClient.invalidateQueries({ queryKey: ['bestellung', orderId] });
queryClient.invalidateQueries({ queryKey: ['bestellungen'] });
showSuccess('Datei hochgeladen'); showSuccess('Datei hochgeladen');
}, },
onError: () => showError('Fehler beim Hochladen der Datei'), onError: () => showError('Fehler beim Hochladen der Datei'),
@@ -266,6 +271,7 @@ export default function BestellungDetail() {
mutationFn: (fileId: number) => bestellungApi.deleteFile(fileId), mutationFn: (fileId: number) => bestellungApi.deleteFile(fileId),
onSuccess: () => { onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['bestellung', orderId] }); queryClient.invalidateQueries({ queryKey: ['bestellung', orderId] });
queryClient.invalidateQueries({ queryKey: ['bestellungen'] });
setDeleteFileTarget(null); setDeleteFileTarget(null);
showSuccess('Datei gelöscht'); showSuccess('Datei gelöscht');
}, },
@@ -276,6 +282,7 @@ export default function BestellungDetail() {
mutationFn: (data: ErinnerungFormData) => bestellungApi.addReminder(orderId, data), mutationFn: (data: ErinnerungFormData) => bestellungApi.addReminder(orderId, data),
onSuccess: () => { onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['bestellung', orderId] }); queryClient.invalidateQueries({ queryKey: ['bestellung', orderId] });
queryClient.invalidateQueries({ queryKey: ['bestellungen'] });
setReminderForm({ faellig_am: '', nachricht: '' }); setReminderForm({ faellig_am: '', nachricht: '' });
setReminderFormOpen(false); setReminderFormOpen(false);
showSuccess('Erinnerung erstellt'); showSuccess('Erinnerung erstellt');
@@ -287,6 +294,7 @@ export default function BestellungDetail() {
mutationFn: (reminderId: number) => bestellungApi.markReminderDone(reminderId), mutationFn: (reminderId: number) => bestellungApi.markReminderDone(reminderId),
onSuccess: () => { onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['bestellung', orderId] }); queryClient.invalidateQueries({ queryKey: ['bestellung', orderId] });
queryClient.invalidateQueries({ queryKey: ['bestellungen'] });
}, },
onError: () => showError('Fehler beim Aktualisieren'), onError: () => showError('Fehler beim Aktualisieren'),
}); });
@@ -295,6 +303,7 @@ export default function BestellungDetail() {
mutationFn: (reminderId: number) => bestellungApi.deleteReminder(reminderId), mutationFn: (reminderId: number) => bestellungApi.deleteReminder(reminderId),
onSuccess: () => { onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['bestellung', orderId] }); queryClient.invalidateQueries({ queryKey: ['bestellung', orderId] });
queryClient.invalidateQueries({ queryKey: ['bestellungen'] });
setDeleteReminderTarget(null); setDeleteReminderTarget(null);
showSuccess('Erinnerung gelöscht'); showSuccess('Erinnerung gelöscht');
}, },
@@ -351,6 +360,7 @@ export default function BestellungDetail() {
} }
} }
await queryClient.invalidateQueries({ queryKey: ['bestellung', orderId] }); await queryClient.invalidateQueries({ queryKey: ['bestellung', orderId] });
queryClient.invalidateQueries({ queryKey: ['bestellungen'] });
showSuccess('Änderungen gespeichert'); showSuccess('Änderungen gespeichert');
setEditMode(false); setEditMode(false);
setEditItemsData({}); setEditItemsData({});

View File

@@ -102,7 +102,7 @@ function PersoenlicheAusruestungPage() {
<Tabs value={activeTab} onChange={(_e, v) => setActiveTab(v)} sx={{ mb: 3 }}> <Tabs value={activeTab} onChange={(_e, v) => setActiveTab(v)} sx={{ mb: 3 }}>
<Tab label="Zuweisungen" /> <Tab label="Zuweisungen" />
<Tab label="Katalog" /> <Tab label="Katalog" />
{canApprove && <Tab label="Nicht Zugewiesen" />} {canApprove && <Tab label="Nicht verfolgt" />}
</Tabs> </Tabs>
{activeTab === 0 && ( {activeTab === 0 && (

View File

@@ -16,6 +16,7 @@ import { useParams, useNavigate } from 'react-router-dom';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import DashboardLayout from '../components/dashboard/DashboardLayout'; import DashboardLayout from '../components/dashboard/DashboardLayout';
import { personalEquipmentApi } from '../services/personalEquipment'; import { personalEquipmentApi } from '../services/personalEquipment';
import { ausruestungsanfrageApi } from '../services/ausruestungsanfrage';
import { membersService } from '../services/members'; import { membersService } from '../services/members';
import { usePermissionContext } from '../contexts/PermissionContext'; import { usePermissionContext } from '../contexts/PermissionContext';
import { useNotification } from '../contexts/NotificationContext'; import { useNotification } from '../contexts/NotificationContext';
@@ -64,10 +65,17 @@ export default function PersoenlicheAusruestungEdit() {
})); }));
}, [membersList]); }, [membersList]);
const { data: artikelEigenschaften = [] } = useQuery({
queryKey: ['ausruestungsanfrage', 'eigenschaften', item?.artikel_id],
queryFn: () => ausruestungsanfrageApi.getArtikelEigenschaften(item!.artikel_id!),
enabled: !!item?.artikel_id,
staleTime: 5 * 60 * 1000,
});
const [catalogEigenschaftValues, setCatalogEigenschaftValues] = useState<Record<number, string>>({});
// Form state // Form state
const [bezeichnung, setBezeichnung] = useState(''); const [bezeichnung, setBezeichnung] = useState('');
const [kategorie, setKategorie] = useState('');
const [groesse, setGroesse] = useState('');
const [seriennummer, setSeriennummer] = useState(''); const [seriennummer, setSeriennummer] = useState('');
const [inventarnummer, setInventarnummer] = useState(''); const [inventarnummer, setInventarnummer] = useState('');
const [anschaffungDatum, setAnschaffungDatum] = useState(''); const [anschaffungDatum, setAnschaffungDatum] = useState('');
@@ -80,8 +88,6 @@ export default function PersoenlicheAusruestungEdit() {
useEffect(() => { useEffect(() => {
if (!item) return; if (!item) return;
setBezeichnung(item.bezeichnung); setBezeichnung(item.bezeichnung);
setKategorie(item.kategorie ?? '');
setGroesse(item.groesse ?? '');
setSeriennummer(item.seriennummer ?? ''); setSeriennummer(item.seriennummer ?? '');
setInventarnummer(item.inventarnummer ?? ''); setInventarnummer(item.inventarnummer ?? '');
setAnschaffungDatum(item.anschaffung_datum ? item.anschaffung_datum.split('T')[0] : ''); setAnschaffungDatum(item.anschaffung_datum ? item.anschaffung_datum.split('T')[0] : '');
@@ -95,6 +101,13 @@ export default function PersoenlicheAusruestungEdit() {
wert: e.wert, wert: e.wert,
}))); })));
} }
if (item.artikel_id && item.eigenschaften) {
const vals: Record<number, string> = {};
item.eigenschaften.forEach(e => {
if (e.eigenschaft_id != null) vals[e.eigenschaft_id] = e.wert;
});
setCatalogEigenschaftValues(vals);
}
}, [item]); }, [item]);
// Set userId when item + memberOptions are ready // Set userId when item + memberOptions are ready
@@ -117,21 +130,27 @@ export default function PersoenlicheAusruestungEdit() {
}); });
const handleSave = () => { const handleSave = () => {
if (!bezeichnung.trim()) return; if (!bezeichnung.trim() || !item) return;
const payload: UpdatePersoenlicheAusruestungPayload = { const payload: UpdatePersoenlicheAusruestungPayload = {
bezeichnung: bezeichnung.trim(), bezeichnung: bezeichnung.trim(),
kategorie: kategorie || null, kategorie: null,
user_id: userId?.id || null, user_id: userId?.id || null,
groesse: groesse || null, groesse: null,
seriennummer: seriennummer || null, seriennummer: seriennummer || null,
inventarnummer: inventarnummer || null, inventarnummer: inventarnummer || null,
anschaffung_datum: anschaffungDatum || null, anschaffung_datum: anschaffungDatum || null,
zustand, zustand,
notizen: notizen || null, notizen: notizen || null,
eigenschaften: eigenschaften eigenschaften: item.artikel_id
.filter(e => e.name.trim() && e.wert.trim()) ? Object.entries(catalogEigenschaftValues)
.map(e => ({ eigenschaft_id: e.eigenschaft_id, name: e.name, wert: e.wert })), .filter(([, v]) => v.trim())
.map(([id, wert]) => ({
eigenschaft_id: Number(id),
name: artikelEigenschaften.find(e => e.id === Number(id))?.name ?? '',
wert,
}))
: eigenschaften.filter(e => e.name.trim() && e.wert.trim()).map(e => ({ eigenschaft_id: e.eigenschaft_id, name: e.name, wert: e.wert })),
}; };
updateMutation.mutate(payload); updateMutation.mutate(payload);
}; };
@@ -198,20 +217,6 @@ export default function PersoenlicheAusruestungEdit() {
/> />
)} )}
<TextField
label="Kategorie"
size="small"
value={kategorie}
onChange={(e) => setKategorie(e.target.value)}
/>
<TextField
label="Größe"
size="small"
value={groesse}
onChange={(e) => setGroesse(e.target.value)}
/>
<TextField <TextField
label="Seriennummer" label="Seriennummer"
size="small" size="small"
@@ -257,6 +262,28 @@ export default function PersoenlicheAusruestungEdit() {
/> />
{/* Eigenschaften */} {/* Eigenschaften */}
{item.artikel_id ? (
<>
<Typography variant="subtitle2">Eigenschaften</Typography>
{artikelEigenschaften.map(e =>
e.typ === 'options' && e.optionen?.length ? (
<TextField key={e.id} select size="small" label={e.name} required={e.pflicht}
value={catalogEigenschaftValues[e.id] ?? ''}
onChange={ev => setCatalogEigenschaftValues(prev => ({ ...prev, [e.id]: ev.target.value }))}
>
<MenuItem value=""></MenuItem>
{e.optionen.map(opt => <MenuItem key={opt} value={opt}>{opt}</MenuItem>)}
</TextField>
) : (
<TextField key={e.id} size="small" label={e.name} required={e.pflicht}
value={catalogEigenschaftValues[e.id] ?? ''}
onChange={ev => setCatalogEigenschaftValues(prev => ({ ...prev, [e.id]: ev.target.value }))}
/>
)
)}
</>
) : (
<>
<Typography variant="subtitle2">Eigenschaften</Typography> <Typography variant="subtitle2">Eigenschaften</Typography>
{eigenschaften.map((e, idx) => ( {eigenschaften.map((e, idx) => (
<Box key={idx} sx={{ display: 'flex', gap: 1, alignItems: 'center' }}> <Box key={idx} sx={{ display: 'flex', gap: 1, alignItems: 'center' }}>
@@ -282,6 +309,8 @@ export default function PersoenlicheAusruestungEdit() {
<Button size="small" startIcon={<AddIcon />} onClick={addEigenschaft}> <Button size="small" startIcon={<AddIcon />} onClick={addEigenschaft}>
Eigenschaft hinzufügen Eigenschaft hinzufügen
</Button> </Button>
</>
)}
<Box sx={{ display: 'flex', gap: 1, justifyContent: 'flex-end', mt: 1 }}> <Box sx={{ display: 'flex', gap: 1, justifyContent: 'flex-end', mt: 1 }}>
<Button onClick={() => navigate(`/persoenliche-ausruestung/${id}`)}> <Button onClick={() => navigate(`/persoenliche-ausruestung/${id}`)}>

View File

@@ -34,13 +34,12 @@ export default function PersoenlicheAusruestungNeu() {
const canViewAll = hasPermission('persoenliche_ausruestung:view_all'); const canViewAll = hasPermission('persoenliche_ausruestung:view_all');
const [formBezeichnung, setFormBezeichnung] = useState<string | AusruestungArtikel | null>(null); const [formArtikel, setFormArtikel] = useState<AusruestungArtikel | null>(null);
const [formKategorie, setFormKategorie] = useState('');
const [formUserId, setFormUserId] = useState<{ id: string; name: string } | null>(null); const [formUserId, setFormUserId] = useState<{ id: string; name: string } | null>(null);
const [formBenutzerName, setFormBenutzerName] = useState(''); const [formBenutzerName, setFormBenutzerName] = useState('');
const [formGroesse, setFormGroesse] = useState('');
const [formZustand, setFormZustand] = useState<PersoenlicheAusruestungZustand>('gut'); const [formZustand, setFormZustand] = useState<PersoenlicheAusruestungZustand>('gut');
const [formNotizen, setFormNotizen] = useState(''); const [formNotizen, setFormNotizen] = useState('');
const [eigenschaftValues, setEigenschaftValues] = useState<Record<number, string>>({});
const { data: catalogItems } = useQuery({ const { data: catalogItems } = useQuery({
queryKey: ['ausruestungsanfrage-items-catalog'], queryKey: ['ausruestungsanfrage-items-catalog'],
@@ -48,6 +47,13 @@ export default function PersoenlicheAusruestungNeu() {
staleTime: 10 * 60 * 1000, staleTime: 10 * 60 * 1000,
}); });
const { data: artikelEigenschaften = [] } = useQuery({
queryKey: ['ausruestungsanfrage', 'eigenschaften', formArtikel?.id],
queryFn: () => ausruestungsanfrageApi.getArtikelEigenschaften(formArtikel!.id),
enabled: !!formArtikel?.id,
staleTime: 5 * 60 * 1000,
});
const { data: membersList } = useQuery({ const { data: membersList } = useQuery({
queryKey: ['members-list-compact'], queryKey: ['members-list-compact'],
queryFn: () => membersService.getMembers({ pageSize: 500 }), queryFn: () => membersService.getMembers({ pageSize: 500 }),
@@ -75,20 +81,23 @@ export default function PersoenlicheAusruestungNeu() {
}); });
const handleCreate = () => { const handleCreate = () => {
const bezeichnung = typeof formBezeichnung === 'string' const bezeichnung = formArtikel?.bezeichnung ?? '';
? formBezeichnung
: formBezeichnung?.bezeichnung ?? '';
if (!bezeichnung.trim()) return; if (!bezeichnung.trim()) return;
const payload: CreatePersoenlicheAusruestungPayload = { const payload: CreatePersoenlicheAusruestungPayload = {
bezeichnung: bezeichnung.trim(), bezeichnung: bezeichnung.trim(),
kategorie: formKategorie || undefined, artikel_id: formArtikel?.id,
artikel_id: typeof formBezeichnung === 'object' && formBezeichnung ? formBezeichnung.id : undefined,
user_id: formUserId?.id || undefined, user_id: formUserId?.id || undefined,
benutzer_name: formBenutzerName || undefined, benutzer_name: formBenutzerName || undefined,
groesse: formGroesse || undefined,
zustand: formZustand, zustand: formZustand,
notizen: formNotizen || undefined, notizen: formNotizen || undefined,
eigenschaften: Object.entries(eigenschaftValues)
.filter(([, v]) => v.trim())
.map(([id, wert]) => ({
eigenschaft_id: Number(id),
name: artikelEigenschaften.find(e => e.id === Number(id))?.name ?? '',
wert,
})),
}; };
createMutation.mutate(payload); createMutation.mutate(payload);
}; };
@@ -103,20 +112,12 @@ export default function PersoenlicheAusruestungNeu() {
<Stack spacing={2}> <Stack spacing={2}>
<Autocomplete <Autocomplete
freeSolo
options={catalogItems ?? []} options={catalogItems ?? []}
getOptionLabel={(o) => typeof o === 'string' ? o : o.bezeichnung} getOptionLabel={(o) => o.bezeichnung}
value={formBezeichnung} value={formArtikel}
onChange={(_e, v) => { onChange={(_e, v) => {
setFormBezeichnung(v); setFormArtikel(v);
if (v && typeof v !== 'string' && v.kategorie) { setEigenschaftValues({});
setFormKategorie(v.kategorie);
}
}}
onInputChange={(_e, v) => {
if (typeof formBezeichnung === 'string' || formBezeichnung === null) {
setFormBezeichnung(v);
}
}} }}
renderInput={(params) => ( renderInput={(params) => (
<TextField {...params} label="Bezeichnung" required size="small" /> <TextField {...params} label="Bezeichnung" required size="small" />
@@ -124,6 +125,23 @@ export default function PersoenlicheAusruestungNeu() {
size="small" size="small"
/> />
{artikelEigenschaften.map(e =>
e.typ === 'options' && e.optionen?.length ? (
<TextField key={e.id} select size="small" label={e.name} required={e.pflicht}
value={eigenschaftValues[e.id] ?? ''}
onChange={ev => setEigenschaftValues(prev => ({ ...prev, [e.id]: ev.target.value }))}
>
<MenuItem value=""></MenuItem>
{e.optionen.map(opt => <MenuItem key={opt} value={opt}>{opt}</MenuItem>)}
</TextField>
) : (
<TextField key={e.id} size="small" label={e.name} required={e.pflicht}
value={eigenschaftValues[e.id] ?? ''}
onChange={ev => setEigenschaftValues(prev => ({ ...prev, [e.id]: ev.target.value }))}
/>
)
)}
{canViewAll && ( {canViewAll && (
<Autocomplete <Autocomplete
options={memberOptions} options={memberOptions}
@@ -146,20 +164,6 @@ export default function PersoenlicheAusruestungNeu() {
/> />
)} )}
<TextField
label="Kategorie"
size="small"
value={formKategorie}
onChange={(e) => setFormKategorie(e.target.value)}
/>
<TextField
label="Größe"
size="small"
value={formGroesse}
onChange={(e) => setFormGroesse(e.target.value)}
/>
<TextField <TextField
label="Zustand" label="Zustand"
select select
@@ -188,7 +192,7 @@ export default function PersoenlicheAusruestungNeu() {
<Button <Button
variant="contained" variant="contained"
onClick={handleCreate} onClick={handleCreate}
disabled={createMutation.isPending || !(typeof formBezeichnung === 'string' ? formBezeichnung.trim() : formBezeichnung?.bezeichnung?.trim())} disabled={createMutation.isPending || !formArtikel}
> >
Erstellen Erstellen
</Button> </Button>

View File

@@ -96,6 +96,7 @@ export interface AusruestungAnfrage {
positionen_count?: number; positionen_count?: number;
geliefert_count?: number; geliefert_count?: number;
items_count?: number; items_count?: number;
im_haus?: boolean;
} }
export interface AusruestungAnfragePosition { export interface AusruestungAnfragePosition {