rework external order tracking system
This commit is contained in:
@@ -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)} · {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>
|
||||
|
||||
Reference in New Issue
Block a user