rework external order tracking system

This commit is contained in:
Matthias Hochmeister
2026-03-25 13:24:52 +01:00
parent e02ada8b95
commit 561334791b
7 changed files with 148 additions and 44 deletions

View File

@@ -37,6 +37,7 @@ import {
History,
Upload as UploadIcon,
ArrowDropDown,
MoreVert,
} from '@mui/icons-material';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { useParams, useNavigate } from 'react-router-dom';
@@ -97,7 +98,9 @@ export default function BestellungDetail() {
const [editingItemId, setEditingItemId] = useState<number | null>(null);
const [editingItemData, setEditingItemData] = useState<Partial<BestellpositionFormData>>({});
const [statusConfirmTarget, setStatusConfirmTarget] = useState<BestellungStatus | null>(null);
const [statusForce, setStatusForce] = useState(false);
const [statusMenuAnchor, setStatusMenuAnchor] = useState<null | HTMLElement>(null);
const [overrideMenuAnchor, setOverrideMenuAnchor] = useState<null | HTMLElement>(null);
const [deleteItemTarget, setDeleteItemTarget] = useState<number | null>(null);
const [deleteFileTarget, setDeleteFileTarget] = useState<number | null>(null);
@@ -118,21 +121,36 @@ export default function BestellungDetail() {
const erinnerungen = data?.erinnerungen ?? [];
const historie = data?.historie ?? [];
const canEdit = hasPermission('bestellungen:create');
const canCreate = hasPermission('bestellungen:create');
const canDelete = hasPermission('bestellungen:delete');
const canManageReminders = hasPermission('bestellungen:manage_reminders');
const validTransitions = bestellung ? STATUS_TRANSITIONS[bestellung.status] : [];
// All statuses except current, for force override
const ALL_STATUSES: BestellungStatus[] = ['entwurf', 'erstellt', 'bestellt', 'teillieferung', 'vollstaendig', 'abgeschlossen'];
const overrideStatuses = bestellung ? ALL_STATUSES.filter((s) => s !== bestellung.status) : [];
// ── Mutations ──
const updateStatus = useMutation({
mutationFn: (status: string) => bestellungApi.updateStatus(orderId, status),
mutationFn: ({ status, force }: { status: string; force?: boolean }) => bestellungApi.updateStatus(orderId, status, force),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['bestellung', orderId] });
showSuccess('Status aktualisiert');
setStatusConfirmTarget(null);
setStatusForce(false);
},
onError: () => showError('Fehler beim Aktualisieren des Status'),
});
const updateOrder = useMutation({
mutationFn: (data: Partial<{ steuersatz: number }>) => bestellungApi.updateOrder(orderId, data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['bestellung', orderId] });
},
onError: () => showError('Fehler beim Aktualisieren'),
});
const addItem = useMutation({
mutationFn: (data: BestellpositionFormData) => bestellungApi.addLineItem(orderId, data),
onSuccess: () => {
@@ -252,6 +270,9 @@ export default function BestellungDetail() {
// Compute totals (NUMERIC columns come as strings from PostgreSQL — parse to float)
const totalCost = positionen.reduce((sum, p) => sum + (parseFloat(String(p.einzelpreis)) || 0) * (parseFloat(String(p.menge)) || 0), 0);
const steuersatz = parseFloat(String(bestellung?.steuersatz ?? 20));
const taxAmount = totalCost * (steuersatz / 100);
const totalBrutto = totalCost + taxAmount;
const totalReceived = positionen.length > 0
? positionen.reduce((sum, p) => sum + (parseFloat(String(p.erhalten_menge)) || 0), 0)
: 0;
@@ -328,13 +349,13 @@ export default function BestellungDetail() {
</Grid>
{/* ── Status Action ── */}
{canEdit && validTransitions.length > 0 && (
<Box sx={{ mb: 3 }}>
{canCreate && (
<Box sx={{ mb: 3, display: 'flex', gap: 1, alignItems: 'center' }}>
{validTransitions.length === 1 ? (
<Button variant="contained" onClick={() => setStatusConfirmTarget(validTransitions[0])}>
<Button variant="contained" onClick={() => { setStatusForce(false); setStatusConfirmTarget(validTransitions[0]); }}>
Status ändern: {BESTELLUNG_STATUS_LABELS[validTransitions[0]]}
</Button>
) : (
) : validTransitions.length > 1 ? (
<>
<Button
variant="contained"
@@ -353,6 +374,41 @@ export default function BestellungDetail() {
key={s}
onClick={() => {
setStatusMenuAnchor(null);
setStatusForce(false);
setStatusConfirmTarget(s);
}}
>
{BESTELLUNG_STATUS_LABELS[s]}
</MenuItem>
))}
</Menu>
</>
) : null}
{/* Manual override menu */}
{overrideStatuses.length > 0 && (
<>
<IconButton
size="small"
title="Status manuell setzen"
onClick={(e) => setOverrideMenuAnchor(e.currentTarget)}
>
<MoreVert />
</IconButton>
<Menu
anchorEl={overrideMenuAnchor}
open={Boolean(overrideMenuAnchor)}
onClose={() => setOverrideMenuAnchor(null)}
>
<MenuItem disabled dense>
<Typography variant="caption" color="text.secondary">Status manuell setzen</Typography>
</MenuItem>
{overrideStatuses.map((s) => (
<MenuItem
key={s}
onClick={() => {
setOverrideMenuAnchor(null);
setStatusForce(true);
setStatusConfirmTarget(s);
}}
>
@@ -396,7 +452,7 @@ export default function BestellungDetail() {
<TableCell align="right">Einzelpreis</TableCell>
<TableCell align="right">Gesamt</TableCell>
<TableCell align="right">Erhalten</TableCell>
{canEdit && <TableCell align="right">Aktionen</TableCell>}
{(canCreate || canDelete) && <TableCell align="right">Aktionen</TableCell>}
</TableRow>
</TableHead>
<TableBody>
@@ -434,7 +490,7 @@ export default function BestellungDetail() {
<TableCell align="right">{formatCurrency(p.einzelpreis)}</TableCell>
<TableCell align="right">{formatCurrency((p.einzelpreis ?? 0) * p.menge)}</TableCell>
<TableCell align="right">
{canEdit ? (
{canCreate ? (
<TextField
size="small"
type="number"
@@ -447,10 +503,10 @@ export default function BestellungDetail() {
p.erhalten_menge
)}
</TableCell>
{canEdit && (
{(canCreate || canDelete) && (
<TableCell align="right">
<IconButton size="small" onClick={() => startEditItem(p)}><EditIcon fontSize="small" /></IconButton>
<IconButton size="small" color="error" onClick={() => setDeleteItemTarget(p.id)}><DeleteIcon fontSize="small" /></IconButton>
{canCreate && <IconButton size="small" onClick={() => startEditItem(p)}><EditIcon fontSize="small" /></IconButton>}
{canDelete && <IconButton size="small" color="error" onClick={() => setDeleteItemTarget(p.id)}><DeleteIcon fontSize="small" /></IconButton>}
</TableCell>
)}
</TableRow>
@@ -458,7 +514,7 @@ export default function BestellungDetail() {
)}
{/* ── Add Item Row ── */}
{canEdit && (
{canCreate && (
<TableRow>
<TableCell>
<TextField size="small" placeholder="Bezeichnung" value={newItem.bezeichnung} onChange={(e) => setNewItem((f) => ({ ...f, bezeichnung: e.target.value }))} />
@@ -485,13 +541,47 @@ export default function BestellungDetail() {
</TableRow>
)}
{/* ── Totals Row ── */}
{/* ── Totals Rows (Netto / MwSt / Brutto) ── */}
{positionen.length > 0 && (
<TableRow>
<TableCell colSpan={5} align="right"><strong>Gesamtsumme</strong></TableCell>
<TableCell align="right"><strong>{formatCurrency(totalCost)}</strong></TableCell>
<TableCell colSpan={canEdit ? 2 : 1} />
</TableRow>
<>
<TableRow>
<TableCell colSpan={5} align="right">Netto</TableCell>
<TableCell align="right">{formatCurrency(totalCost)}</TableCell>
<TableCell colSpan={(canCreate || canDelete) ? 2 : 1} />
</TableRow>
<TableRow>
<TableCell colSpan={5} align="right">
<Box sx={{ display: 'inline-flex', alignItems: 'center', gap: 0.5 }}>
MwSt.
{canCreate ? (
<TextField
size="small"
type="number"
sx={{ width: 70 }}
value={steuersatz}
inputProps={{ min: 0, max: 100, step: 0.5 }}
onChange={(e) => {
const val = parseFloat(e.target.value);
if (!isNaN(val) && val >= 0 && val <= 100) {
updateOrder.mutate({ steuersatz: val });
}
}}
/>
) : (
<span>{steuersatz}</span>
)}
%
</Box>
</TableCell>
<TableCell align="right">{formatCurrency(taxAmount)}</TableCell>
<TableCell colSpan={(canCreate || canDelete) ? 2 : 1} />
</TableRow>
<TableRow>
<TableCell colSpan={5} align="right"><strong>Brutto</strong></TableCell>
<TableCell align="right"><strong>{formatCurrency(totalBrutto)}</strong></TableCell>
<TableCell colSpan={(canCreate || canDelete) ? 2 : 1} />
</TableRow>
</>
)}
</TableBody>
</Table>
@@ -505,7 +595,7 @@ export default function BestellungDetail() {
<Box sx={{ display: 'flex', alignItems: 'center', mb: 2 }}>
<AttachFile sx={{ mr: 1 }} />
<Typography variant="h6" sx={{ flexGrow: 1 }}>Dateien</Typography>
{canEdit && (
{canCreate && (
<>
<input ref={fileInputRef} type="file" hidden onChange={handleFileSelect} />
<Button size="small" startIcon={<UploadIcon />} onClick={() => fileInputRef.current?.click()} disabled={uploadFile.isPending}>
@@ -534,7 +624,7 @@ export default function BestellungDetail() {
<Typography variant="caption" color="text.secondary">
{formatFileSize(d.dateigroesse)} &middot; {formatDate(d.hochgeladen_am)}
</Typography>
{canEdit && (
{canDelete && (
<Box sx={{ mt: 0.5 }}>
<IconButton size="small" color="error" onClick={() => setDeleteFileTarget(d.id)}>
<DeleteIcon fontSize="small" />
@@ -556,7 +646,7 @@ export default function BestellungDetail() {
<Box sx={{ display: 'flex', alignItems: 'center', mb: 2 }}>
<Alarm sx={{ mr: 1 }} />
<Typography variant="h6" sx={{ flexGrow: 1 }}>Erinnerungen</Typography>
{canEdit && (
{canManageReminders && (
<Button size="small" startIcon={<AddIcon />} onClick={() => setReminderFormOpen(true)}>
Neue Erinnerung
</Button>
@@ -570,7 +660,7 @@ export default function BestellungDetail() {
<Box key={r.id} sx={{ display: 'flex', alignItems: 'center', gap: 1, opacity: r.erledigt ? 0.5 : 1 }}>
<Checkbox
checked={r.erledigt}
disabled={r.erledigt || !canEdit}
disabled={r.erledigt || !canManageReminders}
onChange={() => markReminderDone.mutate(r.id)}
size="small"
/>
@@ -582,7 +672,7 @@ export default function BestellungDetail() {
Fällig: {formatDate(r.faellig_am)}
</Typography>
</Box>
{canEdit && (
{canManageReminders && (
<IconButton size="small" color="error" onClick={() => setDeleteReminderTarget(r.id)}>
<DeleteIcon fontSize="small" />
</IconButton>
@@ -670,17 +760,22 @@ export default function BestellungDetail() {
{/* ══════════════════════════════════════════════════════════════════════ */}
{/* Status Confirmation */}
<Dialog open={statusConfirmTarget != null} onClose={() => setStatusConfirmTarget(null)}>
<DialogTitle>Status ändern</DialogTitle>
<Dialog open={statusConfirmTarget != null} onClose={() => { setStatusConfirmTarget(null); setStatusForce(false); }}>
<DialogTitle>Status ändern{statusForce ? ' (manuell)' : ''}</DialogTitle>
<DialogContent>
<Typography>
Status von <strong>{BESTELLUNG_STATUS_LABELS[bestellung.status]}</strong> auf{' '}
<strong>{statusConfirmTarget ? BESTELLUNG_STATUS_LABELS[statusConfirmTarget] : ''}</strong> ändern?
</Typography>
{statusForce && (
<Typography variant="body2" color="warning.main" sx={{ mt: 1 }}>
Dies ist ein manueller Statusübergang außerhalb des normalen Ablaufs.
</Typography>
)}
</DialogContent>
<DialogActions>
<Button onClick={() => setStatusConfirmTarget(null)}>Abbrechen</Button>
<Button variant="contained" onClick={() => statusConfirmTarget && updateStatus.mutate(statusConfirmTarget)} disabled={updateStatus.isPending}>
<Button onClick={() => { setStatusConfirmTarget(null); setStatusForce(false); }}>Abbrechen</Button>
<Button variant="contained" onClick={() => statusConfirmTarget && updateStatus.mutate({ status: statusConfirmTarget, force: statusForce || undefined })} disabled={updateStatus.isPending}>
Bestätigen
</Button>
</DialogActions>

View File

@@ -58,8 +58,8 @@ export const bestellungApi = {
deleteOrder: async (id: number): Promise<void> => {
await api.delete(`/api/bestellungen/${id}`);
},
updateStatus: async (id: number, status: string): Promise<Bestellung> => {
const r = await api.patch(`/api/bestellungen/${id}/status`, { status });
updateStatus: async (id: number, status: string, force?: boolean): Promise<Bestellung> => {
const r = await api.patch(`/api/bestellungen/${id}/status`, { status, force: force || undefined });
return r.data.data;
},

View File

@@ -57,6 +57,7 @@ export interface Bestellung {
besteller_name?: string;
status: BestellungStatus;
budget?: number;
steuersatz?: number;
notizen?: string;
erstellt_von?: string;
erstellt_am: string;
@@ -74,6 +75,7 @@ export interface BestellungFormData {
besteller_id?: string;
status?: BestellungStatus;
budget?: number;
steuersatz?: number;
notizen?: string;
positionen?: BestellpositionFormData[];
}