rework internal order system

This commit is contained in:
Matthias Hochmeister
2026-03-24 08:41:24 +01:00
parent f982fbb2b6
commit 3c0a8a6832
9 changed files with 910 additions and 205 deletions

View File

@@ -5,6 +5,70 @@ import { permissionService } from '../services/permission.service';
import logger from '../utils/logger';
class AusruestungsanfrageController {
// -------------------------------------------------------------------------
// Categories (DB-backed)
// -------------------------------------------------------------------------
async getKategorien(_req: Request, res: Response): Promise<void> {
try {
const kategorien = await ausruestungsanfrageService.getKategorien();
res.status(200).json({ success: true, data: kategorien });
} catch (error) {
logger.error('AusruestungsanfrageController.getKategorien error', { error });
res.status(500).json({ success: false, message: 'Kategorien konnten nicht geladen werden' });
}
}
async createKategorie(req: Request, res: Response): Promise<void> {
try {
const { name } = req.body;
if (!name || name.trim().length === 0) {
res.status(400).json({ success: false, message: 'Name ist erforderlich' });
return;
}
const kategorie = await ausruestungsanfrageService.createKategorie(name.trim());
res.status(201).json({ success: true, data: kategorie });
} catch (error: any) {
if (error?.constraint === 'ausruestung_kategorien_katalog_name_key') {
res.status(409).json({ success: false, message: 'Kategorie existiert bereits' });
return;
}
logger.error('AusruestungsanfrageController.createKategorie error', { error });
res.status(500).json({ success: false, message: 'Kategorie konnte nicht erstellt werden' });
}
}
async updateKategorie(req: Request, res: Response): Promise<void> {
try {
const id = Number(req.params.id);
const { name } = req.body;
if (!name || name.trim().length === 0) {
res.status(400).json({ success: false, message: 'Name ist erforderlich' });
return;
}
const kategorie = await ausruestungsanfrageService.updateKategorie(id, name.trim());
if (!kategorie) {
res.status(404).json({ success: false, message: 'Kategorie nicht gefunden' });
return;
}
res.status(200).json({ success: true, data: kategorie });
} catch (error) {
logger.error('AusruestungsanfrageController.updateKategorie error', { error });
res.status(500).json({ success: false, message: 'Kategorie konnte nicht aktualisiert werden' });
}
}
async deleteKategorie(req: Request, res: Response): Promise<void> {
try {
const id = Number(req.params.id);
await ausruestungsanfrageService.deleteKategorie(id);
res.status(200).json({ success: true, message: 'Kategorie gelöscht' });
} catch (error) {
logger.error('AusruestungsanfrageController.deleteKategorie error', { error });
res.status(500).json({ success: false, message: 'Kategorie konnte nicht gelöscht werden' });
}
}
// -------------------------------------------------------------------------
// Catalog Items
// -------------------------------------------------------------------------
@@ -12,8 +76,9 @@ class AusruestungsanfrageController {
async getItems(req: Request, res: Response): Promise<void> {
try {
const kategorie = req.query.kategorie as string | undefined;
const kategorie_id = req.query.kategorie_id ? Number(req.query.kategorie_id) : undefined;
const aktiv = req.query.aktiv !== undefined ? req.query.aktiv === 'true' : undefined;
const items = await ausruestungsanfrageService.getItems({ kategorie, aktiv });
const items = await ausruestungsanfrageService.getItems({ kategorie, kategorie_id, aktiv });
res.status(200).json({ success: true, data: items });
} catch (error) {
logger.error('AusruestungsanfrageController.getItems error', { error });
@@ -87,6 +152,59 @@ class AusruestungsanfrageController {
}
}
// -------------------------------------------------------------------------
// Artikel Eigenschaften (characteristics)
// -------------------------------------------------------------------------
async getArtikelEigenschaften(req: Request, res: Response): Promise<void> {
try {
const artikelId = Number(req.params.id);
const eigenschaften = await ausruestungsanfrageService.getArtikelEigenschaften(artikelId);
res.status(200).json({ success: true, data: eigenschaften });
} catch (error) {
logger.error('AusruestungsanfrageController.getArtikelEigenschaften error', { error });
res.status(500).json({ success: false, message: 'Eigenschaften konnten nicht geladen werden' });
}
}
async upsertArtikelEigenschaft(req: Request, res: Response): Promise<void> {
try {
const artikelId = Number(req.params.id);
const { name, typ, optionen, pflicht, sort_order, eigenschaft_id } = req.body;
if (!name || name.trim().length === 0) {
res.status(400).json({ success: false, message: 'Name ist erforderlich' });
return;
}
if (!typ || !['options', 'freitext'].includes(typ)) {
res.status(400).json({ success: false, message: 'Typ muss "options" oder "freitext" sein' });
return;
}
const eigenschaft = await ausruestungsanfrageService.upsertArtikelEigenschaft(artikelId, {
id: eigenschaft_id,
name: name.trim(),
typ,
optionen: optionen || undefined,
pflicht: pflicht ?? false,
sort_order: sort_order ?? 0,
});
res.status(eigenschaft_id ? 200 : 201).json({ success: true, data: eigenschaft });
} catch (error) {
logger.error('AusruestungsanfrageController.upsertArtikelEigenschaft error', { error });
res.status(500).json({ success: false, message: 'Eigenschaft konnte nicht gespeichert werden' });
}
}
async deleteArtikelEigenschaft(req: Request, res: Response): Promise<void> {
try {
const id = Number(req.params.eigenschaftId);
await ausruestungsanfrageService.deleteArtikelEigenschaft(id);
res.status(200).json({ success: true, message: 'Eigenschaft gelöscht' });
} catch (error) {
logger.error('AusruestungsanfrageController.deleteArtikelEigenschaft error', { error });
res.status(500).json({ success: false, message: 'Eigenschaft konnte nicht gelöscht werden' });
}
}
// -------------------------------------------------------------------------
// Requests
// -------------------------------------------------------------------------
@@ -131,7 +249,7 @@ class AusruestungsanfrageController {
async createRequest(req: Request, res: Response): Promise<void> {
try {
const { items, notizen, bezeichnung, fuer_benutzer_id } = req.body as {
items?: { artikel_id?: number; bezeichnung: string; menge: number; notizen?: string }[];
items?: { artikel_id?: number; bezeichnung: string; menge: number; notizen?: string; eigenschaften?: { eigenschaft_id: number; wert: string }[] }[];
notizen?: string;
bezeichnung?: string;
fuer_benutzer_id?: string;
@@ -179,7 +297,7 @@ class AusruestungsanfrageController {
const { bezeichnung, notizen, items } = req.body as {
bezeichnung?: string;
notizen?: string;
items?: { artikel_id?: number; bezeichnung: string; menge: number; notizen?: string }[];
items?: { artikel_id?: number; bezeichnung: string; menge: number; notizen?: string; eigenschaften?: { eigenschaft_id: number; wert: string }[] }[];
};
// Validate items if provided
@@ -209,8 +327,8 @@ class AusruestungsanfrageController {
// Check permission: owner + status=offen, OR ausruestungsanfrage:edit
const groups = req.user?.groups ?? [];
const canEditAny = groups.includes('dashboard_admin') || permissionService.hasPermission(groups, 'ausruestungsanfrage:edit');
const isOwner = existing.anfrager_id === req.user!.id;
if (!canEditAny && !(isOwner && existing.status === 'offen')) {
const isOwner = existing.anfrage.anfrager_id === req.user!.id;
if (!canEditAny && !(isOwner && existing.anfrage.status === 'offen')) {
res.status(403).json({ success: false, message: 'Keine Berechtigung zum Bearbeiten dieser Anfrage' });
return;
}
@@ -253,11 +371,11 @@ class AusruestungsanfrageController {
// Notify requester on status changes
if (['genehmigt', 'abgelehnt', 'bestellt', 'erledigt'].includes(status)) {
const orderLabel = existing.bestell_jahr && existing.bestell_nummer
? `${existing.bestell_jahr}/${String(existing.bestell_nummer).padStart(3, '0')}`
const orderLabel = existing.anfrage.bestell_jahr && existing.anfrage.bestell_nummer
? `${existing.anfrage.bestell_jahr}/${String(existing.anfrage.bestell_nummer).padStart(3, '0')}`
: `#${id}`;
await notificationService.createNotification({
user_id: existing.anfrager_id,
user_id: existing.anfrage.anfrager_id,
typ: 'ausruestung_anfrage',
titel: status === 'genehmigt' ? 'Anfrage genehmigt' : status === 'abgelehnt' ? 'Anfrage abgelehnt' : `Anfrage ${status}`,
nachricht: `Deine Ausrüstungsanfrage ${orderLabel} wurde ${status === 'genehmigt' ? 'genehmigt' : status === 'abgelehnt' ? 'abgelehnt' : status}.`,

View File

@@ -0,0 +1,64 @@
-- Migration 048: Catalog categories table + item characteristics
-- - Admin-managed categories (replacing free-text kategorie)
-- - Per-item characteristics (options or free-text)
-- - Characteristic values per request position
-- - Remove view_all permission (approve covers it)
-- - Add manage_categories permission
-- 1. Categories table
CREATE TABLE IF NOT EXISTS ausruestung_kategorien_katalog (
id SERIAL PRIMARY KEY,
name TEXT NOT NULL UNIQUE,
erstellt_am TIMESTAMPTZ DEFAULT NOW()
);
-- Migrate existing categories from free-text
INSERT INTO ausruestung_kategorien_katalog (name)
SELECT DISTINCT kategorie FROM ausruestung_artikel WHERE kategorie IS NOT NULL AND kategorie != ''
ON CONFLICT DO NOTHING;
-- Add kategorie_id FK to artikel
ALTER TABLE ausruestung_artikel ADD COLUMN IF NOT EXISTS kategorie_id INT REFERENCES ausruestung_kategorien_katalog(id) ON DELETE SET NULL;
-- Populate kategorie_id from existing text values
UPDATE ausruestung_artikel a
SET kategorie_id = k.id
FROM ausruestung_kategorien_katalog k
WHERE k.name = a.kategorie AND a.kategorie_id IS NULL;
-- 2. Characteristics definitions per catalog item
CREATE TABLE IF NOT EXISTS ausruestung_artikel_eigenschaften (
id SERIAL PRIMARY KEY,
artikel_id INT NOT NULL REFERENCES ausruestung_artikel(id) ON DELETE CASCADE,
name TEXT NOT NULL,
typ TEXT NOT NULL DEFAULT 'options',
optionen TEXT[],
pflicht BOOLEAN DEFAULT FALSE,
sort_order INT DEFAULT 0,
UNIQUE(artikel_id, name)
);
-- 3. Characteristic values filled per request position
CREATE TABLE IF NOT EXISTS ausruestung_position_eigenschaften (
id SERIAL PRIMARY KEY,
position_id INT NOT NULL REFERENCES ausruestung_anfrage_positionen(id) ON DELETE CASCADE,
eigenschaft_id INT NOT NULL REFERENCES ausruestung_artikel_eigenschaften(id) ON DELETE CASCADE,
wert TEXT NOT NULL,
UNIQUE(position_id, eigenschaft_id)
);
-- 4. Add manage_categories permission
INSERT INTO permissions (id, feature_group_id, label, description, sort_order)
VALUES ('ausruestungsanfrage:manage_categories', 'ausruestungsanfrage', 'Kategorien verwalten', 'Katalog-Kategorien erstellen und bearbeiten', 5)
ON CONFLICT (id) DO NOTHING;
-- Grant manage_categories to groups that have manage_catalog
INSERT INTO group_permissions (authentik_group, permission_id)
SELECT gp.authentik_group, 'ausruestungsanfrage:manage_categories'
FROM group_permissions gp
WHERE gp.permission_id = 'ausruestungsanfrage:manage_catalog'
ON CONFLICT DO NOTHING;
-- 5. Remove view_all permission (approve covers the "Alle Anfragen" tab now)
DELETE FROM group_permissions WHERE permission_id = 'ausruestungsanfrage:view_all';
DELETE FROM permissions WHERE id = 'ausruestungsanfrage:view_all';

View File

@@ -5,6 +5,15 @@ import { requirePermission } from '../middleware/rbac.middleware';
const router = Router();
// ---------------------------------------------------------------------------
// Categories (DB-backed CRUD)
// ---------------------------------------------------------------------------
router.get('/kategorien', authenticate, requirePermission('ausruestungsanfrage:view'), ausruestungsanfrageController.getKategorien.bind(ausruestungsanfrageController));
router.post('/kategorien', authenticate, requirePermission('ausruestungsanfrage:manage_categories'), ausruestungsanfrageController.createKategorie.bind(ausruestungsanfrageController));
router.patch('/kategorien/:id', authenticate, requirePermission('ausruestungsanfrage:manage_categories'), ausruestungsanfrageController.updateKategorie.bind(ausruestungsanfrageController));
router.delete('/kategorien/:id', authenticate, requirePermission('ausruestungsanfrage:manage_categories'), ausruestungsanfrageController.deleteKategorie.bind(ausruestungsanfrageController));
// ---------------------------------------------------------------------------
// Catalog Items
// ---------------------------------------------------------------------------
@@ -15,13 +24,19 @@ router.post('/items', authenticate, requirePermission('ausruestungsanfrage:manag
router.patch('/items/:id', authenticate, requirePermission('ausruestungsanfrage:manage_catalog'), ausruestungsanfrageController.updateItem.bind(ausruestungsanfrageController));
router.delete('/items/:id', authenticate, requirePermission('ausruestungsanfrage:manage_catalog'), ausruestungsanfrageController.deleteItem.bind(ausruestungsanfrageController));
// Item characteristics (Eigenschaften)
router.get('/items/:id/eigenschaften', authenticate, requirePermission('ausruestungsanfrage:view'), ausruestungsanfrageController.getArtikelEigenschaften.bind(ausruestungsanfrageController));
router.post('/items/:id/eigenschaften', authenticate, requirePermission('ausruestungsanfrage:manage_catalog'), ausruestungsanfrageController.upsertArtikelEigenschaft.bind(ausruestungsanfrageController));
router.delete('/eigenschaften/:eigenschaftId', authenticate, requirePermission('ausruestungsanfrage:manage_catalog'), ausruestungsanfrageController.deleteArtikelEigenschaft.bind(ausruestungsanfrageController));
// Legacy text-based categories
router.get('/categories', authenticate, requirePermission('ausruestungsanfrage:view'), ausruestungsanfrageController.getCategories.bind(ausruestungsanfrageController));
// ---------------------------------------------------------------------------
// Overview
// ---------------------------------------------------------------------------
router.get('/overview', authenticate, requirePermission('ausruestungsanfrage:view_all'), ausruestungsanfrageController.getOverview.bind(ausruestungsanfrageController));
router.get('/overview', authenticate, requirePermission('ausruestungsanfrage:approve'), ausruestungsanfrageController.getOverview.bind(ausruestungsanfrageController));
// ---------------------------------------------------------------------------
// Requests

View File

@@ -1,34 +1,90 @@
import pool from '../config/database';
import logger from '../utils/logger';
// ---------------------------------------------------------------------------
// Categories (ausruestung_kategorien_katalog)
// ---------------------------------------------------------------------------
async function getKategorien() {
const result = await pool.query(
'SELECT * FROM ausruestung_kategorien_katalog ORDER BY name',
);
return result.rows;
}
async function createKategorie(name: string) {
const result = await pool.query(
'INSERT INTO ausruestung_kategorien_katalog (name) VALUES ($1) RETURNING *',
[name],
);
return result.rows[0];
}
async function updateKategorie(id: number, name: string) {
const result = await pool.query(
'UPDATE ausruestung_kategorien_katalog SET name = $1 WHERE id = $2 RETURNING *',
[name, id],
);
return result.rows[0] || null;
}
async function deleteKategorie(id: number) {
await pool.query('DELETE FROM ausruestung_kategorien_katalog WHERE id = $1', [id]);
}
// ---------------------------------------------------------------------------
// Catalog Items (ausruestung_artikel)
// ---------------------------------------------------------------------------
async function getItems(filters?: { kategorie?: string; aktiv?: boolean }) {
async function getItems(filters?: { kategorie?: string; kategorie_id?: number; aktiv?: boolean }) {
const conditions: string[] = [];
const params: unknown[] = [];
if (filters?.kategorie) {
params.push(filters.kategorie);
conditions.push(`kategorie = $${params.length}`);
conditions.push(`a.kategorie = $${params.length}`);
}
if (filters?.kategorie_id) {
params.push(filters.kategorie_id);
conditions.push(`a.kategorie_id = $${params.length}`);
}
if (filters?.aktiv !== undefined) {
params.push(filters.aktiv);
conditions.push(`aktiv = $${params.length}`);
conditions.push(`a.aktiv = $${params.length}`);
}
const where = conditions.length > 0 ? `WHERE ${conditions.join(' AND ')}` : '';
const result = await pool.query(
`SELECT * FROM ausruestung_artikel ${where} ORDER BY kategorie, bezeichnung`,
`SELECT a.*, k.name AS kategorie_name,
(SELECT COUNT(*)::int FROM ausruestung_artikel_eigenschaften e WHERE e.artikel_id = a.id) AS eigenschaften_count
FROM ausruestung_artikel a
LEFT JOIN ausruestung_kategorien_katalog k ON k.id = a.kategorie_id
${where}
ORDER BY COALESCE(k.name, a.kategorie), a.bezeichnung`,
params,
);
return result.rows;
}
async function getItemById(id: number) {
const result = await pool.query('SELECT * FROM ausruestung_artikel WHERE id = $1', [id]);
return result.rows[0] || null;
const result = await pool.query(
`SELECT a.*, k.name AS kategorie_name
FROM ausruestung_artikel a
LEFT JOIN ausruestung_kategorien_katalog k ON k.id = a.kategorie_id
WHERE a.id = $1`,
[id],
);
if (!result.rows[0]) return null;
const eigenschaften = await pool.query(
'SELECT * FROM ausruestung_artikel_eigenschaften WHERE artikel_id = $1 ORDER BY sort_order, id',
[id],
);
return {
...result.rows[0],
eigenschaften: eigenschaften.rows,
};
}
async function createItem(
@@ -36,16 +92,17 @@ async function createItem(
bezeichnung: string;
beschreibung?: string;
kategorie?: string;
kategorie_id?: number;
geschaetzter_preis?: number;
aktiv?: boolean;
},
userId: string,
) {
const result = await pool.query(
`INSERT INTO ausruestung_artikel (bezeichnung, beschreibung, kategorie, geschaetzter_preis, aktiv, erstellt_von)
VALUES ($1, $2, $3, $4, COALESCE($5, true), $6)
`INSERT INTO ausruestung_artikel (bezeichnung, beschreibung, kategorie, kategorie_id, geschaetzter_preis, aktiv, erstellt_von)
VALUES ($1, $2, $3, $4, COALESCE($5, true), $6, $7)
RETURNING *`,
[data.bezeichnung, data.beschreibung || null, data.kategorie || null, data.geschaetzter_preis || null, data.aktiv ?? true, userId],
[data.bezeichnung, data.beschreibung || null, data.kategorie || null, data.kategorie_id || null, data.geschaetzter_preis || null, data.aktiv ?? true, userId],
);
return result.rows[0];
}
@@ -56,6 +113,7 @@ async function updateItem(
bezeichnung?: string;
beschreibung?: string;
kategorie?: string;
kategorie_id?: number | null;
geschaetzter_preis?: number;
aktiv?: boolean;
},
@@ -76,6 +134,10 @@ async function updateItem(
params.push(data.kategorie);
fields.push(`kategorie = $${params.length}`);
}
if (data.kategorie_id !== undefined) {
params.push(data.kategorie_id);
fields.push(`kategorie_id = $${params.length}`);
}
if (data.geschaetzter_preis !== undefined) {
params.push(data.geschaetzter_preis);
fields.push(`geschaetzter_preis = $${params.length}`);
@@ -111,6 +173,45 @@ async function getCategories() {
return result.rows.map((r: { kategorie: string }) => r.kategorie);
}
// ---------------------------------------------------------------------------
// Artikel Eigenschaften (characteristics)
// ---------------------------------------------------------------------------
async function getArtikelEigenschaften(artikelId: number) {
const result = await pool.query(
'SELECT * FROM ausruestung_artikel_eigenschaften WHERE artikel_id = $1 ORDER BY sort_order, id',
[artikelId],
);
return result.rows;
}
async function upsertArtikelEigenschaft(
artikelId: number,
data: { id?: number; name: string; typ: string; optionen?: string[]; pflicht?: boolean; sort_order?: number },
) {
if (data.id) {
const result = await pool.query(
`UPDATE ausruestung_artikel_eigenschaften
SET name = $1, typ = $2, optionen = $3, pflicht = $4, sort_order = $5
WHERE id = $6 AND artikel_id = $7
RETURNING *`,
[data.name, data.typ, data.optionen || null, data.pflicht ?? false, data.sort_order ?? 0, data.id, artikelId],
);
return result.rows[0] || null;
}
const result = await pool.query(
`INSERT INTO ausruestung_artikel_eigenschaften (artikel_id, name, typ, optionen, pflicht, sort_order)
VALUES ($1, $2, $3, $4, $5, $6)
RETURNING *`,
[artikelId, data.name, data.typ, data.optionen || null, data.pflicht ?? false, data.sort_order ?? 0],
);
return result.rows[0];
}
async function deleteArtikelEigenschaft(id: number) {
await pool.query('DELETE FROM ausruestung_artikel_eigenschaften WHERE id = $1', [id]);
}
// ---------------------------------------------------------------------------
// Requests (ausruestung_anfragen)
// ---------------------------------------------------------------------------
@@ -178,6 +279,33 @@ async function getRequestById(id: number) {
[id],
);
// Load eigenschaft values per position
const positionIds = positionen.rows.map((p: { id: number }) => p.id);
let eigenschaftenMap: Record<number, { eigenschaft_id: number; eigenschaft_name: string; wert: string }[]> = {};
if (positionIds.length > 0) {
const eigenschaftenResult = await pool.query(
`SELECT pe.position_id, pe.eigenschaft_id, ae.name AS eigenschaft_name, pe.wert
FROM ausruestung_position_eigenschaften pe
JOIN ausruestung_artikel_eigenschaften ae ON ae.id = pe.eigenschaft_id
WHERE pe.position_id = ANY($1)
ORDER BY ae.sort_order, ae.id`,
[positionIds],
);
for (const row of eigenschaftenResult.rows) {
if (!eigenschaftenMap[row.position_id]) eigenschaftenMap[row.position_id] = [];
eigenschaftenMap[row.position_id].push({
eigenschaft_id: row.eigenschaft_id,
eigenschaft_name: row.eigenschaft_name,
wert: row.wert,
});
}
}
const positionenWithEigenschaften = positionen.rows.map((p: { id: number }) => ({
...p,
eigenschaften: eigenschaftenMap[p.id] || [],
}));
const bestellungen = await pool.query(
`SELECT b.*
FROM ausruestung_anfrage_bestellung ab
@@ -187,15 +315,15 @@ async function getRequestById(id: number) {
);
return {
...reqResult.rows[0],
positionen: positionen.rows,
anfrage: reqResult.rows[0],
positionen: positionenWithEigenschaften,
linked_bestellungen: bestellungen.rows,
};
}
async function createRequest(
userId: string,
items: { artikel_id?: number; bezeichnung: string; menge: number; notizen?: string }[],
items: { artikel_id?: number; bezeichnung: string; menge: number; notizen?: string; eigenschaften?: { eigenschaft_id: number; wert: string }[] }[],
notizen?: string,
bezeichnung?: string,
) {
@@ -222,7 +350,7 @@ async function createRequest(
const anfrage = anfrageResult.rows[0];
for (const item of items) {
let bezeichnung = item.bezeichnung;
let itemBezeichnung = item.bezeichnung;
// If artikel_id is provided, copy bezeichnung from catalog
if (item.artikel_id) {
@@ -231,15 +359,28 @@ async function createRequest(
[item.artikel_id],
);
if (artikelResult.rows.length > 0) {
bezeichnung = artikelResult.rows[0].bezeichnung;
itemBezeichnung = artikelResult.rows[0].bezeichnung;
}
}
await client.query(
const posResult = await client.query(
`INSERT INTO ausruestung_anfrage_positionen (anfrage_id, artikel_id, bezeichnung, menge, notizen)
VALUES ($1, $2, $3, $4, $5)`,
[anfrage.id, item.artikel_id || null, bezeichnung, item.menge, item.notizen || null],
VALUES ($1, $2, $3, $4, $5)
RETURNING id`,
[anfrage.id, item.artikel_id || null, itemBezeichnung, item.menge, item.notizen || null],
);
// Save eigenschaft values
if (item.eigenschaften && item.eigenschaften.length > 0) {
for (const e of item.eigenschaften) {
await client.query(
`INSERT INTO ausruestung_position_eigenschaften (position_id, eigenschaft_id, wert)
VALUES ($1, $2, $3)
ON CONFLICT (position_id, eigenschaft_id) DO UPDATE SET wert = $3`,
[posResult.rows[0].id, e.eigenschaft_id, e.wert],
);
}
}
}
await client.query('COMMIT');
@@ -258,7 +399,7 @@ async function updateRequest(
data: {
bezeichnung?: string;
notizen?: string;
items?: { artikel_id?: number; bezeichnung: string; menge: number; notizen?: string }[];
items?: { artikel_id?: number; bezeichnung: string; menge: number; notizen?: string; eigenschaften?: { eigenschaft_id: number; wert: string }[] }[];
},
) {
const client = await pool.connect();
@@ -292,21 +433,34 @@ async function updateRequest(
if (data.items) {
await client.query('DELETE FROM ausruestung_anfrage_positionen WHERE anfrage_id = $1', [id]);
for (const item of data.items) {
let bezeichnung = item.bezeichnung;
let itemBezeichnung = item.bezeichnung;
if (item.artikel_id) {
const artikelResult = await client.query(
'SELECT bezeichnung FROM ausruestung_artikel WHERE id = $1',
[item.artikel_id],
);
if (artikelResult.rows.length > 0) {
bezeichnung = artikelResult.rows[0].bezeichnung;
itemBezeichnung = artikelResult.rows[0].bezeichnung;
}
}
await client.query(
const posResult = await client.query(
`INSERT INTO ausruestung_anfrage_positionen (anfrage_id, artikel_id, bezeichnung, menge, notizen)
VALUES ($1, $2, $3, $4, $5)`,
[id, item.artikel_id || null, bezeichnung, item.menge, item.notizen || null],
VALUES ($1, $2, $3, $4, $5)
RETURNING id`,
[id, item.artikel_id || null, itemBezeichnung, item.menge, item.notizen || null],
);
// Save eigenschaft values
if (item.eigenschaften && item.eigenschaften.length > 0) {
for (const e of item.eigenschaften) {
await client.query(
`INSERT INTO ausruestung_position_eigenschaften (position_id, eigenschaft_id, wert)
VALUES ($1, $2, $3)
ON CONFLICT (position_id, eigenschaft_id) DO UPDATE SET wert = $3`,
[posResult.rows[0].id, e.eigenschaft_id, e.wert],
);
}
}
}
}
@@ -395,6 +549,7 @@ async function getOverview() {
`SELECT
COUNT(*) FILTER (WHERE status = 'offen')::int AS pending_count,
COUNT(*) FILTER (WHERE status = 'genehmigt')::int AS approved_count,
COUNT(*) FILTER (WHERE status = 'offen' AND bearbeitet_von IS NULL)::int AS unhandled_count,
COALESCE(SUM(sub.total), 0)::int AS total_items
FROM ausruestung_anfragen a
LEFT JOIN LATERAL (
@@ -412,12 +567,19 @@ async function getOverview() {
}
export default {
getKategorien,
createKategorie,
updateKategorie,
deleteKategorie,
getItems,
getItemById,
createItem,
updateItem,
deleteItem,
getCategories,
getArtikelEigenschaften,
upsertArtikelEigenschaft,
deleteArtikelEigenschaft,
getRequests,
getMyRequests,
getRequestById,

View File

@@ -100,8 +100,8 @@ const PERMISSION_SUB_GROUPS: Record<string, Record<string, string[]>> = {
'Widget': ['widget'],
},
ausruestungsanfrage: {
'Katalog': ['view', 'manage_catalog'],
'Anfragen': ['create_request', 'approve', 'link_orders', 'view_all', 'order_for_user', 'edit'],
'Katalog': ['view', 'manage_catalog', 'manage_categories'],
'Anfragen': ['create_request', 'approve', 'link_orders', 'order_for_user', 'edit'],
'Widget': ['widget'],
},
admin: {

View File

@@ -190,7 +190,6 @@ function Sidebar({ mobileOpen, onMobileClose }: SidebarProps) {
let ausruestungTabIdx = 0;
if (hasPermission('ausruestungsanfrage:create_request')) { ausruestungSubItems.push({ text: 'Meine Anfragen', path: `/ausruestungsanfrage?tab=${ausruestungTabIdx}` }); ausruestungTabIdx++; }
if (hasPermission('ausruestungsanfrage:approve')) { ausruestungSubItems.push({ text: 'Alle Anfragen', path: `/ausruestungsanfrage?tab=${ausruestungTabIdx}` }); ausruestungTabIdx++; }
if (hasPermission('ausruestungsanfrage:view_all')) { ausruestungSubItems.push({ text: 'Übersicht', path: `/ausruestungsanfrage?tab=${ausruestungTabIdx}` }); ausruestungTabIdx++; }
ausruestungSubItems.push({ text: 'Katalog', path: `/ausruestungsanfrage?tab=${ausruestungTabIdx}` });
// Build Issues sub-items dynamically (tab order must match Issues.tsx)

View File

@@ -1,14 +1,14 @@
import { useState, useMemo, useEffect } from 'react';
import { useState, useMemo, useEffect, useCallback } from 'react';
import {
Box, Tab, Tabs, Typography, Card, CardContent, CardActions, Grid, Button, Chip,
Box, Tab, Tabs, Typography, Grid, Button, Chip,
Table, TableBody, TableCell, TableContainer, TableHead, TableRow, Paper,
Dialog, DialogTitle, DialogContent, DialogActions, TextField, IconButton,
MenuItem, Select, FormControl, InputLabel, Autocomplete,
Divider,
Divider, Checkbox, FormControlLabel, Tooltip,
} from '@mui/material';
import {
Add as AddIcon, Delete as DeleteIcon, Edit as EditIcon, ShoppingCart,
Check as CheckIcon, Close as CloseIcon, Link as LinkIcon,
Add as AddIcon, Delete as DeleteIcon, Edit as EditIcon,
Check as CheckIcon, Close as CloseIcon, Link as LinkIcon, Settings as SettingsIcon,
} from '@mui/icons-material';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { useSearchParams } from 'react-router-dom';
@@ -16,10 +16,15 @@ import DashboardLayout from '../components/dashboard/DashboardLayout';
import ChatAwareFab from '../components/shared/ChatAwareFab';
import { useNotification } from '../contexts/NotificationContext';
import { usePermissionContext } from '../contexts/PermissionContext';
import { useAuth } from '../contexts/AuthContext';
import { ausruestungsanfrageApi } from '../services/ausruestungsanfrage';
import { bestellungApi } from '../services/bestellung';
import { AUSRUESTUNG_STATUS_LABELS, AUSRUESTUNG_STATUS_COLORS } from '../types/ausruestungsanfrage.types';
import type { AusruestungArtikel, AusruestungArtikelFormData, AusruestungAnfrageFormItem, AusruestungAnfrageDetailResponse, AusruestungAnfrageStatus, AusruestungAnfrage, AusruestungOverview } from '../types/ausruestungsanfrage.types';
import type {
AusruestungArtikel, AusruestungArtikelFormData, AusruestungAnfrageFormItem,
AusruestungAnfrageDetailResponse, AusruestungAnfrageStatus, AusruestungAnfrage,
AusruestungOverview, AusruestungEigenschaft,
} from '../types/ausruestungsanfrage.types';
import type { Bestellung } from '../types/bestellung.types';
// ─── Helpers ─────────────────────────────────────────────────────────────────
@@ -33,6 +38,247 @@ function formatOrderId(r: AusruestungAnfrage): string {
const ACTIVE_STATUSES: AusruestungAnfrageStatus[] = ['offen', 'genehmigt', 'bestellt'];
// ─── Eigenschaft Fields Component ────────────────────────────────────────────
interface EigenschaftFieldsProps {
eigenschaften: AusruestungEigenschaft[];
values: Record<number, string>;
onChange: (eigenschaftId: number, wert: string) => void;
}
function EigenschaftFields({ eigenschaften, values, onChange }: EigenschaftFieldsProps) {
if (eigenschaften.length === 0) return null;
return (
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1, ml: 2, mt: 0.5 }}>
{eigenschaften.map(e => (
<Box key={e.id} sx={{ display: 'flex', gap: 1, alignItems: 'center' }}>
{e.typ === 'options' && e.optionen && e.optionen.length > 0 ? (
<FormControl size="small" sx={{ minWidth: 160 }} required={e.pflicht}>
<InputLabel>{e.name}</InputLabel>
<Select
value={values[e.id] || ''}
label={e.name}
onChange={ev => onChange(e.id, ev.target.value)}
>
<MenuItem value=""></MenuItem>
{e.optionen.map(opt => (
<MenuItem key={opt} value={opt}>{opt}</MenuItem>
))}
</Select>
</FormControl>
) : (
<TextField
size="small"
label={e.name}
value={values[e.id] || ''}
onChange={ev => onChange(e.id, ev.target.value)}
required={e.pflicht}
sx={{ minWidth: 160 }}
/>
)}
</Box>
))}
</Box>
);
}
// ─── Category Management Dialog ──────────────────────────────────────────────
interface KategorieDialogProps {
open: boolean;
onClose: () => void;
}
function KategorieDialog({ open, onClose }: KategorieDialogProps) {
const { showSuccess, showError } = useNotification();
const queryClient = useQueryClient();
const [newName, setNewName] = useState('');
const [editId, setEditId] = useState<number | null>(null);
const [editName, setEditName] = useState('');
const { data: kategorien = [] } = useQuery({
queryKey: ['ausruestungsanfrage', 'kategorien'],
queryFn: () => ausruestungsanfrageApi.getKategorien(),
enabled: open,
});
const createMut = useMutation({
mutationFn: (name: string) => ausruestungsanfrageApi.createKategorie(name),
onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['ausruestungsanfrage'] }); showSuccess('Kategorie erstellt'); setNewName(''); },
onError: () => showError('Fehler beim Erstellen'),
});
const updateMut = useMutation({
mutationFn: ({ id, name }: { id: number; name: string }) => ausruestungsanfrageApi.updateKategorie(id, name),
onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['ausruestungsanfrage'] }); showSuccess('Kategorie aktualisiert'); setEditId(null); },
onError: () => showError('Fehler beim Aktualisieren'),
});
const deleteMut = useMutation({
mutationFn: (id: number) => ausruestungsanfrageApi.deleteKategorie(id),
onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['ausruestungsanfrage'] }); showSuccess('Kategorie gelöscht'); },
onError: () => showError('Fehler beim Löschen'),
});
return (
<Dialog open={open} onClose={onClose} maxWidth="sm" fullWidth>
<DialogTitle>Kategorien verwalten</DialogTitle>
<DialogContent sx={{ display: 'flex', flexDirection: 'column', gap: 2, pt: '16px !important' }}>
<Box sx={{ display: 'flex', gap: 1 }}>
<TextField
size="small"
label="Neue Kategorie"
value={newName}
onChange={e => setNewName(e.target.value)}
sx={{ flexGrow: 1 }}
onKeyDown={e => { if (e.key === 'Enter' && newName.trim()) createMut.mutate(newName.trim()); }}
/>
<Button
variant="contained"
size="small"
onClick={() => { if (newName.trim()) createMut.mutate(newName.trim()); }}
disabled={!newName.trim() || createMut.isPending}
>
Erstellen
</Button>
</Box>
<Divider />
{kategorien.length === 0 ? (
<Typography color="text.secondary">Keine Kategorien vorhanden.</Typography>
) : (
kategorien.map(k => (
<Box key={k.id} sx={{ display: 'flex', gap: 1, alignItems: 'center' }}>
{editId === k.id ? (
<>
<TextField
size="small"
value={editName}
onChange={e => setEditName(e.target.value)}
sx={{ flexGrow: 1 }}
onKeyDown={e => { if (e.key === 'Enter' && editName.trim()) updateMut.mutate({ id: k.id, name: editName.trim() }); }}
/>
<IconButton size="small" onClick={() => { if (editName.trim()) updateMut.mutate({ id: k.id, name: editName.trim() }); }}><CheckIcon fontSize="small" /></IconButton>
<IconButton size="small" onClick={() => setEditId(null)}><CloseIcon fontSize="small" /></IconButton>
</>
) : (
<>
<Typography sx={{ flexGrow: 1 }}>{k.name}</Typography>
<IconButton size="small" onClick={() => { setEditId(k.id); setEditName(k.name); }}><EditIcon fontSize="small" /></IconButton>
<IconButton size="small" color="error" onClick={() => deleteMut.mutate(k.id)}><DeleteIcon fontSize="small" /></IconButton>
</>
)}
</Box>
))
)}
</DialogContent>
<DialogActions>
<Button onClick={onClose}>Schließen</Button>
</DialogActions>
</Dialog>
);
}
// ─── Eigenschaften Editor (in Artikel dialog) ────────────────────────────────
interface EigenschaftenEditorProps {
artikelId: number | null;
}
function EigenschaftenEditor({ artikelId }: EigenschaftenEditorProps) {
const { showSuccess, showError } = useNotification();
const queryClient = useQueryClient();
const [newName, setNewName] = useState('');
const [newTyp, setNewTyp] = useState<'options' | 'freitext'>('options');
const [newOptionen, setNewOptionen] = useState('');
const [newPflicht, setNewPflicht] = useState(false);
const { data: eigenschaften = [] } = useQuery({
queryKey: ['ausruestungsanfrage', 'eigenschaften', artikelId],
queryFn: () => ausruestungsanfrageApi.getArtikelEigenschaften(artikelId!),
enabled: artikelId != null,
});
const upsertMut = useMutation({
mutationFn: (data: { eigenschaft_id?: number; name: string; typ: string; optionen?: string[]; pflicht?: boolean; sort_order?: number }) =>
ausruestungsanfrageApi.upsertArtikelEigenschaft(artikelId!, data),
onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['ausruestungsanfrage', 'eigenschaften', artikelId] }); showSuccess('Eigenschaft gespeichert'); },
onError: () => showError('Fehler beim Speichern'),
});
const deleteMut = useMutation({
mutationFn: (id: number) => ausruestungsanfrageApi.deleteArtikelEigenschaft(id),
onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['ausruestungsanfrage', 'eigenschaften', artikelId] }); showSuccess('Eigenschaft gelöscht'); },
onError: () => showError('Fehler beim Löschen'),
});
const handleAdd = () => {
if (!newName.trim()) return;
const optionen = newTyp === 'options' ? newOptionen.split(',').map(s => s.trim()).filter(Boolean) : undefined;
upsertMut.mutate({
name: newName.trim(),
typ: newTyp,
optionen,
pflicht: newPflicht,
sort_order: eigenschaften.length,
});
setNewName('');
setNewOptionen('');
setNewPflicht(false);
};
if (artikelId == null) return <Typography variant="body2" color="text.secondary" sx={{ mt: 1 }}>Bitte speichern Sie den Artikel zuerst, bevor Sie Eigenschaften hinzufügen.</Typography>;
return (
<Box sx={{ mt: 1 }}>
<Typography variant="subtitle2" sx={{ mb: 1 }}>Eigenschaften</Typography>
{eigenschaften.map(e => (
<Box key={e.id} sx={{ display: 'flex', gap: 1, alignItems: 'center', mb: 0.5, pl: 1, borderLeft: '2px solid', borderColor: 'divider' }}>
<Typography variant="body2" sx={{ flexGrow: 1 }}>
{e.name} ({e.typ === 'options' ? `Auswahl: ${e.optionen?.join(', ')}` : 'Freitext'})
{e.pflicht && <Chip label="Pflicht" size="small" sx={{ ml: 0.5 }} />}
</Typography>
<IconButton size="small" color="error" onClick={() => deleteMut.mutate(e.id)}><DeleteIcon fontSize="small" /></IconButton>
</Box>
))}
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1, mt: 1, p: 1, border: '1px dashed', borderColor: 'divider', borderRadius: 1 }}>
<Box sx={{ display: 'flex', gap: 1, alignItems: 'center' }}>
<TextField size="small" label="Name" value={newName} onChange={e => setNewName(e.target.value)} sx={{ flexGrow: 1 }} />
<FormControl size="small" sx={{ minWidth: 120 }}>
<InputLabel>Typ</InputLabel>
<Select value={newTyp} label="Typ" onChange={e => setNewTyp(e.target.value as 'options' | 'freitext')}>
<MenuItem value="options">Auswahl</MenuItem>
<MenuItem value="freitext">Freitext</MenuItem>
</Select>
</FormControl>
<FormControlLabel
control={<Checkbox size="small" checked={newPflicht} onChange={e => setNewPflicht(e.target.checked)} />}
label="Pflicht"
/>
</Box>
{newTyp === 'options' && (
<TextField
size="small"
label="Optionen (kommagetrennt)"
value={newOptionen}
onChange={e => setNewOptionen(e.target.value)}
placeholder="S, M, L, XL"
fullWidth
/>
)}
<Button
size="small"
startIcon={<AddIcon />}
onClick={handleAdd}
disabled={!newName.trim() || upsertMut.isPending}
>
Eigenschaft hinzufügen
</Button>
</Box>
</Box>
);
}
// ─── Detail Modal ─────────────────────────────────────────────────────────────
interface DetailModalProps {
@@ -123,6 +369,7 @@ function DetailModal({ requestId, onClose, showAdminActions, showEditButton, can
bezeichnung: p.bezeichnung,
menge: p.menge,
notizen: p.notizen,
eigenschaften: p.eigenschaften?.map(e => ({ eigenschaft_id: e.eigenschaft_id, wert: e.wert })),
})));
setEditing(true);
};
@@ -257,9 +504,20 @@ function DetailModal({ requestId, onClose, showAdminActions, showEditButton, can
<Divider />
<Typography variant="subtitle2">Positionen</Typography>
{detail.positionen.map(p => (
<Typography key={p.id} variant="body2">
<Box key={p.id}>
<Typography variant="body2">
- {p.menge}x {p.bezeichnung}{p.notizen ? ` (${p.notizen})` : ''}
</Typography>
{p.eigenschaften && p.eigenschaften.length > 0 && (
<Box sx={{ ml: 2, mt: 0.25 }}>
{p.eigenschaften.map(e => (
<Typography key={e.eigenschaft_id} variant="caption" color="text.secondary" display="block">
{e.eigenschaft_name}: {e.wert}
</Typography>
))}
</Box>
)}
</Box>
))}
{detail.linked_bestellungen && detail.linked_bestellungen.length > 0 && (
@@ -417,20 +675,22 @@ function KatalogTab() {
const queryClient = useQueryClient();
const canManage = hasPermission('ausruestungsanfrage:manage_catalog');
const canManageCategories = hasPermission('ausruestungsanfrage:manage_categories');
const [filterKategorie, setFilterKategorie] = useState<string>('');
const [filterKategorie, setFilterKategorie] = useState<number | ''>('');
const [artikelDialogOpen, setArtikelDialogOpen] = useState(false);
const [editArtikel, setEditArtikel] = useState<AusruestungArtikel | null>(null);
const [artikelForm, setArtikelForm] = useState<AusruestungArtikelFormData>({ bezeichnung: '' });
const [kategorieDialogOpen, setKategorieDialogOpen] = useState(false);
const { data: items = [], isLoading } = useQuery({
queryKey: ['ausruestungsanfrage', 'items', filterKategorie],
queryFn: () => ausruestungsanfrageApi.getItems(filterKategorie ? { kategorie: filterKategorie } : undefined),
queryFn: () => ausruestungsanfrageApi.getItems(filterKategorie ? { kategorie_id: filterKategorie as number } : undefined),
});
const { data: categories = [] } = useQuery({
queryKey: ['ausruestungsanfrage', 'categories'],
queryFn: () => ausruestungsanfrageApi.getCategories(),
const { data: kategorien = [] } = useQuery({
queryKey: ['ausruestungsanfrage', 'kategorien'],
queryFn: () => ausruestungsanfrageApi.getKategorien(),
});
const createItemMut = useMutation({
@@ -456,7 +716,7 @@ function KatalogTab() {
};
const openEditArtikel = (a: AusruestungArtikel) => {
setEditArtikel(a);
setArtikelForm({ bezeichnung: a.bezeichnung, beschreibung: a.beschreibung, kategorie: a.kategorie });
setArtikelForm({ bezeichnung: a.bezeichnung, beschreibung: a.beschreibung, kategorie_id: a.kategorie_id ?? null });
setArtikelDialogOpen(true);
};
const saveArtikel = () => {
@@ -471,49 +731,62 @@ function KatalogTab() {
<Box sx={{ display: 'flex', gap: 2, mb: 3, alignItems: 'center', flexWrap: 'wrap' }}>
<FormControl size="small" sx={{ minWidth: 200 }}>
<InputLabel>Kategorie</InputLabel>
<Select value={filterKategorie} label="Kategorie" onChange={e => setFilterKategorie(e.target.value)}>
<Select value={filterKategorie} label="Kategorie" onChange={e => setFilterKategorie(e.target.value as number | '')}>
<MenuItem value="">Alle</MenuItem>
{categories.map(c => <MenuItem key={c} value={c}>{c}</MenuItem>)}
{kategorien.map(k => <MenuItem key={k.id} value={k.id}>{k.name}</MenuItem>)}
</Select>
</FormControl>
{canManageCategories && (
<Tooltip title="Kategorien verwalten">
<IconButton size="small" onClick={() => setKategorieDialogOpen(true)}>
<SettingsIcon fontSize="small" />
</IconButton>
</Tooltip>
)}
</Box>
{/* Catalog grid */}
{/* Catalog table */}
{isLoading ? (
<Typography color="text.secondary">Lade Katalog...</Typography>
) : items.length === 0 ? (
<Typography color="text.secondary">Keine Artikel vorhanden.</Typography>
) : (
<Grid container spacing={2}>
<TableContainer component={Paper} variant="outlined">
<Table size="small">
<TableHead>
<TableRow>
<TableCell>Bezeichnung</TableCell>
<TableCell>Kategorie</TableCell>
<TableCell>Beschreibung</TableCell>
{canManage && <TableCell align="right">Aktionen</TableCell>}
</TableRow>
</TableHead>
<TableBody>
{items.map(item => (
<Grid item xs={12} sm={6} md={4} key={item.id}>
<Card variant="outlined" sx={{ height: '100%', display: 'flex', flexDirection: 'column' }}>
{item.bild_pfad ? (
<Box sx={{ height: 160, backgroundImage: `url(${item.bild_pfad})`, backgroundSize: 'cover', backgroundPosition: 'center', borderBottom: '1px solid', borderColor: 'divider' }} />
) : (
<Box sx={{ height: 160, display: 'flex', alignItems: 'center', justifyContent: 'center', bgcolor: 'action.hover', borderBottom: '1px solid', borderColor: 'divider' }}>
<ShoppingCart sx={{ fontSize: 48, color: 'text.disabled' }} />
</Box>
<TableRow key={item.id} hover>
<TableCell>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
{item.bezeichnung}
{(item.eigenschaften_count ?? 0) > 0 && (
<Chip label={`${item.eigenschaften_count} Eig.`} size="small" variant="outlined" />
)}
<CardContent sx={{ flexGrow: 1 }}>
<Typography variant="subtitle1" fontWeight={600}>{item.bezeichnung}</Typography>
{item.beschreibung && <Typography variant="body2" color="text.secondary" sx={{ mt: 0.5 }}>{item.beschreibung}</Typography>}
{item.kategorie && (
<Box sx={{ mt: 1 }}>
<Chip label={item.kategorie} size="small" />
</Box>
)}
</CardContent>
</TableCell>
<TableCell>{item.kategorie_name || item.kategorie || '-'}</TableCell>
<TableCell sx={{ maxWidth: 300, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
{item.beschreibung || '-'}
</TableCell>
{canManage && (
<CardActions sx={{ justifyContent: 'flex-end' }}>
<TableCell align="right">
<IconButton size="small" onClick={() => openEditArtikel(item)}><EditIcon fontSize="small" /></IconButton>
<IconButton size="small" color="error" onClick={() => deleteItemMut.mutate(item.id)}><DeleteIcon fontSize="small" /></IconButton>
</CardActions>
</TableCell>
)}
</Card>
</Grid>
</TableRow>
))}
</Grid>
</TableBody>
</Table>
</TableContainer>
)}
{/* Artikel create/edit dialog */}
@@ -522,13 +795,18 @@ function KatalogTab() {
<DialogContent sx={{ display: 'flex', flexDirection: 'column', gap: 2, pt: '16px !important' }}>
<TextField label="Bezeichnung" required value={artikelForm.bezeichnung} onChange={e => setArtikelForm(f => ({ ...f, bezeichnung: e.target.value }))} fullWidth />
<TextField label="Beschreibung" multiline rows={2} value={artikelForm.beschreibung ?? ''} onChange={e => setArtikelForm(f => ({ ...f, beschreibung: e.target.value }))} />
<Autocomplete
freeSolo
options={categories}
value={artikelForm.kategorie ?? ''}
onInputChange={(_, val) => setArtikelForm(f => ({ ...f, kategorie: val || undefined }))}
renderInput={params => <TextField {...params} label="Kategorie" />}
/>
<FormControl fullWidth>
<InputLabel>Kategorie</InputLabel>
<Select
value={artikelForm.kategorie_id ?? ''}
label="Kategorie"
onChange={e => setArtikelForm(f => ({ ...f, kategorie_id: e.target.value ? Number(e.target.value) : null }))}
>
<MenuItem value="">Keine</MenuItem>
{kategorien.map(k => <MenuItem key={k.id} value={k.id}>{k.name}</MenuItem>)}
</Select>
</FormControl>
{canManage && <EigenschaftenEditor artikelId={editArtikel?.id ?? null} />}
</DialogContent>
<DialogActions>
<Button onClick={() => setArtikelDialogOpen(false)}>Abbrechen</Button>
@@ -538,6 +816,9 @@ function KatalogTab() {
</DialogActions>
</Dialog>
{/* Kategorie management dialog */}
<KategorieDialog open={kategorieDialogOpen} onClose={() => setKategorieDialogOpen(false)} />
{/* FAB for new catalog item */}
{canManage && (
<ChatAwareFab onClick={openNewArtikel} aria-label="Artikel hinzufügen">
@@ -552,6 +833,7 @@ function KatalogTab() {
function MeineAnfragenTab() {
const { hasPermission } = usePermissionContext();
const { user } = useAuth();
const queryClient = useQueryClient();
const { showSuccess, showError } = useNotification();
@@ -566,6 +848,10 @@ function MeineAnfragenTab() {
const [newNotizen, setNewNotizen] = useState('');
const [newFuerBenutzer, setNewFuerBenutzer] = useState<{ id: string; name: string } | null>(null);
const [newItems, setNewItems] = useState<AusruestungAnfrageFormItem[]>([{ bezeichnung: '', menge: 1 }]);
// Track loaded eigenschaften per item row (by artikel_id)
const [itemEigenschaften, setItemEigenschaften] = useState<Record<number, AusruestungEigenschaft[]>>({});
// Track eigenschaft values per item row index
const [itemEigenschaftValues, setItemEigenschaftValues] = useState<Record<number, Record<number, string>>>({});
const { data: requests = [], isLoading } = useQuery({
queryKey: ['ausruestungsanfrage', 'myRequests'],
@@ -601,11 +887,42 @@ function MeineAnfragenTab() {
setNewNotizen('');
setNewFuerBenutzer(null);
setNewItems([{ bezeichnung: '', menge: 1 }]);
setItemEigenschaften({});
setItemEigenschaftValues({});
};
const loadEigenschaften = useCallback(async (artikelId: number) => {
if (itemEigenschaften[artikelId]) return;
try {
const eigs = await ausruestungsanfrageApi.getArtikelEigenschaften(artikelId);
setItemEigenschaften(prev => ({ ...prev, [artikelId]: eigs }));
} catch { /* ignore */ }
}, [itemEigenschaften]);
const handleCreateSubmit = () => {
const validItems = newItems.filter(i => i.bezeichnung.trim());
const validItems = newItems.filter(i => i.bezeichnung.trim()).map((item, idx) => {
const vals = itemEigenschaftValues[idx] || {};
const eigenschaften = Object.entries(vals)
.filter(([, wert]) => wert.trim())
.map(([eid, wert]) => ({ eigenschaft_id: Number(eid), wert }));
return { ...item, eigenschaften: eigenschaften.length > 0 ? eigenschaften : undefined };
});
if (validItems.length === 0) return;
// Check required eigenschaften
for (let idx = 0; idx < newItems.length; idx++) {
const item = newItems[idx];
if (!item.bezeichnung.trim()) continue;
if (item.artikel_id && itemEigenschaften[item.artikel_id]) {
for (const e of itemEigenschaften[item.artikel_id]) {
if (e.pflicht && !(itemEigenschaftValues[idx]?.[e.id]?.trim())) {
showError(`Pflichtfeld "${e.name}" für "${item.bezeichnung}" fehlt`);
return;
}
}
}
}
createMut.mutate({
items: validItems,
notizen: newNotizen || undefined,
@@ -687,6 +1004,7 @@ function MeineAnfragenTab() {
onClose={() => setDetailId(null)}
showEditButton
canEditAny={canEditAny}
currentUserId={user?.id}
/>
{/* Create Request Dialog */}
@@ -719,7 +1037,8 @@ function MeineAnfragenTab() {
<Divider />
<Typography variant="subtitle2">Positionen</Typography>
{newItems.map((item, idx) => (
<Box key={idx} sx={{ display: 'flex', gap: 1, alignItems: 'center' }}>
<Box key={idx}>
<Box sx={{ display: 'flex', gap: 1, alignItems: 'center' }}>
<Autocomplete
freeSolo
options={catalogItems}
@@ -728,8 +1047,11 @@ function MeineAnfragenTab() {
onChange={(_, v) => {
if (typeof v === 'string') {
setNewItems(prev => prev.map((it, i) => i === idx ? { ...it, bezeichnung: v, artikel_id: undefined } : it));
// Clear eigenschaften for this row
setItemEigenschaftValues(prev => { const n = { ...prev }; delete n[idx]; return n; });
} else if (v) {
setNewItems(prev => prev.map((it, i) => i === idx ? { ...it, artikel_id: v.id, bezeichnung: v.bezeichnung } : it));
loadEigenschaften(v.id);
}
}}
onInputChange={(_, val, reason) => {
@@ -753,6 +1075,18 @@ function MeineAnfragenTab() {
<DeleteIcon fontSize="small" />
</IconButton>
</Box>
{/* Eigenschaft fields for this item */}
{item.artikel_id && itemEigenschaften[item.artikel_id] && itemEigenschaften[item.artikel_id].length > 0 && (
<EigenschaftFields
eigenschaften={itemEigenschaften[item.artikel_id]}
values={itemEigenschaftValues[idx] || {}}
onChange={(eid, wert) => setItemEigenschaftValues(prev => ({
...prev,
[idx]: { ...(prev[idx] || {}), [eid]: wert },
}))}
/>
)}
</Box>
))}
<Button size="small" startIcon={<AddIcon />} onClick={() => setNewItems(prev => [...prev, { bezeichnung: '', menge: 1 }])}>
Position hinzufügen
@@ -780,10 +1114,11 @@ function MeineAnfragenTab() {
);
}
// ─── Admin All Requests Tab ─────────────────────────────────────────────────
// ─── Admin All Requests Tab (merged with overview) ──────────────────────────
function AlleAnfragenTab() {
const { hasPermission } = usePermissionContext();
const { user } = useAuth();
const [statusFilter, setStatusFilter] = useState<string>('');
const [detailId, setDetailId] = useState<number | null>(null);
@@ -794,9 +1129,10 @@ function AlleAnfragenTab() {
queryFn: () => ausruestungsanfrageApi.getRequests(statusFilter ? { status: statusFilter } : undefined),
});
// Summary counts
const openCount = useMemo(() => requests.filter(r => r.status === 'offen').length, [requests]);
const approvedCount = useMemo(() => requests.filter(r => r.status === 'genehmigt').length, [requests]);
const { data: overview } = useQuery<AusruestungOverview>({
queryKey: ['ausruestungsanfrage', 'overview'],
queryFn: () => ausruestungsanfrageApi.getOverview(),
});
if (isLoading) return <Typography color="text.secondary">Lade Anfragen...</Typography>;
@@ -804,22 +1140,28 @@ function AlleAnfragenTab() {
<Box>
{/* Summary cards */}
<Grid container spacing={2} sx={{ mb: 3 }}>
<Grid item xs={12} sm={4}>
<Grid item xs={6} sm={3}>
<Paper variant="outlined" sx={{ p: 2, textAlign: 'center' }}>
<Typography variant="h4" fontWeight={700}>{openCount}</Typography>
<Typography variant="body2" color="text.secondary">Offene Anfragen</Typography>
<Typography variant="h4" fontWeight={700}>{overview?.pending_count ?? '-'}</Typography>
<Typography variant="body2" color="text.secondary">Offene</Typography>
</Paper>
</Grid>
<Grid item xs={12} sm={4}>
<Grid item xs={6} sm={3}>
<Paper variant="outlined" sx={{ p: 2, textAlign: 'center' }}>
<Typography variant="h4" fontWeight={700}>{approvedCount}</Typography>
<Typography variant="body2" color="text.secondary">Genehmigte Anfragen</Typography>
<Typography variant="h4" fontWeight={700}>{overview?.approved_count ?? '-'}</Typography>
<Typography variant="body2" color="text.secondary">Genehmigte</Typography>
</Paper>
</Grid>
<Grid item xs={12} sm={4}>
<Grid item xs={6} sm={3}>
<Paper variant="outlined" sx={{ p: 2, textAlign: 'center' }}>
<Typography variant="h4" fontWeight={700}>{requests.length}</Typography>
<Typography variant="body2" color="text.secondary">Alle Anfragen</Typography>
<Typography variant="h4" fontWeight={700} color="warning.main">{overview?.unhandled_count ?? '-'}</Typography>
<Typography variant="body2" color="text.secondary">Neue (unbearbeitet)</Typography>
</Paper>
</Grid>
<Grid item xs={6} sm={3}>
<Paper variant="outlined" sx={{ p: 2, textAlign: 'center' }}>
<Typography variant="h4" fontWeight={700}>{overview?.total_items ?? '-'}</Typography>
<Typography variant="body2" color="text.secondary">Gesamt Artikel</Typography>
</Paper>
</Grid>
</Grid>
@@ -872,73 +1214,12 @@ function AlleAnfragenTab() {
showAdminActions
showEditButton
canEditAny={canEditAny}
currentUserId={user?.id}
/>
</Box>
);
}
// ─── Overview Tab ────────────────────────────────────────────────────────────
function UebersichtTab() {
const { data: overview, isLoading } = useQuery<AusruestungOverview>({
queryKey: ['ausruestungsanfrage', 'overview'],
queryFn: () => ausruestungsanfrageApi.getOverview(),
});
if (isLoading) return <Typography color="text.secondary">Lade Übersicht...</Typography>;
if (!overview) return <Typography color="text.secondary">Keine Daten verfügbar.</Typography>;
return (
<Box>
<Grid container spacing={2} sx={{ mb: 3 }}>
<Grid item xs={12} sm={4}>
<Paper variant="outlined" sx={{ p: 2, textAlign: 'center' }}>
<Typography variant="h4" fontWeight={700}>{overview.pending_count}</Typography>
<Typography variant="body2" color="text.secondary">Offene Anfragen</Typography>
</Paper>
</Grid>
<Grid item xs={12} sm={4}>
<Paper variant="outlined" sx={{ p: 2, textAlign: 'center' }}>
<Typography variant="h4" fontWeight={700}>{overview.approved_count}</Typography>
<Typography variant="body2" color="text.secondary">Genehmigte Anfragen</Typography>
</Paper>
</Grid>
<Grid item xs={12} sm={4}>
<Paper variant="outlined" sx={{ p: 2, textAlign: 'center' }}>
<Typography variant="h4" fontWeight={700}>{overview.total_items}</Typography>
<Typography variant="body2" color="text.secondary">Artikel insgesamt</Typography>
</Paper>
</Grid>
</Grid>
{overview.items.length === 0 ? (
<Typography color="text.secondary">Keine offenen/genehmigten Anfragen vorhanden.</Typography>
) : (
<TableContainer component={Paper} variant="outlined">
<Table size="small">
<TableHead>
<TableRow>
<TableCell>Artikel</TableCell>
<TableCell align="right">Gesamtmenge</TableCell>
<TableCell align="right">Anfragen</TableCell>
</TableRow>
</TableHead>
<TableBody>
{overview.items.map(item => (
<TableRow key={item.bezeichnung}>
<TableCell>{item.bezeichnung}</TableCell>
<TableCell align="right">{item.total_menge}</TableCell>
<TableCell align="right">{item.anfrage_count}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</TableContainer>
)}
</Box>
);
}
// ─── Main Page ──────────────────────────────────────────────────────────────
export default function Ausruestungsanfrage() {
@@ -948,9 +1229,8 @@ export default function Ausruestungsanfrage() {
const canView = hasPermission('ausruestungsanfrage:view');
const canCreate = hasPermission('ausruestungsanfrage:create_request');
const canApprove = hasPermission('ausruestungsanfrage:approve');
const canViewAll = hasPermission('ausruestungsanfrage:view_all');
const tabCount = 1 + (canCreate ? 1 : 0) + (canApprove ? 1 : 0) + (canViewAll ? 1 : 0);
const tabCount = 1 + (canCreate ? 1 : 0) + (canApprove ? 1 : 0);
const [activeTab, setActiveTab] = useState(() => {
const t = Number(searchParams.get('tab'));
@@ -973,10 +1253,9 @@ export default function Ausruestungsanfrage() {
let next = 0;
if (canCreate) { map.meine = next; next++; }
if (canApprove) { map.alle = next; next++; }
if (canViewAll) { map.uebersicht = next; next++; }
map.katalog = next;
return map;
}, [canCreate, canApprove, canViewAll]);
}, [canCreate, canApprove]);
if (!canView) {
return (
@@ -994,14 +1273,12 @@ export default function Ausruestungsanfrage() {
<Tabs value={activeTab} onChange={handleTabChange} variant="scrollable" scrollButtons="auto">
{canCreate && <Tab label="Meine Anfragen" />}
{canApprove && <Tab label="Alle Anfragen" />}
{canViewAll && <Tab label="Übersicht" />}
<Tab label="Katalog" />
</Tabs>
</Box>
{canCreate && activeTab === tabIndex.meine && <MeineAnfragenTab />}
{canApprove && activeTab === tabIndex.alle && <AlleAnfragenTab />}
{canViewAll && activeTab === tabIndex.uebersicht && <UebersichtTab />}
{activeTab === tabIndex.katalog && <KatalogTab />}
</DashboardLayout>
);

View File

@@ -6,13 +6,33 @@ import type {
AusruestungAnfrageDetailResponse,
AusruestungAnfrageFormItem,
AusruestungOverview,
AusruestungKategorie,
AusruestungEigenschaft,
} from '../types/ausruestungsanfrage.types';
export const ausruestungsanfrageApi = {
// ── Categories (DB-backed) ──
getKategorien: async (): Promise<AusruestungKategorie[]> => {
const r = await api.get('/api/ausruestungsanfragen/kategorien');
return r.data.data;
},
createKategorie: async (name: string): Promise<AusruestungKategorie> => {
const r = await api.post('/api/ausruestungsanfragen/kategorien', { name });
return r.data.data;
},
updateKategorie: async (id: number, name: string): Promise<AusruestungKategorie> => {
const r = await api.patch(`/api/ausruestungsanfragen/kategorien/${id}`, { name });
return r.data.data;
},
deleteKategorie: async (id: number): Promise<void> => {
await api.delete(`/api/ausruestungsanfragen/kategorien/${id}`);
},
// ── Catalog Items ──
getItems: async (filters?: { kategorie?: string; aktiv?: boolean }): Promise<AusruestungArtikel[]> => {
getItems: async (filters?: { kategorie?: string; kategorie_id?: number; aktiv?: boolean }): Promise<AusruestungArtikel[]> => {
const params = new URLSearchParams();
if (filters?.kategorie) params.set('kategorie', filters.kategorie);
if (filters?.kategorie_id) params.set('kategorie_id', String(filters.kategorie_id));
if (filters?.aktiv !== undefined) params.set('aktiv', String(filters.aktiv));
const r = await api.get(`/api/ausruestungsanfragen/items?${params.toString()}`);
return r.data.data;
@@ -37,6 +57,22 @@ export const ausruestungsanfrageApi = {
return r.data.data;
},
// ── Item Eigenschaften (characteristics) ──
getArtikelEigenschaften: async (artikelId: number): Promise<AusruestungEigenschaft[]> => {
const r = await api.get(`/api/ausruestungsanfragen/items/${artikelId}/eigenschaften`);
return r.data.data;
},
upsertArtikelEigenschaft: async (
artikelId: number,
data: { eigenschaft_id?: number; name: string; typ: string; optionen?: string[]; pflicht?: boolean; sort_order?: number },
): Promise<AusruestungEigenschaft> => {
const r = await api.post(`/api/ausruestungsanfragen/items/${artikelId}/eigenschaften`, data);
return r.data.data;
},
deleteArtikelEigenschaft: async (eigenschaftId: number): Promise<void> => {
await api.delete(`/api/ausruestungsanfragen/eigenschaften/${eigenschaftId}`);
},
// ── Requests ──
getRequests: async (filters?: { status?: string }): Promise<AusruestungAnfrage[]> => {
const params = new URLSearchParams();
@@ -57,7 +93,7 @@ export const ausruestungsanfrageApi = {
notizen?: string,
bezeichnung?: string,
fuer_benutzer_id?: string,
): Promise<AusruestungAnfrage> => {
): Promise<AusruestungAnfrageDetailResponse> => {
const r = await api.post('/api/ausruestungsanfragen/requests', { items, notizen, bezeichnung, fuer_benutzer_id });
return r.data.data;
},

View File

@@ -1,5 +1,31 @@
// Ausrüstungsanfrage (Equipment Request) types
// ── Categories ──
export interface AusruestungKategorie {
id: number;
name: string;
erstellt_am?: string;
}
// ── Characteristics ──
export interface AusruestungEigenschaft {
id: number;
artikel_id: number;
name: string;
typ: 'options' | 'freitext';
optionen?: string[];
pflicht: boolean;
sort_order: number;
}
export interface AusruestungPositionEigenschaft {
eigenschaft_id: number;
eigenschaft_name: string;
wert: string;
}
// ── Catalog Items ──
export interface AusruestungArtikel {
@@ -7,18 +33,23 @@ export interface AusruestungArtikel {
bezeichnung: string;
beschreibung?: string;
kategorie?: string;
kategorie_id?: number;
kategorie_name?: string;
bild_pfad?: string;
geschaetzter_preis?: number;
aktiv: boolean;
erstellt_von?: string;
erstellt_am: string;
aktualisiert_am: string;
eigenschaften_count?: number;
eigenschaften?: AusruestungEigenschaft[];
}
export interface AusruestungArtikelFormData {
bezeichnung: string;
beschreibung?: string;
kategorie?: string;
kategorie_id?: number | null;
geschaetzter_preis?: number;
aktiv?: boolean;
}
@@ -69,6 +100,7 @@ export interface AusruestungAnfragePosition {
menge: number;
notizen?: string;
erstellt_am: string;
eigenschaften?: AusruestungPositionEigenschaft[];
}
export interface AusruestungAnfrageFormItem {
@@ -76,6 +108,7 @@ export interface AusruestungAnfrageFormItem {
bezeichnung: string;
menge: number;
notizen?: string;
eigenschaften?: { eigenschaft_id: number; wert: string }[];
}
// ── API Response Types ──
@@ -98,5 +131,6 @@ export interface AusruestungOverview {
items: AusruestungOverviewItem[];
pending_count: number;
approved_count: number;
unhandled_count: number;
total_items: number;
}