feat(ausruestungsanfrage): add personal item tracking, catalog enforcement, and detail pages
This commit is contained in:
@@ -267,7 +267,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, fuer_benutzer_name } = req.body as {
|
const { items, notizen, bezeichnung, fuer_benutzer_id, fuer_benutzer_name } = req.body as {
|
||||||
items?: { artikel_id?: number; bezeichnung: string; menge: number; notizen?: string; eigenschaften?: { eigenschaft_id: number; wert: string }[] }[];
|
items?: { artikel_id?: number; bezeichnung: string; menge: number; notizen?: string; eigenschaften?: { eigenschaft_id: number; wert: string }[]; persoenlich_id?: string; neuer_zustand?: string }[];
|
||||||
notizen?: string;
|
notizen?: string;
|
||||||
bezeichnung?: string;
|
bezeichnung?: string;
|
||||||
fuer_benutzer_id?: string;
|
fuer_benutzer_id?: string;
|
||||||
@@ -280,7 +280,7 @@ class AusruestungsanfrageController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
for (const item of items) {
|
for (const item of items) {
|
||||||
if (!item.bezeichnung || item.bezeichnung.trim().length === 0) {
|
if (!item.persoenlich_id && (!item.bezeichnung || item.bezeichnung.trim().length === 0)) {
|
||||||
res.status(400).json({ success: false, message: 'Bezeichnung ist für alle Positionen erforderlich' });
|
res.status(400).json({ success: false, message: 'Bezeichnung ist für alle Positionen erforderlich' });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -616,6 +616,40 @@ class AusruestungsanfrageController {
|
|||||||
res.status(500).json({ success: false, message: 'Widget-Daten konnten nicht geladen werden' });
|
res.status(500).json({ success: false, message: 'Widget-Daten konnten nicht geladen werden' });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
// Unassigned positions
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
|
||||||
|
async getUnassignedPositions(_req: Request, res: Response): Promise<void> {
|
||||||
|
try {
|
||||||
|
const data = await ausruestungsanfrageService.getUnassignedPositions();
|
||||||
|
res.status(200).json({ success: true, data });
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('AusruestungsanfrageController.getUnassignedPositions error', { error });
|
||||||
|
res.status(500).json({ success: false, message: 'Fehler beim Laden' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async updatePositionArtikelId(req: Request, res: Response): Promise<void> {
|
||||||
|
try {
|
||||||
|
const positionId = Number(req.params.positionId);
|
||||||
|
const artikelId = Number(req.body.artikel_id);
|
||||||
|
if (isNaN(positionId) || isNaN(artikelId) || artikelId <= 0) {
|
||||||
|
res.status(400).json({ success: false, message: 'Ungültige IDs' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const updated = await ausruestungsanfrageService.updatePositionArtikelId(positionId, artikelId);
|
||||||
|
if (!updated) {
|
||||||
|
res.status(404).json({ success: false, message: 'Position nicht gefunden' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
res.status(200).json({ success: true, data: updated });
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('AusruestungsanfrageController.updatePositionArtikelId error', { error });
|
||||||
|
res.status(500).json({ success: false, message: 'Fehler beim Aktualisieren' });
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export default new AusruestungsanfrageController();
|
export default new AusruestungsanfrageController();
|
||||||
|
|||||||
@@ -0,0 +1,26 @@
|
|||||||
|
-- Migration: 085_personal_equipment_eigenschaften
|
||||||
|
-- Adds eigenschaften (characteristics) storage for persoenliche_ausruestung
|
||||||
|
-- and extends ausruestung_anfrage_positionen for status change requests.
|
||||||
|
|
||||||
|
-- 1. Characteristics table for personal equipment items
|
||||||
|
CREATE TABLE IF NOT EXISTS persoenliche_ausruestung_eigenschaften (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
persoenlich_id UUID NOT NULL REFERENCES persoenliche_ausruestung(id) ON DELETE CASCADE,
|
||||||
|
eigenschaft_id INT REFERENCES ausruestung_artikel_eigenschaften(id) ON DELETE SET NULL,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
wert TEXT NOT NULL,
|
||||||
|
UNIQUE(persoenlich_id, eigenschaft_id)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_persoenliche_ausruestung_eigenschaften_persoenlich
|
||||||
|
ON persoenliche_ausruestung_eigenschaften(persoenlich_id);
|
||||||
|
|
||||||
|
-- 2. Add status-change request columns to anfrage positions
|
||||||
|
ALTER TABLE ausruestung_anfrage_positionen
|
||||||
|
ADD COLUMN IF NOT EXISTS persoenlich_id UUID REFERENCES persoenliche_ausruestung(id) ON DELETE SET NULL;
|
||||||
|
|
||||||
|
ALTER TABLE ausruestung_anfrage_positionen
|
||||||
|
ADD COLUMN IF NOT EXISTS aktueller_zustand TEXT;
|
||||||
|
|
||||||
|
ALTER TABLE ausruestung_anfrage_positionen
|
||||||
|
ADD COLUMN IF NOT EXISTS neuer_zustand TEXT CHECK (neuer_zustand IN ('gut','beschaedigt','abgaengig','verloren'));
|
||||||
@@ -57,10 +57,17 @@ router.patch('/requests/:id', authenticate, ausruestungsanfrageController.update
|
|||||||
router.patch('/requests/:id/status', authenticate, requirePermission('ausruestungsanfrage:approve'), ausruestungsanfrageController.updateRequestStatus.bind(ausruestungsanfrageController));
|
router.patch('/requests/:id/status', authenticate, requirePermission('ausruestungsanfrage:approve'), ausruestungsanfrageController.updateRequestStatus.bind(ausruestungsanfrageController));
|
||||||
router.delete('/requests/:id', authenticate, requirePermission('ausruestungsanfrage:approve'), ausruestungsanfrageController.deleteRequest.bind(ausruestungsanfrageController));
|
router.delete('/requests/:id', authenticate, requirePermission('ausruestungsanfrage:approve'), ausruestungsanfrageController.deleteRequest.bind(ausruestungsanfrageController));
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Unassigned positions
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
router.get('/nicht-zugewiesen', authenticate, requirePermission('ausruestungsanfrage:approve'), ausruestungsanfrageController.getUnassignedPositions.bind(ausruestungsanfrageController));
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Position delivery tracking
|
// Position delivery tracking
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
router.patch('/positionen/:positionId/artikel', authenticate, requirePermission('ausruestungsanfrage:approve'), ausruestungsanfrageController.updatePositionArtikelId.bind(ausruestungsanfrageController));
|
||||||
router.patch('/positionen/:positionId/geliefert', authenticate, requirePermission('ausruestungsanfrage:approve'), ausruestungsanfrageController.updatePositionGeliefert.bind(ausruestungsanfrageController));
|
router.patch('/positionen/:positionId/geliefert', authenticate, requirePermission('ausruestungsanfrage:approve'), ausruestungsanfrageController.updatePositionGeliefert.bind(ausruestungsanfrageController));
|
||||||
router.patch('/positionen/:positionId/zurueckgegeben', authenticate, requirePermission('ausruestungsanfrage:approve'), ausruestungsanfrageController.updatePositionZurueckgegeben.bind(ausruestungsanfrageController));
|
router.patch('/positionen/:positionId/zurueckgegeben', authenticate, requirePermission('ausruestungsanfrage:approve'), ausruestungsanfrageController.updatePositionZurueckgegeben.bind(ausruestungsanfrageController));
|
||||||
|
|
||||||
|
|||||||
@@ -428,7 +428,7 @@ async function getRequestById(id: number) {
|
|||||||
|
|
||||||
async function createRequest(
|
async function createRequest(
|
||||||
userId: string,
|
userId: string,
|
||||||
items: { artikel_id?: number; bezeichnung: string; menge: number; notizen?: string; ist_ersatz?: boolean; eigenschaften?: { eigenschaft_id: number; wert: string }[] }[],
|
items: { artikel_id?: number; bezeichnung: string; menge: number; notizen?: string; ist_ersatz?: boolean; eigenschaften?: { eigenschaft_id: number; wert: string }[]; persoenlich_id?: string; neuer_zustand?: string }[],
|
||||||
notizen?: string,
|
notizen?: string,
|
||||||
bezeichnung?: string,
|
bezeichnung?: string,
|
||||||
fuerBenutzerName?: string,
|
fuerBenutzerName?: string,
|
||||||
@@ -471,7 +471,26 @@ async function createRequest(
|
|||||||
|
|
||||||
for (const item of items) {
|
for (const item of items) {
|
||||||
let itemBezeichnung = item.bezeichnung;
|
let itemBezeichnung = item.bezeichnung;
|
||||||
if (item.artikel_id) {
|
let itemArtikelId = item.artikel_id || null;
|
||||||
|
let itemPersoenlichId: string | null = null;
|
||||||
|
let itemAktuellerZustand: string | null = null;
|
||||||
|
let itemNeuerZustand: string | null = null;
|
||||||
|
|
||||||
|
if (item.persoenlich_id) {
|
||||||
|
// Load personal item details
|
||||||
|
const persResult = await client.query(
|
||||||
|
'SELECT bezeichnung, zustand, artikel_id FROM persoenliche_ausruestung WHERE id = $1',
|
||||||
|
[item.persoenlich_id],
|
||||||
|
);
|
||||||
|
if (persResult.rows.length > 0) {
|
||||||
|
const pers = persResult.rows[0];
|
||||||
|
itemBezeichnung = pers.bezeichnung;
|
||||||
|
itemArtikelId = pers.artikel_id || itemArtikelId;
|
||||||
|
itemPersoenlichId = item.persoenlich_id;
|
||||||
|
itemAktuellerZustand = pers.zustand || null;
|
||||||
|
itemNeuerZustand = item.neuer_zustand || null;
|
||||||
|
}
|
||||||
|
} else 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],
|
||||||
@@ -482,9 +501,9 @@ async function createRequest(
|
|||||||
}
|
}
|
||||||
|
|
||||||
await client.query(
|
await client.query(
|
||||||
`INSERT INTO ausruestung_anfrage_positionen (anfrage_id, artikel_id, bezeichnung, menge, notizen, ist_ersatz)
|
`INSERT INTO ausruestung_anfrage_positionen (anfrage_id, artikel_id, bezeichnung, menge, notizen, ist_ersatz, persoenlich_id, aktueller_zustand, neuer_zustand)
|
||||||
VALUES ($1, $2, $3, $4, $5, $6)`,
|
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)`,
|
||||||
[anfrage.id, item.artikel_id || null, itemBezeichnung, item.menge, item.notizen || null, item.ist_ersatz ?? false],
|
[anfrage.id, itemArtikelId, itemBezeichnung, item.menge, item.notizen || null, item.ist_ersatz ?? false, itemPersoenlichId, itemAktuellerZustand, itemNeuerZustand],
|
||||||
);
|
);
|
||||||
|
|
||||||
// NOTE: eigenschaft values are NOT saved in the transaction to avoid
|
// NOTE: eigenschaft values are NOT saved in the transaction to avoid
|
||||||
@@ -710,6 +729,19 @@ async function updateRequestStatus(
|
|||||||
[id],
|
[id],
|
||||||
);
|
);
|
||||||
} catch { /* column may not exist yet */ }
|
} catch { /* column may not exist yet */ }
|
||||||
|
|
||||||
|
// Apply personal equipment status changes
|
||||||
|
const statusChangePositionen = await pool.query(
|
||||||
|
`SELECT persoenlich_id, neuer_zustand FROM ausruestung_anfrage_positionen
|
||||||
|
WHERE anfrage_id = $1 AND persoenlich_id IS NOT NULL AND neuer_zustand IS NOT NULL`,
|
||||||
|
[id],
|
||||||
|
);
|
||||||
|
for (const row of statusChangePositionen.rows) {
|
||||||
|
await pool.query(
|
||||||
|
`UPDATE persoenliche_ausruestung SET zustand = $1 WHERE id = $2`,
|
||||||
|
[row.neuer_zustand, row.persoenlich_id],
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return updated;
|
return updated;
|
||||||
@@ -1006,6 +1038,22 @@ async function assignDeliveredItems(
|
|||||||
);
|
);
|
||||||
const newId = insertResult.rows[0].id;
|
const newId = insertResult.rows[0].id;
|
||||||
|
|
||||||
|
// Copy position eigenschaften to persoenliche_ausruestung_eigenschaften
|
||||||
|
const posEigResult = await client.query(
|
||||||
|
`SELECT poe.eigenschaft_id, aae.name, poe.wert
|
||||||
|
FROM ausruestung_position_eigenschaften poe
|
||||||
|
JOIN ausruestung_artikel_eigenschaften aae ON aae.id = poe.eigenschaft_id
|
||||||
|
WHERE poe.position_id = $1`,
|
||||||
|
[a.positionId],
|
||||||
|
);
|
||||||
|
for (const row of posEigResult.rows) {
|
||||||
|
await client.query(
|
||||||
|
`INSERT INTO persoenliche_ausruestung_eigenschaften (persoenlich_id, eigenschaft_id, name, wert)
|
||||||
|
VALUES ($1, $2, $3, $4) ON CONFLICT DO NOTHING`,
|
||||||
|
[newId, row.eigenschaft_id, row.name, row.wert],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
await client.query(
|
await client.query(
|
||||||
`UPDATE ausruestung_anfrage_positionen
|
`UPDATE ausruestung_anfrage_positionen
|
||||||
SET zuweisung_typ = 'persoenlich', zuweisung_persoenlich_id = $1
|
SET zuweisung_typ = 'persoenlich', zuweisung_persoenlich_id = $1
|
||||||
@@ -1034,6 +1082,38 @@ async function assignDeliveredItems(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Unassigned positions + position artikel update
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
async function getUnassignedPositions() {
|
||||||
|
const result = await pool.query(`
|
||||||
|
SELECT
|
||||||
|
p.id, p.bezeichnung, p.menge, p.artikel_id,
|
||||||
|
a.id AS anfrage_id,
|
||||||
|
a.bezeichnung AS anfrage_bezeichnung,
|
||||||
|
a.bestell_nummer,
|
||||||
|
a.bestell_jahr,
|
||||||
|
COALESCE(a.fuer_benutzer_name,
|
||||||
|
NULLIF(TRIM(COALESCE(u.given_name,'') || ' ' || COALESCE(u.family_name,'')), ''),
|
||||||
|
u.name) AS fuer_wen
|
||||||
|
FROM ausruestung_anfrage_positionen p
|
||||||
|
JOIN ausruestung_anfragen a ON a.id = p.anfrage_id
|
||||||
|
LEFT JOIN users u ON u.id = a.anfrager_id
|
||||||
|
WHERE p.geliefert = true AND p.zuweisung_typ IS NULL
|
||||||
|
ORDER BY a.bestell_jahr DESC NULLS LAST, a.bestell_nummer DESC NULLS LAST, p.bezeichnung
|
||||||
|
`);
|
||||||
|
return result.rows;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function updatePositionArtikelId(positionId: number, artikelId: number) {
|
||||||
|
const result = await pool.query(
|
||||||
|
`UPDATE ausruestung_anfrage_positionen SET artikel_id = $1 WHERE id = $2 RETURNING *`,
|
||||||
|
[artikelId, positionId],
|
||||||
|
);
|
||||||
|
return result.rows[0] || null;
|
||||||
|
}
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
getAllUsers,
|
getAllUsers,
|
||||||
getKategorien,
|
getKategorien,
|
||||||
@@ -1065,4 +1145,6 @@ export default {
|
|||||||
getOverview,
|
getOverview,
|
||||||
getWidgetOverview,
|
getWidgetOverview,
|
||||||
assignDeliveredItems,
|
assignDeliveredItems,
|
||||||
|
getUnassignedPositions,
|
||||||
|
updatePositionArtikelId,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ interface CreatePersonalEquipmentData {
|
|||||||
anschaffung_datum?: string;
|
anschaffung_datum?: string;
|
||||||
zustand?: string;
|
zustand?: string;
|
||||||
notizen?: string;
|
notizen?: string;
|
||||||
|
eigenschaften?: { eigenschaft_id?: number; name: string; wert: string }[];
|
||||||
}
|
}
|
||||||
|
|
||||||
interface UpdatePersonalEquipmentData {
|
interface UpdatePersonalEquipmentData {
|
||||||
@@ -33,6 +34,7 @@ interface UpdatePersonalEquipmentData {
|
|||||||
anschaffung_datum?: string | null;
|
anschaffung_datum?: string | null;
|
||||||
zustand?: string;
|
zustand?: string;
|
||||||
notizen?: string | null;
|
notizen?: string | null;
|
||||||
|
eigenschaften?: { eigenschaft_id?: number | null; name: string; wert: string }[] | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const BASE_SELECT = `
|
const BASE_SELECT = `
|
||||||
@@ -45,6 +47,23 @@ const BASE_SELECT = `
|
|||||||
WHERE pa.geloescht_am IS NULL
|
WHERE pa.geloescht_am IS NULL
|
||||||
`;
|
`;
|
||||||
|
|
||||||
|
async function loadEigenschaften(ids: string[]) {
|
||||||
|
if (ids.length === 0) return new Map<string, { id: number; persoenlich_id: string; eigenschaft_id: number | null; name: string; wert: string }[]>();
|
||||||
|
const result = await pool.query(
|
||||||
|
`SELECT pae.id, pae.persoenlich_id, pae.eigenschaft_id, pae.name, pae.wert
|
||||||
|
FROM persoenliche_ausruestung_eigenschaften pae
|
||||||
|
WHERE pae.persoenlich_id = ANY($1)`,
|
||||||
|
[ids],
|
||||||
|
);
|
||||||
|
const map = new Map<string, typeof result.rows>();
|
||||||
|
for (const row of result.rows) {
|
||||||
|
const arr = map.get(row.persoenlich_id) || [];
|
||||||
|
arr.push(row);
|
||||||
|
map.set(row.persoenlich_id, arr);
|
||||||
|
}
|
||||||
|
return map;
|
||||||
|
}
|
||||||
|
|
||||||
async function getAll(filters: PersonalEquipmentFilters = {}) {
|
async function getAll(filters: PersonalEquipmentFilters = {}) {
|
||||||
try {
|
try {
|
||||||
const conditions: string[] = [];
|
const conditions: string[] = [];
|
||||||
@@ -68,6 +87,13 @@ async function getAll(filters: PersonalEquipmentFilters = {}) {
|
|||||||
`${BASE_SELECT}${where} ORDER BY pa.bezeichnung`,
|
`${BASE_SELECT}${where} ORDER BY pa.bezeichnung`,
|
||||||
params,
|
params,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const ids = result.rows.map((r: { id: string }) => r.id);
|
||||||
|
const eigMap = await loadEigenschaften(ids);
|
||||||
|
for (const row of result.rows) {
|
||||||
|
row.eigenschaften = eigMap.get(row.id) || [];
|
||||||
|
}
|
||||||
|
|
||||||
return result.rows;
|
return result.rows;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error('personalEquipmentService.getAll failed', { error });
|
logger.error('personalEquipmentService.getAll failed', { error });
|
||||||
@@ -81,6 +107,13 @@ async function getByUserId(userId: string) {
|
|||||||
`${BASE_SELECT} AND pa.user_id = $1 ORDER BY pa.bezeichnung`,
|
`${BASE_SELECT} AND pa.user_id = $1 ORDER BY pa.bezeichnung`,
|
||||||
[userId],
|
[userId],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const ids = result.rows.map((r: { id: string }) => r.id);
|
||||||
|
const eigMap = await loadEigenschaften(ids);
|
||||||
|
for (const row of result.rows) {
|
||||||
|
row.eigenschaften = eigMap.get(row.id) || [];
|
||||||
|
}
|
||||||
|
|
||||||
return result.rows;
|
return result.rows;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error('personalEquipmentService.getByUserId failed', { error, userId });
|
logger.error('personalEquipmentService.getByUserId failed', { error, userId });
|
||||||
@@ -94,7 +127,17 @@ async function getById(id: string) {
|
|||||||
`${BASE_SELECT} AND pa.id = $1`,
|
`${BASE_SELECT} AND pa.id = $1`,
|
||||||
[id],
|
[id],
|
||||||
);
|
);
|
||||||
return result.rows[0] || null;
|
if (!result.rows[0]) return null;
|
||||||
|
|
||||||
|
const eigResult = await pool.query(
|
||||||
|
`SELECT pae.id, pae.persoenlich_id, pae.eigenschaft_id, pae.name, pae.wert
|
||||||
|
FROM persoenliche_ausruestung_eigenschaften pae
|
||||||
|
WHERE pae.persoenlich_id = $1`,
|
||||||
|
[id],
|
||||||
|
);
|
||||||
|
result.rows[0].eigenschaften = eigResult.rows;
|
||||||
|
|
||||||
|
return result.rows[0];
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error('personalEquipmentService.getById failed', { error, id });
|
logger.error('personalEquipmentService.getById failed', { error, id });
|
||||||
throw new Error('Failed to fetch personal equipment item');
|
throw new Error('Failed to fetch personal equipment item');
|
||||||
@@ -125,8 +168,20 @@ async function create(data: CreatePersonalEquipmentData, requestingUserId: strin
|
|||||||
requestingUserId,
|
requestingUserId,
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
logger.info('Personal equipment created', { id: result.rows[0].id, by: requestingUserId });
|
const created = result.rows[0];
|
||||||
return result.rows[0];
|
logger.info('Personal equipment created', { id: created.id, by: requestingUserId });
|
||||||
|
|
||||||
|
if (data.eigenschaften && data.eigenschaften.length > 0) {
|
||||||
|
for (const e of data.eigenschaften) {
|
||||||
|
await pool.query(
|
||||||
|
`INSERT INTO persoenliche_ausruestung_eigenschaften (persoenlich_id, eigenschaft_id, name, wert)
|
||||||
|
VALUES ($1, $2, $3, $4)`,
|
||||||
|
[created.id, e.eigenschaft_id ?? null, e.name, e.wert],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return created;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error('personalEquipmentService.create failed', { error });
|
logger.error('personalEquipmentService.create failed', { error });
|
||||||
throw new Error('Failed to create personal equipment');
|
throw new Error('Failed to create personal equipment');
|
||||||
@@ -156,19 +211,46 @@ async function update(id: string, data: UpdatePersonalEquipmentData) {
|
|||||||
if (data.zustand !== undefined) addField('zustand', data.zustand);
|
if (data.zustand !== undefined) addField('zustand', data.zustand);
|
||||||
if (data.notizen !== undefined) addField('notizen', data.notizen);
|
if (data.notizen !== undefined) addField('notizen', data.notizen);
|
||||||
|
|
||||||
if (fields.length === 0) {
|
if (fields.length === 0 && data.eigenschaften === undefined) {
|
||||||
throw new Error('No fields to update');
|
throw new Error('No fields to update');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let updated = null;
|
||||||
|
if (fields.length > 0) {
|
||||||
values.push(id);
|
values.push(id);
|
||||||
const result = await pool.query(
|
const result = await pool.query(
|
||||||
`UPDATE persoenliche_ausruestung SET ${fields.join(', ')} WHERE id = $${p} AND geloescht_am IS NULL RETURNING *`,
|
`UPDATE persoenliche_ausruestung SET ${fields.join(', ')} WHERE id = $${p} AND geloescht_am IS NULL RETURNING *`,
|
||||||
values,
|
values,
|
||||||
);
|
);
|
||||||
|
|
||||||
if (result.rows.length === 0) return null;
|
if (result.rows.length === 0) return null;
|
||||||
|
updated = result.rows[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (data.eigenschaften !== undefined) {
|
||||||
|
await pool.query('DELETE FROM persoenliche_ausruestung_eigenschaften WHERE persoenlich_id = $1', [id]);
|
||||||
|
if (data.eigenschaften && data.eigenschaften.length > 0) {
|
||||||
|
for (const e of data.eigenschaften) {
|
||||||
|
await pool.query(
|
||||||
|
`INSERT INTO persoenliche_ausruestung_eigenschaften (persoenlich_id, eigenschaft_id, name, wert)
|
||||||
|
VALUES ($1, $2, $3, $4)`,
|
||||||
|
[id, e.eigenschaft_id ?? null, e.name, e.wert],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!updated) {
|
||||||
|
// Only eigenschaften were updated, fetch the row
|
||||||
|
const result = await pool.query(
|
||||||
|
`SELECT * FROM persoenliche_ausruestung WHERE id = $1 AND geloescht_am IS NULL`,
|
||||||
|
[id],
|
||||||
|
);
|
||||||
|
if (result.rows.length === 0) return null;
|
||||||
|
updated = result.rows[0];
|
||||||
|
}
|
||||||
|
|
||||||
logger.info('Personal equipment updated', { id });
|
logger.info('Personal equipment updated', { id });
|
||||||
return result.rows[0];
|
return updated;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error('personalEquipmentService.update failed', { error, id });
|
logger.error('personalEquipmentService.update failed', { error, id });
|
||||||
throw error;
|
throw error;
|
||||||
|
|||||||
@@ -22,6 +22,8 @@ import AusruestungDetail from './pages/AusruestungDetail';
|
|||||||
import AusruestungEinstellungen from './pages/AusruestungEinstellungen';
|
import AusruestungEinstellungen from './pages/AusruestungEinstellungen';
|
||||||
import PersoenlicheAusruestung from './pages/PersoenlicheAusruestung';
|
import PersoenlicheAusruestung from './pages/PersoenlicheAusruestung';
|
||||||
import PersoenlicheAusruestungNeu from './pages/PersoenlicheAusruestungNeu';
|
import PersoenlicheAusruestungNeu from './pages/PersoenlicheAusruestungNeu';
|
||||||
|
import PersoenlicheAusruestungDetail from './pages/PersoenlicheAusruestungDetail';
|
||||||
|
import PersoenlicheAusruestungEdit from './pages/PersoenlicheAusruestungEdit';
|
||||||
import Atemschutz from './pages/Atemschutz';
|
import Atemschutz from './pages/Atemschutz';
|
||||||
import Mitglieder from './pages/Mitglieder';
|
import Mitglieder from './pages/Mitglieder';
|
||||||
import MitgliedDetail from './pages/MitgliedDetail';
|
import MitgliedDetail from './pages/MitgliedDetail';
|
||||||
@@ -204,6 +206,22 @@ function App() {
|
|||||||
</ProtectedRoute>
|
</ProtectedRoute>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
<Route
|
||||||
|
path="/persoenliche-ausruestung/:id/edit"
|
||||||
|
element={
|
||||||
|
<ProtectedRoute>
|
||||||
|
<PersoenlicheAusruestungEdit />
|
||||||
|
</ProtectedRoute>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Route
|
||||||
|
path="/persoenliche-ausruestung/:id"
|
||||||
|
element={
|
||||||
|
<ProtectedRoute>
|
||||||
|
<PersoenlicheAusruestungDetail />
|
||||||
|
</ProtectedRoute>
|
||||||
|
}
|
||||||
|
/>
|
||||||
<Route
|
<Route
|
||||||
path="/persoenliche-ausruestung"
|
path="/persoenliche-ausruestung"
|
||||||
element={
|
element={
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { useState, useCallback, useRef } from 'react';
|
import { useState, useCallback, useRef } from 'react';
|
||||||
import {
|
import {
|
||||||
Box, Typography, Paper, Button, TextField, IconButton,
|
Box, Typography, Paper, Button, TextField, IconButton,
|
||||||
Autocomplete, Divider, MenuItem,
|
Autocomplete, Divider, MenuItem, Checkbox, Chip,
|
||||||
} from '@mui/material';
|
} from '@mui/material';
|
||||||
import { ArrowBack, Add as AddIcon, Delete as DeleteIcon } from '@mui/icons-material';
|
import { ArrowBack, Add as AddIcon, Delete as DeleteIcon } from '@mui/icons-material';
|
||||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
@@ -10,6 +10,9 @@ import DashboardLayout from '../components/dashboard/DashboardLayout';
|
|||||||
import { useNotification } from '../contexts/NotificationContext';
|
import { useNotification } from '../contexts/NotificationContext';
|
||||||
import { usePermissionContext } from '../contexts/PermissionContext';
|
import { usePermissionContext } from '../contexts/PermissionContext';
|
||||||
import { ausruestungsanfrageApi } from '../services/ausruestungsanfrage';
|
import { ausruestungsanfrageApi } from '../services/ausruestungsanfrage';
|
||||||
|
import { personalEquipmentApi } from '../services/personalEquipment';
|
||||||
|
import { ZUSTAND_LABELS, ZUSTAND_COLORS } from '../types/personalEquipment.types';
|
||||||
|
import type { PersoenlicheAusruestungZustand } from '../types/personalEquipment.types';
|
||||||
import type {
|
import type {
|
||||||
AusruestungAnfrageFormItem,
|
AusruestungAnfrageFormItem,
|
||||||
AusruestungEigenschaft,
|
AusruestungEigenschaft,
|
||||||
@@ -78,6 +81,7 @@ export default function AusruestungsanfrageNeu() {
|
|||||||
const [fuerBenutzer, setFuerBenutzer] = useState<{ id: string; name: string } | string | null>(null);
|
const [fuerBenutzer, setFuerBenutzer] = useState<{ id: string; name: string } | string | null>(null);
|
||||||
const [catalogItems, setCatalogItems] = useState<AusruestungAnfrageFormItem[]>([{ bezeichnung: '', menge: 1 }]);
|
const [catalogItems, setCatalogItems] = useState<AusruestungAnfrageFormItem[]>([{ bezeichnung: '', menge: 1 }]);
|
||||||
const [freeItems, setFreeItems] = useState<{ bezeichnung: string; menge: number }[]>([]);
|
const [freeItems, setFreeItems] = useState<{ bezeichnung: string; menge: number }[]>([]);
|
||||||
|
const [assignedSelections, setAssignedSelections] = useState<Record<string, string>>({});
|
||||||
|
|
||||||
// Eigenschaften state
|
// Eigenschaften state
|
||||||
const [itemEigenschaften, setItemEigenschaften] = useState<Record<number, AusruestungEigenschaft[]>>({});
|
const [itemEigenschaften, setItemEigenschaften] = useState<Record<number, AusruestungEigenschaft[]>>({});
|
||||||
@@ -97,10 +101,16 @@ export default function AusruestungsanfrageNeu() {
|
|||||||
enabled: canOrderForUser,
|
enabled: canOrderForUser,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const { data: myPersonalItems = [] } = useQuery({
|
||||||
|
queryKey: ['persoenliche-ausruestung', 'my-for-request'],
|
||||||
|
queryFn: () => personalEquipmentApi.getMy(),
|
||||||
|
staleTime: 2 * 60 * 1000,
|
||||||
|
});
|
||||||
|
|
||||||
// ── Mutations ──
|
// ── Mutations ──
|
||||||
const createMut = useMutation({
|
const createMut = useMutation({
|
||||||
mutationFn: (args: { items: AusruestungAnfrageFormItem[]; notizen?: string; bezeichnung?: string; fuer_benutzer_id?: string; fuer_benutzer_name?: string }) =>
|
mutationFn: (args: { items: AusruestungAnfrageFormItem[]; notizen?: string; bezeichnung?: string; fuer_benutzer_id?: string; fuer_benutzer_name?: string; assignedItems?: { persoenlich_id: string; neuer_zustand: string }[] }) =>
|
||||||
ausruestungsanfrageApi.createRequest(args.items, args.notizen, args.bezeichnung, args.fuer_benutzer_id, args.fuer_benutzer_name),
|
ausruestungsanfrageApi.createRequest(args.items, args.notizen, args.bezeichnung, args.fuer_benutzer_id, args.fuer_benutzer_name, args.assignedItems),
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
queryClient.invalidateQueries({ queryKey: ['ausruestungsanfrage'] });
|
queryClient.invalidateQueries({ queryKey: ['ausruestungsanfrage'] });
|
||||||
showSuccess('Anfrage erstellt');
|
showSuccess('Anfrage erstellt');
|
||||||
@@ -161,10 +171,13 @@ export default function AusruestungsanfrageNeu() {
|
|||||||
bezeichnung: bezeichnung || undefined,
|
bezeichnung: bezeichnung || undefined,
|
||||||
fuer_benutzer_id: typeof fuerBenutzer === 'object' && fuerBenutzer ? fuerBenutzer.id : undefined,
|
fuer_benutzer_id: typeof fuerBenutzer === 'object' && fuerBenutzer ? fuerBenutzer.id : undefined,
|
||||||
fuer_benutzer_name: typeof fuerBenutzer === 'string' ? fuerBenutzer : undefined,
|
fuer_benutzer_name: typeof fuerBenutzer === 'string' ? fuerBenutzer : undefined,
|
||||||
|
assignedItems: Object.entries(assignedSelections)
|
||||||
|
.filter(([, v]) => !!v)
|
||||||
|
.map(([id, neuer_zustand]) => ({ persoenlich_id: id, neuer_zustand })),
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
const hasValidItems = catalogItems.some(i => !!i.artikel_id) || freeItems.some(i => i.bezeichnung.trim());
|
const hasValidItems = catalogItems.some(i => !!i.artikel_id) || freeItems.some(i => i.bezeichnung.trim()) || Object.keys(assignedSelections).length > 0;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<DashboardLayout>
|
<DashboardLayout>
|
||||||
@@ -300,6 +313,48 @@ export default function AusruestungsanfrageNeu() {
|
|||||||
Freitext-Position hinzufügen
|
Freitext-Position hinzufügen
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
|
<Divider />
|
||||||
|
<Typography variant="subtitle2">Zugewiesene Gegenstände</Typography>
|
||||||
|
{myPersonalItems.length === 0 ? (
|
||||||
|
<Typography variant="body2" color="text.secondary">Keine zugewiesenen Gegenstände vorhanden.</Typography>
|
||||||
|
) : (
|
||||||
|
myPersonalItems.map((item) => (
|
||||||
|
<Box key={item.id} sx={{ display: 'flex', gap: 1, alignItems: 'center', py: 0.5 }}>
|
||||||
|
<Checkbox
|
||||||
|
size="small"
|
||||||
|
checked={!!assignedSelections[item.id]}
|
||||||
|
onChange={(e) => {
|
||||||
|
if (e.target.checked) {
|
||||||
|
setAssignedSelections(prev => ({ ...prev, [item.id]: item.zustand }));
|
||||||
|
} else {
|
||||||
|
setAssignedSelections(prev => { const n = { ...prev }; delete n[item.id]; return n; });
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Typography variant="body2" sx={{ flex: 1 }}>{item.bezeichnung}</Typography>
|
||||||
|
<Chip
|
||||||
|
label={ZUSTAND_LABELS[item.zustand as PersoenlicheAusruestungZustand]}
|
||||||
|
color={ZUSTAND_COLORS[item.zustand as PersoenlicheAusruestungZustand]}
|
||||||
|
size="small"
|
||||||
|
/>
|
||||||
|
{!!assignedSelections[item.id] && (
|
||||||
|
<TextField
|
||||||
|
select
|
||||||
|
size="small"
|
||||||
|
label="Neuer Status"
|
||||||
|
sx={{ minWidth: 150 }}
|
||||||
|
value={assignedSelections[item.id]}
|
||||||
|
onChange={(e) => setAssignedSelections(prev => ({ ...prev, [item.id]: e.target.value }))}
|
||||||
|
>
|
||||||
|
{Object.entries(ZUSTAND_LABELS).map(([key, label]) => (
|
||||||
|
<MenuItem key={key} value={key}>{label}</MenuItem>
|
||||||
|
))}
|
||||||
|
</TextField>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
|
||||||
<Divider />
|
<Divider />
|
||||||
<Box sx={{ display: 'flex', gap: 1, justifyContent: 'flex-end' }}>
|
<Box sx={{ display: 'flex', gap: 1, justifyContent: 'flex-end' }}>
|
||||||
<Button onClick={() => navigate('/ausruestungsanfrage')}>Abbrechen</Button>
|
<Button onClick={() => navigate('/ausruestungsanfrage')}>Abbrechen</Button>
|
||||||
|
|||||||
@@ -5,11 +5,12 @@ import {
|
|||||||
Stack, Divider, LinearProgress,
|
Stack, Divider, LinearProgress,
|
||||||
} from '@mui/material';
|
} from '@mui/material';
|
||||||
import { Assignment as AssignmentIcon } from '@mui/icons-material';
|
import { Assignment as AssignmentIcon } from '@mui/icons-material';
|
||||||
import { useQuery } from '@tanstack/react-query';
|
import { useQuery, useQueryClient } from '@tanstack/react-query';
|
||||||
import { useParams, useNavigate } from 'react-router-dom';
|
import { useParams, useNavigate } from 'react-router-dom';
|
||||||
import DashboardLayout from '../components/dashboard/DashboardLayout';
|
import DashboardLayout from '../components/dashboard/DashboardLayout';
|
||||||
import { PageHeader } from '../components/templates';
|
import { PageHeader } from '../components/templates';
|
||||||
import { useNotification } from '../contexts/NotificationContext';
|
import { useNotification } from '../contexts/NotificationContext';
|
||||||
|
import { usePermissionContext } from '../contexts/PermissionContext';
|
||||||
import { ausruestungsanfrageApi } from '../services/ausruestungsanfrage';
|
import { ausruestungsanfrageApi } from '../services/ausruestungsanfrage';
|
||||||
import { vehiclesApi } from '../services/vehicles';
|
import { vehiclesApi } from '../services/vehicles';
|
||||||
import { membersService } from '../services/members';
|
import { membersService } from '../services/members';
|
||||||
@@ -36,6 +37,13 @@ export default function AusruestungsanfrageZuweisung() {
|
|||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const { showSuccess, showError } = useNotification();
|
const { showSuccess, showError } = useNotification();
|
||||||
const anfrageId = Number(id);
|
const anfrageId = Number(id);
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
const { hasPermission } = usePermissionContext();
|
||||||
|
const canManageCatalog = hasPermission('ausruestungsanfrage:manage_catalog');
|
||||||
|
|
||||||
|
const [createArtikelFor, setCreateArtikelFor] = useState<number | null>(null);
|
||||||
|
const [newArtikelBezeichnung, setNewArtikelBezeichnung] = useState('');
|
||||||
|
const [newArtikelSubmitting, setNewArtikelSubmitting] = useState(false);
|
||||||
|
|
||||||
const { data: detail, isLoading, isError } = useQuery({
|
const { data: detail, isLoading, isError } = useQuery({
|
||||||
queryKey: ['ausruestungsanfrage', 'request', anfrageId],
|
queryKey: ['ausruestungsanfrage', 'request', anfrageId],
|
||||||
@@ -101,6 +109,22 @@ export default function AusruestungsanfrageZuweisung() {
|
|||||||
setAssignments(updated);
|
setAssignments(updated);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleCreateArtikel = async (posId: number) => {
|
||||||
|
if (!newArtikelBezeichnung.trim()) return;
|
||||||
|
setNewArtikelSubmitting(true);
|
||||||
|
try {
|
||||||
|
const newArtikel = await ausruestungsanfrageApi.createItem({ bezeichnung: newArtikelBezeichnung.trim(), aktiv: true });
|
||||||
|
await ausruestungsanfrageApi.linkPositionToArtikel(posId, newArtikel.id);
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['ausruestungsanfrage', 'request', anfrageId] });
|
||||||
|
setCreateArtikelFor(null);
|
||||||
|
showSuccess('Katalogartikel erstellt und Position verknüpft');
|
||||||
|
} catch {
|
||||||
|
showError('Fehler beim Erstellen des Katalogartikels');
|
||||||
|
} finally {
|
||||||
|
setNewArtikelSubmitting(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const handleSubmit = async () => {
|
const handleSubmit = async () => {
|
||||||
if (!detail) return;
|
if (!detail) return;
|
||||||
setSubmitting(true);
|
setSubmitting(true);
|
||||||
@@ -161,12 +185,55 @@ export default function AusruestungsanfrageZuweisung() {
|
|||||||
<Chip label={`${pos.menge}x`} size="small" variant="outlined" />
|
<Chip label={`${pos.menge}x`} size="small" variant="outlined" />
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
|
{!pos.artikel_id && (
|
||||||
|
<Box sx={{ mb: 1 }}>
|
||||||
|
<Chip label="Nicht im Katalog" color="warning" size="small" sx={{ mb: 1 }} />
|
||||||
|
{canManageCatalog && createArtikelFor !== pos.id && (
|
||||||
|
<Button
|
||||||
|
size="small"
|
||||||
|
variant="outlined"
|
||||||
|
color="warning"
|
||||||
|
sx={{ ml: 1 }}
|
||||||
|
onClick={() => { setCreateArtikelFor(pos.id); setNewArtikelBezeichnung(pos.bezeichnung); }}
|
||||||
|
>
|
||||||
|
Als Katalogartikel anlegen
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
{createArtikelFor === pos.id && (
|
||||||
|
<Box sx={{ display: 'flex', gap: 1, alignItems: 'center', mt: 1 }}>
|
||||||
|
<TextField
|
||||||
|
size="small"
|
||||||
|
label="Bezeichnung"
|
||||||
|
value={newArtikelBezeichnung}
|
||||||
|
onChange={(e) => setNewArtikelBezeichnung(e.target.value)}
|
||||||
|
sx={{ flex: 1 }}
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
size="small"
|
||||||
|
variant="contained"
|
||||||
|
disabled={newArtikelSubmitting || !newArtikelBezeichnung.trim()}
|
||||||
|
onClick={() => handleCreateArtikel(pos.id)}
|
||||||
|
>
|
||||||
|
Erstellen
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
size="small"
|
||||||
|
onClick={() => setCreateArtikelFor(null)}
|
||||||
|
>
|
||||||
|
Abbrechen
|
||||||
|
</Button>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
|
||||||
<ToggleButtonGroup
|
<ToggleButtonGroup
|
||||||
value={a.typ}
|
value={a.typ}
|
||||||
exclusive
|
exclusive
|
||||||
size="small"
|
size="small"
|
||||||
onChange={(_e, val) => val && updateAssignment(pos.id, { typ: val })}
|
onChange={(_e, val) => val && updateAssignment(pos.id, { typ: val })}
|
||||||
sx={{ mb: 1.5 }}
|
sx={{ mb: 1.5 }}
|
||||||
|
disabled={!pos.artikel_id}
|
||||||
>
|
>
|
||||||
<ToggleButton value="ausruestung">Ausrüstung</ToggleButton>
|
<ToggleButton value="ausruestung">Ausrüstung</ToggleButton>
|
||||||
<ToggleButton value="persoenlich">Persönlich</ToggleButton>
|
<ToggleButton value="persoenlich">Persönlich</ToggleButton>
|
||||||
|
|||||||
@@ -869,12 +869,29 @@ function MitgliedDetail() {
|
|||||||
</Typography>
|
</Typography>
|
||||||
) : (
|
) : (
|
||||||
personalEquipment.map((item) => (
|
personalEquipment.map((item) => (
|
||||||
<Box key={item.id} sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', py: 0.75, borderBottom: '1px solid', borderColor: 'divider' }}>
|
<Box
|
||||||
|
key={item.id}
|
||||||
|
sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', py: 0.75, borderBottom: '1px solid', borderColor: 'divider', cursor: 'pointer', '&:hover': { bgcolor: 'action.hover' } }}
|
||||||
|
onClick={() => navigate(`/persoenliche-ausruestung/${item.id}`)}
|
||||||
|
>
|
||||||
<Box>
|
<Box>
|
||||||
<Typography variant="body2" fontWeight={500}>{item.bezeichnung}</Typography>
|
<Typography variant="body2" fontWeight={500}>{item.bezeichnung}</Typography>
|
||||||
{item.kategorie && (
|
{item.kategorie && (
|
||||||
<Typography variant="caption" color="text.secondary">{item.kategorie}</Typography>
|
<Typography variant="caption" color="text.secondary">{item.kategorie}</Typography>
|
||||||
)}
|
)}
|
||||||
|
{item.eigenschaften && item.eigenschaften.length > 0 && (
|
||||||
|
<Box sx={{ display: 'flex', gap: 0.5, flexWrap: 'wrap', mt: 0.25 }}>
|
||||||
|
{item.eigenschaften.map((e) => (
|
||||||
|
<Chip
|
||||||
|
key={e.id}
|
||||||
|
label={`${e.name}: ${e.wert}`}
|
||||||
|
size="small"
|
||||||
|
variant="outlined"
|
||||||
|
sx={{ height: 18, fontSize: '0.65rem' }}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
</Box>
|
</Box>
|
||||||
<Chip
|
<Chip
|
||||||
label={ZUSTAND_LABELS[item.zustand]}
|
label={ZUSTAND_LABELS[item.zustand]}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { useState, useMemo } from 'react';
|
|||||||
import {
|
import {
|
||||||
Autocomplete,
|
Autocomplete,
|
||||||
Box,
|
Box,
|
||||||
|
Button,
|
||||||
Chip,
|
Chip,
|
||||||
Container,
|
Container,
|
||||||
MenuItem,
|
MenuItem,
|
||||||
@@ -16,6 +17,7 @@ import { useQuery } from '@tanstack/react-query';
|
|||||||
import { useNavigate } from 'react-router-dom';
|
import { useNavigate } from 'react-router-dom';
|
||||||
import DashboardLayout from '../components/dashboard/DashboardLayout';
|
import DashboardLayout from '../components/dashboard/DashboardLayout';
|
||||||
import { personalEquipmentApi } from '../services/personalEquipment';
|
import { personalEquipmentApi } from '../services/personalEquipment';
|
||||||
|
import { ausruestungsanfrageApi } from '../services/ausruestungsanfrage';
|
||||||
import { membersService } from '../services/members';
|
import { membersService } from '../services/members';
|
||||||
import { usePermissionContext } from '../contexts/PermissionContext';
|
import { usePermissionContext } from '../contexts/PermissionContext';
|
||||||
import ChatAwareFab from '../components/shared/ChatAwareFab';
|
import ChatAwareFab from '../components/shared/ChatAwareFab';
|
||||||
@@ -35,6 +37,7 @@ function PersoenlicheAusruestungPage() {
|
|||||||
|
|
||||||
const canViewAll = hasPermission('persoenliche_ausruestung:view_all');
|
const canViewAll = hasPermission('persoenliche_ausruestung:view_all');
|
||||||
const canCreate = hasPermission('persoenliche_ausruestung:create');
|
const canCreate = hasPermission('persoenliche_ausruestung:create');
|
||||||
|
const canApprove = hasPermission('ausruestungsanfrage:approve');
|
||||||
|
|
||||||
const [activeTab, setActiveTab] = useState(0);
|
const [activeTab, setActiveTab] = useState(0);
|
||||||
const [filterZustand, setFilterZustand] = useState<string>('');
|
const [filterZustand, setFilterZustand] = useState<string>('');
|
||||||
@@ -55,6 +58,13 @@ function PersoenlicheAusruestungPage() {
|
|||||||
enabled: canViewAll,
|
enabled: canViewAll,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const { data: unassignedPositions, isLoading: unassignedLoading } = useQuery({
|
||||||
|
queryKey: ['ausruestungsanfrage', 'nicht-zugewiesen'],
|
||||||
|
queryFn: () => ausruestungsanfrageApi.getUnassignedPositions(),
|
||||||
|
staleTime: 2 * 60 * 1000,
|
||||||
|
enabled: canApprove && activeTab === 2,
|
||||||
|
});
|
||||||
|
|
||||||
const memberOptions = useMemo(() => {
|
const memberOptions = useMemo(() => {
|
||||||
return (membersList?.items ?? []).map((m) => ({
|
return (membersList?.items ?? []).map((m) => ({
|
||||||
id: m.id,
|
id: m.id,
|
||||||
@@ -92,6 +102,7 @@ function PersoenlicheAusruestungPage() {
|
|||||||
<Tabs value={activeTab} onChange={(_e, v) => setActiveTab(v)} sx={{ mb: 3 }}>
|
<Tabs value={activeTab} onChange={(_e, v) => setActiveTab(v)} sx={{ mb: 3 }}>
|
||||||
<Tab label="Zuweisungen" />
|
<Tab label="Zuweisungen" />
|
||||||
<Tab label="Katalog" />
|
<Tab label="Katalog" />
|
||||||
|
{canApprove && <Tab label="Nicht Zugewiesen" />}
|
||||||
</Tabs>
|
</Tabs>
|
||||||
|
|
||||||
{activeTab === 0 && (
|
{activeTab === 0 && (
|
||||||
@@ -193,7 +204,7 @@ function PersoenlicheAusruestungPage() {
|
|||||||
</tr>
|
</tr>
|
||||||
) : (
|
) : (
|
||||||
filtered.map((item) => (
|
filtered.map((item) => (
|
||||||
<tr key={item.id}>
|
<tr key={item.id} onClick={() => navigate(`/persoenliche-ausruestung/${item.id}`)}>
|
||||||
<td>
|
<td>
|
||||||
<Typography variant="body2" fontWeight={500}>
|
<Typography variant="body2" fontWeight={500}>
|
||||||
{item.bezeichnung}
|
{item.bezeichnung}
|
||||||
@@ -203,6 +214,19 @@ function PersoenlicheAusruestungPage() {
|
|||||||
{item.artikel_bezeichnung}
|
{item.artikel_bezeichnung}
|
||||||
</Typography>
|
</Typography>
|
||||||
)}
|
)}
|
||||||
|
{item.eigenschaften && item.eigenschaften.length > 0 && (
|
||||||
|
<Box sx={{ display: 'flex', gap: 0.5, flexWrap: 'wrap', mt: 0.5 }}>
|
||||||
|
{item.eigenschaften.map((e) => (
|
||||||
|
<Chip
|
||||||
|
key={e.id}
|
||||||
|
label={`${e.name}: ${e.wert}`}
|
||||||
|
size="small"
|
||||||
|
variant="outlined"
|
||||||
|
sx={{ height: 20, fontSize: '0.7rem' }}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
</td>
|
</td>
|
||||||
<td>
|
<td>
|
||||||
<Typography variant="body2">{item.kategorie ?? '—'}</Typography>
|
<Typography variant="body2">{item.kategorie ?? '—'}</Typography>
|
||||||
@@ -242,6 +266,97 @@ function PersoenlicheAusruestungPage() {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{activeTab === 1 && <KatalogTab />}
|
{activeTab === 1 && <KatalogTab />}
|
||||||
|
|
||||||
|
{activeTab === 2 && canApprove && (
|
||||||
|
<Box sx={{ overflowX: 'auto' }}>
|
||||||
|
<Box
|
||||||
|
component="table"
|
||||||
|
sx={{
|
||||||
|
width: '100%',
|
||||||
|
borderCollapse: 'collapse',
|
||||||
|
'& th, & td': {
|
||||||
|
textAlign: 'left',
|
||||||
|
py: 1.5,
|
||||||
|
px: 2,
|
||||||
|
borderBottom: '1px solid',
|
||||||
|
borderColor: 'divider',
|
||||||
|
},
|
||||||
|
'& th': {
|
||||||
|
fontWeight: 600,
|
||||||
|
fontSize: '0.75rem',
|
||||||
|
textTransform: 'uppercase',
|
||||||
|
color: 'text.secondary',
|
||||||
|
letterSpacing: '0.06em',
|
||||||
|
},
|
||||||
|
'& tbody tr': {
|
||||||
|
'&:hover': { bgcolor: 'action.hover' },
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Bezeichnung</th>
|
||||||
|
<th>Anfrage</th>
|
||||||
|
<th>Für wen</th>
|
||||||
|
<th>Im Katalog</th>
|
||||||
|
<th>Aktion</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{unassignedLoading ? (
|
||||||
|
<tr>
|
||||||
|
<td colSpan={5}>
|
||||||
|
<Typography color="text.secondary" sx={{ py: 4, textAlign: 'center' }}>
|
||||||
|
Lade Daten…
|
||||||
|
</Typography>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
) : !unassignedPositions || unassignedPositions.length === 0 ? (
|
||||||
|
<tr>
|
||||||
|
<td colSpan={5}>
|
||||||
|
<Box sx={{ display: 'flex', flexDirection: 'column', alignItems: 'center', py: 6 }}>
|
||||||
|
<CheckroomIcon sx={{ fontSize: 48, color: 'text.disabled', mb: 1 }} />
|
||||||
|
<Typography color="text.secondary">
|
||||||
|
Alle Positionen sind zugewiesen
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
) : (
|
||||||
|
unassignedPositions.map((pos) => (
|
||||||
|
<tr key={pos.id}>
|
||||||
|
<td>
|
||||||
|
<Typography variant="body2" fontWeight={500}>{pos.bezeichnung}</Typography>
|
||||||
|
<Chip label={`${pos.menge}x`} size="small" variant="outlined" sx={{ ml: 1, height: 20 }} />
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<Typography
|
||||||
|
variant="body2"
|
||||||
|
component="span"
|
||||||
|
sx={{ cursor: 'pointer', color: 'primary.main', textDecoration: 'underline' }}
|
||||||
|
onClick={() => navigate(`/ausruestungsanfrage/${pos.anfrage_id}`)}
|
||||||
|
>
|
||||||
|
{pos.anfrage_bezeichnung || (pos.bestell_jahr && pos.bestell_nummer ? `${pos.bestell_jahr}/${String(pos.bestell_nummer).padStart(3, '0')}` : `#${pos.anfrage_id}`)}
|
||||||
|
</Typography>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<Typography variant="body2" fontWeight={500}>{pos.fuer_wen || '—'}</Typography>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<Chip label={pos.artikel_id ? 'Ja' : 'Nein'} color={pos.artikel_id ? 'success' : 'warning'} size="small" variant="outlined" />
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<Button size="small" variant="outlined" onClick={() => navigate(`/ausruestungsanfrage/${pos.anfrage_id}/zuweisen`)}>
|
||||||
|
Zuweisen
|
||||||
|
</Button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</tbody>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
</Container>
|
</Container>
|
||||||
|
|
||||||
{/* FAB */}
|
{/* FAB */}
|
||||||
|
|||||||
166
frontend/src/pages/PersoenlicheAusruestungDetail.tsx
Normal file
166
frontend/src/pages/PersoenlicheAusruestungDetail.tsx
Normal file
@@ -0,0 +1,166 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
import {
|
||||||
|
Box, Typography, Container, Chip, Button, Paper, Divider,
|
||||||
|
Dialog, DialogTitle, DialogContent, DialogContentText, DialogActions,
|
||||||
|
LinearProgress,
|
||||||
|
} from '@mui/material';
|
||||||
|
import { Edit as EditIcon, Delete as DeleteIcon } from '@mui/icons-material';
|
||||||
|
import { useParams, useNavigate } from 'react-router-dom';
|
||||||
|
import { useQuery, useQueryClient } from '@tanstack/react-query';
|
||||||
|
import DashboardLayout from '../components/dashboard/DashboardLayout';
|
||||||
|
import { PageHeader } from '../components/templates';
|
||||||
|
import { usePermissionContext } from '../contexts/PermissionContext';
|
||||||
|
import { useNotification } from '../contexts/NotificationContext';
|
||||||
|
import { personalEquipmentApi } from '../services/personalEquipment';
|
||||||
|
import { ZUSTAND_LABELS, ZUSTAND_COLORS } from '../types/personalEquipment.types';
|
||||||
|
import type { PersoenlicheAusruestungZustand } from '../types/personalEquipment.types';
|
||||||
|
|
||||||
|
export default function PersoenlicheAusruestungDetail() {
|
||||||
|
const { id } = useParams<{ id: string }>();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
const { hasPermission } = usePermissionContext();
|
||||||
|
const { showSuccess, showError } = useNotification();
|
||||||
|
const [deleteOpen, setDeleteOpen] = useState(false);
|
||||||
|
const [deleting, setDeleting] = useState(false);
|
||||||
|
|
||||||
|
const { data: item, isLoading, isError } = useQuery({
|
||||||
|
queryKey: ['persoenliche-ausruestung', 'detail', id],
|
||||||
|
queryFn: () => personalEquipmentApi.getById(id!),
|
||||||
|
enabled: !!id,
|
||||||
|
});
|
||||||
|
|
||||||
|
const canEdit = hasPermission('persoenliche_ausruestung:edit');
|
||||||
|
const canDelete = hasPermission('persoenliche_ausruestung:delete');
|
||||||
|
|
||||||
|
const handleDelete = async () => {
|
||||||
|
if (!id) return;
|
||||||
|
setDeleting(true);
|
||||||
|
try {
|
||||||
|
await personalEquipmentApi.delete(id);
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['persoenliche-ausruestung'] });
|
||||||
|
showSuccess('Persönliche Ausrüstung gelöscht');
|
||||||
|
navigate('/persoenliche-ausruestung');
|
||||||
|
} catch {
|
||||||
|
showError('Fehler beim Löschen');
|
||||||
|
} finally {
|
||||||
|
setDeleting(false);
|
||||||
|
setDeleteOpen(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<DashboardLayout>
|
||||||
|
<Container maxWidth="md">
|
||||||
|
{isLoading ? (
|
||||||
|
<LinearProgress />
|
||||||
|
) : isError || !item ? (
|
||||||
|
<Typography color="error">Fehler beim Laden.</Typography>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<PageHeader
|
||||||
|
title={item.bezeichnung}
|
||||||
|
backTo="/persoenliche-ausruestung"
|
||||||
|
subtitle={item.kategorie || undefined}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Status + actions row */}
|
||||||
|
<Box sx={{ display: 'flex', gap: 1, alignItems: 'center', mb: 3 }}>
|
||||||
|
<Chip
|
||||||
|
label={ZUSTAND_LABELS[item.zustand as PersoenlicheAusruestungZustand]}
|
||||||
|
color={ZUSTAND_COLORS[item.zustand as PersoenlicheAusruestungZustand]}
|
||||||
|
variant="outlined"
|
||||||
|
/>
|
||||||
|
<Box sx={{ flex: 1 }} />
|
||||||
|
{canEdit && (
|
||||||
|
<Button startIcon={<EditIcon />} variant="outlined" size="small"
|
||||||
|
onClick={() => navigate(`/persoenliche-ausruestung/${id}/edit`)}>
|
||||||
|
Bearbeiten
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
{canDelete && (
|
||||||
|
<Button startIcon={<DeleteIcon />} variant="outlined" color="error" size="small"
|
||||||
|
onClick={() => setDeleteOpen(true)}>
|
||||||
|
Löschen
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{/* Info card */}
|
||||||
|
<Paper sx={{ p: 2.5, mb: 2 }}>
|
||||||
|
<Typography variant="subtitle2" gutterBottom>Details</Typography>
|
||||||
|
<Box sx={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 1.5 }}>
|
||||||
|
{([
|
||||||
|
['Benutzer', item.user_display_name || item.benutzer_name],
|
||||||
|
['Größe', item.groesse],
|
||||||
|
['Seriennummer', item.seriennummer],
|
||||||
|
['Inventarnummer', item.inventarnummer],
|
||||||
|
['Anschaffungsdatum', item.anschaffung_datum ? new Date(item.anschaffung_datum).toLocaleDateString('de-AT') : null],
|
||||||
|
['Erstellt am', new Date(item.erstellt_am).toLocaleDateString('de-AT')],
|
||||||
|
] as [string, string | null | undefined][]).map(([label, value]) => value ? (
|
||||||
|
<Box key={label}>
|
||||||
|
<Typography variant="caption" color="text.secondary">{label}</Typography>
|
||||||
|
<Typography variant="body2">{value}</Typography>
|
||||||
|
</Box>
|
||||||
|
) : null)}
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{item.anfrage_id && (
|
||||||
|
<>
|
||||||
|
<Divider sx={{ my: 1.5 }} />
|
||||||
|
<Typography variant="caption" color="text.secondary">Aus Anfrage</Typography>
|
||||||
|
<Typography
|
||||||
|
variant="body2"
|
||||||
|
sx={{ cursor: 'pointer', color: 'primary.main' }}
|
||||||
|
onClick={() => navigate(`/ausruestungsanfrage/${item.anfrage_id}`)}
|
||||||
|
>
|
||||||
|
Anfrage #{item.anfrage_id}
|
||||||
|
</Typography>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Paper>
|
||||||
|
|
||||||
|
{/* Eigenschaften */}
|
||||||
|
{item.eigenschaften && item.eigenschaften.length > 0 && (
|
||||||
|
<Paper sx={{ p: 2.5, mb: 2 }}>
|
||||||
|
<Typography variant="subtitle2" gutterBottom>Eigenschaften</Typography>
|
||||||
|
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 1 }}>
|
||||||
|
{item.eigenschaften.map((e) => (
|
||||||
|
<Box key={e.id}>
|
||||||
|
<Typography variant="caption" color="text.secondary">{e.name}</Typography>
|
||||||
|
<Typography variant="body2" fontWeight={500}>{e.wert}</Typography>
|
||||||
|
</Box>
|
||||||
|
))}
|
||||||
|
</Box>
|
||||||
|
</Paper>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Notizen */}
|
||||||
|
{item.notizen && (
|
||||||
|
<Paper sx={{ p: 2.5 }}>
|
||||||
|
<Typography variant="subtitle2" gutterBottom>Notizen</Typography>
|
||||||
|
<Typography variant="body2" sx={{ whiteSpace: 'pre-wrap' }}>{item.notizen}</Typography>
|
||||||
|
</Paper>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Container>
|
||||||
|
|
||||||
|
{/* Delete confirmation */}
|
||||||
|
<Dialog open={deleteOpen} onClose={() => setDeleteOpen(false)}>
|
||||||
|
<DialogTitle>Persönliche Ausrüstung löschen?</DialogTitle>
|
||||||
|
<DialogContent>
|
||||||
|
<DialogContentText>
|
||||||
|
Dieser Eintrag wird dauerhaft gelöscht und kann nicht wiederhergestellt werden.
|
||||||
|
</DialogContentText>
|
||||||
|
</DialogContent>
|
||||||
|
<DialogActions>
|
||||||
|
<Button onClick={() => setDeleteOpen(false)}>Abbrechen</Button>
|
||||||
|
<Button onClick={handleDelete} color="error" disabled={deleting}>
|
||||||
|
Löschen
|
||||||
|
</Button>
|
||||||
|
</DialogActions>
|
||||||
|
</Dialog>
|
||||||
|
</DashboardLayout>
|
||||||
|
);
|
||||||
|
}
|
||||||
302
frontend/src/pages/PersoenlicheAusruestungEdit.tsx
Normal file
302
frontend/src/pages/PersoenlicheAusruestungEdit.tsx
Normal file
@@ -0,0 +1,302 @@
|
|||||||
|
import { useState, useEffect, useMemo } from 'react';
|
||||||
|
import {
|
||||||
|
Autocomplete,
|
||||||
|
Box,
|
||||||
|
Button,
|
||||||
|
Container,
|
||||||
|
IconButton,
|
||||||
|
LinearProgress,
|
||||||
|
MenuItem,
|
||||||
|
Stack,
|
||||||
|
TextField,
|
||||||
|
Typography,
|
||||||
|
} from '@mui/material';
|
||||||
|
import { Add as AddIcon, Delete as DeleteIcon } from '@mui/icons-material';
|
||||||
|
import { useParams, useNavigate } from 'react-router-dom';
|
||||||
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
|
import DashboardLayout from '../components/dashboard/DashboardLayout';
|
||||||
|
import { personalEquipmentApi } from '../services/personalEquipment';
|
||||||
|
import { membersService } from '../services/members';
|
||||||
|
import { usePermissionContext } from '../contexts/PermissionContext';
|
||||||
|
import { useNotification } from '../contexts/NotificationContext';
|
||||||
|
import { PageHeader } from '../components/templates';
|
||||||
|
import { ZUSTAND_LABELS } from '../types/personalEquipment.types';
|
||||||
|
import type {
|
||||||
|
PersoenlicheAusruestungZustand,
|
||||||
|
UpdatePersoenlicheAusruestungPayload,
|
||||||
|
} from '../types/personalEquipment.types';
|
||||||
|
|
||||||
|
const ZUSTAND_OPTIONS = Object.entries(ZUSTAND_LABELS) as [PersoenlicheAusruestungZustand, string][];
|
||||||
|
|
||||||
|
interface EigenschaftRow {
|
||||||
|
id?: number;
|
||||||
|
eigenschaft_id?: number | null;
|
||||||
|
name: string;
|
||||||
|
wert: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function PersoenlicheAusruestungEdit() {
|
||||||
|
const { id } = useParams<{ id: string }>();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
const { hasPermission } = usePermissionContext();
|
||||||
|
const { showSuccess, showError } = useNotification();
|
||||||
|
|
||||||
|
const canViewAll = hasPermission('persoenliche_ausruestung:view_all');
|
||||||
|
|
||||||
|
const { data: item, isLoading, isError } = useQuery({
|
||||||
|
queryKey: ['persoenliche-ausruestung', 'detail', id],
|
||||||
|
queryFn: () => personalEquipmentApi.getById(id!),
|
||||||
|
enabled: !!id,
|
||||||
|
});
|
||||||
|
|
||||||
|
const { data: membersList } = useQuery({
|
||||||
|
queryKey: ['members-list-compact'],
|
||||||
|
queryFn: () => membersService.getMembers({ pageSize: 500 }),
|
||||||
|
staleTime: 5 * 60 * 1000,
|
||||||
|
enabled: canViewAll,
|
||||||
|
});
|
||||||
|
|
||||||
|
const memberOptions = useMemo(() => {
|
||||||
|
return (membersList?.items ?? []).map((m) => ({
|
||||||
|
id: m.id,
|
||||||
|
name: [m.given_name, m.family_name].filter(Boolean).join(' ') || m.email,
|
||||||
|
}));
|
||||||
|
}, [membersList]);
|
||||||
|
|
||||||
|
// Form state
|
||||||
|
const [bezeichnung, setBezeichnung] = useState('');
|
||||||
|
const [kategorie, setKategorie] = useState('');
|
||||||
|
const [groesse, setGroesse] = useState('');
|
||||||
|
const [seriennummer, setSeriennummer] = useState('');
|
||||||
|
const [inventarnummer, setInventarnummer] = useState('');
|
||||||
|
const [anschaffungDatum, setAnschaffungDatum] = useState('');
|
||||||
|
const [zustand, setZustand] = useState<PersoenlicheAusruestungZustand>('gut');
|
||||||
|
const [notizen, setNotizen] = useState('');
|
||||||
|
const [userId, setUserId] = useState<{ id: string; name: string } | null>(null);
|
||||||
|
const [eigenschaften, setEigenschaften] = useState<EigenschaftRow[]>([]);
|
||||||
|
|
||||||
|
// Initialize form from loaded item
|
||||||
|
useEffect(() => {
|
||||||
|
if (!item) return;
|
||||||
|
setBezeichnung(item.bezeichnung);
|
||||||
|
setKategorie(item.kategorie ?? '');
|
||||||
|
setGroesse(item.groesse ?? '');
|
||||||
|
setSeriennummer(item.seriennummer ?? '');
|
||||||
|
setInventarnummer(item.inventarnummer ?? '');
|
||||||
|
setAnschaffungDatum(item.anschaffung_datum ? item.anschaffung_datum.split('T')[0] : '');
|
||||||
|
setZustand(item.zustand);
|
||||||
|
setNotizen(item.notizen ?? '');
|
||||||
|
if (item.eigenschaften) {
|
||||||
|
setEigenschaften(item.eigenschaften.map(e => ({
|
||||||
|
id: e.id,
|
||||||
|
eigenschaft_id: e.eigenschaft_id,
|
||||||
|
name: e.name,
|
||||||
|
wert: e.wert,
|
||||||
|
})));
|
||||||
|
}
|
||||||
|
}, [item]);
|
||||||
|
|
||||||
|
// Set userId when item + memberOptions are ready
|
||||||
|
useEffect(() => {
|
||||||
|
if (!item?.user_id || memberOptions.length === 0) return;
|
||||||
|
const match = memberOptions.find(m => m.id === item.user_id);
|
||||||
|
if (match) setUserId(match);
|
||||||
|
}, [item, memberOptions]);
|
||||||
|
|
||||||
|
const updateMutation = useMutation({
|
||||||
|
mutationFn: (data: UpdatePersoenlicheAusruestungPayload) => personalEquipmentApi.update(id!, data),
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['persoenliche-ausruestung'] });
|
||||||
|
showSuccess('Persönliche Ausrüstung aktualisiert');
|
||||||
|
navigate(`/persoenliche-ausruestung/${id}`);
|
||||||
|
},
|
||||||
|
onError: () => {
|
||||||
|
showError('Fehler beim Speichern');
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const handleSave = () => {
|
||||||
|
if (!bezeichnung.trim()) return;
|
||||||
|
|
||||||
|
const payload: UpdatePersoenlicheAusruestungPayload = {
|
||||||
|
bezeichnung: bezeichnung.trim(),
|
||||||
|
kategorie: kategorie || null,
|
||||||
|
user_id: userId?.id || null,
|
||||||
|
groesse: groesse || null,
|
||||||
|
seriennummer: seriennummer || null,
|
||||||
|
inventarnummer: inventarnummer || null,
|
||||||
|
anschaffung_datum: anschaffungDatum || null,
|
||||||
|
zustand,
|
||||||
|
notizen: notizen || null,
|
||||||
|
eigenschaften: eigenschaften
|
||||||
|
.filter(e => e.name.trim() && e.wert.trim())
|
||||||
|
.map(e => ({ eigenschaft_id: e.eigenschaft_id, name: e.name, wert: e.wert })),
|
||||||
|
};
|
||||||
|
updateMutation.mutate(payload);
|
||||||
|
};
|
||||||
|
|
||||||
|
const addEigenschaft = () => {
|
||||||
|
setEigenschaften(prev => [...prev, { name: '', wert: '' }]);
|
||||||
|
};
|
||||||
|
|
||||||
|
const updateEigenschaft = (idx: number, field: 'name' | 'wert', value: string) => {
|
||||||
|
setEigenschaften(prev => prev.map((e, i) => i === idx ? { ...e, [field]: value } : e));
|
||||||
|
};
|
||||||
|
|
||||||
|
const removeEigenschaft = (idx: number) => {
|
||||||
|
setEigenschaften(prev => prev.filter((_, i) => i !== idx));
|
||||||
|
};
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
return (
|
||||||
|
<DashboardLayout>
|
||||||
|
<Container maxWidth="sm">
|
||||||
|
<LinearProgress />
|
||||||
|
</Container>
|
||||||
|
</DashboardLayout>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isError || !item) {
|
||||||
|
return (
|
||||||
|
<DashboardLayout>
|
||||||
|
<Container maxWidth="sm">
|
||||||
|
<Typography color="error">Fehler beim Laden.</Typography>
|
||||||
|
</Container>
|
||||||
|
</DashboardLayout>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<DashboardLayout>
|
||||||
|
<Container maxWidth="sm">
|
||||||
|
<PageHeader
|
||||||
|
title="Ausrüstung bearbeiten"
|
||||||
|
backTo={`/persoenliche-ausruestung/${id}`}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Stack spacing={2}>
|
||||||
|
<TextField
|
||||||
|
label="Bezeichnung"
|
||||||
|
required
|
||||||
|
size="small"
|
||||||
|
value={bezeichnung}
|
||||||
|
onChange={(e) => setBezeichnung(e.target.value)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{canViewAll && (
|
||||||
|
<Autocomplete
|
||||||
|
options={memberOptions}
|
||||||
|
getOptionLabel={(o) => o.name}
|
||||||
|
value={userId}
|
||||||
|
onChange={(_e, v) => setUserId(v)}
|
||||||
|
renderInput={(params) => (
|
||||||
|
<TextField {...params} label="Benutzer" size="small" />
|
||||||
|
)}
|
||||||
|
size="small"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<TextField
|
||||||
|
label="Kategorie"
|
||||||
|
size="small"
|
||||||
|
value={kategorie}
|
||||||
|
onChange={(e) => setKategorie(e.target.value)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<TextField
|
||||||
|
label="Größe"
|
||||||
|
size="small"
|
||||||
|
value={groesse}
|
||||||
|
onChange={(e) => setGroesse(e.target.value)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<TextField
|
||||||
|
label="Seriennummer"
|
||||||
|
size="small"
|
||||||
|
value={seriennummer}
|
||||||
|
onChange={(e) => setSeriennummer(e.target.value)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<TextField
|
||||||
|
label="Inventarnummer"
|
||||||
|
size="small"
|
||||||
|
value={inventarnummer}
|
||||||
|
onChange={(e) => setInventarnummer(e.target.value)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<TextField
|
||||||
|
label="Anschaffungsdatum"
|
||||||
|
type="date"
|
||||||
|
size="small"
|
||||||
|
value={anschaffungDatum}
|
||||||
|
onChange={(e) => setAnschaffungDatum(e.target.value)}
|
||||||
|
InputLabelProps={{ shrink: true }}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<TextField
|
||||||
|
label="Zustand"
|
||||||
|
select
|
||||||
|
size="small"
|
||||||
|
value={zustand}
|
||||||
|
onChange={(e) => setZustand(e.target.value as PersoenlicheAusruestungZustand)}
|
||||||
|
>
|
||||||
|
{ZUSTAND_OPTIONS.map(([key, label]) => (
|
||||||
|
<MenuItem key={key} value={key}>{label}</MenuItem>
|
||||||
|
))}
|
||||||
|
</TextField>
|
||||||
|
|
||||||
|
<TextField
|
||||||
|
label="Notizen"
|
||||||
|
size="small"
|
||||||
|
multiline
|
||||||
|
rows={2}
|
||||||
|
value={notizen}
|
||||||
|
onChange={(e) => setNotizen(e.target.value)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Eigenschaften */}
|
||||||
|
<Typography variant="subtitle2">Eigenschaften</Typography>
|
||||||
|
{eigenschaften.map((e, idx) => (
|
||||||
|
<Box key={idx} sx={{ display: 'flex', gap: 1, alignItems: 'center' }}>
|
||||||
|
<TextField
|
||||||
|
size="small"
|
||||||
|
label="Name"
|
||||||
|
value={e.name}
|
||||||
|
onChange={(ev) => updateEigenschaft(idx, 'name', ev.target.value)}
|
||||||
|
sx={{ flex: 1 }}
|
||||||
|
/>
|
||||||
|
<TextField
|
||||||
|
size="small"
|
||||||
|
label="Wert"
|
||||||
|
value={e.wert}
|
||||||
|
onChange={(ev) => updateEigenschaft(idx, 'wert', ev.target.value)}
|
||||||
|
sx={{ flex: 1 }}
|
||||||
|
/>
|
||||||
|
<IconButton size="small" onClick={() => removeEigenschaft(idx)}>
|
||||||
|
<DeleteIcon fontSize="small" />
|
||||||
|
</IconButton>
|
||||||
|
</Box>
|
||||||
|
))}
|
||||||
|
<Button size="small" startIcon={<AddIcon />} onClick={addEigenschaft}>
|
||||||
|
Eigenschaft hinzufügen
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<Box sx={{ display: 'flex', gap: 1, justifyContent: 'flex-end', mt: 1 }}>
|
||||||
|
<Button onClick={() => navigate(`/persoenliche-ausruestung/${id}`)}>
|
||||||
|
Abbrechen
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="contained"
|
||||||
|
onClick={handleSave}
|
||||||
|
disabled={updateMutation.isPending || !bezeichnung.trim()}
|
||||||
|
>
|
||||||
|
Speichern
|
||||||
|
</Button>
|
||||||
|
</Box>
|
||||||
|
</Stack>
|
||||||
|
</Container>
|
||||||
|
</DashboardLayout>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -11,6 +11,7 @@ import type {
|
|||||||
AusruestungWidgetOverview,
|
AusruestungWidgetOverview,
|
||||||
CreateOrdersRequest,
|
CreateOrdersRequest,
|
||||||
CreateOrdersResponse,
|
CreateOrdersResponse,
|
||||||
|
UnassignedPosition,
|
||||||
} from '../types/ausruestungsanfrage.types';
|
} from '../types/ausruestungsanfrage.types';
|
||||||
|
|
||||||
export const ausruestungsanfrageApi = {
|
export const ausruestungsanfrageApi = {
|
||||||
@@ -98,8 +99,9 @@ export const ausruestungsanfrageApi = {
|
|||||||
bezeichnung?: string,
|
bezeichnung?: string,
|
||||||
fuer_benutzer_id?: string,
|
fuer_benutzer_id?: string,
|
||||||
fuer_benutzer_name?: string,
|
fuer_benutzer_name?: string,
|
||||||
|
assignedItems?: { persoenlich_id: string; neuer_zustand: string }[],
|
||||||
): Promise<AusruestungAnfrageDetailResponse> => {
|
): Promise<AusruestungAnfrageDetailResponse> => {
|
||||||
const r = await api.post('/api/ausruestungsanfragen/requests', { items, notizen, bezeichnung, fuer_benutzer_id, fuer_benutzer_name });
|
const r = await api.post('/api/ausruestungsanfragen/requests', { items, notizen, bezeichnung, fuer_benutzer_id, fuer_benutzer_name, assignedItems });
|
||||||
return r.data.data;
|
return r.data.data;
|
||||||
},
|
},
|
||||||
updateRequest: async (
|
updateRequest: async (
|
||||||
@@ -174,4 +176,15 @@ export const ausruestungsanfrageApi = {
|
|||||||
const r = await api.get('/api/ausruestungsanfragen/users');
|
const r = await api.get('/api/ausruestungsanfragen/users');
|
||||||
return r.data.data;
|
return r.data.data;
|
||||||
},
|
},
|
||||||
|
|
||||||
|
// ── Position linking ──
|
||||||
|
linkPositionToArtikel: async (positionId: number, artikelId: number): Promise<void> => {
|
||||||
|
await api.patch(`/api/ausruestungsanfrage/positionen/${positionId}/artikel`, { artikel_id: artikelId });
|
||||||
|
},
|
||||||
|
|
||||||
|
// ── Unassigned positions ──
|
||||||
|
getUnassignedPositions: async (): Promise<UnassignedPosition[]> => {
|
||||||
|
const r = await api.get<{ success: boolean; data: UnassignedPosition[] }>('/api/ausruestungsanfrage/nicht-zugewiesen');
|
||||||
|
return r.data.data;
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -114,6 +114,9 @@ export interface AusruestungAnfragePosition {
|
|||||||
zuweisung_typ?: 'ausruestung' | 'persoenlich' | 'keine' | null;
|
zuweisung_typ?: 'ausruestung' | 'persoenlich' | 'keine' | null;
|
||||||
zuweisung_ausruestung_id?: string | null;
|
zuweisung_ausruestung_id?: string | null;
|
||||||
zuweisung_persoenlich_id?: string | null;
|
zuweisung_persoenlich_id?: string | null;
|
||||||
|
persoenlich_id?: string | null;
|
||||||
|
aktueller_zustand?: string | null;
|
||||||
|
neuer_zustand?: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface AusruestungAnfrageFormItem {
|
export interface AusruestungAnfrageFormItem {
|
||||||
@@ -123,6 +126,8 @@ export interface AusruestungAnfrageFormItem {
|
|||||||
notizen?: string;
|
notizen?: string;
|
||||||
eigenschaften?: { eigenschaft_id: number; wert: string }[];
|
eigenschaften?: { eigenschaft_id: number; wert: string }[];
|
||||||
ist_ersatz?: boolean;
|
ist_ersatz?: boolean;
|
||||||
|
persoenlich_id?: string;
|
||||||
|
neuer_zustand?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── API Response Types ──
|
// ── API Response Types ──
|
||||||
@@ -159,6 +164,18 @@ export interface AusruestungWidgetOverview {
|
|||||||
|
|
||||||
// ── Create-Orders Wizard ──
|
// ── Create-Orders Wizard ──
|
||||||
|
|
||||||
|
export interface UnassignedPosition {
|
||||||
|
id: number;
|
||||||
|
bezeichnung: string;
|
||||||
|
menge: number;
|
||||||
|
artikel_id: number | null;
|
||||||
|
anfrage_id: number;
|
||||||
|
anfrage_bezeichnung: string | null;
|
||||||
|
bestell_nummer: number | null;
|
||||||
|
bestell_jahr: number | null;
|
||||||
|
fuer_wen: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
export interface CreateOrderPositionPayload {
|
export interface CreateOrderPositionPayload {
|
||||||
position_id: number;
|
position_id: number;
|
||||||
bezeichnung: string;
|
bezeichnung: string;
|
||||||
|
|||||||
@@ -32,6 +32,8 @@ export interface PersoenlicheAusruestung {
|
|||||||
zustand: PersoenlicheAusruestungZustand;
|
zustand: PersoenlicheAusruestungZustand;
|
||||||
notizen?: string;
|
notizen?: string;
|
||||||
anfrage_id?: number;
|
anfrage_id?: number;
|
||||||
|
anfrage_position_id?: number;
|
||||||
|
eigenschaften?: { id: number; eigenschaft_id?: number | null; name: string; wert: string }[];
|
||||||
erstellt_am: string;
|
erstellt_am: string;
|
||||||
aktualisiert_am: string;
|
aktualisiert_am: string;
|
||||||
}
|
}
|
||||||
@@ -48,6 +50,7 @@ export interface CreatePersoenlicheAusruestungPayload {
|
|||||||
anschaffung_datum?: string;
|
anschaffung_datum?: string;
|
||||||
zustand?: PersoenlicheAusruestungZustand;
|
zustand?: PersoenlicheAusruestungZustand;
|
||||||
notizen?: string;
|
notizen?: string;
|
||||||
|
eigenschaften?: { eigenschaft_id?: number; name: string; wert: string }[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface UpdatePersoenlicheAusruestungPayload {
|
export interface UpdatePersoenlicheAusruestungPayload {
|
||||||
@@ -62,4 +65,5 @@ export interface UpdatePersoenlicheAusruestungPayload {
|
|||||||
anschaffung_datum?: string | null;
|
anschaffung_datum?: string | null;
|
||||||
zustand?: PersoenlicheAusruestungZustand;
|
zustand?: PersoenlicheAusruestungZustand;
|
||||||
notizen?: string | null;
|
notizen?: string | null;
|
||||||
|
eigenschaften?: { eigenschaft_id?: number | null; name: string; wert: string }[] | null;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user