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'; import logger from '../utils/logger';
class AusruestungsanfrageController { 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 // Catalog Items
// ------------------------------------------------------------------------- // -------------------------------------------------------------------------
@@ -12,8 +76,9 @@ class AusruestungsanfrageController {
async getItems(req: Request, res: Response): Promise<void> { async getItems(req: Request, res: Response): Promise<void> {
try { try {
const kategorie = req.query.kategorie as string | undefined; 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 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 }); res.status(200).json({ success: true, data: items });
} catch (error) { } catch (error) {
logger.error('AusruestungsanfrageController.getItems error', { 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 // Requests
// ------------------------------------------------------------------------- // -------------------------------------------------------------------------
@@ -131,7 +249,7 @@ class AusruestungsanfrageController {
async createRequest(req: Request, res: Response): Promise<void> { async createRequest(req: Request, res: Response): Promise<void> {
try { try {
const { items, notizen, bezeichnung, fuer_benutzer_id } = req.body as { 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; notizen?: string;
bezeichnung?: string; bezeichnung?: string;
fuer_benutzer_id?: string; fuer_benutzer_id?: string;
@@ -179,7 +297,7 @@ class AusruestungsanfrageController {
const { bezeichnung, notizen, items } = req.body as { const { bezeichnung, notizen, items } = req.body as {
bezeichnung?: string; bezeichnung?: string;
notizen?: 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 // Validate items if provided
@@ -209,8 +327,8 @@ class AusruestungsanfrageController {
// Check permission: owner + status=offen, OR ausruestungsanfrage:edit // Check permission: owner + status=offen, OR ausruestungsanfrage:edit
const groups = req.user?.groups ?? []; const groups = req.user?.groups ?? [];
const canEditAny = groups.includes('dashboard_admin') || permissionService.hasPermission(groups, 'ausruestungsanfrage:edit'); const canEditAny = groups.includes('dashboard_admin') || permissionService.hasPermission(groups, 'ausruestungsanfrage:edit');
const isOwner = existing.anfrager_id === req.user!.id; const isOwner = existing.anfrage.anfrager_id === req.user!.id;
if (!canEditAny && !(isOwner && existing.status === 'offen')) { if (!canEditAny && !(isOwner && existing.anfrage.status === 'offen')) {
res.status(403).json({ success: false, message: 'Keine Berechtigung zum Bearbeiten dieser Anfrage' }); res.status(403).json({ success: false, message: 'Keine Berechtigung zum Bearbeiten dieser Anfrage' });
return; return;
} }
@@ -253,11 +371,11 @@ class AusruestungsanfrageController {
// Notify requester on status changes // Notify requester on status changes
if (['genehmigt', 'abgelehnt', 'bestellt', 'erledigt'].includes(status)) { if (['genehmigt', 'abgelehnt', 'bestellt', 'erledigt'].includes(status)) {
const orderLabel = existing.bestell_jahr && existing.bestell_nummer const orderLabel = existing.anfrage.bestell_jahr && existing.anfrage.bestell_nummer
? `${existing.bestell_jahr}/${String(existing.bestell_nummer).padStart(3, '0')}` ? `${existing.anfrage.bestell_jahr}/${String(existing.anfrage.bestell_nummer).padStart(3, '0')}`
: `#${id}`; : `#${id}`;
await notificationService.createNotification({ await notificationService.createNotification({
user_id: existing.anfrager_id, user_id: existing.anfrage.anfrager_id,
typ: 'ausruestung_anfrage', typ: 'ausruestung_anfrage',
titel: status === 'genehmigt' ? 'Anfrage genehmigt' : status === 'abgelehnt' ? 'Anfrage abgelehnt' : `Anfrage ${status}`, titel: status === 'genehmigt' ? 'Anfrage genehmigt' : status === 'abgelehnt' ? 'Anfrage abgelehnt' : `Anfrage ${status}`,
nachricht: `Deine Ausrüstungsanfrage ${orderLabel} wurde ${status === 'genehmigt' ? 'genehmigt' : status === 'abgelehnt' ? 'abgelehnt' : 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(); 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 // 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.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)); 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)); router.get('/categories', authenticate, requirePermission('ausruestungsanfrage:view'), ausruestungsanfrageController.getCategories.bind(ausruestungsanfrageController));
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Overview // 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 // Requests

View File

@@ -1,34 +1,90 @@
import pool from '../config/database'; import pool from '../config/database';
import logger from '../utils/logger'; 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) // 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 conditions: string[] = [];
const params: unknown[] = []; const params: unknown[] = [];
if (filters?.kategorie) { if (filters?.kategorie) {
params.push(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) { if (filters?.aktiv !== undefined) {
params.push(filters.aktiv); 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 where = conditions.length > 0 ? `WHERE ${conditions.join(' AND ')}` : '';
const result = await pool.query( 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, params,
); );
return result.rows; return result.rows;
} }
async function getItemById(id: number) { async function getItemById(id: number) {
const result = await pool.query('SELECT * FROM ausruestung_artikel WHERE id = $1', [id]); const result = await pool.query(
return result.rows[0] || null; `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( async function createItem(
@@ -36,16 +92,17 @@ async function createItem(
bezeichnung: string; bezeichnung: string;
beschreibung?: string; beschreibung?: string;
kategorie?: string; kategorie?: string;
kategorie_id?: number;
geschaetzter_preis?: number; geschaetzter_preis?: number;
aktiv?: boolean; aktiv?: boolean;
}, },
userId: string, userId: string,
) { ) {
const result = await pool.query( const result = await pool.query(
`INSERT INTO ausruestung_artikel (bezeichnung, beschreibung, kategorie, geschaetzter_preis, aktiv, erstellt_von) `INSERT INTO ausruestung_artikel (bezeichnung, beschreibung, kategorie, kategorie_id, geschaetzter_preis, aktiv, erstellt_von)
VALUES ($1, $2, $3, $4, COALESCE($5, true), $6) VALUES ($1, $2, $3, $4, COALESCE($5, true), $6, $7)
RETURNING *`, 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]; return result.rows[0];
} }
@@ -56,6 +113,7 @@ async function updateItem(
bezeichnung?: string; bezeichnung?: string;
beschreibung?: string; beschreibung?: string;
kategorie?: string; kategorie?: string;
kategorie_id?: number | null;
geschaetzter_preis?: number; geschaetzter_preis?: number;
aktiv?: boolean; aktiv?: boolean;
}, },
@@ -76,6 +134,10 @@ async function updateItem(
params.push(data.kategorie); params.push(data.kategorie);
fields.push(`kategorie = $${params.length}`); 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) { if (data.geschaetzter_preis !== undefined) {
params.push(data.geschaetzter_preis); params.push(data.geschaetzter_preis);
fields.push(`geschaetzter_preis = $${params.length}`); fields.push(`geschaetzter_preis = $${params.length}`);
@@ -111,6 +173,45 @@ async function getCategories() {
return result.rows.map((r: { kategorie: string }) => r.kategorie); 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) // Requests (ausruestung_anfragen)
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -178,6 +279,33 @@ async function getRequestById(id: number) {
[id], [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( const bestellungen = await pool.query(
`SELECT b.* `SELECT b.*
FROM ausruestung_anfrage_bestellung ab FROM ausruestung_anfrage_bestellung ab
@@ -187,15 +315,15 @@ async function getRequestById(id: number) {
); );
return { return {
...reqResult.rows[0], anfrage: reqResult.rows[0],
positionen: positionen.rows, positionen: positionenWithEigenschaften,
linked_bestellungen: bestellungen.rows, linked_bestellungen: bestellungen.rows,
}; };
} }
async function createRequest( async function createRequest(
userId: string, 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, notizen?: string,
bezeichnung?: string, bezeichnung?: string,
) { ) {
@@ -222,7 +350,7 @@ async function createRequest(
const anfrage = anfrageResult.rows[0]; const anfrage = anfrageResult.rows[0];
for (const item of items) { for (const item of items) {
let bezeichnung = item.bezeichnung; let itemBezeichnung = item.bezeichnung;
// If artikel_id is provided, copy bezeichnung from catalog // If artikel_id is provided, copy bezeichnung from catalog
if (item.artikel_id) { if (item.artikel_id) {
@@ -231,15 +359,28 @@ async function createRequest(
[item.artikel_id], [item.artikel_id],
); );
if (artikelResult.rows.length > 0) { 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) `INSERT INTO ausruestung_anfrage_positionen (anfrage_id, artikel_id, bezeichnung, menge, notizen)
VALUES ($1, $2, $3, $4, $5)`, VALUES ($1, $2, $3, $4, $5)
[anfrage.id, item.artikel_id || null, bezeichnung, item.menge, item.notizen || null], 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'); await client.query('COMMIT');
@@ -258,7 +399,7 @@ async function updateRequest(
data: { data: {
bezeichnung?: string; bezeichnung?: string;
notizen?: 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(); const client = await pool.connect();
@@ -292,21 +433,34 @@ async function updateRequest(
if (data.items) { if (data.items) {
await client.query('DELETE FROM ausruestung_anfrage_positionen WHERE anfrage_id = $1', [id]); await client.query('DELETE FROM ausruestung_anfrage_positionen WHERE anfrage_id = $1', [id]);
for (const item of data.items) { for (const item of data.items) {
let bezeichnung = item.bezeichnung; let itemBezeichnung = item.bezeichnung;
if (item.artikel_id) { if (item.artikel_id) {
const artikelResult = await client.query( const artikelResult = await client.query(
'SELECT bezeichnung FROM ausruestung_artikel WHERE id = $1', 'SELECT bezeichnung FROM ausruestung_artikel WHERE id = $1',
[item.artikel_id], [item.artikel_id],
); );
if (artikelResult.rows.length > 0) { 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) `INSERT INTO ausruestung_anfrage_positionen (anfrage_id, artikel_id, bezeichnung, menge, notizen)
VALUES ($1, $2, $3, $4, $5)`, VALUES ($1, $2, $3, $4, $5)
[id, item.artikel_id || null, bezeichnung, item.menge, item.notizen || null], 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 `SELECT
COUNT(*) FILTER (WHERE status = 'offen')::int AS pending_count, COUNT(*) FILTER (WHERE status = 'offen')::int AS pending_count,
COUNT(*) FILTER (WHERE status = 'genehmigt')::int AS approved_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 COALESCE(SUM(sub.total), 0)::int AS total_items
FROM ausruestung_anfragen a FROM ausruestung_anfragen a
LEFT JOIN LATERAL ( LEFT JOIN LATERAL (
@@ -412,12 +567,19 @@ async function getOverview() {
} }
export default { export default {
getKategorien,
createKategorie,
updateKategorie,
deleteKategorie,
getItems, getItems,
getItemById, getItemById,
createItem, createItem,
updateItem, updateItem,
deleteItem, deleteItem,
getCategories, getCategories,
getArtikelEigenschaften,
upsertArtikelEigenschaft,
deleteArtikelEigenschaft,
getRequests, getRequests,
getMyRequests, getMyRequests,
getRequestById, getRequestById,

View File

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

View File

@@ -190,7 +190,6 @@ function Sidebar({ mobileOpen, onMobileClose }: SidebarProps) {
let ausruestungTabIdx = 0; let ausruestungTabIdx = 0;
if (hasPermission('ausruestungsanfrage:create_request')) { ausruestungSubItems.push({ text: 'Meine Anfragen', path: `/ausruestungsanfrage?tab=${ausruestungTabIdx}` }); ausruestungTabIdx++; } 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: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}` }); ausruestungSubItems.push({ text: 'Katalog', path: `/ausruestungsanfrage?tab=${ausruestungTabIdx}` });
// Build Issues sub-items dynamically (tab order must match Issues.tsx) // 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 { 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, Table, TableBody, TableCell, TableContainer, TableHead, TableRow, Paper,
Dialog, DialogTitle, DialogContent, DialogActions, TextField, IconButton, Dialog, DialogTitle, DialogContent, DialogActions, TextField, IconButton,
MenuItem, Select, FormControl, InputLabel, Autocomplete, MenuItem, Select, FormControl, InputLabel, Autocomplete,
Divider, Divider, Checkbox, FormControlLabel, Tooltip,
} from '@mui/material'; } from '@mui/material';
import { import {
Add as AddIcon, Delete as DeleteIcon, Edit as EditIcon, ShoppingCart, Add as AddIcon, Delete as DeleteIcon, Edit as EditIcon,
Check as CheckIcon, Close as CloseIcon, Link as LinkIcon, Check as CheckIcon, Close as CloseIcon, Link as LinkIcon, Settings as SettingsIcon,
} from '@mui/icons-material'; } from '@mui/icons-material';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { useSearchParams } from 'react-router-dom'; import { useSearchParams } from 'react-router-dom';
@@ -16,10 +16,15 @@ import DashboardLayout from '../components/dashboard/DashboardLayout';
import ChatAwareFab from '../components/shared/ChatAwareFab'; import ChatAwareFab from '../components/shared/ChatAwareFab';
import { useNotification } from '../contexts/NotificationContext'; import { useNotification } from '../contexts/NotificationContext';
import { usePermissionContext } from '../contexts/PermissionContext'; import { usePermissionContext } from '../contexts/PermissionContext';
import { useAuth } from '../contexts/AuthContext';
import { ausruestungsanfrageApi } from '../services/ausruestungsanfrage'; import { ausruestungsanfrageApi } from '../services/ausruestungsanfrage';
import { bestellungApi } from '../services/bestellung'; import { bestellungApi } from '../services/bestellung';
import { AUSRUESTUNG_STATUS_LABELS, AUSRUESTUNG_STATUS_COLORS } from '../types/ausruestungsanfrage.types'; 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'; import type { Bestellung } from '../types/bestellung.types';
// ─── Helpers ───────────────────────────────────────────────────────────────── // ─── Helpers ─────────────────────────────────────────────────────────────────
@@ -33,6 +38,247 @@ function formatOrderId(r: AusruestungAnfrage): string {
const ACTIVE_STATUSES: AusruestungAnfrageStatus[] = ['offen', 'genehmigt', 'bestellt']; 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 ───────────────────────────────────────────────────────────── // ─── Detail Modal ─────────────────────────────────────────────────────────────
interface DetailModalProps { interface DetailModalProps {
@@ -123,6 +369,7 @@ function DetailModal({ requestId, onClose, showAdminActions, showEditButton, can
bezeichnung: p.bezeichnung, bezeichnung: p.bezeichnung,
menge: p.menge, menge: p.menge,
notizen: p.notizen, notizen: p.notizen,
eigenschaften: p.eigenschaften?.map(e => ({ eigenschaft_id: e.eigenschaft_id, wert: e.wert })),
}))); })));
setEditing(true); setEditing(true);
}; };
@@ -257,9 +504,20 @@ function DetailModal({ requestId, onClose, showAdminActions, showEditButton, can
<Divider /> <Divider />
<Typography variant="subtitle2">Positionen</Typography> <Typography variant="subtitle2">Positionen</Typography>
{detail.positionen.map(p => ( {detail.positionen.map(p => (
<Typography key={p.id} variant="body2"> <Box key={p.id}>
- {p.menge}x {p.bezeichnung}{p.notizen ? ` (${p.notizen})` : ''} <Typography variant="body2">
</Typography> - {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 && ( {detail.linked_bestellungen && detail.linked_bestellungen.length > 0 && (
@@ -417,20 +675,22 @@ function KatalogTab() {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const canManage = hasPermission('ausruestungsanfrage:manage_catalog'); 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 [artikelDialogOpen, setArtikelDialogOpen] = useState(false);
const [editArtikel, setEditArtikel] = useState<AusruestungArtikel | null>(null); const [editArtikel, setEditArtikel] = useState<AusruestungArtikel | null>(null);
const [artikelForm, setArtikelForm] = useState<AusruestungArtikelFormData>({ bezeichnung: '' }); const [artikelForm, setArtikelForm] = useState<AusruestungArtikelFormData>({ bezeichnung: '' });
const [kategorieDialogOpen, setKategorieDialogOpen] = useState(false);
const { data: items = [], isLoading } = useQuery({ const { data: items = [], isLoading } = useQuery({
queryKey: ['ausruestungsanfrage', 'items', filterKategorie], 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({ const { data: kategorien = [] } = useQuery({
queryKey: ['ausruestungsanfrage', 'categories'], queryKey: ['ausruestungsanfrage', 'kategorien'],
queryFn: () => ausruestungsanfrageApi.getCategories(), queryFn: () => ausruestungsanfrageApi.getKategorien(),
}); });
const createItemMut = useMutation({ const createItemMut = useMutation({
@@ -456,7 +716,7 @@ function KatalogTab() {
}; };
const openEditArtikel = (a: AusruestungArtikel) => { const openEditArtikel = (a: AusruestungArtikel) => {
setEditArtikel(a); 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); setArtikelDialogOpen(true);
}; };
const saveArtikel = () => { const saveArtikel = () => {
@@ -471,49 +731,62 @@ function KatalogTab() {
<Box sx={{ display: 'flex', gap: 2, mb: 3, alignItems: 'center', flexWrap: 'wrap' }}> <Box sx={{ display: 'flex', gap: 2, mb: 3, alignItems: 'center', flexWrap: 'wrap' }}>
<FormControl size="small" sx={{ minWidth: 200 }}> <FormControl size="small" sx={{ minWidth: 200 }}>
<InputLabel>Kategorie</InputLabel> <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> <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> </Select>
</FormControl> </FormControl>
{canManageCategories && (
<Tooltip title="Kategorien verwalten">
<IconButton size="small" onClick={() => setKategorieDialogOpen(true)}>
<SettingsIcon fontSize="small" />
</IconButton>
</Tooltip>
)}
</Box> </Box>
{/* Catalog grid */} {/* Catalog table */}
{isLoading ? ( {isLoading ? (
<Typography color="text.secondary">Lade Katalog...</Typography> <Typography color="text.secondary">Lade Katalog...</Typography>
) : items.length === 0 ? ( ) : items.length === 0 ? (
<Typography color="text.secondary">Keine Artikel vorhanden.</Typography> <Typography color="text.secondary">Keine Artikel vorhanden.</Typography>
) : ( ) : (
<Grid container spacing={2}> <TableContainer component={Paper} variant="outlined">
{items.map(item => ( <Table size="small">
<Grid item xs={12} sm={6} md={4} key={item.id}> <TableHead>
<Card variant="outlined" sx={{ height: '100%', display: 'flex', flexDirection: 'column' }}> <TableRow>
{item.bild_pfad ? ( <TableCell>Bezeichnung</TableCell>
<Box sx={{ height: 160, backgroundImage: `url(${item.bild_pfad})`, backgroundSize: 'cover', backgroundPosition: 'center', borderBottom: '1px solid', borderColor: 'divider' }} /> <TableCell>Kategorie</TableCell>
) : ( <TableCell>Beschreibung</TableCell>
<Box sx={{ height: 160, display: 'flex', alignItems: 'center', justifyContent: 'center', bgcolor: 'action.hover', borderBottom: '1px solid', borderColor: 'divider' }}> {canManage && <TableCell align="right">Aktionen</TableCell>}
<ShoppingCart sx={{ fontSize: 48, color: 'text.disabled' }} /> </TableRow>
</Box> </TableHead>
)} <TableBody>
<CardContent sx={{ flexGrow: 1 }}> {items.map(item => (
<Typography variant="subtitle1" fontWeight={600}>{item.bezeichnung}</Typography> <TableRow key={item.id} hover>
{item.beschreibung && <Typography variant="body2" color="text.secondary" sx={{ mt: 0.5 }}>{item.beschreibung}</Typography>} <TableCell>
{item.kategorie && ( <Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<Box sx={{ mt: 1 }}> {item.bezeichnung}
<Chip label={item.kategorie} size="small" /> {(item.eigenschaften_count ?? 0) > 0 && (
<Chip label={`${item.eigenschaften_count} Eig.`} size="small" variant="outlined" />
)}
</Box> </Box>
</TableCell>
<TableCell>{item.kategorie_name || item.kategorie || '-'}</TableCell>
<TableCell sx={{ maxWidth: 300, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
{item.beschreibung || '-'}
</TableCell>
{canManage && (
<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>
</TableCell>
)} )}
</CardContent> </TableRow>
{canManage && ( ))}
<CardActions sx={{ justifyContent: 'flex-end' }}> </TableBody>
<IconButton size="small" onClick={() => openEditArtikel(item)}><EditIcon fontSize="small" /></IconButton> </Table>
<IconButton size="small" color="error" onClick={() => deleteItemMut.mutate(item.id)}><DeleteIcon fontSize="small" /></IconButton> </TableContainer>
</CardActions>
)}
</Card>
</Grid>
))}
</Grid>
)} )}
{/* Artikel create/edit dialog */} {/* Artikel create/edit dialog */}
@@ -522,13 +795,18 @@ function KatalogTab() {
<DialogContent sx={{ display: 'flex', flexDirection: 'column', gap: 2, pt: '16px !important' }}> <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="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 }))} /> <TextField label="Beschreibung" multiline rows={2} value={artikelForm.beschreibung ?? ''} onChange={e => setArtikelForm(f => ({ ...f, beschreibung: e.target.value }))} />
<Autocomplete <FormControl fullWidth>
freeSolo <InputLabel>Kategorie</InputLabel>
options={categories} <Select
value={artikelForm.kategorie ?? ''} value={artikelForm.kategorie_id ?? ''}
onInputChange={(_, val) => setArtikelForm(f => ({ ...f, kategorie: val || undefined }))} label="Kategorie"
renderInput={params => <TextField {...params} 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> </DialogContent>
<DialogActions> <DialogActions>
<Button onClick={() => setArtikelDialogOpen(false)}>Abbrechen</Button> <Button onClick={() => setArtikelDialogOpen(false)}>Abbrechen</Button>
@@ -538,6 +816,9 @@ function KatalogTab() {
</DialogActions> </DialogActions>
</Dialog> </Dialog>
{/* Kategorie management dialog */}
<KategorieDialog open={kategorieDialogOpen} onClose={() => setKategorieDialogOpen(false)} />
{/* FAB for new catalog item */} {/* FAB for new catalog item */}
{canManage && ( {canManage && (
<ChatAwareFab onClick={openNewArtikel} aria-label="Artikel hinzufügen"> <ChatAwareFab onClick={openNewArtikel} aria-label="Artikel hinzufügen">
@@ -552,6 +833,7 @@ function KatalogTab() {
function MeineAnfragenTab() { function MeineAnfragenTab() {
const { hasPermission } = usePermissionContext(); const { hasPermission } = usePermissionContext();
const { user } = useAuth();
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const { showSuccess, showError } = useNotification(); const { showSuccess, showError } = useNotification();
@@ -566,6 +848,10 @@ function MeineAnfragenTab() {
const [newNotizen, setNewNotizen] = useState(''); const [newNotizen, setNewNotizen] = useState('');
const [newFuerBenutzer, setNewFuerBenutzer] = useState<{ id: string; name: string } | null>(null); const [newFuerBenutzer, setNewFuerBenutzer] = useState<{ id: string; name: string } | null>(null);
const [newItems, setNewItems] = useState<AusruestungAnfrageFormItem[]>([{ bezeichnung: '', menge: 1 }]); 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({ const { data: requests = [], isLoading } = useQuery({
queryKey: ['ausruestungsanfrage', 'myRequests'], queryKey: ['ausruestungsanfrage', 'myRequests'],
@@ -601,11 +887,42 @@ function MeineAnfragenTab() {
setNewNotizen(''); setNewNotizen('');
setNewFuerBenutzer(null); setNewFuerBenutzer(null);
setNewItems([{ bezeichnung: '', menge: 1 }]); 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 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; 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({ createMut.mutate({
items: validItems, items: validItems,
notizen: newNotizen || undefined, notizen: newNotizen || undefined,
@@ -687,6 +1004,7 @@ function MeineAnfragenTab() {
onClose={() => setDetailId(null)} onClose={() => setDetailId(null)}
showEditButton showEditButton
canEditAny={canEditAny} canEditAny={canEditAny}
currentUserId={user?.id}
/> />
{/* Create Request Dialog */} {/* Create Request Dialog */}
@@ -719,39 +1037,55 @@ function MeineAnfragenTab() {
<Divider /> <Divider />
<Typography variant="subtitle2">Positionen</Typography> <Typography variant="subtitle2">Positionen</Typography>
{newItems.map((item, idx) => ( {newItems.map((item, idx) => (
<Box key={idx} sx={{ display: 'flex', gap: 1, alignItems: 'center' }}> <Box key={idx}>
<Autocomplete <Box sx={{ display: 'flex', gap: 1, alignItems: 'center' }}>
freeSolo <Autocomplete
options={catalogItems} freeSolo
getOptionLabel={o => typeof o === 'string' ? o : o.bezeichnung} options={catalogItems}
value={item.artikel_id ? catalogItems.find(c => c.id === item.artikel_id) || item.bezeichnung : item.bezeichnung} getOptionLabel={o => typeof o === 'string' ? o : o.bezeichnung}
onChange={(_, v) => { value={item.artikel_id ? catalogItems.find(c => c.id === item.artikel_id) || item.bezeichnung : item.bezeichnung}
if (typeof v === 'string') { onChange={(_, v) => {
setNewItems(prev => prev.map((it, i) => i === idx ? { ...it, bezeichnung: v, artikel_id: undefined } : it)); if (typeof v === 'string') {
} else if (v) { setNewItems(prev => prev.map((it, i) => i === idx ? { ...it, bezeichnung: v, artikel_id: undefined } : it));
setNewItems(prev => prev.map((it, i) => i === idx ? { ...it, artikel_id: v.id, bezeichnung: v.bezeichnung } : it)); // Clear eigenschaften for this row
} setItemEigenschaftValues(prev => { const n = { ...prev }; delete n[idx]; return n; });
}} } else if (v) {
onInputChange={(_, val, reason) => { setNewItems(prev => prev.map((it, i) => i === idx ? { ...it, artikel_id: v.id, bezeichnung: v.bezeichnung } : it));
if (reason === 'input') { loadEigenschaften(v.id);
setNewItems(prev => prev.map((it, i) => i === idx ? { ...it, bezeichnung: val, artikel_id: undefined } : it)); }
} }}
}} onInputChange={(_, val, reason) => {
renderInput={params => <TextField {...params} label="Artikel" size="small" />} if (reason === 'input') {
sx={{ flexGrow: 1 }} setNewItems(prev => prev.map((it, i) => i === idx ? { ...it, bezeichnung: val, artikel_id: undefined } : it));
/> }
<TextField }}
size="small" renderInput={params => <TextField {...params} label="Artikel" size="small" />}
type="number" sx={{ flexGrow: 1 }}
label="Menge" />
value={item.menge} <TextField
onChange={e => setNewItems(prev => prev.map((it, i) => i === idx ? { ...it, menge: Math.max(1, Number(e.target.value)) } : it))} size="small"
sx={{ width: 90 }} type="number"
inputProps={{ min: 1 }} label="Menge"
/> value={item.menge}
<IconButton size="small" onClick={() => setNewItems(prev => prev.filter((_, i) => i !== idx))} disabled={newItems.length <= 1}> onChange={e => setNewItems(prev => prev.map((it, i) => i === idx ? { ...it, menge: Math.max(1, Number(e.target.value)) } : it))}
<DeleteIcon fontSize="small" /> sx={{ width: 90 }}
</IconButton> inputProps={{ min: 1 }}
/>
<IconButton size="small" onClick={() => setNewItems(prev => prev.filter((_, i) => i !== idx))} disabled={newItems.length <= 1}>
<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> </Box>
))} ))}
<Button size="small" startIcon={<AddIcon />} onClick={() => setNewItems(prev => [...prev, { bezeichnung: '', menge: 1 }])}> <Button size="small" startIcon={<AddIcon />} onClick={() => setNewItems(prev => [...prev, { bezeichnung: '', menge: 1 }])}>
@@ -780,10 +1114,11 @@ function MeineAnfragenTab() {
); );
} }
// ─── Admin All Requests Tab ───────────────────────────────────────────────── // ─── Admin All Requests Tab (merged with overview) ──────────────────────────
function AlleAnfragenTab() { function AlleAnfragenTab() {
const { hasPermission } = usePermissionContext(); const { hasPermission } = usePermissionContext();
const { user } = useAuth();
const [statusFilter, setStatusFilter] = useState<string>(''); const [statusFilter, setStatusFilter] = useState<string>('');
const [detailId, setDetailId] = useState<number | null>(null); const [detailId, setDetailId] = useState<number | null>(null);
@@ -794,9 +1129,10 @@ function AlleAnfragenTab() {
queryFn: () => ausruestungsanfrageApi.getRequests(statusFilter ? { status: statusFilter } : undefined), queryFn: () => ausruestungsanfrageApi.getRequests(statusFilter ? { status: statusFilter } : undefined),
}); });
// Summary counts const { data: overview } = useQuery<AusruestungOverview>({
const openCount = useMemo(() => requests.filter(r => r.status === 'offen').length, [requests]); queryKey: ['ausruestungsanfrage', 'overview'],
const approvedCount = useMemo(() => requests.filter(r => r.status === 'genehmigt').length, [requests]); queryFn: () => ausruestungsanfrageApi.getOverview(),
});
if (isLoading) return <Typography color="text.secondary">Lade Anfragen...</Typography>; if (isLoading) return <Typography color="text.secondary">Lade Anfragen...</Typography>;
@@ -804,22 +1140,28 @@ function AlleAnfragenTab() {
<Box> <Box>
{/* Summary cards */} {/* Summary cards */}
<Grid container spacing={2} sx={{ mb: 3 }}> <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' }}> <Paper variant="outlined" sx={{ p: 2, textAlign: 'center' }}>
<Typography variant="h4" fontWeight={700}>{openCount}</Typography> <Typography variant="h4" fontWeight={700}>{overview?.pending_count ?? '-'}</Typography>
<Typography variant="body2" color="text.secondary">Offene Anfragen</Typography> <Typography variant="body2" color="text.secondary">Offene</Typography>
</Paper> </Paper>
</Grid> </Grid>
<Grid item xs={12} sm={4}> <Grid item xs={6} sm={3}>
<Paper variant="outlined" sx={{ p: 2, textAlign: 'center' }}> <Paper variant="outlined" sx={{ p: 2, textAlign: 'center' }}>
<Typography variant="h4" fontWeight={700}>{approvedCount}</Typography> <Typography variant="h4" fontWeight={700}>{overview?.approved_count ?? '-'}</Typography>
<Typography variant="body2" color="text.secondary">Genehmigte Anfragen</Typography> <Typography variant="body2" color="text.secondary">Genehmigte</Typography>
</Paper> </Paper>
</Grid> </Grid>
<Grid item xs={12} sm={4}> <Grid item xs={6} sm={3}>
<Paper variant="outlined" sx={{ p: 2, textAlign: 'center' }}> <Paper variant="outlined" sx={{ p: 2, textAlign: 'center' }}>
<Typography variant="h4" fontWeight={700}>{requests.length}</Typography> <Typography variant="h4" fontWeight={700} color="warning.main">{overview?.unhandled_count ?? '-'}</Typography>
<Typography variant="body2" color="text.secondary">Alle Anfragen</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> </Paper>
</Grid> </Grid>
</Grid> </Grid>
@@ -872,73 +1214,12 @@ function AlleAnfragenTab() {
showAdminActions showAdminActions
showEditButton showEditButton
canEditAny={canEditAny} canEditAny={canEditAny}
currentUserId={user?.id}
/> />
</Box> </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 ────────────────────────────────────────────────────────────── // ─── Main Page ──────────────────────────────────────────────────────────────
export default function Ausruestungsanfrage() { export default function Ausruestungsanfrage() {
@@ -948,9 +1229,8 @@ export default function Ausruestungsanfrage() {
const canView = hasPermission('ausruestungsanfrage:view'); const canView = hasPermission('ausruestungsanfrage:view');
const canCreate = hasPermission('ausruestungsanfrage:create_request'); const canCreate = hasPermission('ausruestungsanfrage:create_request');
const canApprove = hasPermission('ausruestungsanfrage:approve'); 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 [activeTab, setActiveTab] = useState(() => {
const t = Number(searchParams.get('tab')); const t = Number(searchParams.get('tab'));
@@ -973,10 +1253,9 @@ export default function Ausruestungsanfrage() {
let next = 0; 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 (canViewAll) { map.uebersicht = next; next++; }
map.katalog = next; map.katalog = next;
return map; return map;
}, [canCreate, canApprove, canViewAll]); }, [canCreate, canApprove]);
if (!canView) { if (!canView) {
return ( return (
@@ -994,14 +1273,12 @@ export default function Ausruestungsanfrage() {
<Tabs value={activeTab} onChange={handleTabChange} variant="scrollable" scrollButtons="auto"> <Tabs value={activeTab} onChange={handleTabChange} variant="scrollable" scrollButtons="auto">
{canCreate && <Tab label="Meine Anfragen" />} {canCreate && <Tab label="Meine Anfragen" />}
{canApprove && <Tab label="Alle Anfragen" />} {canApprove && <Tab label="Alle Anfragen" />}
{canViewAll && <Tab label="Übersicht" />}
<Tab label="Katalog" /> <Tab label="Katalog" />
</Tabs> </Tabs>
</Box> </Box>
{canCreate && activeTab === tabIndex.meine && <MeineAnfragenTab />} {canCreate && activeTab === tabIndex.meine && <MeineAnfragenTab />}
{canApprove && activeTab === tabIndex.alle && <AlleAnfragenTab />} {canApprove && activeTab === tabIndex.alle && <AlleAnfragenTab />}
{canViewAll && activeTab === tabIndex.uebersicht && <UebersichtTab />}
{activeTab === tabIndex.katalog && <KatalogTab />} {activeTab === tabIndex.katalog && <KatalogTab />}
</DashboardLayout> </DashboardLayout>
); );

View File

@@ -6,13 +6,33 @@ import type {
AusruestungAnfrageDetailResponse, AusruestungAnfrageDetailResponse,
AusruestungAnfrageFormItem, AusruestungAnfrageFormItem,
AusruestungOverview, AusruestungOverview,
AusruestungKategorie,
AusruestungEigenschaft,
} from '../types/ausruestungsanfrage.types'; } from '../types/ausruestungsanfrage.types';
export const ausruestungsanfrageApi = { 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 ── // ── 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(); const params = new URLSearchParams();
if (filters?.kategorie) params.set('kategorie', filters.kategorie); 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)); if (filters?.aktiv !== undefined) params.set('aktiv', String(filters.aktiv));
const r = await api.get(`/api/ausruestungsanfragen/items?${params.toString()}`); const r = await api.get(`/api/ausruestungsanfragen/items?${params.toString()}`);
return r.data.data; return r.data.data;
@@ -37,6 +57,22 @@ export const ausruestungsanfrageApi = {
return r.data.data; 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 ── // ── Requests ──
getRequests: async (filters?: { status?: string }): Promise<AusruestungAnfrage[]> => { getRequests: async (filters?: { status?: string }): Promise<AusruestungAnfrage[]> => {
const params = new URLSearchParams(); const params = new URLSearchParams();
@@ -57,7 +93,7 @@ export const ausruestungsanfrageApi = {
notizen?: string, notizen?: string,
bezeichnung?: string, bezeichnung?: string,
fuer_benutzer_id?: string, fuer_benutzer_id?: string,
): Promise<AusruestungAnfrage> => { ): Promise<AusruestungAnfrageDetailResponse> => {
const r = await api.post('/api/ausruestungsanfragen/requests', { items, notizen, bezeichnung, fuer_benutzer_id }); const r = await api.post('/api/ausruestungsanfragen/requests', { items, notizen, bezeichnung, fuer_benutzer_id });
return r.data.data; return r.data.data;
}, },

View File

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