new features
This commit is contained in:
@@ -113,7 +113,7 @@ class BestellungController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async createOrder(req: Request, res: Response): Promise<void> {
|
async createOrder(req: Request, res: Response): Promise<void> {
|
||||||
const { bezeichnung, lieferant_id, budget, besteller_id } = req.body;
|
const { bezeichnung, lieferant_id, budget, besteller_id, positionen } = req.body;
|
||||||
if (!bezeichnung || typeof bezeichnung !== 'string' || bezeichnung.trim().length === 0) {
|
if (!bezeichnung || typeof bezeichnung !== 'string' || bezeichnung.trim().length === 0) {
|
||||||
res.status(400).json({ success: false, message: 'Bezeichnung ist erforderlich' });
|
res.status(400).json({ success: false, message: 'Bezeichnung ist erforderlich' });
|
||||||
return;
|
return;
|
||||||
@@ -130,6 +130,10 @@ class BestellungController {
|
|||||||
res.status(400).json({ success: false, message: 'Ungültige Besteller-ID' });
|
res.status(400).json({ success: false, message: 'Ungültige Besteller-ID' });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (positionen != null && !Array.isArray(positionen)) {
|
||||||
|
res.status(400).json({ success: false, message: 'Positionen muss ein Array sein' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
try {
|
try {
|
||||||
const order = await bestellungService.createOrder(req.body, req.user!.id);
|
const order = await bestellungService.createOrder(req.body, req.user!.id);
|
||||||
res.status(201).json({ success: true, data: order });
|
res.status(201).json({ success: true, data: order });
|
||||||
|
|||||||
@@ -174,21 +174,38 @@ async function getOrderById(id: number) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function createOrder(data: { bezeichnung: string; lieferant_id?: number; besteller_id?: string; notizen?: string; budget?: number }, userId: string) {
|
async function createOrder(data: { bezeichnung: string; lieferant_id?: number; besteller_id?: string; notizen?: string; budget?: number; positionen?: Array<{ bezeichnung: string; menge: number; einheit?: string }> }, userId: string) {
|
||||||
|
const client = await pool.connect();
|
||||||
try {
|
try {
|
||||||
|
await client.query('BEGIN');
|
||||||
const bestellerId = data.besteller_id && data.besteller_id.trim() ? data.besteller_id.trim() : null;
|
const bestellerId = data.besteller_id && data.besteller_id.trim() ? data.besteller_id.trim() : null;
|
||||||
const result = await pool.query(
|
const result = await client.query(
|
||||||
`INSERT INTO bestellungen (bezeichnung, lieferant_id, besteller_id, notizen, budget, erstellt_von)
|
`INSERT INTO bestellungen (bezeichnung, lieferant_id, besteller_id, notizen, budget, erstellt_von)
|
||||||
VALUES ($1, $2, $3, $4, $5, $6)
|
VALUES ($1, $2, $3, $4, $5, $6)
|
||||||
RETURNING *`,
|
RETURNING *`,
|
||||||
[data.bezeichnung, data.lieferant_id || null, bestellerId, data.notizen || null, data.budget || null, userId]
|
[data.bezeichnung, data.lieferant_id || null, bestellerId, data.notizen || null, data.budget || null, userId]
|
||||||
);
|
);
|
||||||
const order = result.rows[0];
|
const order = result.rows[0];
|
||||||
|
|
||||||
|
if (data.positionen && data.positionen.length > 0) {
|
||||||
|
for (const pos of data.positionen) {
|
||||||
|
await client.query(
|
||||||
|
`INSERT INTO bestellpositionen (bestellung_id, bezeichnung, menge, einheit)
|
||||||
|
VALUES ($1, $2, $3, $4)`,
|
||||||
|
[order.id, pos.bezeichnung, pos.menge, pos.einheit || 'Stk']
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
await logAction(order.id, 'Bestellung erstellt', `Bestellung "${data.bezeichnung}" erstellt`, userId);
|
await logAction(order.id, 'Bestellung erstellt', `Bestellung "${data.bezeichnung}" erstellt`, userId);
|
||||||
|
await client.query('COMMIT');
|
||||||
return order;
|
return order;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
await client.query('ROLLBACK');
|
||||||
logger.error('BestellungService.createOrder failed', { error });
|
logger.error('BestellungService.createOrder failed', { error });
|
||||||
throw new Error('Bestellung konnte nicht erstellt werden');
|
throw new Error('Bestellung konnte nicht erstellt werden');
|
||||||
|
} finally {
|
||||||
|
client.release();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -126,12 +126,7 @@ const baseNavigationItems: NavigationItem[] = [
|
|||||||
text: 'Shop',
|
text: 'Shop',
|
||||||
icon: <Store />,
|
icon: <Store />,
|
||||||
path: '/shop',
|
path: '/shop',
|
||||||
subItems: [
|
// subItems computed dynamically in navigationItems useMemo
|
||||||
{ text: 'Katalog', path: '/shop?tab=0' },
|
|
||||||
{ text: 'Meine Anfragen', path: '/shop?tab=1' },
|
|
||||||
{ text: 'Alle Anfragen', path: '/shop?tab=2' },
|
|
||||||
{ text: 'Übersicht', path: '/shop?tab=3' },
|
|
||||||
],
|
|
||||||
permission: 'shop:view',
|
permission: 'shop:view',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -194,8 +189,21 @@ function Sidebar({ mobileOpen, onMobileClose }: SidebarProps) {
|
|||||||
subItems: vehicleSubItems.length > 0 ? vehicleSubItems : undefined,
|
subItems: vehicleSubItems.length > 0 ? vehicleSubItems : undefined,
|
||||||
permission: 'fahrzeuge:view',
|
permission: 'fahrzeuge:view',
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Build Shop sub-items dynamically based on permissions (tab order must match Shop.tsx)
|
||||||
|
const shopSubItems: SubItem[] = [];
|
||||||
|
let shopTabIdx = 0;
|
||||||
|
if (hasPermission('shop:create_request')) { shopSubItems.push({ text: 'Meine Anfragen', path: `/shop?tab=${shopTabIdx}` }); shopTabIdx++; }
|
||||||
|
if (hasPermission('shop:approve_requests')) { shopSubItems.push({ text: 'Alle Anfragen', path: `/shop?tab=${shopTabIdx}` }); shopTabIdx++; }
|
||||||
|
if (hasPermission('shop:view_overview')) { shopSubItems.push({ text: 'Übersicht', path: `/shop?tab=${shopTabIdx}` }); shopTabIdx++; }
|
||||||
|
shopSubItems.push({ text: 'Katalog', path: `/shop?tab=${shopTabIdx}` });
|
||||||
|
|
||||||
const items = baseNavigationItems
|
const items = baseNavigationItems
|
||||||
.map((item) => item.path === '/fahrzeuge' ? fahrzeugeItem : item)
|
.map((item) => {
|
||||||
|
if (item.path === '/fahrzeuge') return fahrzeugeItem;
|
||||||
|
if (item.path === '/shop') return { ...item, subItems: shopSubItems };
|
||||||
|
return item;
|
||||||
|
})
|
||||||
.filter((item) => !item.permission || hasPermission(item.permission));
|
.filter((item) => !item.permission || hasPermission(item.permission));
|
||||||
return hasPermission('admin:view') ? [...items, adminItem, adminSettingsItem] : items;
|
return hasPermission('admin:view') ? [...items, adminItem, adminSettingsItem] : items;
|
||||||
}, [vehicleSubItems, hasPermission]);
|
}, [vehicleSubItems, hasPermission]);
|
||||||
|
|||||||
@@ -26,7 +26,7 @@ import {
|
|||||||
Tooltip,
|
Tooltip,
|
||||||
Autocomplete,
|
Autocomplete,
|
||||||
} from '@mui/material';
|
} from '@mui/material';
|
||||||
import { Add as AddIcon, Edit as EditIcon, Delete as DeleteIcon } from '@mui/icons-material';
|
import { Add as AddIcon, Edit as EditIcon, Delete as DeleteIcon, RemoveCircleOutline as RemoveIcon } from '@mui/icons-material';
|
||||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
import { useQuery, useMutation, useQueryClient } 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';
|
||||||
@@ -35,7 +35,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, BestellungFormData, LieferantFormData, Lieferant } from '../types/bestellung.types';
|
import type { BestellungStatus, BestellungFormData, BestellpositionFormData, LieferantFormData, Lieferant } from '../types/bestellung.types';
|
||||||
|
|
||||||
// ── Helpers ──
|
// ── Helpers ──
|
||||||
|
|
||||||
@@ -61,8 +61,9 @@ const ALL_STATUSES: BestellungStatus[] = ['entwurf', 'erstellt', 'bestellt', 'te
|
|||||||
|
|
||||||
// ── Empty form data ──
|
// ── Empty form data ──
|
||||||
|
|
||||||
const emptyOrderForm: BestellungFormData = { bezeichnung: '', lieferant_id: undefined, besteller_id: '', budget: undefined, notizen: '' };
|
const emptyOrderForm: BestellungFormData = { bezeichnung: '', lieferant_id: undefined, besteller_id: '', budget: undefined, notizen: '', positionen: [] };
|
||||||
const emptyVendorForm: LieferantFormData = { name: '', kontakt_name: '', email: '', telefon: '', adresse: '', website: '', notizen: '' };
|
const emptyVendorForm: LieferantFormData = { name: '', kontakt_name: '', email: '', telefon: '', adresse: '', website: '', notizen: '' };
|
||||||
|
const emptyPosition: BestellpositionFormData = { bezeichnung: '', menge: 1, einheit: 'Stk' };
|
||||||
|
|
||||||
// ══════════════════════════════════════════════════════════════════════════════
|
// ══════════════════════════════════════════════════════════════════════════════
|
||||||
// Component
|
// Component
|
||||||
@@ -330,12 +331,13 @@ export default function Bestellungen() {
|
|||||||
</TabPanel>
|
</TabPanel>
|
||||||
|
|
||||||
{/* ── Create Order Dialog ── */}
|
{/* ── Create Order Dialog ── */}
|
||||||
<Dialog open={orderDialogOpen} onClose={() => setOrderDialogOpen(false)} maxWidth="sm" fullWidth>
|
<Dialog open={orderDialogOpen} onClose={() => setOrderDialogOpen(false)} maxWidth="md" fullWidth>
|
||||||
<DialogTitle>Neue Bestellung</DialogTitle>
|
<DialogTitle>Neue Bestellung</DialogTitle>
|
||||||
<DialogContent sx={{ display: 'flex', flexDirection: 'column', gap: 2, pt: '16px !important' }}>
|
<DialogContent sx={{ display: 'flex', flexDirection: 'column', gap: 2, pt: '16px !important' }}>
|
||||||
<TextField
|
<TextField
|
||||||
label="Bezeichnung"
|
label="Bezeichnung"
|
||||||
required
|
required
|
||||||
|
InputLabelProps={{ shrink: true }}
|
||||||
value={orderForm.bezeichnung}
|
value={orderForm.bezeichnung}
|
||||||
onChange={(e) => setOrderForm((f) => ({ ...f, bezeichnung: e.target.value }))}
|
onChange={(e) => setOrderForm((f) => ({ ...f, bezeichnung: e.target.value }))}
|
||||||
/>
|
/>
|
||||||
@@ -378,13 +380,6 @@ export default function Bestellungen() {
|
|||||||
onChange={(_e, v) => setOrderForm((f) => ({ ...f, besteller_id: v?.id || '' }))}
|
onChange={(_e, v) => setOrderForm((f) => ({ ...f, besteller_id: v?.id || '' }))}
|
||||||
renderInput={(params) => <TextField {...params} label="Besteller" />}
|
renderInput={(params) => <TextField {...params} label="Besteller" />}
|
||||||
/>
|
/>
|
||||||
<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
|
<TextField
|
||||||
label="Notizen"
|
label="Notizen"
|
||||||
multiline
|
multiline
|
||||||
@@ -392,6 +387,69 @@ export default function Bestellungen() {
|
|||||||
value={orderForm.notizen || ''}
|
value={orderForm.notizen || ''}
|
||||||
onChange={(e) => setOrderForm((f) => ({ ...f, notizen: e.target.value }))}
|
onChange={(e) => setOrderForm((f) => ({ ...f, notizen: e.target.value }))}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
{/* ── Dynamic Items List ── */}
|
||||||
|
<Typography variant="subtitle2" sx={{ mt: 1 }}>Positionen</Typography>
|
||||||
|
{(orderForm.positionen || []).map((pos, idx) => (
|
||||||
|
<Box key={idx} sx={{ display: 'flex', gap: 1, alignItems: 'center' }}>
|
||||||
|
<TextField
|
||||||
|
label="Bezeichnung"
|
||||||
|
size="small"
|
||||||
|
required
|
||||||
|
InputLabelProps={{ shrink: true }}
|
||||||
|
value={pos.bezeichnung}
|
||||||
|
onChange={(e) => {
|
||||||
|
const next = [...(orderForm.positionen || [])];
|
||||||
|
next[idx] = { ...next[idx], bezeichnung: e.target.value };
|
||||||
|
setOrderForm((f) => ({ ...f, positionen: next }));
|
||||||
|
}}
|
||||||
|
sx={{ flexGrow: 1 }}
|
||||||
|
/>
|
||||||
|
<TextField
|
||||||
|
label="Menge"
|
||||||
|
size="small"
|
||||||
|
type="number"
|
||||||
|
InputLabelProps={{ shrink: true }}
|
||||||
|
value={pos.menge}
|
||||||
|
onChange={(e) => {
|
||||||
|
const next = [...(orderForm.positionen || [])];
|
||||||
|
next[idx] = { ...next[idx], menge: Math.max(1, Number(e.target.value) || 1) };
|
||||||
|
setOrderForm((f) => ({ ...f, positionen: next }));
|
||||||
|
}}
|
||||||
|
sx={{ width: 90 }}
|
||||||
|
inputProps={{ min: 1 }}
|
||||||
|
/>
|
||||||
|
<TextField
|
||||||
|
label="Einheit"
|
||||||
|
size="small"
|
||||||
|
InputLabelProps={{ shrink: true }}
|
||||||
|
value={pos.einheit || 'Stk'}
|
||||||
|
onChange={(e) => {
|
||||||
|
const next = [...(orderForm.positionen || [])];
|
||||||
|
next[idx] = { ...next[idx], einheit: e.target.value };
|
||||||
|
setOrderForm((f) => ({ ...f, positionen: next }));
|
||||||
|
}}
|
||||||
|
sx={{ width: 100 }}
|
||||||
|
/>
|
||||||
|
<IconButton
|
||||||
|
size="small"
|
||||||
|
color="error"
|
||||||
|
onClick={() => {
|
||||||
|
const next = (orderForm.positionen || []).filter((_, i) => i !== idx);
|
||||||
|
setOrderForm((f) => ({ ...f, positionen: next }));
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<RemoveIcon />
|
||||||
|
</IconButton>
|
||||||
|
</Box>
|
||||||
|
))}
|
||||||
|
<Button
|
||||||
|
size="small"
|
||||||
|
startIcon={<AddIcon />}
|
||||||
|
onClick={() => setOrderForm((f) => ({ ...f, positionen: [...(f.positionen || []), { ...emptyPosition }] }))}
|
||||||
|
>
|
||||||
|
Position hinzufügen
|
||||||
|
</Button>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
<DialogActions>
|
<DialogActions>
|
||||||
<Button onClick={() => setOrderDialogOpen(false)}>Abbrechen</Button>
|
<Button onClick={() => setOrderDialogOpen(false)}>Abbrechen</Button>
|
||||||
|
|||||||
@@ -653,11 +653,12 @@ export default function Shop() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const tabIndex = useMemo(() => {
|
const tabIndex = useMemo(() => {
|
||||||
const map: Record<string, number> = { katalog: 0 };
|
const map: Record<string, number> = {};
|
||||||
let next = 1;
|
let next = 0;
|
||||||
if (canCreate) { map.meine = next; next++; }
|
if (canCreate) { map.meine = next; next++; }
|
||||||
if (canApprove) { map.alle = next; next++; }
|
if (canApprove) { map.alle = next; next++; }
|
||||||
if (canViewOverview) { map.uebersicht = next; }
|
if (canViewOverview) { map.uebersicht = next; next++; }
|
||||||
|
map.katalog = next;
|
||||||
return map;
|
return map;
|
||||||
}, [canCreate, canApprove, canViewOverview]);
|
}, [canCreate, canApprove, canViewOverview]);
|
||||||
|
|
||||||
@@ -675,17 +676,17 @@ export default function Shop() {
|
|||||||
|
|
||||||
<Box sx={{ borderBottom: 1, borderColor: 'divider', mb: 3 }}>
|
<Box sx={{ borderBottom: 1, borderColor: 'divider', mb: 3 }}>
|
||||||
<Tabs value={activeTab} onChange={handleTabChange} variant="scrollable" scrollButtons="auto">
|
<Tabs value={activeTab} onChange={handleTabChange} variant="scrollable" scrollButtons="auto">
|
||||||
<Tab label="Katalog" />
|
|
||||||
{canCreate && <Tab label="Meine Anfragen" />}
|
{canCreate && <Tab label="Meine Anfragen" />}
|
||||||
{canApprove && <Tab label="Alle Anfragen" />}
|
{canApprove && <Tab label="Alle Anfragen" />}
|
||||||
{canViewOverview && <Tab label="Übersicht" />}
|
{canViewOverview && <Tab label="Übersicht" />}
|
||||||
|
<Tab label="Katalog" />
|
||||||
</Tabs>
|
</Tabs>
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
{activeTab === tabIndex.katalog && <KatalogTab />}
|
|
||||||
{canCreate && activeTab === tabIndex.meine && <MeineAnfragenTab />}
|
{canCreate && activeTab === tabIndex.meine && <MeineAnfragenTab />}
|
||||||
{canApprove && activeTab === tabIndex.alle && <AlleAnfragenTab />}
|
{canApprove && activeTab === tabIndex.alle && <AlleAnfragenTab />}
|
||||||
{canViewOverview && activeTab === tabIndex.uebersicht && <UebersichtTab />}
|
{canViewOverview && activeTab === tabIndex.uebersicht && <UebersichtTab />}
|
||||||
|
{activeTab === tabIndex.katalog && <KatalogTab />}
|
||||||
</DashboardLayout>
|
</DashboardLayout>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -75,6 +75,7 @@ export interface BestellungFormData {
|
|||||||
status?: BestellungStatus;
|
status?: BestellungStatus;
|
||||||
budget?: number;
|
budget?: number;
|
||||||
notizen?: string;
|
notizen?: string;
|
||||||
|
positionen?: BestellpositionFormData[];
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Line Items ──
|
// ── Line Items ──
|
||||||
|
|||||||
Reference in New Issue
Block a user