feat(ausruestungsanfrage): show vendor order status and delivery progress in request detail

This commit is contained in:
Matthias Hochmeister
2026-04-17 12:39:40 +02:00
parent d8afcc1f63
commit 169d045e4c
3 changed files with 133 additions and 9 deletions

View File

@@ -409,19 +409,64 @@ async function getRequestById(id: number) {
} catch { /* table may not exist */ }
}
// Load per-position vendor order info (anfrage_position_id FK, added in migration 096)
let positionBestellInfoMap: Record<number, {
bestellung_id: number;
bestellung_bezeichnung: string;
bestellung_status: string;
bestellung_laufende_nummer: number | null;
bestellung_erstellt_am: string;
erhalten_menge: number;
bestellt_menge: number;
}> = {};
if (positionIds.length > 0) {
try {
const bpResult = await pool.query(
`SELECT bp.anfrage_position_id,
bp.bestellung_id,
bp.erhalten_menge,
bp.menge AS bestellt_menge,
b.bezeichnung AS bestellung_bezeichnung,
b.status AS bestellung_status,
b.laufende_nummer AS bestellung_laufende_nummer,
b.erstellt_am AS bestellung_erstellt_am
FROM bestellpositionen bp
JOIN bestellungen b ON b.id = bp.bestellung_id
WHERE bp.anfrage_position_id = ANY($1)`,
[positionIds],
);
for (const row of bpResult.rows) {
positionBestellInfoMap[row.anfrage_position_id] = {
bestellung_id: row.bestellung_id,
bestellung_bezeichnung: row.bestellung_bezeichnung,
bestellung_status: row.bestellung_status,
bestellung_laufende_nummer: row.bestellung_laufende_nummer ?? null,
bestellung_erstellt_am: row.bestellung_erstellt_am,
erhalten_menge: Number(row.erhalten_menge),
bestellt_menge: Number(row.bestellt_menge),
};
}
} catch { /* bestellpositionen.anfrage_position_id may not exist */ }
}
const positionenWithEigenschaften = positionen.rows.map((p: { id: number }) => ({
...p,
eigenschaften: eigenschaftenMap[p.id] || [],
bestellung_info: positionBestellInfoMap[p.id] ?? null,
}));
// Load linked bestellungen
// Load linked bestellungen with delivery totals
let linkedBestellungen: unknown[] = [];
try {
const bestellungen = await pool.query(
`SELECT b.*
`SELECT b.id, b.bezeichnung, b.status, b.laufende_nummer, b.erstellt_am,
COALESCE(SUM(bp.erhalten_menge), 0)::int AS total_received,
COALESCE(SUM(bp.menge), 0)::int AS total_ordered
FROM ausruestung_anfrage_bestellung ab
JOIN bestellungen b ON b.id = ab.bestellung_id
WHERE ab.anfrage_id = $1`,
LEFT JOIN bestellpositionen bp ON bp.bestellung_id = b.id
WHERE ab.anfrage_id = $1
GROUP BY b.id, b.bezeichnung, b.status, b.laufende_nummer, b.erstellt_am`,
[id],
);
linkedBestellungen = bestellungen.rows;

View File

@@ -19,6 +19,7 @@ import { usePermissionContext } from '../contexts/PermissionContext';
import { useAuth } from '../contexts/AuthContext';
import { ausruestungsanfrageApi } from '../services/ausruestungsanfrage';
import { AUSRUESTUNG_STATUS_LABELS, AUSRUESTUNG_STATUS_COLORS } from '../types/ausruestungsanfrage.types';
import { BESTELLUNG_STATUS_LABELS, BESTELLUNG_STATUS_COLORS } from '../types/bestellung.types';
import type {
AusruestungAnfrage, AusruestungAnfrageDetailResponse,
AusruestungAnfrageFormItem, AusruestungAnfrageStatus,
@@ -34,6 +35,14 @@ function formatOrderId(r: AusruestungAnfrage): string {
return `#${r.id}`;
}
function formatBestellungKennung(b: { id: number; laufende_nummer?: number | null; erstellt_am?: string }): string {
if (b.laufende_nummer != null && b.erstellt_am) {
const year = new Date(b.erstellt_am).getFullYear();
return `${year}/${b.laufende_nummer}`;
}
return `#${b.id}`;
}
// ── Helpers ──
function getUnassignedPositions(positions: AusruestungAnfragePosition[]): AusruestungAnfragePosition[] {
@@ -433,6 +442,24 @@ export default function AusruestungsanfrageDetail() {
{p.im_haus && (
<Chip label="Im Haus" size="small" color="success" />
)}
{p.bestellung_info && (() => {
const bi = p.bestellung_info!;
const kennung = formatBestellungKennung({
id: bi.bestellung_id,
laufende_nummer: bi.bestellung_laufende_nummer,
erstellt_am: bi.bestellung_erstellt_am,
});
return (
<Chip
label={`${kennung} · ${bi.erhalten_menge}/${bi.bestellt_menge} erhalten`}
size="small"
color={bi.erhalten_menge >= bi.bestellt_menge ? 'success' : 'default'}
variant="outlined"
onClick={(e) => { e.stopPropagation(); navigate(`/bestellungen/${bi.bestellung_id}`); }}
sx={{ cursor: 'pointer' }}
/>
);
})()}
{p.geliefert && p.zuweisung_typ === 'keine' && (
<Chip label="Nicht zugewiesen" size="small" color="default" variant="outlined" />
)}
@@ -475,11 +502,46 @@ export default function AusruestungsanfrageDetail() {
{/* Linked Bestellungen */}
{detail.linked_bestellungen && detail.linked_bestellungen.length > 0 && (
<Paper sx={{ p: 3, mb: 3 }}>
<Typography variant="subtitle2" sx={{ mb: 1 }}>Verknüpfte Bestellungen</Typography>
<Box sx={{ display: 'flex', gap: 0.5, flexWrap: 'wrap' }}>
{detail.linked_bestellungen.map(b => (
<Chip key={b.id} label={`#${b.id} ${b.bezeichnung}`} size="small" />
))}
<Typography variant="subtitle2" sx={{ mb: 1.5 }}>Verknüpfte Bestellungen</Typography>
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1 }}>
{detail.linked_bestellungen.map(b => {
const totalOrdered = b.total_ordered ?? 0;
const totalReceived = b.total_received ?? 0;
const pct = totalOrdered > 0 ? (totalReceived / totalOrdered) * 100 : 0;
const kennung = formatBestellungKennung(b);
return (
<Paper
key={b.id}
variant="outlined"
onClick={() => navigate(`/bestellungen/${b.id}`)}
sx={{ p: 1.5, cursor: 'pointer', '&:hover': { bgcolor: 'action.hover' } }}
>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, flexWrap: 'wrap' }}>
<Typography variant="body2" fontWeight={500} sx={{ flexGrow: 1 }}>
{kennung} {b.bezeichnung}
</Typography>
<Chip
label={BESTELLUNG_STATUS_LABELS[b.status as keyof typeof BESTELLUNG_STATUS_LABELS] ?? b.status}
color={(BESTELLUNG_STATUS_COLORS[b.status as keyof typeof BESTELLUNG_STATUS_COLORS] as 'default' | 'primary' | 'secondary' | 'error' | 'info' | 'success' | 'warning') ?? 'default'}
size="small"
/>
</Box>
{totalOrdered > 0 && (
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mt: 1 }}>
<LinearProgress
variant="determinate"
value={Math.min(pct, 100)}
color={pct >= 100 ? 'success' : 'primary'}
sx={{ flexGrow: 1, height: 6, borderRadius: 3 }}
/>
<Typography variant="caption" color="text.secondary" sx={{ whiteSpace: 'nowrap' }}>
{totalReceived}/{totalOrdered} erhalten
</Typography>
</Box>
)}
</Paper>
);
})}
</Box>
</Paper>
)}

View File

@@ -120,6 +120,15 @@ export interface AusruestungAnfragePosition {
persoenlich_id?: string | null;
aktueller_zustand?: string | null;
neuer_zustand?: string | null;
bestellung_info?: {
bestellung_id: number;
bestellung_bezeichnung: string;
bestellung_status: string;
bestellung_laufende_nummer?: number | null;
bestellung_erstellt_am?: string;
erhalten_menge: number;
bestellt_menge: number;
} | null;
}
export interface AusruestungAnfrageFormItem {
@@ -138,7 +147,15 @@ export interface AusruestungAnfrageFormItem {
export interface AusruestungAnfrageDetailResponse {
anfrage: AusruestungAnfrage;
positionen: AusruestungAnfragePosition[];
linked_bestellungen?: { id: number; bezeichnung: string; status: string }[];
linked_bestellungen?: {
id: number;
bezeichnung: string;
status: string;
laufende_nummer?: number;
erstellt_am?: string;
total_received?: number;
total_ordered?: number;
}[];
im_haus?: boolean;
}