Files
dashboard/frontend/src/pages/Bestellungen.tsx
Matthias Hochmeister 5032e1593b new features
2026-03-23 13:08:19 +01:00

435 lines
17 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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>
);
}