Files
dashboard/frontend/src/pages/Bestellungen.tsx

403 lines
16 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, useMemo } from 'react';
import {
Accordion,
AccordionSummary,
AccordionDetails,
Box,
IconButton,
Tab,
Tabs,
Tooltip,
Typography,
Chip,
Button,
Checkbox,
FormControlLabel,
FormGroup,
LinearProgress,
Divider,
} from '@mui/material';
import { Add as AddIcon, ExpandMore as ExpandMoreIcon, FilterList as FilterListIcon, PictureAsPdf as PdfIcon } from '@mui/icons-material';
import { useQuery } from '@tanstack/react-query';
import { useNavigate, useSearchParams } from 'react-router-dom';
import DashboardLayout from '../components/dashboard/DashboardLayout';
import ChatAwareFab from '../components/shared/ChatAwareFab';
import { KatalogTab } from '../components/shared/KatalogTab';
import { usePermissionContext } from '../contexts/PermissionContext';
import { bestellungApi } from '../services/bestellung';
import { configApi } from '../services/config';
import { addPdfHeader, addPdfFooter } from '../utils/pdfExport';
import { BESTELLUNG_STATUS_LABELS, BESTELLUNG_STATUS_COLORS } from '../types/bestellung.types';
import type { BestellungStatus, Bestellung } from '../types/bestellung.types';
import { StatusChip, DataTable, SummaryCards } from '../components/templates';
import type { SummaryStat } from '../components/templates';
// ── 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 = 3;
// ── Status options ──
const ALL_STATUSES: BestellungStatus[] = ['entwurf', 'wartet_auf_genehmigung', 'bereit_zur_bestellung', 'bestellt', 'teillieferung', 'lieferung_pruefen', 'abgeschlossen'];
const DEFAULT_EXCLUDED_STATUSES: BestellungStatus[] = ['abgeschlossen'];
// ── Kennung formatter ──
function formatKennung(o: Bestellung): string {
if (o.laufende_nummer == null) return '';
const year = new Date(o.erstellt_am).getFullYear();
return `${year}/${o.laufende_nummer}`;
}
// ── Brutto calculator ──
function calcBrutto(o: Bestellung): number | undefined {
if (o.total_cost == null) return undefined;
const rate = (parseFloat(String(o.steuersatz)) || 20) / 100;
return o.total_cost * (1 + rate);
}
// ══════════════════════════════════════════════════════════════════════════════
// Component
// ══════════════════════════════════════════════════════════════════════════════
export default function Bestellungen() {
const navigate = useNavigate();
const [searchParams] = useSearchParams();
const { hasPermission } = usePermissionContext();
const canManageVendors = hasPermission('bestellungen:manage_vendors');
const canExport = hasPermission('bestellungen:export');
// 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]);
// ── Filter state ──
const [selectedVendors, setSelectedVendors] = useState<Set<string> | null>(null); // null = all
const [selectedStatuses, setSelectedStatuses] = useState<Set<BestellungStatus>>(
() => new Set(ALL_STATUSES.filter((s) => !DEFAULT_EXCLUDED_STATUSES.includes(s)))
);
// ── Queries ──
const { data: orders = [], isLoading: ordersLoading } = useQuery({
queryKey: ['bestellungen'],
queryFn: () => bestellungApi.getOrders(),
});
const { data: vendors = [], isLoading: vendorsLoading } = useQuery({
queryKey: ['lieferanten'],
queryFn: bestellungApi.getVendors,
});
// ── Derive unique filter values from data ──
const uniqueVendors = useMemo(() => {
const map = new Map<string, string>();
orders.forEach((o) => {
if (o.lieferant_name) map.set(String(o.lieferant_id ?? o.lieferant_name), o.lieferant_name);
});
return Array.from(map.entries()).sort((a, b) => a[1].localeCompare(b[1]));
}, [orders]);
// ── Filtered orders ──
const filteredOrders = useMemo(() => {
return orders.filter((o) => {
// Status filter
if (!selectedStatuses.has(o.status)) return false;
// Vendor filter (null = all selected)
if (selectedVendors !== null) {
const key = String(o.lieferant_id ?? o.lieferant_name ?? '');
if (!selectedVendors.has(key)) return false;
}
return true;
});
}, [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++;
return count;
}, [selectedStatuses, selectedVendors]);
// ── Filter handlers ──
function resetFilters() {
setSelectedVendors(null);
setSelectedStatuses(new Set(ALL_STATUSES.filter((s) => !DEFAULT_EXCLUDED_STATUSES.includes(s))));
}
function toggleStatus(s: BestellungStatus) {
setSelectedStatuses((prev) => {
const next = new Set(prev);
if (next.has(s)) next.delete(s);
else next.add(s);
return next;
});
}
function toggleVendor(key: string) {
setSelectedVendors((prev) => {
if (prev === null) {
// was "all selected" → deselect this one
const allKeys = new Set(uniqueVendors.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 all are selected again, go back to null
if (next.size === uniqueVendors.length) return null;
return next;
});
}
function isVendorSelected(key: string) {
return selectedVendors === null || selectedVendors.has(key);
}
// ── PDF Export ──
async function generateBestellungenPdf() {
const { jsPDF } = await import('jspdf');
const autoTable = (await import('jspdf-autotable')).default;
const doc = new jsPDF({ orientation: 'portrait', unit: 'mm', format: 'a4' });
let settings;
try { settings = await configApi.getPdfSettings(); } catch { settings = { pdf_header: '', pdf_footer: '', pdf_logo: '', pdf_org_name: '' }; }
let startY = await addPdfHeader(doc, settings, 210);
// Document title below header
doc.setFontSize(14);
doc.setFont('helvetica', 'bold');
doc.text('Bestellungen — Übersicht', 10, startY);
startY += 10;
const rows = filteredOrders.map((o) => {
const brutto = calcBrutto(o);
return [
formatKennung(o),
o.lieferant_name || '',
BESTELLUNG_STATUS_LABELS[o.status],
String(o.items_count ?? 0),
formatCurrency(brutto),
formatDate(o.bestellt_am || o.erstellt_am),
];
});
autoTable(doc, {
head: [['Kennung', 'Lieferant', 'Status', 'Pos.', 'Betrag (brutto)', 'Datum']],
body: rows,
startY,
headStyles: { fillColor: [66, 66, 66], textColor: 255, fontStyle: 'bold' },
alternateRowStyles: { fillColor: [245, 245, 245] },
margin: { left: 10, right: 10 },
styles: { fontSize: 9, cellPadding: 2 },
columnStyles: {
0: { cellWidth: 22 },
1: { cellWidth: 40 },
2: { cellWidth: 30 },
3: { cellWidth: 15, halign: 'right' },
4: { cellWidth: 35, halign: 'right' },
5: { cellWidth: 25 },
},
didDrawPage: addPdfFooter(doc, settings),
});
const today = new Date().toISOString().slice(0, 10);
doc.save(`bestellungen_uebersicht_${today}.pdf`);
}
// ── Render ──
return (
<DashboardLayout>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 3 }}>
<Typography variant="h4" sx={{ flexGrow: 1 }}>Bestellungen</Typography>
{canExport && (
<Tooltip title="PDF Export">
<IconButton onClick={generateBestellungenPdf} color="primary">
<PdfIcon />
</IconButton>
</Tooltip>
)}
</Box>
<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" />
{canManageVendors && <Tab label="Lieferanten" />}
<Tab label="Katalog" />
</Tabs>
</Box>
{/* ── Tab 0: Orders ── */}
<TabPanel value={tab} index={0}>
{/* ── Summary Cards ── */}
<Box sx={{ mb: 3 }}>
<SummaryCards
stats={[
{ label: 'Wartet auf Genehmigung', value: orders.filter(o => o.status === 'wartet_auf_genehmigung').length, color: 'warning.main' },
{ label: 'Bereit zur Bestellung', value: orders.filter(o => o.status === 'bereit_zur_bestellung').length, color: 'info.main' },
{ label: 'Bestellt', value: orders.filter(o => o.status === 'bestellt').length, color: 'primary.main' },
{ label: 'Lieferung prüfen', value: orders.filter(o => o.status === 'lieferung_pruefen').length, color: 'secondary.main' },
] as SummaryStat[]}
isLoading={ordersLoading}
/>
</Box>
{/* ── 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>
<Typography variant="subtitle2" sx={{ mb: 0.5 }}>Status</Typography>
<FormGroup row>
{ALL_STATUSES.map((s) => (
<FormControlLabel
key={s}
control={<Checkbox size="small" checked={selectedStatuses.has(s)} onChange={() => toggleStatus(s)} />}
label={BESTELLUNG_STATUS_LABELS[s]}
/>
))}
</FormGroup>
</Box>
<Divider />
{/* Vendor */}
{uniqueVendors.length > 0 && (
<Box>
<Typography variant="subtitle2" sx={{ mb: 0.5 }}>Lieferant</Typography>
<FormGroup>
{uniqueVendors.map(([key, label]) => (
<FormControlLabel
key={key}
control={<Checkbox size="small" checked={isVendorSelected(key)} onChange={() => toggleVendor(key)} />}
label={label}
/>
))}
</FormGroup>
</Box>
)}
<Divider />
<Box sx={{ display: 'flex', justifyContent: 'flex-end' }}>
<Button size="small" onClick={resetFilters}>Zurücksetzen</Button>
</Box>
</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>
<DataTable<Bestellung>
columns={[
{ key: 'laufende_nummer', label: 'Kennung', width: 90, render: (o) => (
<Typography sx={{ whiteSpace: 'nowrap', fontFamily: 'monospace', fontSize: '0.85rem' }}>{formatKennung(o)}</Typography>
)},
{ key: 'bezeichnung', label: 'Bezeichnung' },
{ key: 'lieferant_name', label: 'Lieferant', render: (o) => o.lieferant_name || '' },
{ key: 'besteller_name', label: 'Besteller', render: (o) => o.besteller_name || '' },
{ key: 'status', label: 'Status', render: (o) => (
<StatusChip status={o.status} labelMap={BESTELLUNG_STATUS_LABELS} colorMap={BESTELLUNG_STATUS_COLORS} />
)},
{ key: 'items_count', label: 'Positionen', align: 'right', render: (o) => o.items_count ?? 0 },
{ key: 'total_cost', label: 'Gesamtpreis (brutto)', align: 'right', render: (o) => formatCurrency(calcBrutto(o)) },
{ key: 'total_received', label: 'Lieferung', render: (o) => {
const totalOrdered = o.total_ordered ?? 0;
const totalReceived = o.total_received ?? 0;
const deliveryPct = totalOrdered > 0 ? (totalReceived / totalOrdered) * 100 : 0;
return totalOrdered > 0 ? (
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, minWidth: 100 }}>
<LinearProgress variant="determinate" value={Math.min(deliveryPct, 100)} color={deliveryPct >= 100 ? 'success' : 'primary'} sx={{ flexGrow: 1, height: 6, borderRadius: 3 }} />
<Typography variant="caption" sx={{ whiteSpace: 'nowrap' }}>{totalReceived}/{totalOrdered}</Typography>
</Box>
) : '';
}},
{ key: 'erstellt_am', label: 'Erstellt am', render: (o) => formatDate(o.erstellt_am) },
]}
data={filteredOrders}
rowKey={(o) => o.id}
onRowClick={(o) => navigate(`/bestellungen/${o.id}`)}
isLoading={ordersLoading}
emptyMessage="Keine Bestellungen vorhanden"
searchEnabled={false}
/>
{hasPermission('bestellungen:create') && (
<ChatAwareFab onClick={() => navigate('/bestellungen/neu')} aria-label="Neue Bestellung">
<AddIcon />
</ChatAwareFab>
)}
</TabPanel>
{/* ── Tab 1: Vendors ── */}
{canManageVendors && (
<TabPanel value={tab} index={1}>
<DataTable
columns={[
{ key: 'name', label: 'Name' },
{ key: 'kontakt_name', label: 'Kontakt', render: (v) => v.kontakt_name || '' },
{ key: 'email', label: 'E-Mail', render: (v) => v.email ? <a href={`mailto:${v.email}`} onClick={(e) => e.stopPropagation()}>{v.email}</a> : '' },
{ key: 'telefon', label: 'Telefon', render: (v) => v.telefon || '' },
{ key: 'website', label: 'Website', render: (v) => v.website ? (
<a href={v.website.startsWith('http') ? v.website : `https://${v.website}`} target="_blank" rel="noopener noreferrer" onClick={(e) => e.stopPropagation()}>{v.website}</a>
) : '' },
]}
data={vendors}
rowKey={(v) => v.id}
onRowClick={(v) => navigate(`/bestellungen/lieferanten/${v.id}`)}
isLoading={vendorsLoading}
emptyMessage="Keine Lieferanten vorhanden"
/>
<ChatAwareFab onClick={() => navigate('/bestellungen/lieferanten/neu')} aria-label="Lieferant hinzufügen">
<AddIcon />
</ChatAwareFab>
</TabPanel>
)}
{/* ── Tab 2: Katalog ── */}
<TabPanel value={tab} index={canManageVendors ? 2 : 1}>
<KatalogTab />
</TabPanel>
</DashboardLayout>
);
}