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

@@ -222,7 +222,7 @@ async function createOrder(data: { bezeichnung: string; lieferant_id?: number; b
} }
} }
async function updateOrder(id: number, data: { bezeichnung?: string; lieferant_id?: number; notizen?: string; budget?: number; status?: string; steuersatz?: number }, userId: string) { async function updateOrder(id: number, data: { bezeichnung?: string; lieferant_id?: number; besteller_id?: string | null; notizen?: string; budget?: number; status?: string; steuersatz?: number }, userId: string) {
try { try {
// Check current order for status change detection // Check current order for status change detection
const current = await pool.query(`SELECT * FROM bestellungen WHERE id = $1`, [id]); const current = await pool.query(`SELECT * FROM bestellungen WHERE id = $1`, [id]);
@@ -247,22 +247,24 @@ async function updateOrder(id: number, data: { bezeichnung?: string; lieferant_i
`UPDATE bestellungen `UPDATE bestellungen
SET bezeichnung = COALESCE($1, bezeichnung), SET bezeichnung = COALESCE($1, bezeichnung),
lieferant_id = COALESCE($2, lieferant_id), lieferant_id = COALESCE($2, lieferant_id),
notizen = COALESCE($3, notizen), besteller_id = CASE WHEN $3::text IS NOT NULL AND $3::text != '' THEN $3::uuid ELSE besteller_id END,
budget = COALESCE($4, budget), notizen = COALESCE($4, notizen),
status = COALESCE($5, status), budget = COALESCE($5, budget),
bestellt_am = $6, status = COALESCE($6, status),
abgeschlossen_am = $7, bestellt_am = $7,
steuersatz = COALESCE($8, steuersatz), abgeschlossen_am = $8,
steuersatz = COALESCE($9, steuersatz),
aktualisiert_am = NOW() aktualisiert_am = NOW()
WHERE id = $9 WHERE id = $10
RETURNING *`, RETURNING *`,
[data.bezeichnung, data.lieferant_id, data.notizen, data.budget, data.status, bestellt_am, abgeschlossen_am, data.steuersatz, id] [data.bezeichnung, data.lieferant_id, data.besteller_id ?? null, data.notizen, data.budget, data.status, bestellt_am, abgeschlossen_am, data.steuersatz, id]
); );
if (result.rows.length === 0) return null; if (result.rows.length === 0) return null;
const changes: string[] = []; const changes: string[] = [];
if (data.bezeichnung) changes.push(`Bezeichnung geändert`); if (data.bezeichnung) changes.push(`Bezeichnung geändert`);
if (data.lieferant_id) changes.push(`Lieferant geändert`); if (data.lieferant_id) changes.push(`Lieferant geändert`);
if (data.besteller_id) changes.push('Besteller geändert');
if (data.status && data.status !== oldStatus) changes.push(`Status: ${oldStatus}${data.status}`); if (data.status && data.status !== oldStatus) changes.push(`Status: ${oldStatus}${data.status}`);
if (data.budget) changes.push(`Budget geändert`); if (data.budget) changes.push(`Budget geändert`);
if (data.steuersatz != null) changes.push(`Steuersatz: ${data.steuersatz}%`); if (data.steuersatz != null) changes.push(`Steuersatz: ${data.steuersatz}%`);

View File

@@ -27,21 +27,20 @@ import {
Accordion, Accordion,
AccordionSummary, AccordionSummary,
AccordionDetails, AccordionDetails,
Autocomplete,
} from '@mui/material'; } from '@mui/material';
import { import {
ArrowBack, ArrowBack,
Add as AddIcon, Add as AddIcon,
Delete as DeleteIcon, Delete as DeleteIcon,
Edit as EditIcon, Edit as EditIcon,
Check as CheckIcon,
Close as CloseIcon,
AttachFile, AttachFile,
Alarm, Alarm,
History, History,
Upload as UploadIcon, Upload as UploadIcon,
ArrowDropDown, ArrowDropDown,
MoreVert,
ExpandMore as ExpandMoreIcon, ExpandMore as ExpandMoreIcon,
Save as SaveIcon,
} from '@mui/icons-material'; } from '@mui/icons-material';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { useParams, useNavigate } from 'react-router-dom'; import { useParams, useNavigate } from 'react-router-dom';
@@ -50,7 +49,7 @@ import { useNotification } from '../contexts/NotificationContext';
import { usePermissionContext } from '../contexts/PermissionContext'; import { usePermissionContext } from '../contexts/PermissionContext';
import { bestellungApi } from '../services/bestellung'; import { bestellungApi } from '../services/bestellung';
import { BESTELLUNG_STATUS_LABELS, BESTELLUNG_STATUS_COLORS } from '../types/bestellung.types'; 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 ── // ── Helpers ──
@@ -99,8 +98,6 @@ export default function BestellungDetail() {
// ── State ── // ── State ──
const [newItem, setNewItem] = useState<BestellpositionFormData>({ ...emptyItem }); 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 [statusConfirmTarget, setStatusConfirmTarget] = useState<BestellungStatus | null>(null);
const [statusForce, setStatusForce] = useState(false); const [statusForce, setStatusForce] = useState(false);
const [statusMenuAnchor, setStatusMenuAnchor] = useState<null | HTMLElement>(null); const [statusMenuAnchor, setStatusMenuAnchor] = useState<null | HTMLElement>(null);
@@ -110,6 +107,24 @@ export default function BestellungDetail() {
const [editMode, setEditMode] = useState(false); 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 [reminderForm, setReminderForm] = useState<ErinnerungFormData>({ faellig_am: '', nachricht: '' });
const [reminderFormOpen, setReminderFormOpen] = useState(false); const [reminderFormOpen, setReminderFormOpen] = useState(false);
const [deleteReminderTarget, setDeleteReminderTarget] = useState<number | null>(null); const [deleteReminderTarget, setDeleteReminderTarget] = useState<number | null>(null);
@@ -127,6 +142,18 @@ export default function BestellungDetail() {
const erinnerungen = data?.erinnerungen ?? []; const erinnerungen = data?.erinnerungen ?? [];
const historie = data?.historie ?? []; 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 canCreate = hasPermission('bestellungen:create');
const canDelete = hasPermission('bestellungen:delete'); const canDelete = hasPermission('bestellungen:delete');
const canManageReminders = hasPermission('bestellungen:manage_reminders'); const canManageReminders = hasPermission('bestellungen:manage_reminders');
@@ -150,14 +177,6 @@ export default function BestellungDetail() {
onError: () => showError('Fehler beim Aktualisieren des Status'), 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({ const addItem = useMutation({
mutationFn: (data: BestellpositionFormData) => bestellungApi.addLineItem(orderId, data), mutationFn: (data: BestellpositionFormData) => bestellungApi.addLineItem(orderId, data),
onSuccess: () => { onSuccess: () => {
@@ -168,17 +187,6 @@ export default function BestellungDetail() {
onError: () => showError('Fehler beim Hinzufügen der Position'), 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({ const deleteItem = useMutation({
mutationFn: (itemId: number) => bestellungApi.deleteLineItem(itemId), mutationFn: (itemId: number) => bestellungApi.deleteLineItem(itemId),
onSuccess: () => { onSuccess: () => {
@@ -248,20 +256,58 @@ export default function BestellungDetail() {
// ── Handlers ── // ── Handlers ──
function startEditItem(item: Bestellposition) { function enterEditMode() {
setEditingItemId(item.id); if (!bestellung) return;
setEditingItemData({ setEditOrderData({
bezeichnung: item.bezeichnung, bezeichnung: bestellung.bezeichnung,
artikelnummer: item.artikelnummer || '', lieferant_id: bestellung.lieferant_id,
menge: item.menge, besteller_id: bestellung.besteller_id || '',
einheit: item.einheit, notizen: bestellung.notizen || '',
einzelpreis: item.einzelpreis, 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() { function cancelEditMode() {
if (editingItemId == null) return; setEditMode(false);
updateItem.mutate({ itemId: editingItemId, data: editingItemData }); 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() { function handleAddItem() {
@@ -276,8 +322,15 @@ export default function BestellungDetail() {
} }
// Compute totals (NUMERIC columns come as strings from PostgreSQL — parse to float) // 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 = editMode ? editOrderData.steuersatz : parseFloat(String(bestellung?.steuersatz ?? 20));
const 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 taxAmount = totalCost * (steuersatz / 100);
const totalBrutto = totalCost + taxAmount; const totalBrutto = totalCost + taxAmount;
const totalReceived = positionen.length > 0 const totalReceived = positionen.length > 0
@@ -321,6 +374,17 @@ export default function BestellungDetail() {
<ArrowBack /> <ArrowBack />
</IconButton> </IconButton>
<Typography variant="h4" sx={{ flexGrow: 1 }}>{bestellung.bezeichnung}</Typography> <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 <Chip
label={BESTELLUNG_STATUS_LABELS[bestellung.status]} label={BESTELLUNG_STATUS_LABELS[bestellung.status]}
color={BESTELLUNG_STATUS_COLORS[bestellung.status]} color={BESTELLUNG_STATUS_COLORS[bestellung.status]}
@@ -328,6 +392,40 @@ export default function BestellungDetail() {
</Box> </Box>
{/* ── Info Cards ── */} {/* ── Info Cards ── */}
{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 container spacing={2} sx={{ mb: 3 }}>
<Grid item xs={12} sm={6} md={4}> <Grid item xs={12} sm={6} md={4}>
<Card variant="outlined"><CardContent> <Card variant="outlined"><CardContent>
@@ -348,6 +446,7 @@ export default function BestellungDetail() {
</CardContent></Card> </CardContent></Card>
</Grid> </Grid>
</Grid> </Grid>
)}
{/* ── Status Action ── */} {/* ── Status Action ── */}
{canManageOrders && ( {canManageOrders && (
@@ -387,23 +486,21 @@ export default function BestellungDetail() {
) : null} ) : null}
{/* Manual override menu */} {/* Manual override menu */}
{overrideStatuses.length > 0 && ( {overrideStatuses.length > 0 && canManageOrders && (
<> <>
<IconButton <Button
variant="outlined"
size="small" size="small"
title="Status manuell setzen" endIcon={<ArrowDropDown />}
onClick={(e) => setOverrideMenuAnchor(e.currentTarget)} onClick={(e) => setOverrideMenuAnchor(e.currentTarget)}
> >
<MoreVert /> Status manuell setzen
</IconButton> </Button>
<Menu <Menu
anchorEl={overrideMenuAnchor} anchorEl={overrideMenuAnchor}
open={Boolean(overrideMenuAnchor)} open={Boolean(overrideMenuAnchor)}
onClose={() => setOverrideMenuAnchor(null)} onClose={() => setOverrideMenuAnchor(null)}
> >
<MenuItem disabled dense>
<Typography variant="caption" color="text.secondary">Status manuell setzen</Typography>
</MenuItem>
{overrideStatuses.map((s) => ( {overrideStatuses.map((s) => (
<MenuItem <MenuItem
key={s} key={s}
@@ -443,16 +540,6 @@ export default function BestellungDetail() {
<Paper sx={{ p: 2, mb: 3 }}> <Paper sx={{ p: 2, mb: 3 }}>
<Box sx={{ display: 'flex', alignItems: 'center', mb: 2 }}> <Box sx={{ display: 'flex', alignItems: 'center', mb: 2 }}>
<Typography variant="h6" sx={{ flexGrow: 1 }}>Positionen</Typography> <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> </Box>
<TableContainer> <TableContainer>
<Table size="small"> <Table size="small">
@@ -465,34 +552,55 @@ export default function BestellungDetail() {
<TableCell align="right">Einzelpreis</TableCell> <TableCell align="right">Einzelpreis</TableCell>
<TableCell align="right">Gesamt</TableCell> <TableCell align="right">Gesamt</TableCell>
<TableCell align="right">Erhalten</TableCell> <TableCell align="right">Erhalten</TableCell>
{(canCreate || canDelete) && <TableCell align="right">Aktionen</TableCell>} {(editMode && (canCreate || canDelete)) && <TableCell align="right">Aktionen</TableCell>}
</TableRow> </TableRow>
</TableHead> </TableHead>
<TableBody> <TableBody>
{positionen.map((p) => {positionen.map((p) =>
editMode && editingItemId === p.id ? ( editMode ? (
<TableRow key={p.id}> <TableRow key={p.id}>
<TableCell> <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>
<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>
<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>
<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>
<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>
<TableCell align="right">{formatCurrency((editingItemData.einzelpreis ?? 0) * (editingItemData.menge ?? 0))}</TableCell>
<TableCell align="right">{p.erhalten_menge}</TableCell>
<TableCell align="right"> <TableCell align="right">
<IconButton size="small" color="primary" onClick={saveEditItem}><CheckIcon fontSize="small" /></IconButton> {formatCurrency((editItemsData[p.id]?.einzelpreis ?? parseFloat(String(p.einzelpreis ?? 0))) * (editItemsData[p.id]?.menge ?? parseFloat(String(p.menge))))}
<IconButton size="small" onClick={() => setEditingItemId(null)}><CloseIcon fontSize="small" /></IconButton>
</TableCell> </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>
) : ( ) : (
<TableRow key={p.id}> <TableRow key={p.id}>
@@ -516,12 +624,6 @@ export default function BestellungDetail() {
p.erhalten_menge p.erhalten_menge
)} )}
</TableCell> </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> </TableRow>
), ),
)} )}
@@ -560,7 +662,7 @@ export default function BestellungDetail() {
<TableRow> <TableRow>
<TableCell colSpan={5} align="right">Netto</TableCell> <TableCell colSpan={5} align="right">Netto</TableCell>
<TableCell align="right">{formatCurrency(totalCost)}</TableCell> <TableCell align="right">{formatCurrency(totalCost)}</TableCell>
<TableCell colSpan={(canCreate || canDelete) ? 2 : 1} /> <TableCell colSpan={(editMode && (canCreate || canDelete)) ? 2 : 1} />
</TableRow> </TableRow>
<TableRow> <TableRow>
<TableCell colSpan={5} align="right"> <TableCell colSpan={5} align="right">
@@ -571,12 +673,12 @@ export default function BestellungDetail() {
size="small" size="small"
type="number" type="number"
sx={{ width: 70 }} sx={{ width: 70 }}
value={steuersatz} value={editOrderData.steuersatz}
inputProps={{ min: 0, max: 100, step: 0.5 }} inputProps={{ min: 0, max: 100, step: 0.5 }}
onChange={(e) => { onChange={(e) => {
const val = parseFloat(e.target.value); const val = parseFloat(e.target.value);
if (!isNaN(val) && val >= 0 && val <= 100) { 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> </Box>
</TableCell> </TableCell>
<TableCell align="right">{formatCurrency(taxAmount)}</TableCell> <TableCell align="right">{formatCurrency(taxAmount)}</TableCell>
<TableCell colSpan={(canCreate || canDelete) ? 2 : 1} /> <TableCell colSpan={(editMode && (canCreate || canDelete)) ? 2 : 1} />
</TableRow> </TableRow>
<TableRow> <TableRow>
<TableCell colSpan={5} align="right"><strong>Brutto</strong></TableCell> <TableCell colSpan={5} align="right"><strong>Brutto</strong></TableCell>
<TableCell align="right"><strong>{formatCurrency(totalBrutto)}</strong></TableCell> <TableCell align="right"><strong>{formatCurrency(totalBrutto)}</strong></TableCell>
<TableCell colSpan={(canCreate || canDelete) ? 2 : 1} /> <TableCell colSpan={(editMode && (canCreate || canDelete)) ? 2 : 1} />
</TableRow> </TableRow>
</> </>
)} )}
@@ -729,7 +831,7 @@ export default function BestellungDetail() {
</Paper> </Paper>
{/* ── Notizen ── */} {/* ── Notizen ── */}
{bestellung.notizen && ( {!editMode && bestellung.notizen && (
<Paper sx={{ p: 2, mb: 3 }}> <Paper sx={{ p: 2, mb: 3 }}>
<Typography variant="h6" sx={{ mb: 1 }}>Notizen</Typography> <Typography variant="h6" sx={{ mb: 1 }}>Notizen</Typography>
<Typography variant="body2" sx={{ whiteSpace: 'pre-wrap' }}>{bestellung.notizen}</Typography> <Typography variant="body2" sx={{ whiteSpace: 'pre-wrap' }}>{bestellung.notizen}</Typography>

View File

@@ -1,6 +1,12 @@
import { useState, useEffect, useMemo } from 'react'; import { useState, useEffect, useMemo } from 'react';
import { import {
Accordion,
AccordionSummary,
AccordionDetails,
Box, Box,
Card,
CardContent,
Grid,
Tab, Tab,
Tabs, Tabs,
Typography, Typography,
@@ -16,12 +22,10 @@ import {
Checkbox, Checkbox,
FormControlLabel, FormControlLabel,
FormGroup, FormGroup,
Popover,
Badge,
LinearProgress, LinearProgress,
Divider, Divider,
} from '@mui/material'; } from '@mui/material';
import { Add as AddIcon, FilterList as FilterListIcon } from '@mui/icons-material'; import { Add as AddIcon, ExpandMore as ExpandMoreIcon, FilterList as FilterListIcon } from '@mui/icons-material';
import { useQuery } from '@tanstack/react-query'; import { useQuery } from '@tanstack/react-query';
import { useNavigate, useSearchParams } from 'react-router-dom'; import { useNavigate, useSearchParams } from 'react-router-dom';
import DashboardLayout from '../components/dashboard/DashboardLayout'; import DashboardLayout from '../components/dashboard/DashboardLayout';
@@ -78,6 +82,7 @@ export default function Bestellungen() {
const navigate = useNavigate(); const navigate = useNavigate();
const [searchParams] = useSearchParams(); const [searchParams] = useSearchParams();
const { hasPermission } = usePermissionContext(); const { hasPermission } = usePermissionContext();
const canManageVendors = hasPermission('bestellungen:manage_vendors');
// Tab from URL // Tab from URL
const [tab, setTab] = useState(() => { const [tab, setTab] = useState(() => {
@@ -90,10 +95,7 @@ export default function Bestellungen() {
}, [searchParams]); }, [searchParams]);
// ── Filter state ── // ── Filter state ──
const [filterAnchor, setFilterAnchor] = useState<HTMLElement | null>(null);
const [selectedVendors, setSelectedVendors] = useState<Set<string> | null>(null); // null = all const [selectedVendors, setSelectedVendors] = useState<Set<string> | null>(null); // null = all
const [selectedOrderers, setSelectedOrderers] = useState<Set<string> | null>(null);
const [selectedStatuses, setSelectedStatuses] = useState<Set<BestellungStatus>>( const [selectedStatuses, setSelectedStatuses] = useState<Set<BestellungStatus>>(
() => new Set(ALL_STATUSES.filter((s) => !DEFAULT_EXCLUDED_STATUSES.includes(s))) () => new Set(ALL_STATUSES.filter((s) => !DEFAULT_EXCLUDED_STATUSES.includes(s)))
); );
@@ -118,14 +120,6 @@ export default function Bestellungen() {
return Array.from(map.entries()).sort((a, b) => a[1].localeCompare(b[1])); return Array.from(map.entries()).sort((a, b) => a[1].localeCompare(b[1]));
}, [orders]); }, [orders]);
const uniqueOrderers = useMemo(() => {
const map = new Map<string, string>();
orders.forEach((o) => {
if (o.besteller_name) map.set(o.besteller_id ?? o.besteller_name, o.besteller_name);
});
return Array.from(map.entries()).sort((a, b) => a[1].localeCompare(b[1]));
}, [orders]);
// ── Filtered orders ── // ── Filtered orders ──
const filteredOrders = useMemo(() => { const filteredOrders = useMemo(() => {
return orders.filter((o) => { return orders.filter((o) => {
@@ -136,28 +130,21 @@ export default function Bestellungen() {
const key = String(o.lieferant_id ?? o.lieferant_name ?? ''); const key = String(o.lieferant_id ?? o.lieferant_name ?? '');
if (!selectedVendors.has(key)) return false; if (!selectedVendors.has(key)) return false;
} }
// Orderer filter (null = all selected)
if (selectedOrderers !== null) {
const key = o.besteller_id ?? o.besteller_name ?? '';
if (!selectedOrderers.has(key)) return false;
}
return true; return true;
}); });
}, [orders, selectedStatuses, selectedVendors, selectedOrderers]); }, [orders, selectedStatuses, selectedVendors]);
// ── Active filter count ── // ── Active filter count ──
const activeFilterCount = useMemo(() => { const activeFilterCount = useMemo(() => {
let count = 0; let count = 0;
if (selectedStatuses.size !== ALL_STATUSES.length - DEFAULT_EXCLUDED_STATUSES.length) count++; if (selectedStatuses.size !== ALL_STATUSES.length - DEFAULT_EXCLUDED_STATUSES.length) count++;
if (selectedVendors !== null) count++; if (selectedVendors !== null) count++;
if (selectedOrderers !== null) count++;
return count; return count;
}, [selectedStatuses, selectedVendors, selectedOrderers]); }, [selectedStatuses, selectedVendors]);
// ── Filter handlers ── // ── Filter handlers ──
function resetFilters() { function resetFilters() {
setSelectedVendors(null); setSelectedVendors(null);
setSelectedOrderers(null);
setSelectedStatuses(new Set(ALL_STATUSES.filter((s) => !DEFAULT_EXCLUDED_STATUSES.includes(s)))); setSelectedStatuses(new Set(ALL_STATUSES.filter((s) => !DEFAULT_EXCLUDED_STATUSES.includes(s))));
} }
@@ -187,29 +174,10 @@ export default function Bestellungen() {
}); });
} }
function toggleOrderer(key: string) {
setSelectedOrderers((prev) => {
if (prev === null) {
const allKeys = new Set(uniqueOrderers.map(([k]) => k));
allKeys.delete(key);
return allKeys;
}
const next = new Set(prev);
if (next.has(key)) next.delete(key);
else next.add(key);
if (next.size === uniqueOrderers.length) return null;
return next;
});
}
function isVendorSelected(key: string) { function isVendorSelected(key: string) {
return selectedVendors === null || selectedVendors.has(key); return selectedVendors === null || selectedVendors.has(key);
} }
function isOrdererSelected(key: string) {
return selectedOrderers === null || selectedOrderers.has(key);
}
// ── Render ── // ── Render ──
return ( return (
@@ -219,40 +187,44 @@ export default function Bestellungen() {
<Box sx={{ borderBottom: 1, borderColor: 'divider' }}> <Box sx={{ borderBottom: 1, borderColor: 'divider' }}>
<Tabs value={tab} onChange={(_e, v) => { setTab(v); navigate(`/bestellungen?tab=${v}`, { replace: true }); }} variant="scrollable" scrollButtons="auto"> <Tabs value={tab} onChange={(_e, v) => { setTab(v); navigate(`/bestellungen?tab=${v}`, { replace: true }); }} variant="scrollable" scrollButtons="auto">
<Tab label="Bestellungen" /> <Tab label="Bestellungen" />
<Tab label="Lieferanten" /> {canManageVendors && <Tab label="Lieferanten" />}
</Tabs> </Tabs>
</Box> </Box>
{/* ── Tab 0: Orders ── */} {/* ── Tab 0: Orders ── */}
<TabPanel value={tab} index={0}> <TabPanel value={tab} index={0}>
<Box sx={{ mb: 2, display: 'flex', gap: 2, alignItems: 'center' }}> {/* ── Summary Cards ── */}
<Badge badgeContent={activeFilterCount} color="primary"> <Grid container spacing={2} sx={{ mb: 3 }}>
<Button {[
variant="outlined" { label: 'Noch nicht bestellt', count: orders.filter(o => o.status === 'entwurf' || o.status === 'erstellt').length, color: 'text.secondary' },
size="small" { label: 'Bestellt', count: orders.filter(o => o.status === 'bestellt').length, color: 'primary.main' },
startIcon={<FilterListIcon />} { label: 'Teillieferung', count: orders.filter(o => o.status === 'teillieferung').length, color: 'warning.main' },
onClick={(e) => setFilterAnchor(e.currentTarget)} { label: 'Vollständig', count: orders.filter(o => o.status === 'vollstaendig').length, color: 'success.main' },
> { label: 'Gesamt', count: orders.length, color: 'text.primary' },
Filter ].map(({ label, count, color }) => (
</Button> <Grid item xs={6} sm={4} md={2} key={label}>
</Badge> <Card variant="outlined">
{activeFilterCount > 0 && ( <CardContent sx={{ py: 1.5, '&:last-child': { pb: 1.5 } }}>
<Button size="small" onClick={resetFilters}>Zurücksetzen</Button> <Typography variant="h4" sx={{ color, fontWeight: 700 }}>{count}</Typography>
)} <Typography variant="caption" color="text.secondary">{label}</Typography>
<Typography variant="body2" color="text.secondary"> </CardContent>
{filteredOrders.length} von {orders.length} Bestellungen </Card>
</Typography> </Grid>
</Box> ))}
</Grid>
{/* Filter Popover */} {/* ── Filter ── */}
<Popover <Accordion defaultExpanded={false} disableGutters sx={{ mb: 2, '&:before': { display: 'none' }, border: 1, borderColor: 'divider', borderRadius: 1 }}>
open={!!filterAnchor} <AccordionSummary expandIcon={<ExpandMoreIcon />}>
anchorEl={filterAnchor} <Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
onClose={() => setFilterAnchor(null)} <FilterListIcon fontSize="small" />
anchorOrigin={{ vertical: 'bottom', horizontal: 'left' }} <Typography variant="body2">Filter</Typography>
transformOrigin={{ vertical: 'top', horizontal: 'left' }} {activeFilterCount > 0 && (
slotProps={{ paper: { sx: { p: 2, maxWidth: 480, maxHeight: '70vh', overflow: 'auto' } } }} <Chip label={activeFilterCount} size="small" color="primary" sx={{ height: 18, fontSize: '0.7rem' }} />
> )}
</Box>
</AccordionSummary>
<AccordionDetails>
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 2 }}> <Box sx={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
{/* Status */} {/* Status */}
<Box> <Box>
@@ -268,7 +240,6 @@ export default function Bestellungen() {
</FormGroup> </FormGroup>
</Box> </Box>
<Divider /> <Divider />
{/* Vendor */} {/* Vendor */}
{uniqueVendors.length > 0 && ( {uniqueVendors.length > 0 && (
<Box> <Box>
@@ -284,28 +255,23 @@ export default function Bestellungen() {
</FormGroup> </FormGroup>
</Box> </Box>
)} )}
{uniqueVendors.length > 0 && <Divider />}
{/* Orderer */}
{uniqueOrderers.length > 0 && (
<Box>
<Typography variant="subtitle2" sx={{ mb: 0.5 }}>Besteller</Typography>
<FormGroup>
{uniqueOrderers.map(([key, label]) => (
<FormControlLabel
key={key}
control={<Checkbox size="small" checked={isOrdererSelected(key)} onChange={() => toggleOrderer(key)} />}
label={label}
/>
))}
</FormGroup>
</Box>
)}
<Divider /> <Divider />
<Box sx={{ display: 'flex', justifyContent: 'flex-end' }}>
<Button size="small" onClick={resetFilters}>Zurücksetzen</Button> <Button size="small" onClick={resetFilters}>Zurücksetzen</Button>
</Box> </Box>
</Popover> </Box>
</AccordionDetails>
</Accordion>
{/* Active filter info */}
<Box sx={{ mb: 2, display: 'flex', gap: 2, alignItems: 'center' }}>
{activeFilterCount > 0 && (
<Button size="small" onClick={resetFilters}>Zurücksetzen</Button>
)}
<Typography variant="body2" color="text.secondary">
{filteredOrders.length} von {orders.length} Bestellungen
</Typography>
</Box>
<TableContainer component={Paper}> <TableContainer component={Paper}>
<Table size="small"> <Table size="small">
@@ -360,7 +326,8 @@ export default function Bestellungen() {
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}> <Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<LinearProgress <LinearProgress
variant="determinate" variant="determinate"
value={deliveryPct} value={Math.min(deliveryPct, 100)}
color={deliveryPct >= 100 ? 'success' : 'primary'}
sx={{ flexGrow: 1, height: 6, borderRadius: 3 }} sx={{ flexGrow: 1, height: 6, borderRadius: 3 }}
/> />
<Typography variant="caption" sx={{ whiteSpace: 'nowrap' }}> <Typography variant="caption" sx={{ whiteSpace: 'nowrap' }}>
@@ -386,6 +353,7 @@ export default function Bestellungen() {
</TabPanel> </TabPanel>
{/* ── Tab 1: Vendors ── */} {/* ── Tab 1: Vendors ── */}
{canManageVendors && (
<TabPanel value={tab} index={1}> <TabPanel value={tab} index={1}>
<TableContainer component={Paper}> <TableContainer component={Paper}>
<Table size="small"> <Table size="small">
@@ -417,7 +385,7 @@ export default function Bestellungen() {
<TableCell>{v.telefon || ''}</TableCell> <TableCell>{v.telefon || ''}</TableCell>
<TableCell> <TableCell>
{v.website ? ( {v.website ? (
<a href={v.website} target="_blank" rel="noopener noreferrer" onClick={(e) => e.stopPropagation()}>{v.website}</a> <a href={v.website.startsWith('http') ? v.website : `https://${v.website}`} target="_blank" rel="noopener noreferrer" onClick={(e) => e.stopPropagation()}>{v.website}</a>
) : ''} ) : ''}
</TableCell> </TableCell>
</TableRow> </TableRow>
@@ -427,12 +395,11 @@ export default function Bestellungen() {
</Table> </Table>
</TableContainer> </TableContainer>
{hasPermission('bestellungen:manage_vendors') && (
<ChatAwareFab onClick={() => navigate('/bestellungen/lieferanten/neu')} aria-label="Lieferant hinzufügen"> <ChatAwareFab onClick={() => navigate('/bestellungen/lieferanten/neu')} aria-label="Lieferant hinzufügen">
<AddIcon /> <AddIcon />
</ChatAwareFab> </ChatAwareFab>
)}
</TabPanel> </TabPanel>
)}
</DashboardLayout> </DashboardLayout>
); );
} }

View File

@@ -26,6 +26,12 @@ import type { LieferantFormData } from '../types/bestellung.types';
const emptyForm: LieferantFormData = { name: '', kontakt_name: '', email: '', telefon: '', adresse: '', website: '', notizen: '' }; const emptyForm: LieferantFormData = { name: '', kontakt_name: '', email: '', telefon: '', adresse: '', website: '', notizen: '' };
function ensureUrl(url: string): string {
if (!url) return url;
if (url.startsWith('http://') || url.startsWith('https://')) return url;
return `https://${url}`;
}
export default function LieferantDetail() { export default function LieferantDetail() {
const { id } = useParams<{ id: string }>(); const { id } = useParams<{ id: string }>();
const navigate = useNavigate(); const navigate = useNavigate();
@@ -265,7 +271,7 @@ export default function LieferantDetail() {
<Card variant="outlined"><CardContent> <Card variant="outlined"><CardContent>
<Typography variant="caption" color="text.secondary">Website</Typography> <Typography variant="caption" color="text.secondary">Website</Typography>
<Typography> <Typography>
{vendor!.website ? <a href={vendor!.website} target="_blank" rel="noopener noreferrer">{vendor!.website}</a> : ''} {vendor!.website ? <a href={ensureUrl(vendor!.website)} target="_blank" rel="noopener noreferrer">{vendor!.website}</a> : ''}
</Typography> </Typography>
</CardContent></Card> </CardContent></Card>
</Grid> </Grid>

View File

@@ -77,7 +77,6 @@ export interface BestellungFormData {
lieferant_id?: number; lieferant_id?: number;
besteller_id?: string; besteller_id?: string;
status?: BestellungStatus; status?: BestellungStatus;
budget?: number;
steuersatz?: number; steuersatz?: number;
notizen?: string; notizen?: string;
positionen?: BestellpositionFormData[]; positionen?: BestellpositionFormData[];