new features
This commit is contained in:
434
frontend/src/pages/Bestellungen.tsx
Normal file
434
frontend/src/pages/Bestellungen.tsx
Normal file
@@ -0,0 +1,434 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import {
|
||||
Box,
|
||||
Tab,
|
||||
Tabs,
|
||||
Typography,
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableContainer,
|
||||
TableHead,
|
||||
TableRow,
|
||||
Paper,
|
||||
Chip,
|
||||
IconButton,
|
||||
Button,
|
||||
Dialog,
|
||||
DialogTitle,
|
||||
DialogContent,
|
||||
DialogActions,
|
||||
TextField,
|
||||
MenuItem,
|
||||
Select,
|
||||
FormControl,
|
||||
InputLabel,
|
||||
Tooltip,
|
||||
Autocomplete,
|
||||
} from '@mui/material';
|
||||
import { Add as AddIcon, Edit as EditIcon, Delete as DeleteIcon } from '@mui/icons-material';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { useNavigate, useSearchParams } from 'react-router-dom';
|
||||
import DashboardLayout from '../components/dashboard/DashboardLayout';
|
||||
import ChatAwareFab from '../components/shared/ChatAwareFab';
|
||||
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, BestellungFormData, LieferantFormData, Lieferant } from '../types/bestellung.types';
|
||||
|
||||
// ── Helpers ──
|
||||
|
||||
const formatCurrency = (value?: number) =>
|
||||
value != null ? new Intl.NumberFormat('de-AT', { style: 'currency', currency: 'EUR' }).format(value) : '–';
|
||||
|
||||
const formatDate = (iso?: string) =>
|
||||
iso ? new Date(iso).toLocaleDateString('de-DE', { day: '2-digit', month: '2-digit', year: 'numeric' }) : '–';
|
||||
|
||||
// ── Tab Panel ──
|
||||
|
||||
interface TabPanelProps { children: React.ReactNode; index: number; value: number }
|
||||
function TabPanel({ children, value, index }: TabPanelProps) {
|
||||
if (value !== index) return null;
|
||||
return <Box sx={{ pt: 3 }}>{children}</Box>;
|
||||
}
|
||||
|
||||
const TAB_COUNT = 2;
|
||||
|
||||
// ── Status options for filter ──
|
||||
|
||||
const ALL_STATUSES: BestellungStatus[] = ['entwurf', 'erstellt', 'bestellt', 'teillieferung', 'vollstaendig', 'abgeschlossen'];
|
||||
|
||||
// ── Empty form data ──
|
||||
|
||||
const emptyOrderForm: BestellungFormData = { bezeichnung: '', lieferant_id: undefined, besteller_id: '', budget: undefined, notizen: '' };
|
||||
const emptyVendorForm: LieferantFormData = { name: '', kontakt_name: '', email: '', telefon: '', adresse: '', website: '', notizen: '' };
|
||||
|
||||
// ══════════════════════════════════════════════════════════════════════════════
|
||||
// Component
|
||||
// ══════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
export default function Bestellungen() {
|
||||
const navigate = useNavigate();
|
||||
const [searchParams] = useSearchParams();
|
||||
const queryClient = useQueryClient();
|
||||
const { showSuccess, showError } = useNotification();
|
||||
const { hasPermission } = usePermissionContext();
|
||||
|
||||
// Tab from URL
|
||||
const [tab, setTab] = useState(() => {
|
||||
const t = Number(searchParams.get('tab'));
|
||||
return t >= 0 && t < TAB_COUNT ? t : 0;
|
||||
});
|
||||
useEffect(() => {
|
||||
const t = Number(searchParams.get('tab'));
|
||||
if (t >= 0 && t < TAB_COUNT) setTab(t);
|
||||
}, [searchParams]);
|
||||
|
||||
// ── State ──
|
||||
const [statusFilter, setStatusFilter] = useState<string>('');
|
||||
const [orderDialogOpen, setOrderDialogOpen] = useState(false);
|
||||
const [orderForm, setOrderForm] = useState<BestellungFormData>({ ...emptyOrderForm });
|
||||
|
||||
const [vendorDialogOpen, setVendorDialogOpen] = useState(false);
|
||||
const [vendorForm, setVendorForm] = useState<LieferantFormData>({ ...emptyVendorForm });
|
||||
const [editingVendor, setEditingVendor] = useState<Lieferant | null>(null);
|
||||
|
||||
const [deleteVendorTarget, setDeleteVendorTarget] = useState<Lieferant | null>(null);
|
||||
|
||||
// ── Queries ──
|
||||
const { data: orders = [], isLoading: ordersLoading } = useQuery({
|
||||
queryKey: ['bestellungen', statusFilter],
|
||||
queryFn: () => bestellungApi.getOrders(statusFilter ? { status: statusFilter } : undefined),
|
||||
});
|
||||
|
||||
const { data: vendors = [], isLoading: vendorsLoading } = useQuery({
|
||||
queryKey: ['lieferanten'],
|
||||
queryFn: bestellungApi.getVendors,
|
||||
});
|
||||
|
||||
// ── Mutations ──
|
||||
const createOrder = useMutation({
|
||||
mutationFn: (data: BestellungFormData) => bestellungApi.createOrder(data),
|
||||
onSuccess: (created) => {
|
||||
queryClient.invalidateQueries({ queryKey: ['bestellungen'] });
|
||||
showSuccess('Bestellung erstellt');
|
||||
setOrderDialogOpen(false);
|
||||
setOrderForm({ ...emptyOrderForm });
|
||||
navigate(`/bestellungen/${created.id}`);
|
||||
},
|
||||
onError: () => showError('Fehler beim Erstellen der Bestellung'),
|
||||
});
|
||||
|
||||
const createVendor = useMutation({
|
||||
mutationFn: (data: LieferantFormData) => bestellungApi.createVendor(data),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['lieferanten'] });
|
||||
showSuccess('Lieferant erstellt');
|
||||
closeVendorDialog();
|
||||
},
|
||||
onError: () => showError('Fehler beim Erstellen des Lieferanten'),
|
||||
});
|
||||
|
||||
const updateVendor = useMutation({
|
||||
mutationFn: ({ id, data }: { id: number; data: LieferantFormData }) => bestellungApi.updateVendor(id, data),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['lieferanten'] });
|
||||
showSuccess('Lieferant aktualisiert');
|
||||
closeVendorDialog();
|
||||
},
|
||||
onError: () => showError('Fehler beim Aktualisieren des Lieferanten'),
|
||||
});
|
||||
|
||||
const deleteVendor = useMutation({
|
||||
mutationFn: (id: number) => bestellungApi.deleteVendor(id),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['lieferanten'] });
|
||||
showSuccess('Lieferant gelöscht');
|
||||
setDeleteVendorTarget(null);
|
||||
},
|
||||
onError: () => showError('Fehler beim Löschen des Lieferanten'),
|
||||
});
|
||||
|
||||
// ── Dialog helpers ──
|
||||
|
||||
function openEditVendor(v: Lieferant) {
|
||||
setEditingVendor(v);
|
||||
setVendorForm({ name: v.name, kontakt_name: v.kontakt_name || '', email: v.email || '', telefon: v.telefon || '', adresse: v.adresse || '', website: v.website || '', notizen: v.notizen || '' });
|
||||
setVendorDialogOpen(true);
|
||||
}
|
||||
|
||||
function closeVendorDialog() {
|
||||
setVendorDialogOpen(false);
|
||||
setEditingVendor(null);
|
||||
setVendorForm({ ...emptyVendorForm });
|
||||
}
|
||||
|
||||
function handleVendorSave() {
|
||||
if (!vendorForm.name.trim()) return;
|
||||
if (editingVendor) {
|
||||
updateVendor.mutate({ id: editingVendor.id, data: vendorForm });
|
||||
} else {
|
||||
createVendor.mutate(vendorForm);
|
||||
}
|
||||
}
|
||||
|
||||
function handleOrderSave() {
|
||||
if (!orderForm.bezeichnung.trim()) return;
|
||||
createOrder.mutate(orderForm);
|
||||
}
|
||||
|
||||
// ── Render ──
|
||||
|
||||
return (
|
||||
<DashboardLayout>
|
||||
<Typography variant="h4" sx={{ mb: 3 }}>Bestellungen</Typography>
|
||||
|
||||
<Box sx={{ borderBottom: 1, borderColor: 'divider' }}>
|
||||
<Tabs value={tab} onChange={(_e, v) => { setTab(v); navigate(`/bestellungen?tab=${v}`, { replace: true }); }}>
|
||||
<Tab label="Bestellungen" />
|
||||
<Tab label="Lieferanten" />
|
||||
</Tabs>
|
||||
</Box>
|
||||
|
||||
{/* ── Tab 0: Orders ── */}
|
||||
<TabPanel value={tab} index={0}>
|
||||
<Box sx={{ mb: 2, display: 'flex', gap: 2, alignItems: 'center' }}>
|
||||
<FormControl size="small" sx={{ minWidth: 200 }}>
|
||||
<InputLabel>Status Filter</InputLabel>
|
||||
<Select
|
||||
value={statusFilter}
|
||||
label="Status Filter"
|
||||
onChange={(e) => setStatusFilter(e.target.value)}
|
||||
>
|
||||
<MenuItem value="">Alle</MenuItem>
|
||||
{ALL_STATUSES.map((s) => (
|
||||
<MenuItem key={s} value={s}>{BESTELLUNG_STATUS_LABELS[s]}</MenuItem>
|
||||
))}
|
||||
</Select>
|
||||
</FormControl>
|
||||
</Box>
|
||||
|
||||
<TableContainer component={Paper}>
|
||||
<Table size="small">
|
||||
<TableHead>
|
||||
<TableRow>
|
||||
<TableCell>Bezeichnung</TableCell>
|
||||
<TableCell>Lieferant</TableCell>
|
||||
<TableCell>Besteller</TableCell>
|
||||
<TableCell>Status</TableCell>
|
||||
<TableCell align="right">Positionen</TableCell>
|
||||
<TableCell align="right">Gesamtpreis</TableCell>
|
||||
<TableCell>Erstellt am</TableCell>
|
||||
</TableRow>
|
||||
</TableHead>
|
||||
<TableBody>
|
||||
{ordersLoading ? (
|
||||
<TableRow><TableCell colSpan={7} align="center">Laden...</TableCell></TableRow>
|
||||
) : orders.length === 0 ? (
|
||||
<TableRow><TableCell colSpan={7} align="center">Keine Bestellungen vorhanden</TableCell></TableRow>
|
||||
) : (
|
||||
orders.map((o) => (
|
||||
<TableRow
|
||||
key={o.id}
|
||||
hover
|
||||
sx={{ cursor: 'pointer' }}
|
||||
onClick={() => navigate(`/bestellungen/${o.id}`)}
|
||||
>
|
||||
<TableCell>{o.bezeichnung}</TableCell>
|
||||
<TableCell>{o.lieferant_name || '–'}</TableCell>
|
||||
<TableCell>{o.besteller_name || '–'}</TableCell>
|
||||
<TableCell>
|
||||
<Chip
|
||||
label={BESTELLUNG_STATUS_LABELS[o.status]}
|
||||
color={BESTELLUNG_STATUS_COLORS[o.status]}
|
||||
size="small"
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell align="right">{o.items_count ?? 0}</TableCell>
|
||||
<TableCell align="right">{formatCurrency(o.total_cost)}</TableCell>
|
||||
<TableCell>{formatDate(o.erstellt_am)}</TableCell>
|
||||
</TableRow>
|
||||
))
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</TableContainer>
|
||||
|
||||
{hasPermission('bestellungen:create') && (
|
||||
<ChatAwareFab onClick={() => setOrderDialogOpen(true)} aria-label="Neue Bestellung">
|
||||
<AddIcon />
|
||||
</ChatAwareFab>
|
||||
)}
|
||||
</TabPanel>
|
||||
|
||||
{/* ── Tab 1: Vendors ── */}
|
||||
<TabPanel value={tab} index={1}>
|
||||
<Box sx={{ mb: 2, display: 'flex', justifyContent: 'flex-end' }}>
|
||||
{hasPermission('bestellungen:create') && (
|
||||
<Button variant="contained" startIcon={<AddIcon />} onClick={() => setVendorDialogOpen(true)}>
|
||||
Lieferant hinzufügen
|
||||
</Button>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
<TableContainer component={Paper}>
|
||||
<Table size="small">
|
||||
<TableHead>
|
||||
<TableRow>
|
||||
<TableCell>Name</TableCell>
|
||||
<TableCell>Kontakt</TableCell>
|
||||
<TableCell>E-Mail</TableCell>
|
||||
<TableCell>Telefon</TableCell>
|
||||
<TableCell>Website</TableCell>
|
||||
<TableCell align="right">Aktionen</TableCell>
|
||||
</TableRow>
|
||||
</TableHead>
|
||||
<TableBody>
|
||||
{vendorsLoading ? (
|
||||
<TableRow><TableCell colSpan={6} align="center">Laden...</TableCell></TableRow>
|
||||
) : vendors.length === 0 ? (
|
||||
<TableRow><TableCell colSpan={6} align="center">Keine Lieferanten vorhanden</TableCell></TableRow>
|
||||
) : (
|
||||
vendors.map((v) => (
|
||||
<TableRow key={v.id}>
|
||||
<TableCell>{v.name}</TableCell>
|
||||
<TableCell>{v.kontakt_name || '–'}</TableCell>
|
||||
<TableCell>{v.email ? <a href={`mailto:${v.email}`}>{v.email}</a> : '–'}</TableCell>
|
||||
<TableCell>{v.telefon || '–'}</TableCell>
|
||||
<TableCell>
|
||||
{v.website ? (
|
||||
<a href={v.website} target="_blank" rel="noopener noreferrer">{v.website}</a>
|
||||
) : '–'}
|
||||
</TableCell>
|
||||
<TableCell align="right">
|
||||
<Tooltip title="Bearbeiten">
|
||||
<IconButton size="small" onClick={() => openEditVendor(v)}><EditIcon fontSize="small" /></IconButton>
|
||||
</Tooltip>
|
||||
<Tooltip title="Löschen">
|
||||
<IconButton size="small" color="error" onClick={() => setDeleteVendorTarget(v)}><DeleteIcon fontSize="small" /></IconButton>
|
||||
</Tooltip>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</TableContainer>
|
||||
</TabPanel>
|
||||
|
||||
{/* ── Create Order Dialog ── */}
|
||||
<Dialog open={orderDialogOpen} onClose={() => setOrderDialogOpen(false)} maxWidth="sm" fullWidth>
|
||||
<DialogTitle>Neue Bestellung</DialogTitle>
|
||||
<DialogContent sx={{ display: 'flex', flexDirection: 'column', gap: 2, pt: '16px !important' }}>
|
||||
<TextField
|
||||
label="Bezeichnung"
|
||||
required
|
||||
value={orderForm.bezeichnung}
|
||||
onChange={(e) => setOrderForm((f) => ({ ...f, bezeichnung: e.target.value }))}
|
||||
/>
|
||||
<Autocomplete
|
||||
options={vendors}
|
||||
getOptionLabel={(o) => o.name}
|
||||
value={vendors.find((v) => v.id === orderForm.lieferant_id) || null}
|
||||
onChange={(_e, v) => setOrderForm((f) => ({ ...f, lieferant_id: v?.id }))}
|
||||
renderInput={(params) => <TextField {...params} label="Lieferant" />}
|
||||
/>
|
||||
<TextField
|
||||
label="Besteller"
|
||||
value={orderForm.besteller_id || ''}
|
||||
onChange={(e) => setOrderForm((f) => ({ ...f, besteller_id: e.target.value }))}
|
||||
/>
|
||||
<TextField
|
||||
label="Budget"
|
||||
type="number"
|
||||
value={orderForm.budget ?? ''}
|
||||
onChange={(e) => setOrderForm((f) => ({ ...f, budget: e.target.value ? Number(e.target.value) : undefined }))}
|
||||
inputProps={{ min: 0, step: 0.01 }}
|
||||
/>
|
||||
<TextField
|
||||
label="Notizen"
|
||||
multiline
|
||||
rows={3}
|
||||
value={orderForm.notizen || ''}
|
||||
onChange={(e) => setOrderForm((f) => ({ ...f, notizen: e.target.value }))}
|
||||
/>
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button onClick={() => setOrderDialogOpen(false)}>Abbrechen</Button>
|
||||
<Button variant="contained" onClick={handleOrderSave} disabled={!orderForm.bezeichnung.trim() || createOrder.isPending}>
|
||||
Erstellen
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
|
||||
{/* ── Create/Edit Vendor Dialog ── */}
|
||||
<Dialog open={vendorDialogOpen} onClose={closeVendorDialog} maxWidth="sm" fullWidth>
|
||||
<DialogTitle>{editingVendor ? 'Lieferant bearbeiten' : 'Neuer Lieferant'}</DialogTitle>
|
||||
<DialogContent sx={{ display: 'flex', flexDirection: 'column', gap: 2, pt: '16px !important' }}>
|
||||
<TextField
|
||||
label="Name"
|
||||
required
|
||||
value={vendorForm.name}
|
||||
onChange={(e) => setVendorForm((f) => ({ ...f, name: e.target.value }))}
|
||||
/>
|
||||
<TextField
|
||||
label="Kontakt-Name"
|
||||
value={vendorForm.kontakt_name || ''}
|
||||
onChange={(e) => setVendorForm((f) => ({ ...f, kontakt_name: e.target.value }))}
|
||||
/>
|
||||
<TextField
|
||||
label="E-Mail"
|
||||
type="email"
|
||||
value={vendorForm.email || ''}
|
||||
onChange={(e) => setVendorForm((f) => ({ ...f, email: e.target.value }))}
|
||||
/>
|
||||
<TextField
|
||||
label="Telefon"
|
||||
value={vendorForm.telefon || ''}
|
||||
onChange={(e) => setVendorForm((f) => ({ ...f, telefon: e.target.value }))}
|
||||
/>
|
||||
<TextField
|
||||
label="Adresse"
|
||||
value={vendorForm.adresse || ''}
|
||||
onChange={(e) => setVendorForm((f) => ({ ...f, adresse: e.target.value }))}
|
||||
/>
|
||||
<TextField
|
||||
label="Website"
|
||||
value={vendorForm.website || ''}
|
||||
onChange={(e) => setVendorForm((f) => ({ ...f, website: e.target.value }))}
|
||||
/>
|
||||
<TextField
|
||||
label="Notizen"
|
||||
multiline
|
||||
rows={3}
|
||||
value={vendorForm.notizen || ''}
|
||||
onChange={(e) => setVendorForm((f) => ({ ...f, notizen: e.target.value }))}
|
||||
/>
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button onClick={closeVendorDialog}>Abbrechen</Button>
|
||||
<Button variant="contained" onClick={handleVendorSave} disabled={!vendorForm.name.trim() || createVendor.isPending || updateVendor.isPending}>
|
||||
{editingVendor ? 'Speichern' : 'Erstellen'}
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
|
||||
{/* ── Delete Vendor Confirm ── */}
|
||||
<Dialog open={!!deleteVendorTarget} onClose={() => setDeleteVendorTarget(null)}>
|
||||
<DialogTitle>Lieferant löschen</DialogTitle>
|
||||
<DialogContent>
|
||||
<Typography>
|
||||
Soll der Lieferant <strong>{deleteVendorTarget?.name}</strong> wirklich gelöscht werden?
|
||||
</Typography>
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button onClick={() => setDeleteVendorTarget(null)}>Abbrechen</Button>
|
||||
<Button color="error" variant="contained" onClick={() => deleteVendorTarget && deleteVendor.mutate(deleteVendorTarget.id)} disabled={deleteVendor.isPending}>
|
||||
Löschen
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
</DashboardLayout>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user