refactor external orders
This commit is contained in:
@@ -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 {
|
||||
// Check current order for status change detection
|
||||
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
|
||||
SET bezeichnung = COALESCE($1, bezeichnung),
|
||||
lieferant_id = COALESCE($2, lieferant_id),
|
||||
notizen = COALESCE($3, notizen),
|
||||
budget = COALESCE($4, budget),
|
||||
status = COALESCE($5, status),
|
||||
bestellt_am = $6,
|
||||
abgeschlossen_am = $7,
|
||||
steuersatz = COALESCE($8, steuersatz),
|
||||
besteller_id = CASE WHEN $3::text IS NOT NULL AND $3::text != '' THEN $3::uuid ELSE besteller_id END,
|
||||
notizen = COALESCE($4, notizen),
|
||||
budget = COALESCE($5, budget),
|
||||
status = COALESCE($6, status),
|
||||
bestellt_am = $7,
|
||||
abgeschlossen_am = $8,
|
||||
steuersatz = COALESCE($9, steuersatz),
|
||||
aktualisiert_am = NOW()
|
||||
WHERE id = $9
|
||||
WHERE id = $10
|
||||
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;
|
||||
|
||||
const changes: string[] = [];
|
||||
if (data.bezeichnung) changes.push(`Bezeichnung 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.budget) changes.push(`Budget geändert`);
|
||||
if (data.steuersatz != null) changes.push(`Steuersatz: ${data.steuersatz}%`);
|
||||
|
||||
@@ -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,6 +392,40 @@ export default function BestellungDetail() {
|
||||
</Box>
|
||||
|
||||
{/* ── 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 item xs={12} sm={6} md={4}>
|
||||
<Card variant="outlined"><CardContent>
|
||||
@@ -348,6 +446,7 @@ export default function BestellungDetail() {
|
||||
</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>
|
||||
|
||||
@@ -1,6 +1,12 @@
|
||||
import { useState, useEffect, useMemo } from 'react';
|
||||
import {
|
||||
Accordion,
|
||||
AccordionSummary,
|
||||
AccordionDetails,
|
||||
Box,
|
||||
Card,
|
||||
CardContent,
|
||||
Grid,
|
||||
Tab,
|
||||
Tabs,
|
||||
Typography,
|
||||
@@ -16,12 +22,10 @@ import {
|
||||
Checkbox,
|
||||
FormControlLabel,
|
||||
FormGroup,
|
||||
Popover,
|
||||
Badge,
|
||||
LinearProgress,
|
||||
Divider,
|
||||
} 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 { useNavigate, useSearchParams } from 'react-router-dom';
|
||||
import DashboardLayout from '../components/dashboard/DashboardLayout';
|
||||
@@ -78,6 +82,7 @@ export default function Bestellungen() {
|
||||
const navigate = useNavigate();
|
||||
const [searchParams] = useSearchParams();
|
||||
const { hasPermission } = usePermissionContext();
|
||||
const canManageVendors = hasPermission('bestellungen:manage_vendors');
|
||||
|
||||
// Tab from URL
|
||||
const [tab, setTab] = useState(() => {
|
||||
@@ -90,10 +95,7 @@ export default function Bestellungen() {
|
||||
}, [searchParams]);
|
||||
|
||||
// ── Filter state ──
|
||||
const [filterAnchor, setFilterAnchor] = useState<HTMLElement | null>(null);
|
||||
|
||||
const [selectedVendors, setSelectedVendors] = useState<Set<string> | null>(null); // null = all
|
||||
const [selectedOrderers, setSelectedOrderers] = useState<Set<string> | null>(null);
|
||||
const [selectedStatuses, setSelectedStatuses] = useState<Set<BestellungStatus>>(
|
||||
() => 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]));
|
||||
}, [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 ──
|
||||
const filteredOrders = useMemo(() => {
|
||||
return orders.filter((o) => {
|
||||
@@ -136,28 +130,21 @@ export default function Bestellungen() {
|
||||
const key = String(o.lieferant_id ?? o.lieferant_name ?? '');
|
||||
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;
|
||||
});
|
||||
}, [orders, selectedStatuses, selectedVendors, selectedOrderers]);
|
||||
}, [orders, selectedStatuses, selectedVendors]);
|
||||
|
||||
// ── Active filter count ──
|
||||
const activeFilterCount = useMemo(() => {
|
||||
let count = 0;
|
||||
if (selectedStatuses.size !== ALL_STATUSES.length - DEFAULT_EXCLUDED_STATUSES.length) count++;
|
||||
if (selectedVendors !== null) count++;
|
||||
if (selectedOrderers !== null) count++;
|
||||
return count;
|
||||
}, [selectedStatuses, selectedVendors, selectedOrderers]);
|
||||
}, [selectedStatuses, selectedVendors]);
|
||||
|
||||
// ── Filter handlers ──
|
||||
function resetFilters() {
|
||||
setSelectedVendors(null);
|
||||
setSelectedOrderers(null);
|
||||
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) {
|
||||
return selectedVendors === null || selectedVendors.has(key);
|
||||
}
|
||||
|
||||
function isOrdererSelected(key: string) {
|
||||
return selectedOrderers === null || selectedOrderers.has(key);
|
||||
}
|
||||
|
||||
// ── Render ──
|
||||
|
||||
return (
|
||||
@@ -219,40 +187,44 @@ export default function Bestellungen() {
|
||||
<Box sx={{ borderBottom: 1, borderColor: 'divider' }}>
|
||||
<Tabs value={tab} onChange={(_e, v) => { setTab(v); navigate(`/bestellungen?tab=${v}`, { replace: true }); }} variant="scrollable" scrollButtons="auto">
|
||||
<Tab label="Bestellungen" />
|
||||
<Tab label="Lieferanten" />
|
||||
{canManageVendors && <Tab label="Lieferanten" />}
|
||||
</Tabs>
|
||||
</Box>
|
||||
|
||||
{/* ── Tab 0: Orders ── */}
|
||||
<TabPanel value={tab} index={0}>
|
||||
<Box sx={{ mb: 2, display: 'flex', gap: 2, alignItems: 'center' }}>
|
||||
<Badge badgeContent={activeFilterCount} color="primary">
|
||||
<Button
|
||||
variant="outlined"
|
||||
size="small"
|
||||
startIcon={<FilterListIcon />}
|
||||
onClick={(e) => setFilterAnchor(e.currentTarget)}
|
||||
>
|
||||
Filter
|
||||
</Button>
|
||||
</Badge>
|
||||
{activeFilterCount > 0 && (
|
||||
<Button size="small" onClick={resetFilters}>Zurücksetzen</Button>
|
||||
)}
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
{filteredOrders.length} von {orders.length} Bestellungen
|
||||
</Typography>
|
||||
</Box>
|
||||
{/* ── Summary Cards ── */}
|
||||
<Grid container spacing={2} sx={{ mb: 3 }}>
|
||||
{[
|
||||
{ label: 'Noch nicht bestellt', count: orders.filter(o => o.status === 'entwurf' || o.status === 'erstellt').length, color: 'text.secondary' },
|
||||
{ label: 'Bestellt', count: orders.filter(o => o.status === 'bestellt').length, color: 'primary.main' },
|
||||
{ label: 'Teillieferung', count: orders.filter(o => o.status === 'teillieferung').length, color: 'warning.main' },
|
||||
{ label: 'Vollständig', count: orders.filter(o => o.status === 'vollstaendig').length, color: 'success.main' },
|
||||
{ label: 'Gesamt', count: orders.length, color: 'text.primary' },
|
||||
].map(({ label, count, color }) => (
|
||||
<Grid item xs={6} sm={4} md={2} key={label}>
|
||||
<Card variant="outlined">
|
||||
<CardContent sx={{ py: 1.5, '&:last-child': { pb: 1.5 } }}>
|
||||
<Typography variant="h4" sx={{ color, fontWeight: 700 }}>{count}</Typography>
|
||||
<Typography variant="caption" color="text.secondary">{label}</Typography>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Grid>
|
||||
))}
|
||||
</Grid>
|
||||
|
||||
{/* Filter Popover */}
|
||||
<Popover
|
||||
open={!!filterAnchor}
|
||||
anchorEl={filterAnchor}
|
||||
onClose={() => setFilterAnchor(null)}
|
||||
anchorOrigin={{ vertical: 'bottom', horizontal: 'left' }}
|
||||
transformOrigin={{ vertical: 'top', horizontal: 'left' }}
|
||||
slotProps={{ paper: { sx: { p: 2, maxWidth: 480, maxHeight: '70vh', overflow: 'auto' } } }}
|
||||
>
|
||||
{/* ── Filter ── */}
|
||||
<Accordion defaultExpanded={false} disableGutters sx={{ mb: 2, '&:before': { display: 'none' }, border: 1, borderColor: 'divider', borderRadius: 1 }}>
|
||||
<AccordionSummary expandIcon={<ExpandMoreIcon />}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||
<FilterListIcon fontSize="small" />
|
||||
<Typography variant="body2">Filter</Typography>
|
||||
{activeFilterCount > 0 && (
|
||||
<Chip label={activeFilterCount} size="small" color="primary" sx={{ height: 18, fontSize: '0.7rem' }} />
|
||||
)}
|
||||
</Box>
|
||||
</AccordionSummary>
|
||||
<AccordionDetails>
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
|
||||
{/* Status */}
|
||||
<Box>
|
||||
@@ -268,7 +240,6 @@ export default function Bestellungen() {
|
||||
</FormGroup>
|
||||
</Box>
|
||||
<Divider />
|
||||
|
||||
{/* Vendor */}
|
||||
{uniqueVendors.length > 0 && (
|
||||
<Box>
|
||||
@@ -284,28 +255,23 @@ export default function Bestellungen() {
|
||||
</FormGroup>
|
||||
</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 />
|
||||
<Box sx={{ display: 'flex', justifyContent: 'flex-end' }}>
|
||||
<Button size="small" onClick={resetFilters}>Zurücksetzen</Button>
|
||||
</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}>
|
||||
<Table size="small">
|
||||
@@ -360,7 +326,8 @@ export default function Bestellungen() {
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||
<LinearProgress
|
||||
variant="determinate"
|
||||
value={deliveryPct}
|
||||
value={Math.min(deliveryPct, 100)}
|
||||
color={deliveryPct >= 100 ? 'success' : 'primary'}
|
||||
sx={{ flexGrow: 1, height: 6, borderRadius: 3 }}
|
||||
/>
|
||||
<Typography variant="caption" sx={{ whiteSpace: 'nowrap' }}>
|
||||
@@ -386,6 +353,7 @@ export default function Bestellungen() {
|
||||
</TabPanel>
|
||||
|
||||
{/* ── Tab 1: Vendors ── */}
|
||||
{canManageVendors && (
|
||||
<TabPanel value={tab} index={1}>
|
||||
<TableContainer component={Paper}>
|
||||
<Table size="small">
|
||||
@@ -417,7 +385,7 @@ export default function Bestellungen() {
|
||||
<TableCell>{v.telefon || '–'}</TableCell>
|
||||
<TableCell>
|
||||
{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>
|
||||
</TableRow>
|
||||
@@ -427,12 +395,11 @@ export default function Bestellungen() {
|
||||
</Table>
|
||||
</TableContainer>
|
||||
|
||||
{hasPermission('bestellungen:manage_vendors') && (
|
||||
<ChatAwareFab onClick={() => navigate('/bestellungen/lieferanten/neu')} aria-label="Lieferant hinzufügen">
|
||||
<AddIcon />
|
||||
</ChatAwareFab>
|
||||
)}
|
||||
</TabPanel>
|
||||
)}
|
||||
</DashboardLayout>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -26,6 +26,12 @@ import type { LieferantFormData } from '../types/bestellung.types';
|
||||
|
||||
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() {
|
||||
const { id } = useParams<{ id: string }>();
|
||||
const navigate = useNavigate();
|
||||
@@ -265,7 +271,7 @@ export default function LieferantDetail() {
|
||||
<Card variant="outlined"><CardContent>
|
||||
<Typography variant="caption" color="text.secondary">Website</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>
|
||||
</CardContent></Card>
|
||||
</Grid>
|
||||
|
||||
@@ -77,7 +77,6 @@ export interface BestellungFormData {
|
||||
lieferant_id?: number;
|
||||
besteller_id?: string;
|
||||
status?: BestellungStatus;
|
||||
budget?: number;
|
||||
steuersatz?: number;
|
||||
notizen?: string;
|
||||
positionen?: BestellpositionFormData[];
|
||||
|
||||
Reference in New Issue
Block a user