refactor external orders

This commit is contained in:
Matthias Hochmeister
2026-03-25 14:55:25 +01:00
parent 5add6590e5
commit 0bb2feaba2
5 changed files with 305 additions and 229 deletions

View File

@@ -27,21 +27,20 @@ import {
Accordion,
AccordionSummary,
AccordionDetails,
Autocomplete,
} from '@mui/material';
import {
ArrowBack,
Add as AddIcon,
Delete as DeleteIcon,
Edit as EditIcon,
Check as CheckIcon,
Close as CloseIcon,
AttachFile,
Alarm,
History,
Upload as UploadIcon,
ArrowDropDown,
MoreVert,
ExpandMore as ExpandMoreIcon,
Save as SaveIcon,
} from '@mui/icons-material';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { useParams, useNavigate } from 'react-router-dom';
@@ -50,7 +49,7 @@ import { useNotification } from '../contexts/NotificationContext';
import { usePermissionContext } from '../contexts/PermissionContext';
import { bestellungApi } from '../services/bestellung';
import { BESTELLUNG_STATUS_LABELS, BESTELLUNG_STATUS_COLORS } from '../types/bestellung.types';
import type { BestellungStatus, BestellpositionFormData, ErinnerungFormData, Bestellposition } from '../types/bestellung.types';
import type { BestellungStatus, BestellpositionFormData, ErinnerungFormData } from '../types/bestellung.types';
// ── Helpers ──
@@ -99,8 +98,6 @@ export default function BestellungDetail() {
// ── State ──
const [newItem, setNewItem] = useState<BestellpositionFormData>({ ...emptyItem });
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);
@@ -110,6 +107,24 @@ export default function BestellungDetail() {
const [editMode, setEditMode] = useState(false);
const [editOrderData, setEditOrderData] = useState<{
bezeichnung: string;
lieferant_id?: number;
besteller_id?: string;
notizen: string;
steuersatz: number;
}>({ bezeichnung: '', notizen: '', steuersatz: 20 });
const [editItemsData, setEditItemsData] = useState<Record<number, {
bezeichnung: string;
artikelnummer: string;
menge: number;
einheit: string;
einzelpreis?: number;
}>>({});
const [isSavingAll, setIsSavingAll] = useState(false);
const [reminderForm, setReminderForm] = useState<ErinnerungFormData>({ faellig_am: '', nachricht: '' });
const [reminderFormOpen, setReminderFormOpen] = useState(false);
const [deleteReminderTarget, setDeleteReminderTarget] = useState<number | null>(null);
@@ -127,6 +142,18 @@ export default function BestellungDetail() {
const erinnerungen = data?.erinnerungen ?? [];
const historie = data?.historie ?? [];
const { data: vendors = [] } = useQuery({
queryKey: ['lieferanten'],
queryFn: bestellungApi.getVendors,
enabled: editMode,
});
const { data: orderUsers = [] } = useQuery({
queryKey: ['bestellungen', 'order-users'],
queryFn: bestellungApi.getOrderUsers,
enabled: editMode,
});
const canCreate = hasPermission('bestellungen:create');
const canDelete = hasPermission('bestellungen:delete');
const canManageReminders = hasPermission('bestellungen:manage_reminders');
@@ -150,14 +177,6 @@ export default function BestellungDetail() {
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: () => {
@@ -168,17 +187,6 @@ export default function BestellungDetail() {
onError: () => showError('Fehler beim Hinzufügen der Position'),
});
const updateItem = useMutation({
mutationFn: ({ itemId, data }: { itemId: number; data: Partial<BestellpositionFormData> }) =>
bestellungApi.updateLineItem(itemId, data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['bestellung', orderId] });
setEditingItemId(null);
showSuccess('Position aktualisiert');
},
onError: () => showError('Fehler beim Aktualisieren der Position'),
});
const deleteItem = useMutation({
mutationFn: (itemId: number) => bestellungApi.deleteLineItem(itemId),
onSuccess: () => {
@@ -248,20 +256,58 @@ export default function BestellungDetail() {
// ── Handlers ──
function startEditItem(item: Bestellposition) {
setEditingItemId(item.id);
setEditingItemData({
bezeichnung: item.bezeichnung,
artikelnummer: item.artikelnummer || '',
menge: item.menge,
einheit: item.einheit,
einzelpreis: item.einzelpreis,
function enterEditMode() {
if (!bestellung) return;
setEditOrderData({
bezeichnung: bestellung.bezeichnung,
lieferant_id: bestellung.lieferant_id,
besteller_id: bestellung.besteller_id || '',
notizen: bestellung.notizen || '',
steuersatz: parseFloat(String(bestellung.steuersatz ?? 20)),
});
setEditItemsData(
Object.fromEntries(positionen.map(p => [p.id, {
bezeichnung: p.bezeichnung,
artikelnummer: p.artikelnummer || '',
menge: parseFloat(String(p.menge)) || 1,
einheit: p.einheit,
einzelpreis: p.einzelpreis != null ? parseFloat(String(p.einzelpreis)) : undefined,
}]))
);
setEditMode(true);
}
function saveEditItem() {
if (editingItemId == null) return;
updateItem.mutate({ itemId: editingItemId, data: editingItemData });
function cancelEditMode() {
setEditMode(false);
setEditItemsData({});
}
async function handleSaveAll() {
if (!bestellung) return;
setIsSavingAll(true);
try {
await bestellungApi.updateOrder(orderId, {
bezeichnung: editOrderData.bezeichnung,
lieferant_id: editOrderData.lieferant_id,
besteller_id: editOrderData.besteller_id || undefined,
notizen: editOrderData.notizen,
steuersatz: editOrderData.steuersatz,
});
for (const item of positionen) {
const itemEdit = editItemsData[item.id];
if (itemEdit) {
await bestellungApi.updateLineItem(item.id, itemEdit);
}
}
await queryClient.invalidateQueries({ queryKey: ['bestellung', orderId] });
showSuccess('Änderungen gespeichert');
setEditMode(false);
setEditItemsData({});
} catch {
showError('Fehler beim Speichern');
} finally {
setIsSavingAll(false);
}
}
function handleAddItem() {
@@ -276,8 +322,15 @@ 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 steuersatz = editMode ? editOrderData.steuersatz : parseFloat(String(bestellung?.steuersatz ?? 20));
const totalCost = editMode
? positionen.reduce((sum, p) => {
const d = editItemsData[p.id];
const einzelpreis = d?.einzelpreis != null ? d.einzelpreis : (parseFloat(String(p.einzelpreis)) || 0);
const menge = d?.menge != null ? d.menge : (parseFloat(String(p.menge)) || 0);
return sum + einzelpreis * menge;
}, 0)
: positionen.reduce((sum, p) => sum + (parseFloat(String(p.einzelpreis)) || 0) * (parseFloat(String(p.menge)) || 0), 0);
const taxAmount = totalCost * (steuersatz / 100);
const totalBrutto = totalCost + taxAmount;
const totalReceived = positionen.length > 0
@@ -321,6 +374,17 @@ export default function BestellungDetail() {
<ArrowBack />
</IconButton>
<Typography variant="h4" sx={{ flexGrow: 1 }}>{bestellung.bezeichnung}</Typography>
{canCreate && !editMode && (
<Button startIcon={<EditIcon />} onClick={enterEditMode}>Bearbeiten</Button>
)}
{editMode && (
<>
<Button variant="contained" startIcon={<SaveIcon />} onClick={handleSaveAll} disabled={isSavingAll}>
Speichern
</Button>
<Button onClick={cancelEditMode} disabled={isSavingAll}>Abbrechen</Button>
</>
)}
<Chip
label={BESTELLUNG_STATUS_LABELS[bestellung.status]}
color={BESTELLUNG_STATUS_COLORS[bestellung.status]}
@@ -328,26 +392,61 @@ export default function BestellungDetail() {
</Box>
{/* ── Info Cards ── */}
<Grid container spacing={2} sx={{ mb: 3 }}>
<Grid item xs={12} sm={6} md={4}>
<Card variant="outlined"><CardContent>
<Typography variant="caption" color="text.secondary">Lieferant</Typography>
<Typography>{bestellung.lieferant_name || ''}</Typography>
</CardContent></Card>
{editMode ? (
<Paper sx={{ p: 2, mb: 3 }}>
<Grid container spacing={2}>
<Grid item xs={12} sm={6}>
<TextField label="Bezeichnung" required fullWidth size="small"
value={editOrderData.bezeichnung}
onChange={(e) => setEditOrderData(d => ({ ...d, bezeichnung: e.target.value }))} />
</Grid>
<Grid item xs={12} sm={6}>
<Autocomplete
options={vendors}
getOptionLabel={(o) => o.name}
value={vendors.find(v => v.id === editOrderData.lieferant_id) ?? null}
onChange={(_, v) => setEditOrderData(d => ({ ...d, lieferant_id: v?.id }))}
renderInput={(params) => <TextField {...params} label="Lieferant" size="small" />}
/>
</Grid>
<Grid item xs={12} sm={6}>
<Autocomplete
options={orderUsers}
getOptionLabel={(o) => o.name}
value={orderUsers.find(u => u.id === editOrderData.besteller_id) ?? null}
onChange={(_, v) => setEditOrderData(d => ({ ...d, besteller_id: v?.id || '' }))}
renderInput={(params) => <TextField {...params} label="Besteller" size="small" />}
/>
</Grid>
<Grid item xs={12}>
<TextField label="Notizen" fullWidth multiline rows={3} size="small"
value={editOrderData.notizen}
onChange={(e) => setEditOrderData(d => ({ ...d, notizen: e.target.value }))} />
</Grid>
</Grid>
</Paper>
) : (
<Grid container spacing={2} sx={{ mb: 3 }}>
<Grid item xs={12} sm={6} md={4}>
<Card variant="outlined"><CardContent>
<Typography variant="caption" color="text.secondary">Lieferant</Typography>
<Typography>{bestellung.lieferant_name || ''}</Typography>
</CardContent></Card>
</Grid>
<Grid item xs={12} sm={6} md={4}>
<Card variant="outlined"><CardContent>
<Typography variant="caption" color="text.secondary">Besteller</Typography>
<Typography>{bestellung.besteller_name || ''}</Typography>
</CardContent></Card>
</Grid>
<Grid item xs={12} sm={6} md={4}>
<Card variant="outlined"><CardContent>
<Typography variant="caption" color="text.secondary">Erstellt am</Typography>
<Typography>{formatDate(bestellung.erstellt_am)}</Typography>
</CardContent></Card>
</Grid>
</Grid>
<Grid item xs={12} sm={6} md={4}>
<Card variant="outlined"><CardContent>
<Typography variant="caption" color="text.secondary">Besteller</Typography>
<Typography>{bestellung.besteller_name || ''}</Typography>
</CardContent></Card>
</Grid>
<Grid item xs={12} sm={6} md={4}>
<Card variant="outlined"><CardContent>
<Typography variant="caption" color="text.secondary">Erstellt am</Typography>
<Typography>{formatDate(bestellung.erstellt_am)}</Typography>
</CardContent></Card>
</Grid>
</Grid>
)}
{/* ── Status Action ── */}
{canManageOrders && (
@@ -387,23 +486,21 @@ export default function BestellungDetail() {
) : null}
{/* Manual override menu */}
{overrideStatuses.length > 0 && (
{overrideStatuses.length > 0 && canManageOrders && (
<>
<IconButton
<Button
variant="outlined"
size="small"
title="Status manuell setzen"
endIcon={<ArrowDropDown />}
onClick={(e) => setOverrideMenuAnchor(e.currentTarget)}
>
<MoreVert />
</IconButton>
Status manuell setzen
</Button>
<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}
@@ -443,16 +540,6 @@ export default function BestellungDetail() {
<Paper sx={{ p: 2, mb: 3 }}>
<Box sx={{ display: 'flex', alignItems: 'center', mb: 2 }}>
<Typography variant="h6" sx={{ flexGrow: 1 }}>Positionen</Typography>
{canCreate && (
<Button
size="small"
variant={editMode ? 'outlined' : 'text'}
startIcon={editMode ? <CloseIcon /> : <EditIcon />}
onClick={() => setEditMode((m) => !m)}
>
{editMode ? 'Abbrechen' : 'Bearbeiten'}
</Button>
)}
</Box>
<TableContainer>
<Table size="small">
@@ -465,34 +552,55 @@ export default function BestellungDetail() {
<TableCell align="right">Einzelpreis</TableCell>
<TableCell align="right">Gesamt</TableCell>
<TableCell align="right">Erhalten</TableCell>
{(canCreate || canDelete) && <TableCell align="right">Aktionen</TableCell>}
{(editMode && (canCreate || canDelete)) && <TableCell align="right">Aktionen</TableCell>}
</TableRow>
</TableHead>
<TableBody>
{positionen.map((p) =>
editMode && editingItemId === p.id ? (
editMode ? (
<TableRow key={p.id}>
<TableCell>
<TextField size="small" value={editingItemData.bezeichnung || ''} onChange={(e) => setEditingItemData((d) => ({ ...d, bezeichnung: e.target.value }))} />
<TextField size="small" value={editItemsData[p.id]?.bezeichnung ?? p.bezeichnung}
onChange={(e) => setEditItemsData(d => ({ ...d, [p.id]: { ...(d[p.id] || { bezeichnung: p.bezeichnung, artikelnummer: p.artikelnummer || '', menge: parseFloat(String(p.menge)) || 1, einheit: p.einheit, einzelpreis: p.einzelpreis != null ? parseFloat(String(p.einzelpreis)) : undefined }), bezeichnung: e.target.value } }))} />
</TableCell>
<TableCell>
<TextField size="small" value={editingItemData.artikelnummer || ''} onChange={(e) => setEditingItemData((d) => ({ ...d, artikelnummer: e.target.value }))} />
<TextField size="small" value={editItemsData[p.id]?.artikelnummer ?? p.artikelnummer ?? ''}
onChange={(e) => setEditItemsData(d => ({ ...d, [p.id]: { ...(d[p.id] || { bezeichnung: p.bezeichnung, artikelnummer: p.artikelnummer || '', menge: parseFloat(String(p.menge)) || 1, einheit: p.einheit, einzelpreis: p.einzelpreis != null ? parseFloat(String(p.einzelpreis)) : undefined }), artikelnummer: e.target.value } }))} />
</TableCell>
<TableCell>
<TextField size="small" type="number" sx={{ width: 80 }} value={editingItemData.menge ?? ''} onChange={(e) => setEditingItemData((d) => ({ ...d, menge: Number(e.target.value) }))} />
<TextField size="small" type="number" sx={{ width: 80 }}
value={editItemsData[p.id]?.menge ?? p.menge}
onChange={(e) => setEditItemsData(d => ({ ...d, [p.id]: { ...(d[p.id] || { bezeichnung: p.bezeichnung, artikelnummer: p.artikelnummer || '', menge: parseFloat(String(p.menge)) || 1, einheit: p.einheit, einzelpreis: p.einzelpreis != null ? parseFloat(String(p.einzelpreis)) : undefined }), menge: Number(e.target.value) } }))} />
</TableCell>
<TableCell>
<TextField size="small" sx={{ width: 80 }} value={editingItemData.einheit || ''} onChange={(e) => setEditingItemData((d) => ({ ...d, einheit: e.target.value }))} />
<TextField size="small" sx={{ width: 80 }}
value={editItemsData[p.id]?.einheit ?? p.einheit}
onChange={(e) => setEditItemsData(d => ({ ...d, [p.id]: { ...(d[p.id] || { bezeichnung: p.bezeichnung, artikelnummer: p.artikelnummer || '', menge: parseFloat(String(p.menge)) || 1, einheit: p.einheit, einzelpreis: p.einzelpreis != null ? parseFloat(String(p.einzelpreis)) : undefined }), einheit: e.target.value } }))} />
</TableCell>
<TableCell>
<TextField size="small" type="number" sx={{ width: 100 }} value={editingItemData.einzelpreis ?? ''} onChange={(e) => setEditingItemData((d) => ({ ...d, einzelpreis: e.target.value ? Number(e.target.value) : undefined }))} />
<TextField size="small" type="number" sx={{ width: 100 }}
value={editItemsData[p.id]?.einzelpreis ?? p.einzelpreis ?? ''}
onChange={(e) => setEditItemsData(d => ({ ...d, [p.id]: { ...(d[p.id] || { bezeichnung: p.bezeichnung, artikelnummer: p.artikelnummer || '', menge: parseFloat(String(p.menge)) || 1, einheit: p.einheit, einzelpreis: p.einzelpreis != null ? parseFloat(String(p.einzelpreis)) : undefined }), einzelpreis: e.target.value ? Number(e.target.value) : undefined } }))} />
</TableCell>
<TableCell align="right">{formatCurrency((editingItemData.einzelpreis ?? 0) * (editingItemData.menge ?? 0))}</TableCell>
<TableCell align="right">{p.erhalten_menge}</TableCell>
<TableCell align="right">
<IconButton size="small" color="primary" onClick={saveEditItem}><CheckIcon fontSize="small" /></IconButton>
<IconButton size="small" onClick={() => setEditingItemId(null)}><CloseIcon fontSize="small" /></IconButton>
{formatCurrency((editItemsData[p.id]?.einzelpreis ?? parseFloat(String(p.einzelpreis ?? 0))) * (editItemsData[p.id]?.menge ?? parseFloat(String(p.menge))))}
</TableCell>
<TableCell align="right">
{canManageOrders ? (
<TextField size="small" type="number" sx={{ width: 70 }} value={p.erhalten_menge}
inputProps={{ min: 0, max: p.menge }}
onChange={(e) => updateReceived.mutate({ itemId: p.id, menge: Number(e.target.value) })} />
) : p.erhalten_menge}
</TableCell>
{(canCreate || canDelete) && (
<TableCell align="right">
{canDelete && (
<IconButton size="small" color="error" onClick={() => setDeleteItemTarget(p.id)}>
<DeleteIcon fontSize="small" />
</IconButton>
)}
</TableCell>
)}
</TableRow>
) : (
<TableRow key={p.id}>
@@ -516,12 +624,6 @@ export default function BestellungDetail() {
p.erhalten_menge
)}
</TableCell>
{(canCreate || canDelete) && (
<TableCell align="right">
{editMode && canCreate && <IconButton size="small" onClick={() => startEditItem(p)}><EditIcon fontSize="small" /></IconButton>}
{editMode && canDelete && <IconButton size="small" color="error" onClick={() => setDeleteItemTarget(p.id)}><DeleteIcon fontSize="small" /></IconButton>}
</TableCell>
)}
</TableRow>
),
)}
@@ -560,7 +662,7 @@ export default function BestellungDetail() {
<TableRow>
<TableCell colSpan={5} align="right">Netto</TableCell>
<TableCell align="right">{formatCurrency(totalCost)}</TableCell>
<TableCell colSpan={(canCreate || canDelete) ? 2 : 1} />
<TableCell colSpan={(editMode && (canCreate || canDelete)) ? 2 : 1} />
</TableRow>
<TableRow>
<TableCell colSpan={5} align="right">
@@ -571,12 +673,12 @@ export default function BestellungDetail() {
size="small"
type="number"
sx={{ width: 70 }}
value={steuersatz}
value={editOrderData.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 });
setEditOrderData(d => ({ ...d, steuersatz: val }));
}
}}
/>
@@ -587,12 +689,12 @@ export default function BestellungDetail() {
</Box>
</TableCell>
<TableCell align="right">{formatCurrency(taxAmount)}</TableCell>
<TableCell colSpan={(canCreate || canDelete) ? 2 : 1} />
<TableCell colSpan={(editMode && (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} />
<TableCell colSpan={(editMode && (canCreate || canDelete)) ? 2 : 1} />
</TableRow>
</>
)}
@@ -729,7 +831,7 @@ export default function BestellungDetail() {
</Paper>
{/* ── Notizen ── */}
{bestellung.notizen && (
{!editMode && bestellung.notizen && (
<Paper sx={{ p: 2, mb: 3 }}>
<Typography variant="h6" sx={{ mb: 1 }}>Notizen</Typography>
<Typography variant="body2" sx={{ whiteSpace: 'pre-wrap' }}>{bestellung.notizen}</Typography>