feat(bestellungen): add optional "Für Mitglied" field, auto-populated from internal request submitter

This commit is contained in:
Matthias Hochmeister
2026-04-15 18:17:54 +02:00
parent 67fd0878ce
commit eb2342684e
8 changed files with 86 additions and 13 deletions

View File

@@ -193,7 +193,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, positionen } = req.body; const { bezeichnung, lieferant_id, budget, besteller_id, positionen, mitglied_id } = 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;
@@ -210,6 +210,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 (mitglied_id != null && mitglied_id !== '' && (typeof mitglied_id !== 'string' || !/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(mitglied_id))) {
res.status(400).json({ success: false, message: 'Ungültige Mitglied-ID' });
return;
}
if (positionen != null && !Array.isArray(positionen)) { if (positionen != null && !Array.isArray(positionen)) {
res.status(400).json({ success: false, message: 'Positionen muss ein Array sein' }); res.status(400).json({ success: false, message: 'Positionen muss ein Array sein' });
return; return;

View File

@@ -0,0 +1,3 @@
-- Add optional mitglied_id (the member an order is for) to bestellungen
ALTER TABLE bestellungen
ADD COLUMN IF NOT EXISTS mitglied_id UUID REFERENCES users(id) ON DELETE SET NULL;

View File

@@ -809,6 +809,13 @@ async function createOrdersFromRequest(
const createdBestellungen: Array<{ id: number; bezeichnung: string; lieferant_name: string }> = []; const createdBestellungen: Array<{ id: number; bezeichnung: string; lieferant_name: string }> = [];
// Fetch anfrager_id so we can set mitglied_id on created orders
const anfrageResult = await client.query(
'SELECT anfrager_id FROM ausruestung_anfragen WHERE id = $1',
[anfrageId]
);
const anfragerId: string | null = anfrageResult.rows[0]?.anfrager_id ?? null;
for (const orderData of orders) { for (const orderData of orders) {
const nrResult = await client.query( const nrResult = await client.query(
`SELECT COALESCE(MAX(laufende_nummer), 0) + 1 AS next_nr `SELECT COALESCE(MAX(laufende_nummer), 0) + 1 AS next_nr
@@ -818,10 +825,10 @@ async function createOrdersFromRequest(
const laufendeNummer = nrResult.rows[0].next_nr; const laufendeNummer = nrResult.rows[0].next_nr;
const bestellungResult = await client.query( const bestellungResult = await client.query(
`INSERT INTO bestellungen (bezeichnung, lieferant_id, status, laufende_nummer, erstellt_von) `INSERT INTO bestellungen (bezeichnung, lieferant_id, status, mitglied_id, laufende_nummer, erstellt_von)
VALUES ($1, $2, 'wartet_auf_genehmigung', $3, $4) VALUES ($1, $2, 'wartet_auf_genehmigung', $3, $4, $5)
RETURNING id, bezeichnung`, RETURNING id, bezeichnung`,
[orderData.bezeichnung, orderData.lieferant_id, laufendeNummer, userId] [orderData.bezeichnung, orderData.lieferant_id, anfragerId, laufendeNummer, userId]
); );
const bestellung = bestellungResult.rows[0]; const bestellung = bestellungResult.rows[0];

View File

@@ -153,6 +153,7 @@ async function getOrders(filters?: { status?: string; lieferant_id?: number; bes
`SELECT b.*, `SELECT b.*,
l.name AS lieferant_name, l.name AS lieferant_name,
COALESCE(u.name, u.preferred_username, u.email) AS besteller_name, COALESCE(u.name, u.preferred_username, u.email) AS besteller_name,
COALESCE(mu.name, mu.preferred_username, mu.email) AS mitglied_name,
COALESCE(pos.total_cost, 0) AS total_cost, COALESCE(pos.total_cost, 0) AS total_cost,
COALESCE(pos.items_count, 0) AS items_count, COALESCE(pos.items_count, 0) AS items_count,
COALESCE(pos.total_received, 0) AS total_received, COALESCE(pos.total_received, 0) AS total_received,
@@ -160,6 +161,7 @@ async function getOrders(filters?: { status?: string; lieferant_id?: number; bes
FROM bestellungen b FROM bestellungen b
LEFT JOIN lieferanten l ON l.id = b.lieferant_id LEFT JOIN lieferanten l ON l.id = b.lieferant_id
LEFT JOIN users u ON u.id = b.erstellt_von LEFT JOIN users u ON u.id = b.erstellt_von
LEFT JOIN users mu ON mu.id = b.mitglied_id
LEFT JOIN LATERAL ( LEFT JOIN LATERAL (
SELECT SUM(einzelpreis * menge) AS total_cost, SELECT SUM(einzelpreis * menge) AS total_cost,
COUNT(*) AS items_count, COUNT(*) AS items_count,
@@ -191,11 +193,16 @@ async function getOrderById(id: number) {
COALESCE(u.name, u.preferred_username, u.email) AS besteller_name, COALESCE(u.name, u.preferred_username, u.email) AS besteller_name,
u.email AS besteller_email, u.email AS besteller_email,
COALESCE(mp.telefon_mobil, mp.telefon_privat) AS besteller_telefon, COALESCE(mp.telefon_mobil, mp.telefon_privat) AS besteller_telefon,
mp.dienstgrad AS besteller_dienstgrad mp.dienstgrad AS besteller_dienstgrad,
mu.id AS mitglied_id,
COALESCE(mu.name, mu.preferred_username, mu.email) AS mitglied_name,
mmp.dienstgrad AS mitglied_dienstgrad
FROM bestellungen b FROM bestellungen b
LEFT JOIN lieferanten l ON l.id = b.lieferant_id LEFT JOIN lieferanten l ON l.id = b.lieferant_id
LEFT JOIN users u ON u.id = COALESCE(b.besteller_id, b.erstellt_von) LEFT JOIN users u ON u.id = COALESCE(b.besteller_id, b.erstellt_von)
LEFT JOIN mitglieder_profile mp ON mp.user_id = COALESCE(b.besteller_id, b.erstellt_von) LEFT JOIN mitglieder_profile mp ON mp.user_id = COALESCE(b.besteller_id, b.erstellt_von)
LEFT JOIN users mu ON mu.id = b.mitglied_id
LEFT JOIN mitglieder_profile mmp ON mmp.user_id = b.mitglied_id
WHERE b.id = $1`, WHERE b.id = $1`,
[id] [id]
); );
@@ -227,7 +234,7 @@ async function getOrderById(id: number) {
} }
} }
async function createOrder(data: { bezeichnung: string; lieferant_id?: number; besteller_id?: string; notizen?: string; budget?: number; steuersatz?: number; positionen?: Array<{ bezeichnung: string; menge: number; einheit?: string }> }, userId: string) { async function createOrder(data: { bezeichnung: string; lieferant_id?: number; besteller_id?: string; mitglied_id?: string; notizen?: string; budget?: number; steuersatz?: number; positionen?: Array<{ bezeichnung: string; menge: number; einheit?: string }> }, userId: string) {
const client = await pool.connect(); const client = await pool.connect();
try { try {
await client.query('BEGIN'); await client.query('BEGIN');
@@ -242,10 +249,10 @@ async function createOrder(data: { bezeichnung: string; lieferant_id?: number; b
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 client.query( const result = await client.query(
`INSERT INTO bestellungen (bezeichnung, lieferant_id, besteller_id, notizen, budget, steuersatz, laufende_nummer, erstellt_von) `INSERT INTO bestellungen (bezeichnung, lieferant_id, besteller_id, notizen, budget, steuersatz, mitglied_id, laufende_nummer, erstellt_von)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)
RETURNING *`, RETURNING *`,
[data.bezeichnung, data.lieferant_id || null, bestellerId, data.notizen || null, data.budget || null, data.steuersatz ?? 20, laufendeNummer, userId] [data.bezeichnung, data.lieferant_id || null, bestellerId, data.notizen || null, data.budget || null, data.steuersatz ?? 20, data.mitglied_id || null, laufendeNummer, userId]
); );
const order = result.rows[0]; const order = result.rows[0];
@@ -271,7 +278,7 @@ async function createOrder(data: { bezeichnung: string; lieferant_id?: number; b
} }
} }
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) { async function updateOrder(id: number, data: { bezeichnung?: string; lieferant_id?: number; besteller_id?: string | null; notizen?: string; budget?: number; status?: string; steuersatz?: number; mitglied_id?: string | null }, userId: string) {
try { try {
// Check current order for status change detection // Check current order for status change detection
const current = await pool.query(`SELECT * FROM bestellungen WHERE id = $1`, [id]); const current = await pool.query(`SELECT * FROM bestellungen WHERE id = $1`, [id]);
@@ -303,10 +310,11 @@ async function updateOrder(id: number, data: { bezeichnung?: string; lieferant_i
bestellt_am = $7, bestellt_am = $7,
abgeschlossen_am = $8, abgeschlossen_am = $8,
steuersatz = COALESCE($9, steuersatz), steuersatz = COALESCE($9, steuersatz),
mitglied_id = CASE WHEN $10::text IS NOT NULL AND $10::text != '' THEN $10::uuid ELSE mitglied_id END,
aktualisiert_am = NOW() aktualisiert_am = NOW()
WHERE id = $10 WHERE id = $11
RETURNING *`, RETURNING *`,
[data.bezeichnung, data.lieferant_id, data.besteller_id ?? null, 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, data.mitglied_id ?? null, id]
); );
if (result.rows.length === 0) return null; if (result.rows.length === 0) return null;
@@ -317,6 +325,7 @@ async function updateOrder(id: number, data: { bezeichnung?: string; lieferant_i
if (data.status && data.status !== oldStatus) changes.push(`Status: ${oldStatus}${data.status}`); if (data.status && data.status !== oldStatus) changes.push(`Status: ${oldStatus}${data.status}`);
if (data.budget) changes.push(`Budget geändert`); if (data.budget) changes.push(`Budget geändert`);
if (data.steuersatz != null) changes.push(`Steuersatz: ${data.steuersatz}%`); if (data.steuersatz != null) changes.push(`Steuersatz: ${data.steuersatz}%`);
if (data.mitglied_id) changes.push('Mitglied geändert');
await logAction(id, 'Bestellung aktualisiert', changes.join(', ') || 'Bestellung bearbeitet', userId); await logAction(id, 'Bestellung aktualisiert', changes.join(', ') || 'Bestellung bearbeitet', userId);
return result.rows[0]; return result.rows[0];

View File

@@ -480,6 +480,7 @@ export default function BestellungDetail() {
bezeichnung: string; bezeichnung: string;
lieferant_id?: number; lieferant_id?: number;
besteller_id?: string; besteller_id?: string;
mitglied_id?: string;
notizen: string; notizen: string;
steuersatz: number; steuersatz: number;
}>({ bezeichnung: '', notizen: '', steuersatz: 20 }); }>({ bezeichnung: '', notizen: '', steuersatz: 20 });
@@ -661,6 +662,7 @@ export default function BestellungDetail() {
bezeichnung: bestellung.bezeichnung, bezeichnung: bestellung.bezeichnung,
lieferant_id: bestellung.lieferant_id, lieferant_id: bestellung.lieferant_id,
besteller_id: bestellung.besteller_id || '', besteller_id: bestellung.besteller_id || '',
mitglied_id: bestellung.mitglied_id || '',
notizen: bestellung.notizen || '', notizen: bestellung.notizen || '',
steuersatz: parseFloat(String(bestellung.steuersatz ?? 20)), steuersatz: parseFloat(String(bestellung.steuersatz ?? 20)),
}); });
@@ -690,6 +692,7 @@ export default function BestellungDetail() {
bezeichnung: editOrderData.bezeichnung, bezeichnung: editOrderData.bezeichnung,
lieferant_id: editOrderData.lieferant_id, lieferant_id: editOrderData.lieferant_id,
besteller_id: editOrderData.besteller_id || undefined, besteller_id: editOrderData.besteller_id || undefined,
mitglied_id: editOrderData.mitglied_id || undefined,
notizen: editOrderData.notizen, notizen: editOrderData.notizen,
steuersatz: editOrderData.steuersatz, steuersatz: editOrderData.steuersatz,
}); });
@@ -822,6 +825,19 @@ export default function BestellungDetail() {
curY += 3; curY += 3;
} }
// ── Für Mitglied block ──
if (bestellung.mitglied_name) {
doc.setFontSize(10);
doc.setFont('helvetica', 'bold');
doc.text('Für Mitglied', 10, curY);
curY += 5;
const mitgliedNameWithRank = bestellung.mitglied_dienstgrad
? `${kurzDienstgrad(bestellung.mitglied_dienstgrad)} ${bestellung.mitglied_name}`
: bestellung.mitglied_name;
row('Name', mitgliedNameWithRank);
curY += 3;
}
// ── Order info block ── // ── Order info block ──
doc.setFontSize(10); doc.setFontSize(10);
doc.setFont('helvetica', 'bold'); doc.setFont('helvetica', 'bold');
@@ -1115,6 +1131,15 @@ export default function BestellungDetail() {
renderInput={(params) => <TextField {...params} label="Besteller" size="small" />} renderInput={(params) => <TextField {...params} label="Besteller" size="small" />}
/> />
</Grid> </Grid>
<Grid item xs={12} sm={6}>
<Autocomplete
options={orderUsers}
getOptionLabel={(o) => o.name}
value={orderUsers.find(u => u.id === editOrderData.mitglied_id) ?? null}
onChange={(_, v) => setEditOrderData(d => ({ ...d, mitglied_id: v?.id || '' }))}
renderInput={(params) => <TextField {...params} label="Für Mitglied" size="small" />}
/>
</Grid>
<Grid item xs={12}> <Grid item xs={12}>
<TextField label="Notizen" fullWidth multiline rows={3} size="small" <TextField label="Notizen" fullWidth multiline rows={3} size="small"
value={editOrderData.notizen} value={editOrderData.notizen}
@@ -1136,6 +1161,18 @@ export default function BestellungDetail() {
<Typography>{bestellung.besteller_name || ''}</Typography> <Typography>{bestellung.besteller_name || ''}</Typography>
</CardContent></Card> </CardContent></Card>
</Grid> </Grid>
{bestellung.mitglied_name && (
<Grid item xs={12} sm={6} md={4}>
<Card variant="outlined"><CardContent>
<Typography variant="caption" color="text.secondary">Für Mitglied</Typography>
<Typography>
{bestellung.mitglied_dienstgrad
? `${kurzDienstgrad(bestellung.mitglied_dienstgrad)} ${bestellung.mitglied_name}`
: bestellung.mitglied_name}
</Typography>
</CardContent></Card>
</Grid>
)}
<Grid item xs={12} sm={6} md={4}> <Grid item xs={12} sm={6} md={4}>
<Card variant="outlined"><CardContent> <Card variant="outlined"><CardContent>
<Typography variant="caption" color="text.secondary">Erstellt am</Typography> <Typography variant="caption" color="text.secondary">Erstellt am</Typography>

View File

@@ -23,7 +23,7 @@ import { ausruestungsanfrageApi } from '../services/ausruestungsanfrage';
import type { BestellungFormData, BestellpositionFormData, LieferantFormData, Lieferant } from '../types/bestellung.types'; import type { BestellungFormData, BestellpositionFormData, LieferantFormData, Lieferant } from '../types/bestellung.types';
import type { AusruestungArtikel } from '../types/ausruestungsanfrage.types'; import type { AusruestungArtikel } from '../types/ausruestungsanfrage.types';
const emptyOrderForm: BestellungFormData = { bezeichnung: '', lieferant_id: undefined, besteller_id: '', notizen: '', positionen: [] }; const emptyOrderForm: BestellungFormData = { bezeichnung: '', lieferant_id: undefined, besteller_id: '', mitglied_id: 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' }; const emptyPosition: BestellpositionFormData = { bezeichnung: '', menge: 1, einheit: 'Stk' };
@@ -150,6 +150,14 @@ export default function BestellungNeu() {
renderInput={(params) => <TextField {...params} label="Besteller" />} renderInput={(params) => <TextField {...params} label="Besteller" />}
/> />
<Autocomplete
options={orderUsers}
getOptionLabel={(o) => o.name}
value={orderUsers.find((u) => u.id === orderForm.mitglied_id) || null}
onChange={(_e, v) => setOrderForm((f) => ({ ...f, mitglied_id: v?.id || '' }))}
renderInput={(params) => <TextField {...params} label="Für Mitglied" />}
/>
<TextField <TextField
label="Notizen" label="Notizen"
multiline multiline

View File

@@ -334,6 +334,7 @@ export default function Bestellungen() {
{ key: 'bezeichnung', label: 'Bezeichnung' }, { key: 'bezeichnung', label: 'Bezeichnung' },
{ key: 'lieferant_name', label: 'Lieferant', render: (o) => o.lieferant_name || '' }, { key: 'lieferant_name', label: 'Lieferant', render: (o) => o.lieferant_name || '' },
{ key: 'besteller_name', label: 'Besteller', render: (o) => o.besteller_name || '' }, { key: 'besteller_name', label: 'Besteller', render: (o) => o.besteller_name || '' },
{ key: 'mitglied_name', label: 'Für Mitglied', render: (o) => o.mitglied_name || '' },
{ key: 'status', label: 'Status', render: (o) => ( { key: 'status', label: 'Status', render: (o) => (
<StatusChip status={o.status} labelMap={BESTELLUNG_STATUS_LABELS} colorMap={BESTELLUNG_STATUS_COLORS} /> <StatusChip status={o.status} labelMap={BESTELLUNG_STATUS_LABELS} colorMap={BESTELLUNG_STATUS_COLORS} />
)}, )},

View File

@@ -64,6 +64,9 @@ export interface Bestellung {
besteller_email?: string; besteller_email?: string;
besteller_telefon?: string; besteller_telefon?: string;
besteller_dienstgrad?: string; besteller_dienstgrad?: string;
mitglied_id?: string;
mitglied_name?: string;
mitglied_dienstgrad?: string;
status: BestellungStatus; status: BestellungStatus;
budget?: number; budget?: number;
steuersatz?: number; steuersatz?: number;
@@ -88,6 +91,7 @@ export interface BestellungFormData {
bezeichnung: string; bezeichnung: string;
lieferant_id?: number; lieferant_id?: number;
besteller_id?: string; besteller_id?: string;
mitglied_id?: string;
status?: BestellungStatus; status?: BestellungStatus;
steuersatz?: number; steuersatz?: number;
notizen?: string; notizen?: string;