feat: personal equipment tracking, order assignment, purge fix, widget consolidation

- Migration 084: new persoenliche_ausruestung table with catalog link, user
  assignment, soft delete; adds zuweisung_typ/ausruestung_id/persoenlich_id
  columns to ausruestung_anfrage_positionen; seeds feature group + 5 permissions

- Fix user data purge: table was shop_anfragen, renamed to ausruestung_anfragen
  in mig 046 — caused full transaction rollback. Also keep mitglieder_profile
  row but NULL FDISK-synced fields (dienstgrad, geburtsdatum, etc.) instead of
  deleting the profile

- Personal equipment CRUD: backend service/controller/routes at
  /api/persoenliche-ausruestung; frontend page with DataTable, user filter,
  catalog Autocomplete, FAB create dialog; widget in Status group; sidebar
  entry (Checkroom icon); card in MitgliedDetail Tab 0

- Ausruestungsanfrage item assignment: when a request reaches erledigt,
  auto-opens ItemAssignmentDialog listing all delivered positions; each item
  can be assigned as general equipment (vehicle/storage), personal item (user,
  prefilled with requester), or not tracked; POST /requests/:id/assign backend

- StatCard refactored to use WidgetCard as outer shell for consistent header
  styling across all dashboard widget templates

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Matthias Hochmeister
2026-04-13 19:19:35 +02:00
parent b477e5dbe0
commit 1215e9ea70
23 changed files with 1700 additions and 63 deletions

View File

@@ -108,6 +108,7 @@ import checklistRoutes from './routes/checklist.routes';
import fahrzeugTypRoutes from './routes/fahrzeugTyp.routes'; import fahrzeugTypRoutes from './routes/fahrzeugTyp.routes';
import ausruestungTypRoutes from './routes/ausruestungTyp.routes'; import ausruestungTypRoutes from './routes/ausruestungTyp.routes';
import buchhaltungRoutes from './routes/buchhaltung.routes'; import buchhaltungRoutes from './routes/buchhaltung.routes';
import personalEquipmentRoutes from './routes/personalEquipment.routes';
app.use('/api/auth', authRoutes); app.use('/api/auth', authRoutes);
app.use('/api/user', userRoutes); app.use('/api/user', userRoutes);
@@ -138,6 +139,7 @@ app.use('/api/checklisten', checklistRoutes);
app.use('/api/fahrzeug-typen', fahrzeugTypRoutes); app.use('/api/fahrzeug-typen', fahrzeugTypRoutes);
app.use('/api/ausruestung-typen', ausruestungTypRoutes); app.use('/api/ausruestung-typen', ausruestungTypRoutes);
app.use('/api/buchhaltung', buchhaltungRoutes); app.use('/api/buchhaltung', buchhaltungRoutes);
app.use('/api/persoenliche-ausruestung', personalEquipmentRoutes);
// Static file serving for uploads (authenticated) // Static file serving for uploads (authenticated)
const uploadsDir = process.env.NODE_ENV === 'production' ? '/app/uploads' : path.resolve(__dirname, '../../uploads'); const uploadsDir = process.env.NODE_ENV === 'production' ? '/app/uploads' : path.resolve(__dirname, '../../uploads');

View File

@@ -555,6 +555,54 @@ class AusruestungsanfrageController {
} }
} }
// -------------------------------------------------------------------------
// Assignment of delivered items
// -------------------------------------------------------------------------
async assignDeliveredItems(req: Request, res: Response): Promise<void> {
try {
const id = Number(req.params.id);
const { assignments } = req.body as {
assignments?: Array<{
positionId: number;
typ: 'ausruestung' | 'persoenlich' | 'keine';
fahrzeugId?: string;
standort?: string;
userId?: string;
benutzerName?: string;
groesse?: string;
kategorie?: string;
}>;
};
if (!assignments || assignments.length === 0) {
res.status(400).json({ success: false, message: 'Mindestens eine Zuweisung ist erforderlich' });
return;
}
for (const a of assignments) {
if (!a.positionId || !a.typ) {
res.status(400).json({ success: false, message: 'positionId und typ sind erforderlich' });
return;
}
if (!['ausruestung', 'persoenlich', 'keine'].includes(a.typ)) {
res.status(400).json({ success: false, message: 'Ungültiger Zuweisungstyp' });
return;
}
}
const result = await ausruestungsanfrageService.assignDeliveredItems(id, req.user!.id, assignments);
res.status(200).json({ success: true, data: result });
} catch (error: any) {
if (error?.message === 'Anfrage nicht gefunden') {
res.status(404).json({ success: false, message: error.message });
return;
}
logger.error('AusruestungsanfrageController.assignDeliveredItems error', { error });
res.status(500).json({ success: false, message: 'Zuweisung konnte nicht gespeichert werden' });
}
}
// ------------------------------------------------------------------------- // -------------------------------------------------------------------------
// Widget overview (lightweight, for dashboard widget) // Widget overview (lightweight, for dashboard widget)
// ------------------------------------------------------------------------- // -------------------------------------------------------------------------

View File

@@ -0,0 +1,177 @@
import { Request, Response } from 'express';
import { z } from 'zod';
import personalEquipmentService from '../services/personalEquipment.service';
import logger from '../utils/logger';
const uuidString = z.string().regex(
/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i,
'Ungültige UUID',
);
const isoDate = z.string().regex(
/^\d{4}-(0[1-9]|1[0-2])-(0[1-9]|[12]\d|3[01])$/,
'Erwartet ISO-Datum im Format YYYY-MM-DD',
);
const ZustandEnum = z.enum(['gut', 'beschaedigt', 'abgaengig', 'verloren']);
const CreateSchema = z.object({
bezeichnung: z.string().min(1).max(200),
kategorie: z.string().max(100).optional(),
artikel_id: z.number().int().positive().optional(),
user_id: uuidString.optional(),
benutzer_name: z.string().max(200).optional(),
groesse: z.string().max(50).optional(),
seriennummer: z.string().max(100).optional(),
inventarnummer: z.string().max(50).optional(),
anschaffung_datum: isoDate.optional(),
zustand: ZustandEnum.optional(),
notizen: z.string().max(2000).optional(),
});
const UpdateSchema = z.object({
bezeichnung: z.string().min(1).max(200).optional(),
kategorie: z.string().max(100).nullable().optional(),
artikel_id: z.number().int().positive().nullable().optional(),
user_id: uuidString.nullable().optional(),
benutzer_name: z.string().max(200).nullable().optional(),
groesse: z.string().max(50).nullable().optional(),
seriennummer: z.string().max(100).nullable().optional(),
inventarnummer: z.string().max(50).nullable().optional(),
anschaffung_datum: isoDate.nullable().optional(),
zustand: ZustandEnum.optional(),
notizen: z.string().max(2000).nullable().optional(),
});
function isValidUUID(s: string): boolean {
return /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(s);
}
class PersonalEquipmentController {
async list(req: Request, res: Response): Promise<void> {
try {
const filters: Record<string, string> = {};
if (req.query.user_id) filters.userId = String(req.query.user_id);
if (req.query.kategorie) filters.kategorie = String(req.query.kategorie);
if (req.query.zustand) filters.zustand = String(req.query.zustand);
const items = await personalEquipmentService.getAll(filters);
res.status(200).json({ success: true, data: items });
} catch (error) {
logger.error('personalEquipment.list error', { error });
res.status(500).json({ success: false, message: 'Persönliche Ausrüstung konnte nicht geladen werden' });
}
}
async getMy(req: Request, res: Response): Promise<void> {
try {
const items = await personalEquipmentService.getByUserId(req.user!.id);
res.status(200).json({ success: true, data: items });
} catch (error) {
logger.error('personalEquipment.getMy error', { error });
res.status(500).json({ success: false, message: 'Persönliche Ausrüstung konnte nicht geladen werden' });
}
}
async getByUser(req: Request, res: Response): Promise<void> {
try {
const { userId } = req.params as Record<string, string>;
if (!isValidUUID(userId)) {
res.status(400).json({ success: false, message: 'Ungültige User-ID' });
return;
}
const items = await personalEquipmentService.getByUserId(userId);
res.status(200).json({ success: true, data: items });
} catch (error) {
logger.error('personalEquipment.getByUser error', { error });
res.status(500).json({ success: false, message: 'Persönliche Ausrüstung konnte nicht geladen werden' });
}
}
async getById(req: Request, res: Response): Promise<void> {
try {
const { id } = req.params as Record<string, string>;
if (!isValidUUID(id)) {
res.status(400).json({ success: false, message: 'Ungültige ID' });
return;
}
const item = await personalEquipmentService.getById(id);
if (!item) {
res.status(404).json({ success: false, message: 'Nicht gefunden' });
return;
}
res.status(200).json({ success: true, data: item });
} catch (error) {
logger.error('personalEquipment.getById error', { error });
res.status(500).json({ success: false, message: 'Persönliche Ausrüstung konnte nicht geladen werden' });
}
}
async create(req: Request, res: Response): Promise<void> {
try {
const parsed = CreateSchema.safeParse(req.body);
if (!parsed.success) {
res.status(400).json({ success: false, message: 'Validierungsfehler', errors: parsed.error.flatten().fieldErrors });
return;
}
const item = await personalEquipmentService.create(parsed.data, req.user!.id);
res.status(201).json({ success: true, data: item });
} catch (error) {
logger.error('personalEquipment.create error', { error });
res.status(500).json({ success: false, message: 'Persönliche Ausrüstung konnte nicht erstellt werden' });
}
}
async update(req: Request, res: Response): Promise<void> {
try {
const { id } = req.params as Record<string, string>;
if (!isValidUUID(id)) {
res.status(400).json({ success: false, message: 'Ungültige ID' });
return;
}
const parsed = UpdateSchema.safeParse(req.body);
if (!parsed.success) {
res.status(400).json({ success: false, message: 'Validierungsfehler', errors: parsed.error.flatten().fieldErrors });
return;
}
if (Object.keys(parsed.data).length === 0) {
res.status(400).json({ success: false, message: 'Kein Feld zum Aktualisieren angegeben' });
return;
}
const item = await personalEquipmentService.update(id, parsed.data as any);
if (!item) {
res.status(404).json({ success: false, message: 'Nicht gefunden' });
return;
}
res.status(200).json({ success: true, data: item });
} catch (error: any) {
if (error?.message === 'No fields to update') {
res.status(400).json({ success: false, message: 'Kein Feld zum Aktualisieren angegeben' });
return;
}
logger.error('personalEquipment.update error', { error, id: req.params.id });
res.status(500).json({ success: false, message: 'Persönliche Ausrüstung konnte nicht aktualisiert werden' });
}
}
async delete(req: Request, res: Response): Promise<void> {
try {
const { id } = req.params as Record<string, string>;
if (!isValidUUID(id)) {
res.status(400).json({ success: false, message: 'Ungültige ID' });
return;
}
const deleted = await personalEquipmentService.delete(id);
if (!deleted) {
res.status(404).json({ success: false, message: 'Nicht gefunden' });
return;
}
res.status(200).json({ success: true, message: 'Persönliche Ausrüstung gelöscht' });
} catch (error) {
logger.error('personalEquipment.delete error', { error, id: req.params.id });
res.status(500).json({ success: false, message: 'Persönliche Ausrüstung konnte nicht gelöscht werden' });
}
}
}
export default new PersonalEquipmentController();

View File

@@ -0,0 +1,82 @@
-- Migration: 084_persoenliche_ausruestung
-- Creates persoenliche_ausruestung table, adds assignment columns to
-- ausruestung_anfrage_positionen, and seeds feature_group + permissions.
-- 1. Create persoenliche_ausruestung table
CREATE TABLE IF NOT EXISTS persoenliche_ausruestung (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
bezeichnung TEXT NOT NULL,
kategorie TEXT,
artikel_id INT REFERENCES ausruestung_artikel(id) ON DELETE SET NULL,
user_id UUID REFERENCES users(id) ON DELETE SET NULL,
benutzer_name TEXT,
groesse TEXT,
seriennummer TEXT,
inventarnummer TEXT,
anschaffung_datum DATE,
zustand TEXT DEFAULT 'gut' CHECK (zustand IN ('gut','beschaedigt','abgaengig','verloren')),
notizen TEXT,
anfrage_id INT REFERENCES ausruestung_anfragen(id) ON DELETE SET NULL,
anfrage_position_id INT REFERENCES ausruestung_anfrage_positionen(id) ON DELETE SET NULL,
erstellt_von UUID REFERENCES users(id) ON DELETE SET NULL,
erstellt_am TIMESTAMPTZ DEFAULT NOW(),
aktualisiert_am TIMESTAMPTZ DEFAULT NOW(),
geloescht_am TIMESTAMPTZ
);
CREATE INDEX IF NOT EXISTS idx_persoenliche_ausruestung_user
ON persoenliche_ausruestung(user_id) WHERE geloescht_am IS NULL;
CREATE INDEX IF NOT EXISTS idx_persoenliche_ausruestung_artikel
ON persoenliche_ausruestung(artikel_id);
-- Auto-update aktualisiert_am trigger (uses the aktualisiert_am variant from migration 018)
CREATE TRIGGER trg_persoenliche_ausruestung_aktualisiert_am
BEFORE UPDATE ON persoenliche_ausruestung
FOR EACH ROW EXECUTE FUNCTION update_aktualisiert_am_column();
-- 2. Add assignment columns to ausruestung_anfrage_positionen
ALTER TABLE ausruestung_anfrage_positionen
ADD COLUMN IF NOT EXISTS zuweisung_typ TEXT CHECK (zuweisung_typ IN ('ausruestung','persoenlich','keine'));
ALTER TABLE ausruestung_anfrage_positionen
ADD COLUMN IF NOT EXISTS zuweisung_ausruestung_id UUID REFERENCES ausruestung(id) ON DELETE SET NULL;
ALTER TABLE ausruestung_anfrage_positionen
ADD COLUMN IF NOT EXISTS zuweisung_persoenlich_id UUID REFERENCES persoenliche_ausruestung(id) ON DELETE SET NULL;
-- 3. Feature group + permissions
INSERT INTO feature_groups (id, name, maintenance_mode)
VALUES ('persoenliche_ausruestung', 'Persönliche Ausrüstung', false)
ON CONFLICT DO NOTHING;
INSERT INTO permissions (id, feature_group_id, description) VALUES
('persoenliche_ausruestung:view', 'persoenliche_ausruestung', 'Eigene persönliche Ausrüstung anzeigen'),
('persoenliche_ausruestung:view_all', 'persoenliche_ausruestung', 'Alle persönliche Ausrüstung anzeigen'),
('persoenliche_ausruestung:create', 'persoenliche_ausruestung', 'Persönliche Ausrüstung erstellen'),
('persoenliche_ausruestung:edit', 'persoenliche_ausruestung', 'Persönliche Ausrüstung bearbeiten'),
('persoenliche_ausruestung:delete', 'persoenliche_ausruestung', 'Persönliche Ausrüstung löschen')
ON CONFLICT DO NOTHING;
-- Seed permissions for groups: admin, kommandant, gruppenkommandant, zeugmeister get all; others get view only
INSERT INTO group_permissions (group_name, permission_id)
SELECT g.name, p.id
FROM (VALUES
('dashboard_admin'),
('dashboard_kommandant'),
('dashboard_gruppenkommandant'),
('dashboard_zeugmeister')
) AS g(name)
CROSS JOIN permissions p
WHERE p.feature_group_id = 'persoenliche_ausruestung'
ON CONFLICT DO NOTHING;
-- All other groups get view only
INSERT INTO group_permissions (group_name, permission_id)
SELECT g.name, 'persoenliche_ausruestung:view'
FROM (VALUES
('dashboard_feuerwehrmitglied'),
('dashboard_atemschutztraeger'),
('dashboard_fahrmeister'),
('dashboard_jugend')
) AS g(name)
ON CONFLICT DO NOTHING;

View File

@@ -71,5 +71,6 @@ router.patch('/positionen/:positionId/zurueckgegeben', authenticate, requirePerm
router.post('/requests/:id/link', authenticate, requirePermission('ausruestungsanfrage:link_orders'), ausruestungsanfrageController.linkToOrder.bind(ausruestungsanfrageController)); router.post('/requests/:id/link', authenticate, requirePermission('ausruestungsanfrage:link_orders'), ausruestungsanfrageController.linkToOrder.bind(ausruestungsanfrageController));
router.delete('/requests/:id/link/:bestellungId', authenticate, requirePermission('ausruestungsanfrage:link_orders'), ausruestungsanfrageController.unlinkFromOrder.bind(ausruestungsanfrageController)); router.delete('/requests/:id/link/:bestellungId', authenticate, requirePermission('ausruestungsanfrage:link_orders'), ausruestungsanfrageController.unlinkFromOrder.bind(ausruestungsanfrageController));
router.post('/requests/:id/create-orders', authenticate, requirePermission('ausruestungsanfrage:link_orders'), ausruestungsanfrageController.createOrders.bind(ausruestungsanfrageController)); router.post('/requests/:id/create-orders', authenticate, requirePermission('ausruestungsanfrage:link_orders'), ausruestungsanfrageController.createOrders.bind(ausruestungsanfrageController));
router.post('/requests/:id/assign', authenticate, requirePermission('ausruestungsanfrage:approve'), ausruestungsanfrageController.assignDeliveredItems.bind(ausruestungsanfrageController));
export default router; export default router;

View File

@@ -0,0 +1,39 @@
import { Router } from 'express';
import personalEquipmentController from '../controllers/personalEquipment.controller';
import { authenticate } from '../middleware/auth.middleware';
import { requirePermission } from '../middleware/rbac.middleware';
import { permissionService } from '../services/permission.service';
const router = Router();
// Own items — any authenticated user with view permission
router.get('/my', authenticate, personalEquipmentController.getMy.bind(personalEquipmentController));
// All items — requires view_all
router.get('/', authenticate, requirePermission('persoenliche_ausruestung:view_all'), personalEquipmentController.list.bind(personalEquipmentController));
// By user — own data or view_all
router.get('/user/:userId', authenticate, async (req, res, next) => {
const isOwn = req.user!.id === req.params.userId;
if (isOwn) {
const hasView = await permissionService.hasPermission(req.user!.groups ?? [], 'persoenliche_ausruestung:view');
if (hasView || req.user!.groups?.includes('dashboard_admin')) return next();
}
const hasViewAll = await permissionService.hasPermission(req.user!.groups ?? [], 'persoenliche_ausruestung:view_all');
if (hasViewAll || req.user!.groups?.includes('dashboard_admin')) return next();
res.status(403).json({ success: false, message: 'Keine Berechtigung' });
}, personalEquipmentController.getByUser.bind(personalEquipmentController));
// Single item
router.get('/:id', authenticate, personalEquipmentController.getById.bind(personalEquipmentController));
// Create
router.post('/', authenticate, requirePermission('persoenliche_ausruestung:create'), personalEquipmentController.create.bind(personalEquipmentController));
// Update
router.patch('/:id', authenticate, requirePermission('persoenliche_ausruestung:edit'), personalEquipmentController.update.bind(personalEquipmentController));
// Delete (soft)
router.delete('/:id', authenticate, requirePermission('persoenliche_ausruestung:delete'), personalEquipmentController.delete.bind(personalEquipmentController));
export default router;

View File

@@ -889,6 +889,145 @@ async function getWidgetOverview() {
return result.rows[0]; return result.rows[0];
} }
// ---------------------------------------------------------------------------
// Assignment of delivered items
// ---------------------------------------------------------------------------
interface AssignmentInput {
positionId: number;
typ: 'ausruestung' | 'persoenlich' | 'keine';
fahrzeugId?: string;
standort?: string;
userId?: string;
benutzerName?: string;
groesse?: string;
kategorie?: string;
}
async function assignDeliveredItems(
anfrageId: number,
requestingUserId: string,
assignments: AssignmentInput[],
) {
const client = await pool.connect();
try {
await client.query('BEGIN');
// Load anfrage to get anfrager_id (default user for personal assignments)
const anfrageResult = await client.query(
'SELECT anfrager_id FROM ausruestung_anfragen WHERE id = $1',
[anfrageId],
);
if (anfrageResult.rows.length === 0) {
throw new Error('Anfrage nicht gefunden');
}
const anfragerId = anfrageResult.rows[0].anfrager_id;
let assigned = 0;
for (const a of assignments) {
// Load position details
const posResult = await client.query(
'SELECT bezeichnung, artikel_id FROM ausruestung_anfrage_positionen WHERE id = $1 AND anfrage_id = $2',
[a.positionId, anfrageId],
);
if (posResult.rows.length === 0) continue;
const pos = posResult.rows[0];
if (a.typ === 'ausruestung') {
// Look up kategorie_id from artikel if available
let kategorieId: string | null = null;
if (pos.artikel_id) {
const artikelResult = await client.query(
'SELECT kategorie_id FROM ausruestung_artikel WHERE id = $1',
[pos.artikel_id],
);
if (artikelResult.rows[0]?.kategorie_id) {
// artikel has kategorie_id (int FK to ausruestung_kategorien_katalog), but
// ausruestung.kategorie_id is UUID FK to ausruestung_kategorien — look up by name
const katNameResult = await client.query(
'SELECT name FROM ausruestung_kategorien_katalog WHERE id = $1',
[artikelResult.rows[0].kategorie_id],
);
if (katNameResult.rows[0]?.name) {
const katResult = await client.query(
'SELECT id FROM ausruestung_kategorien WHERE name = $1 LIMIT 1',
[katNameResult.rows[0].name],
);
kategorieId = katResult.rows[0]?.id ?? null;
}
}
}
// Fallback: pick first category
if (!kategorieId) {
const fallback = await client.query('SELECT id FROM ausruestung_kategorien LIMIT 1');
kategorieId = fallback.rows[0]?.id ?? null;
}
const insertResult = await client.query(
`INSERT INTO ausruestung (
id, bezeichnung, kategorie_id, fahrzeug_id, standort, status, ist_wichtig
) VALUES (gen_random_uuid(), $1, $2, $3, $4, 'einsatzbereit', false)
RETURNING id`,
[pos.bezeichnung, kategorieId, a.fahrzeugId ?? null, a.standort ?? 'Lager'],
);
const newId = insertResult.rows[0].id;
await client.query(
`UPDATE ausruestung_anfrage_positionen
SET zuweisung_typ = 'ausruestung', zuweisung_ausruestung_id = $1
WHERE id = $2`,
[newId, a.positionId],
);
} else if (a.typ === 'persoenlich') {
const insertResult = await client.query(
`INSERT INTO persoenliche_ausruestung (
bezeichnung, kategorie, groesse, user_id, benutzer_name,
anfrage_id, anfrage_position_id, artikel_id, erstellt_von
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)
RETURNING id`,
[
pos.bezeichnung,
a.kategorie ?? null,
a.groesse ?? null,
a.userId ?? anfragerId,
a.benutzerName ?? null,
anfrageId,
a.positionId,
pos.artikel_id ?? null,
requestingUserId,
],
);
const newId = insertResult.rows[0].id;
await client.query(
`UPDATE ausruestung_anfrage_positionen
SET zuweisung_typ = 'persoenlich', zuweisung_persoenlich_id = $1
WHERE id = $2`,
[newId, a.positionId],
);
} else {
// typ === 'keine'
await client.query(
`UPDATE ausruestung_anfrage_positionen SET zuweisung_typ = 'keine' WHERE id = $1`,
[a.positionId],
);
}
assigned++;
}
await client.query('COMMIT');
return { assigned };
} catch (error) {
await client.query('ROLLBACK');
logger.error('ausruestungsanfrageService.assignDeliveredItems failed', { error, anfrageId });
throw error;
} finally {
client.release();
}
}
export default { export default {
getAllUsers, getAllUsers,
getKategorien, getKategorien,
@@ -919,4 +1058,5 @@ export default {
createOrdersFromRequest, createOrdersFromRequest,
getOverview, getOverview,
getWidgetOverview, getWidgetOverview,
assignDeliveredItems,
}; };

View File

@@ -0,0 +1,200 @@
import pool from '../config/database';
import logger from '../utils/logger';
interface PersonalEquipmentFilters {
userId?: string;
kategorie?: string;
zustand?: string;
}
interface CreatePersonalEquipmentData {
bezeichnung: string;
kategorie?: string;
artikel_id?: number;
user_id?: string;
benutzer_name?: string;
groesse?: string;
seriennummer?: string;
inventarnummer?: string;
anschaffung_datum?: string;
zustand?: string;
notizen?: string;
}
interface UpdatePersonalEquipmentData {
bezeichnung?: string;
kategorie?: string;
artikel_id?: number | null;
user_id?: string | null;
benutzer_name?: string | null;
groesse?: string | null;
seriennummer?: string | null;
inventarnummer?: string | null;
anschaffung_datum?: string | null;
zustand?: string;
notizen?: string | null;
}
const BASE_SELECT = `
SELECT pa.*,
COALESCE(u.given_name || ' ' || u.family_name, u.name) AS user_display_name,
aa.bezeichnung AS artikel_bezeichnung
FROM persoenliche_ausruestung pa
LEFT JOIN users u ON u.id = pa.user_id
LEFT JOIN ausruestung_artikel aa ON aa.id = pa.artikel_id
WHERE pa.geloescht_am IS NULL
`;
async function getAll(filters: PersonalEquipmentFilters = {}) {
try {
const conditions: string[] = [];
const params: unknown[] = [];
if (filters.userId) {
params.push(filters.userId);
conditions.push(`pa.user_id = $${params.length}`);
}
if (filters.kategorie) {
params.push(filters.kategorie);
conditions.push(`pa.kategorie = $${params.length}`);
}
if (filters.zustand) {
params.push(filters.zustand);
conditions.push(`pa.zustand = $${params.length}`);
}
const where = conditions.length > 0 ? ` AND ${conditions.join(' AND ')}` : '';
const result = await pool.query(
`${BASE_SELECT}${where} ORDER BY pa.bezeichnung`,
params,
);
return result.rows;
} catch (error) {
logger.error('personalEquipmentService.getAll failed', { error });
throw new Error('Failed to fetch personal equipment');
}
}
async function getByUserId(userId: string) {
try {
const result = await pool.query(
`${BASE_SELECT} AND pa.user_id = $1 ORDER BY pa.bezeichnung`,
[userId],
);
return result.rows;
} catch (error) {
logger.error('personalEquipmentService.getByUserId failed', { error, userId });
throw new Error('Failed to fetch personal equipment for user');
}
}
async function getById(id: string) {
try {
const result = await pool.query(
`${BASE_SELECT} AND pa.id = $1`,
[id],
);
return result.rows[0] || null;
} catch (error) {
logger.error('personalEquipmentService.getById failed', { error, id });
throw new Error('Failed to fetch personal equipment item');
}
}
async function create(data: CreatePersonalEquipmentData, requestingUserId: string) {
try {
const result = await pool.query(
`INSERT INTO persoenliche_ausruestung (
bezeichnung, kategorie, artikel_id, user_id, benutzer_name,
groesse, seriennummer, inventarnummer, anschaffung_datum,
zustand, notizen, erstellt_von
) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12)
RETURNING *`,
[
data.bezeichnung,
data.kategorie ?? null,
data.artikel_id ?? null,
data.user_id ?? null,
data.benutzer_name ?? null,
data.groesse ?? null,
data.seriennummer ?? null,
data.inventarnummer ?? null,
data.anschaffung_datum ?? null,
data.zustand ?? 'gut',
data.notizen ?? null,
requestingUserId,
],
);
logger.info('Personal equipment created', { id: result.rows[0].id, by: requestingUserId });
return result.rows[0];
} catch (error) {
logger.error('personalEquipmentService.create failed', { error });
throw new Error('Failed to create personal equipment');
}
}
async function update(id: string, data: UpdatePersonalEquipmentData) {
try {
const fields: string[] = [];
const values: unknown[] = [];
let p = 1;
const addField = (col: string, value: unknown) => {
fields.push(`${col} = $${p++}`);
values.push(value);
};
if (data.bezeichnung !== undefined) addField('bezeichnung', data.bezeichnung);
if (data.kategorie !== undefined) addField('kategorie', data.kategorie);
if (data.artikel_id !== undefined) addField('artikel_id', data.artikel_id);
if (data.user_id !== undefined) addField('user_id', data.user_id);
if (data.benutzer_name !== undefined) addField('benutzer_name', data.benutzer_name);
if (data.groesse !== undefined) addField('groesse', data.groesse);
if (data.seriennummer !== undefined) addField('seriennummer', data.seriennummer);
if (data.inventarnummer !== undefined) addField('inventarnummer', data.inventarnummer);
if (data.anschaffung_datum !== undefined) addField('anschaffung_datum', data.anschaffung_datum);
if (data.zustand !== undefined) addField('zustand', data.zustand);
if (data.notizen !== undefined) addField('notizen', data.notizen);
if (fields.length === 0) {
throw new Error('No fields to update');
}
values.push(id);
const result = await pool.query(
`UPDATE persoenliche_ausruestung SET ${fields.join(', ')} WHERE id = $${p} AND geloescht_am IS NULL RETURNING *`,
values,
);
if (result.rows.length === 0) return null;
logger.info('Personal equipment updated', { id });
return result.rows[0];
} catch (error) {
logger.error('personalEquipmentService.update failed', { error, id });
throw error;
}
}
async function softDelete(id: string) {
try {
const result = await pool.query(
`UPDATE persoenliche_ausruestung SET geloescht_am = NOW() WHERE id = $1 AND geloescht_am IS NULL RETURNING id`,
[id],
);
if (result.rows.length === 0) return false;
logger.info('Personal equipment soft-deleted', { id });
return true;
} catch (error) {
logger.error('personalEquipmentService.softDelete failed', { error, id });
throw new Error('Failed to delete personal equipment');
}
}
export default {
getAll,
getByUserId,
getById,
create,
update,
delete: softDelete,
};

View File

@@ -350,7 +350,13 @@ class UserService {
// User-owned data tables (DELETE rows) // User-owned data tables (DELETE rows)
await purge('notifications'); await purge('notifications');
await purge('mitglieder_profile'); // Keep mitglieder_profile row but NULL FDISK-synced fields
await client.query(
`UPDATE mitglieder_profile SET dienstgrad = NULL, dienstgrad_seit = NULL, eintrittsdatum = NULL, austrittsdatum = NULL, geburtsdatum = NULL, fuehrerscheinklassen = '{}' WHERE user_id = $1`,
[userId]
);
results['mitglieder_profile_fdisk_cleared'] = 1;
await purge('dienstgrad_verlauf'); await purge('dienstgrad_verlauf');
await purge('atemschutz_traeger'); await purge('atemschutz_traeger');
await purge('ausbildungen'); await purge('ausbildungen');
@@ -362,7 +368,14 @@ class UserService {
await purge('veranstaltung_teilnahmen'); await purge('veranstaltung_teilnahmen');
await purge('veranstaltung_ical_tokens'); await purge('veranstaltung_ical_tokens');
await purge('fahrzeug_ical_tokens'); await purge('fahrzeug_ical_tokens');
await purge('shop_anfragen', 'anfrager_id'); // Delete child positions before parent anfragen (FK constraint)
const posResult = await client.query(
'DELETE FROM ausruestung_anfrage_positionen WHERE anfrage_id IN (SELECT id FROM ausruestung_anfragen WHERE anfrager_id = $1)',
[userId]
);
results['ausruestung_anfrage_positionen'] = posResult.rowCount ?? 0;
await purge('ausruestung_anfragen', 'anfrager_id');
await purge('persoenliche_ausruestung');
// Clear user preferences (widget layout, etc.) // Clear user preferences (widget layout, etc.)
await client.query( await client.query(

View File

@@ -20,6 +20,7 @@ import Ausruestung from './pages/Ausruestung';
import AusruestungForm from './pages/AusruestungForm'; import AusruestungForm from './pages/AusruestungForm';
import AusruestungDetail from './pages/AusruestungDetail'; import AusruestungDetail from './pages/AusruestungDetail';
import AusruestungEinstellungen from './pages/AusruestungEinstellungen'; import AusruestungEinstellungen from './pages/AusruestungEinstellungen';
import PersoenlicheAusruestung from './pages/PersoenlicheAusruestung';
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';
@@ -192,6 +193,14 @@ function App() {
</ProtectedRoute> </ProtectedRoute>
} }
/> />
<Route
path="/persoenliche-ausruestung"
element={
<ProtectedRoute>
<PersoenlicheAusruestung />
</ProtectedRoute>
}
/>
<Route <Route
path="/atemschutz" path="/atemschutz"
element={ element={

View File

@@ -0,0 +1,64 @@
import { List, ListItem, ListItemText, Chip, Typography } from '@mui/material';
import CheckroomIcon from '@mui/icons-material/Checkroom';
import { useQuery } from '@tanstack/react-query';
import { useNavigate } from 'react-router-dom';
import { personalEquipmentApi } from '../../services/personalEquipment';
import { ZUSTAND_LABELS, ZUSTAND_COLORS } from '../../types/personalEquipment.types';
import { WidgetCard } from '../templates/WidgetCard';
import { ItemListSkeleton } from '../templates/SkeletonPresets';
function PersoenlicheAusruestungWidget() {
const navigate = useNavigate();
const { data: items, isLoading, isError } = useQuery({
queryKey: ['persoenliche-ausruestung', 'my'],
queryFn: () => personalEquipmentApi.getMy(),
staleTime: 5 * 60 * 1000,
retry: 1,
});
const displayItems = (items ?? []).slice(0, 5);
return (
<WidgetCard
title="Meine Ausrüstung"
icon={<CheckroomIcon fontSize="small" />}
isLoading={isLoading}
skeleton={<ItemListSkeleton count={3} />}
isError={isError}
errorMessage="Ausrüstung konnte nicht geladen werden."
isEmpty={!isLoading && !isError && (items ?? []).length === 0}
emptyMessage="Keine persönlichen Gegenstände erfasst"
onClick={() => navigate('/persoenliche-ausruestung')}
footer={
items && items.length > 5 ? (
<Typography variant="caption" color="text.secondary">
+{items.length - 5} weitere
</Typography>
) : undefined
}
>
<List dense disablePadding>
{displayItems.map((item) => (
<ListItem key={item.id} disablePadding sx={{ py: 0.5 }}>
<ListItemText
primary={item.bezeichnung}
secondary={[item.kategorie, item.groesse].filter(Boolean).join(' · ') || undefined}
primaryTypographyProps={{ variant: 'body2', noWrap: true }}
secondaryTypographyProps={{ variant: 'caption' }}
/>
<Chip
label={ZUSTAND_LABELS[item.zustand]}
color={ZUSTAND_COLORS[item.zustand]}
size="small"
variant="outlined"
sx={{ ml: 1, flexShrink: 0 }}
/>
</ListItem>
))}
</List>
</WidgetCard>
);
}
export default PersoenlicheAusruestungWidget;

View File

@@ -25,3 +25,4 @@ export { default as IssueOverviewWidget } from './IssueOverviewWidget';
export { default as ChecklistWidget } from './ChecklistWidget'; export { default as ChecklistWidget } from './ChecklistWidget';
export { default as SortableWidget } from './SortableWidget'; export { default as SortableWidget } from './SortableWidget';
export { default as BuchhaltungWidget } from './BuchhaltungWidget'; export { default as BuchhaltungWidget } from './BuchhaltungWidget';
export { default as PersoenlicheAusruestungWidget } from './PersoenlicheAusruestungWidget';

View File

@@ -31,6 +31,7 @@ import {
Forum, Forum,
AssignmentTurnedIn, AssignmentTurnedIn,
AccountBalance as AccountBalanceIcon, AccountBalance as AccountBalanceIcon,
Checkroom as CheckroomIcon,
} from '@mui/icons-material'; } from '@mui/icons-material';
import { useNavigate, useLocation } from 'react-router-dom'; import { useNavigate, useLocation } from 'react-router-dom';
import { useQuery } from '@tanstack/react-query'; import { useQuery } from '@tanstack/react-query';
@@ -102,6 +103,12 @@ const baseNavigationItems: NavigationItem[] = [
path: '/ausruestung', path: '/ausruestung',
permission: 'ausruestung:view', permission: 'ausruestung:view',
}, },
{
text: 'Pers. Ausrüstung',
icon: <CheckroomIcon />,
path: '/persoenliche-ausruestung',
permission: 'persoenliche_ausruestung:view',
},
{ {
text: 'Mitglieder', text: 'Mitglieder',
icon: <People />, icon: <People />,

View File

@@ -1,6 +1,7 @@
import React from 'react'; import React from 'react';
import { Box, Card, CardActionArea, CardContent, Typography } from '@mui/material'; import { Box, Typography } from '@mui/material';
import { GOLDEN_RATIO } from '../../theme/theme'; import { GOLDEN_RATIO } from '../../theme/theme';
import { WidgetCard } from './WidgetCard';
import { StatSkeleton } from './SkeletonPresets'; import { StatSkeleton } from './SkeletonPresets';
export interface StatCardProps { export interface StatCardProps {
@@ -22,63 +23,48 @@ export const StatCard: React.FC<StatCardProps> = ({
trend, trend,
isLoading = false, isLoading = false,
onClick, onClick,
}) => { }) => (
const content = ( <WidgetCard
<CardContent sx={{ p: 2.5, '&:last-child': { pb: 2.5 } }}> title={title}
{isLoading ? ( isLoading={isLoading}
<StatSkeleton /> skeleton={<StatSkeleton />}
) : ( onClick={onClick}
<Box display="flex" alignItems="center"> noPadding
<Box sx={{ flex: GOLDEN_RATIO }}> >
<Typography variant="caption" textTransform="uppercase" color="text.secondary" sx={{ letterSpacing: '0.06em', fontWeight: 600 }}> <Box display="flex" alignItems="center" sx={{ px: 2.5, pb: 2.5 }}>
{title} <Box sx={{ flex: GOLDEN_RATIO }}>
</Typography> <Typography variant="h3" fontWeight={800} sx={{ lineHeight: 1.1, letterSpacing: '-0.02em' }}>
<Typography variant="h3" fontWeight={800} sx={{ lineHeight: 1.1, mt: 0.5, letterSpacing: '-0.02em' }}> {value}
{value} </Typography>
</Typography> {trend && (
{trend && ( <Box sx={{ display: 'inline-flex', alignItems: 'center', mt: 1, px: 1, py: 0.25, borderRadius: 1, bgcolor: trend.value >= 0 ? 'rgba(34,197,94,0.1)' : 'rgba(239,68,68,0.1)' }}>
<Box sx={{ display: 'inline-flex', alignItems: 'center', mt: 1, px: 1, py: 0.25, borderRadius: 1, bgcolor: trend.value >= 0 ? 'rgba(34,197,94,0.1)' : 'rgba(239,68,68,0.1)' }}> <Typography
<Typography variant="caption"
variant="caption" fontWeight={600}
fontWeight={600} color={trend.value >= 0 ? 'success.main' : 'error.main'}
color={trend.value >= 0 ? 'success.main' : 'error.main'}
>
{trend.value >= 0 ? '↑' : '↓'} {Math.abs(trend.value)}%
{trend.label && ` ${trend.label}`}
</Typography>
</Box>
)}
</Box>
<Box sx={{ flex: 1, display: 'flex', justifyContent: 'center' }}>
<Box
sx={{
width: 52,
height: 52,
borderRadius: 3,
bgcolor: `${color}12`,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
color,
}}
> >
{icon} {trend.value >= 0 ? '↑' : '↓'} {Math.abs(trend.value)}%
</Box> {trend.label && ` ${trend.label}`}
</Typography>
</Box> </Box>
)}
</Box>
<Box sx={{ flex: 1, display: 'flex', justifyContent: 'center' }}>
<Box
sx={{
width: 52,
height: 52,
borderRadius: 3,
bgcolor: `${color}12`,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
color,
}}
>
{icon}
</Box> </Box>
)} </Box>
</CardContent> </Box>
); </WidgetCard>
);
return (
<Card sx={{ height: '100%' }}>
{onClick ? (
<CardActionArea onClick={onClick} sx={{ height: '100%' }}>
{content}
</CardActionArea>
) : (
content
)}
</Card>
);
};

View File

@@ -19,6 +19,7 @@ export const WIDGETS = [
{ key: 'issueOverview', label: 'Issue Übersicht', defaultVisible: true }, { key: 'issueOverview', label: 'Issue Übersicht', defaultVisible: true },
{ key: 'checklistOverdue', label: 'Checklisten', defaultVisible: true }, { key: 'checklistOverdue', label: 'Checklisten', defaultVisible: true },
{ key: 'buchhaltung', label: 'Buchhaltung', defaultVisible: true }, { key: 'buchhaltung', label: 'Buchhaltung', defaultVisible: true },
{ key: 'persoenlicheAusruestung', label: 'Pers. Ausrüstung', defaultVisible: true },
] as const; ] as const;
export type WidgetKey = typeof WIDGETS[number]['key']; export type WidgetKey = typeof WIDGETS[number]['key'];

View File

@@ -5,10 +5,12 @@ import {
Dialog, DialogTitle, DialogContent, DialogActions, TextField, Dialog, DialogTitle, DialogContent, DialogActions, TextField,
MenuItem, Select, FormControl, InputLabel, Autocomplete, MenuItem, Select, FormControl, InputLabel, Autocomplete,
Checkbox, LinearProgress, Switch, FormControlLabel, Alert, Checkbox, LinearProgress, Switch, FormControlLabel, Alert,
ToggleButton, ToggleButtonGroup, Stack, Divider,
} from '@mui/material'; } from '@mui/material';
import { import {
ArrowBack, Add as AddIcon, Delete as DeleteIcon, Edit as EditIcon, ArrowBack, Add as AddIcon, Delete as DeleteIcon, Edit as EditIcon,
Check as CheckIcon, Close as CloseIcon, ShoppingCart as ShoppingCartIcon, Check as CheckIcon, Close as CloseIcon, ShoppingCart as ShoppingCartIcon,
Assignment as AssignmentIcon,
} 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 { useParams, useNavigate } from 'react-router-dom'; import { useParams, useNavigate } from 'react-router-dom';
@@ -18,11 +20,13 @@ import { useNotification } from '../contexts/NotificationContext';
import { usePermissionContext } from '../contexts/PermissionContext'; import { usePermissionContext } from '../contexts/PermissionContext';
import { useAuth } from '../contexts/AuthContext'; import { useAuth } from '../contexts/AuthContext';
import { ausruestungsanfrageApi } from '../services/ausruestungsanfrage'; import { ausruestungsanfrageApi } from '../services/ausruestungsanfrage';
import { vehiclesApi } from '../services/vehicles';
import { membersService } from '../services/members';
import { AUSRUESTUNG_STATUS_LABELS, AUSRUESTUNG_STATUS_COLORS } from '../types/ausruestungsanfrage.types'; import { AUSRUESTUNG_STATUS_LABELS, AUSRUESTUNG_STATUS_COLORS } from '../types/ausruestungsanfrage.types';
import type { import type {
AusruestungAnfrage, AusruestungAnfrageDetailResponse, AusruestungAnfrage, AusruestungAnfrageDetailResponse,
AusruestungAnfrageFormItem, AusruestungAnfrageStatus, AusruestungAnfrageFormItem, AusruestungAnfrageStatus,
AusruestungEigenschaft, AusruestungAnfragePosition, AusruestungEigenschaft,
} from '../types/ausruestungsanfrage.types'; } from '../types/ausruestungsanfrage.types';
// ── Helpers ── // ── Helpers ──
@@ -34,6 +38,225 @@ function formatOrderId(r: AusruestungAnfrage): string {
return `#${r.id}`; return `#${r.id}`;
} }
// ── Helpers ──
type AssignmentTyp = 'ausruestung' | 'persoenlich' | 'keine';
interface PositionAssignment {
typ: AssignmentTyp;
fahrzeugId?: string;
standort?: string;
userId?: string;
benutzerName?: string;
groesse?: string;
kategorie?: string;
}
function getUnassignedPositions(positions: AusruestungAnfragePosition[]): AusruestungAnfragePosition[] {
return positions.filter((p) => p.geliefert && !p.zuweisung_typ);
}
// ══════════════════════════════════════════════════════════════════════════════
// ItemAssignmentDialog
// ══════════════════════════════════════════════════════════════════════════════
interface ItemAssignmentDialogProps {
open: boolean;
onClose: () => void;
anfrage: AusruestungAnfrage;
positions: AusruestungAnfragePosition[];
onSuccess: () => void;
}
function ItemAssignmentDialog({ open, onClose, anfrage, positions, onSuccess }: ItemAssignmentDialogProps) {
const { showSuccess, showError } = useNotification();
const unassigned = getUnassignedPositions(positions);
const [assignments, setAssignments] = useState<Record<number, PositionAssignment>>(() => {
const init: Record<number, PositionAssignment> = {};
for (const p of unassigned) {
init[p.id] = { typ: 'persoenlich' };
}
return init;
});
const { data: vehicleList } = useQuery({
queryKey: ['vehicles', 'sidebar'],
queryFn: () => vehiclesApi.getAll(),
staleTime: 2 * 60 * 1000,
enabled: open,
});
const { data: membersList } = useQuery({
queryKey: ['members-list-compact'],
queryFn: () => membersService.getMembers({ pageSize: 500 }),
staleTime: 5 * 60 * 1000,
enabled: open,
});
const memberOptions = (membersList?.items ?? []).map((m) => ({
id: m.id,
name: [m.given_name, m.family_name].filter(Boolean).join(' ') || m.email,
}));
const vehicleOptions = (vehicleList ?? []).map((v) => ({
id: v.id,
name: v.bezeichnung ?? v.kurzname,
}));
const [submitting, setSubmitting] = useState(false);
const updateAssignment = (posId: number, patch: Partial<PositionAssignment>) => {
setAssignments((prev) => ({
...prev,
[posId]: { ...prev[posId], ...patch },
}));
};
const handleSkipAll = () => {
const updated: Record<number, PositionAssignment> = {};
for (const p of unassigned) {
updated[p.id] = { typ: 'keine' };
}
setAssignments(updated);
};
const handleSubmit = async () => {
setSubmitting(true);
try {
const payload = Object.entries(assignments).map(([posId, a]) => ({
positionId: Number(posId),
typ: a.typ,
fahrzeugId: a.typ === 'ausruestung' ? a.fahrzeugId : undefined,
standort: a.typ === 'ausruestung' ? a.standort : undefined,
userId: a.typ === 'persoenlich' ? a.userId : undefined,
benutzerName: a.typ === 'persoenlich' ? (a.benutzerName || anfrage.fuer_benutzer_name || anfrage.anfrager_name) : undefined,
groesse: a.typ === 'persoenlich' ? a.groesse : undefined,
kategorie: a.typ === 'persoenlich' ? a.kategorie : undefined,
}));
await ausruestungsanfrageApi.assignItems(anfrage.id, payload);
showSuccess('Gegenstände zugewiesen');
onSuccess();
onClose();
} catch {
showError('Fehler beim Zuweisen');
} finally {
setSubmitting(false);
}
};
if (unassigned.length === 0) return null;
return (
<Dialog open={open} onClose={onClose} maxWidth="md" fullWidth>
<DialogTitle>Gegenstände zuweisen</DialogTitle>
<DialogContent>
<Typography variant="body2" color="text.secondary" sx={{ mb: 2 }}>
Wähle für jeden gelieferten Gegenstand, wie er erfasst werden soll.
</Typography>
<Stack spacing={3} divider={<Divider />}>
{unassigned.map((pos) => {
const a = assignments[pos.id] ?? { typ: 'persoenlich' as const };
return (
<Box key={pos.id}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 1.5 }}>
<Typography variant="body2" fontWeight={600}>
{pos.bezeichnung}
</Typography>
<Chip label={`${pos.menge}x`} size="small" variant="outlined" />
</Box>
<ToggleButtonGroup
value={a.typ}
exclusive
size="small"
onChange={(_e, val) => val && updateAssignment(pos.id, { typ: val })}
sx={{ mb: 1.5 }}
>
<ToggleButton value="ausruestung">Ausrüstung</ToggleButton>
<ToggleButton value="persoenlich">Persönlich</ToggleButton>
<ToggleButton value="keine">Nicht erfassen</ToggleButton>
</ToggleButtonGroup>
{a.typ === 'ausruestung' && (
<Box sx={{ display: 'flex', gap: 1.5, flexWrap: 'wrap' }}>
<Autocomplete
size="small"
options={vehicleOptions}
getOptionLabel={(o) => o.name}
value={vehicleOptions.find((v) => v.id === a.fahrzeugId) ?? null}
onChange={(_e, v) => updateAssignment(pos.id, { fahrzeugId: v?.id })}
renderInput={(params) => <TextField {...params} label="Fahrzeug" />}
sx={{ minWidth: 200, flex: 1 }}
/>
<TextField
size="small"
label="Standort"
value={a.standort ?? ''}
onChange={(e) => updateAssignment(pos.id, { standort: e.target.value })}
sx={{ minWidth: 160, flex: 1 }}
/>
</Box>
)}
{a.typ === 'persoenlich' && (
<Box sx={{ display: 'flex', gap: 1.5, flexWrap: 'wrap' }}>
<Autocomplete
size="small"
options={memberOptions}
getOptionLabel={(o) => o.name}
value={memberOptions.find((m) => m.id === a.userId) ?? null}
onChange={(_e, v) => updateAssignment(pos.id, { userId: v?.id, benutzerName: v?.name })}
renderInput={(params) => (
<TextField
{...params}
label="Benutzer"
placeholder={anfrage.fuer_benutzer_name || anfrage.anfrager_name || ''}
/>
)}
sx={{ minWidth: 200, flex: 1 }}
/>
<TextField
size="small"
label="Größe"
value={a.groesse ?? ''}
onChange={(e) => updateAssignment(pos.id, { groesse: e.target.value })}
sx={{ minWidth: 100 }}
/>
<TextField
size="small"
label="Kategorie"
value={a.kategorie ?? ''}
onChange={(e) => updateAssignment(pos.id, { kategorie: e.target.value })}
sx={{ minWidth: 140 }}
/>
</Box>
)}
</Box>
);
})}
</Stack>
</DialogContent>
<DialogActions>
<Button onClick={handleSkipAll} color="inherit">
Alle überspringen
</Button>
<Box sx={{ flex: 1 }} />
<Button onClick={onClose}>Abbrechen</Button>
<Button
variant="contained"
onClick={handleSubmit}
disabled={submitting}
startIcon={<AssignmentIcon />}
>
Zuweisen
</Button>
</DialogActions>
</Dialog>
);
}
// ══════════════════════════════════════════════════════════════════════════════ // ══════════════════════════════════════════════════════════════════════════════
// Component // Component
// ══════════════════════════════════════════════════════════════════════════════ // ══════════════════════════════════════════════════════════════════════════════
@@ -59,6 +282,9 @@ export default function AusruestungsanfrageDetail() {
const [adminNotizen, setAdminNotizen] = useState(''); const [adminNotizen, setAdminNotizen] = useState('');
const [statusChangeValue, setStatusChangeValue] = useState(''); const [statusChangeValue, setStatusChangeValue] = useState('');
// Assignment dialog state
const [assignmentOpen, setAssignmentOpen] = useState(false);
// Eigenschaften state for edit mode // Eigenschaften state for edit mode
const [editItemEigenschaften, setEditItemEigenschaften] = useState<Record<number, AusruestungEigenschaft[]>>({}); const [editItemEigenschaften, setEditItemEigenschaften] = useState<Record<number, AusruestungEigenschaft[]>>({});
const [editItemEigenschaftValues, setEditItemEigenschaftValues] = useState<Record<number, Record<number, string>>>({}); const [editItemEigenschaftValues, setEditItemEigenschaftValues] = useState<Record<number, Record<number, string>>>({});
@@ -105,12 +331,19 @@ export default function AusruestungsanfrageDetail() {
const statusMut = useMutation({ const statusMut = useMutation({
mutationFn: ({ status, notes }: { status: string; notes?: string }) => mutationFn: ({ status, notes }: { status: string; notes?: string }) =>
ausruestungsanfrageApi.updateRequestStatus(requestId, status, notes), ausruestungsanfrageApi.updateRequestStatus(requestId, status, notes),
onSuccess: () => { onSuccess: (_data, variables) => {
queryClient.invalidateQueries({ queryKey: ['ausruestungsanfrage'] }); queryClient.invalidateQueries({ queryKey: ['ausruestungsanfrage'] });
showSuccess('Status aktualisiert'); showSuccess('Status aktualisiert');
setActionDialog(null); setActionDialog(null);
setAdminNotizen(''); setAdminNotizen('');
setStatusChangeValue(''); setStatusChangeValue('');
// Auto-open assignment dialog when status changes to 'erledigt' and unassigned positions exist
if (variables.status === 'erledigt' && detail) {
const unassigned = getUnassignedPositions(detail.positionen);
if (unassigned.length > 0) {
setAssignmentOpen(true);
}
}
}, },
onError: () => showError('Fehler beim Aktualisieren'), onError: () => showError('Fehler beim Aktualisieren'),
}); });
@@ -506,6 +739,15 @@ export default function AusruestungsanfrageDetail() {
Bestellungen erstellen Bestellungen erstellen
</Button> </Button>
)} )}
{showAdminActions && anfrage && anfrage.status === 'erledigt' && detail && getUnassignedPositions(detail.positionen).length > 0 && (
<Button
variant="outlined"
startIcon={<AssignmentIcon />}
onClick={() => setAssignmentOpen(true)}
>
Zuweisen
</Button>
)}
{canEdit && !editing && ( {canEdit && !editing && (
<Button startIcon={<EditIcon />} onClick={startEditing}> <Button startIcon={<EditIcon />} onClick={startEditing}>
Bearbeiten Bearbeiten
@@ -544,6 +786,20 @@ export default function AusruestungsanfrageDetail() {
</DialogActions> </DialogActions>
</Dialog> </Dialog>
{/* Assignment dialog */}
{detail && anfrage && (
<ItemAssignmentDialog
open={assignmentOpen}
onClose={() => setAssignmentOpen(false)}
anfrage={anfrage}
positions={detail.positionen}
onSuccess={() => {
queryClient.invalidateQueries({ queryKey: ['ausruestungsanfrage'] });
queryClient.invalidateQueries({ queryKey: ['persoenliche-ausruestung'] });
}}
/>
)}
</DashboardLayout> </DashboardLayout>
); );
} }

View File

@@ -62,6 +62,7 @@ import IssueQuickAddWidget from '../components/dashboard/IssueQuickAddWidget';
import IssueOverviewWidget from '../components/dashboard/IssueOverviewWidget'; import IssueOverviewWidget from '../components/dashboard/IssueOverviewWidget';
import ChecklistWidget from '../components/dashboard/ChecklistWidget'; import ChecklistWidget from '../components/dashboard/ChecklistWidget';
import BuchhaltungWidget from '../components/dashboard/BuchhaltungWidget'; import BuchhaltungWidget from '../components/dashboard/BuchhaltungWidget';
import PersoenlicheAusruestungWidget from '../components/dashboard/PersoenlicheAusruestungWidget';
import { preferencesApi } from '../services/settings'; import { preferencesApi } from '../services/settings';
import { configApi } from '../services/config'; import { configApi } from '../services/config';
import { WidgetKey } from '../constants/widgets'; import { WidgetKey } from '../constants/widgets';
@@ -85,7 +86,7 @@ const BUILTIN_GROUPS: { name: string; title: string }[] = [
// Default widget order per group (used when no preference is set) // Default widget order per group (used when no preference is set)
const DEFAULT_ORDER: Record<string, string[]> = { const DEFAULT_ORDER: Record<string, string[]> = {
status: ['vehicles', 'equipment', 'atemschutz', 'adminStatus', 'bestellungen', 'ausruestungsanfragen', 'issueOverview', 'checklistOverdue', 'buchhaltung'], status: ['vehicles', 'equipment', 'atemschutz', 'adminStatus', 'bestellungen', 'ausruestungsanfragen', 'issueOverview', 'checklistOverdue', 'buchhaltung', 'persoenlicheAusruestung'],
kalender: ['events', 'vehicleBookingList', 'vehicleBooking', 'eventQuickAdd'], kalender: ['events', 'vehicleBookingList', 'vehicleBooking', 'eventQuickAdd'],
dienste: ['bookstackRecent', 'bookstackSearch', 'vikunjaTasks', 'vikunjaQuickAdd', 'issueQuickAdd'], dienste: ['bookstackRecent', 'bookstackSearch', 'vikunjaTasks', 'vikunjaQuickAdd', 'issueQuickAdd'],
information: ['links', 'bannerWidget'], information: ['links', 'bannerWidget'],
@@ -138,6 +139,7 @@ function Dashboard() {
{ key: 'issueOverview', widgetKey: 'issueOverview', permission: 'issues:view_all', component: <IssueOverviewWidget /> }, { key: 'issueOverview', widgetKey: 'issueOverview', permission: 'issues:view_all', component: <IssueOverviewWidget /> },
{ key: 'checklistOverdue', widgetKey: 'checklistOverdue', permission: 'checklisten:widget', component: <ChecklistWidget /> }, { key: 'checklistOverdue', widgetKey: 'checklistOverdue', permission: 'checklisten:widget', component: <ChecklistWidget /> },
{ key: 'buchhaltung', widgetKey: 'buchhaltung', permission: 'buchhaltung:widget', component: <BuchhaltungWidget /> }, { key: 'buchhaltung', widgetKey: 'buchhaltung', permission: 'buchhaltung:widget', component: <BuchhaltungWidget /> },
{ key: 'persoenlicheAusruestung', widgetKey: 'persoenlicheAusruestung', permission: 'persoenliche_ausruestung:view', component: <PersoenlicheAusruestungWidget /> },
], ],
kalender: [ kalender: [
{ key: 'events', widgetKey: 'events', permission: 'kalender:view', component: <UpcomingEventsWidget /> }, { key: 'events', widgetKey: 'events', permission: 'kalender:view', component: <UpcomingEventsWidget /> },

View File

@@ -43,6 +43,9 @@ import { useAuth } from '../contexts/AuthContext';
import { usePermissionContext } from '../contexts/PermissionContext'; import { usePermissionContext } from '../contexts/PermissionContext';
import { membersService } from '../services/members'; import { membersService } from '../services/members';
import { atemschutzApi } from '../services/atemschutz'; import { atemschutzApi } from '../services/atemschutz';
import { personalEquipmentApi } from '../services/personalEquipment';
import { ZUSTAND_LABELS, ZUSTAND_COLORS } from '../types/personalEquipment.types';
import type { PersoenlicheAusruestung } from '../types/personalEquipment.types';
import { toGermanDate, fromGermanDate } from '../utils/dateInput'; import { toGermanDate, fromGermanDate } from '../utils/dateInput';
import { import {
MemberWithProfile, MemberWithProfile,
@@ -208,6 +211,7 @@ function MitgliedDetail() {
const { userId } = useParams<{ userId: string }>(); const { userId } = useParams<{ userId: string }>();
const navigate = useNavigate(); const navigate = useNavigate();
const canWrite = useCanWrite(); const canWrite = useCanWrite();
const { hasPermission } = usePermissionContext();
const currentUserId = useCurrentUserId(); const currentUserId = useCurrentUserId();
const isOwnProfile = currentUserId === userId; const isOwnProfile = currentUserId === userId;
const canEdit = canWrite || isOwnProfile; const canEdit = canWrite || isOwnProfile;
@@ -225,6 +229,10 @@ function MitgliedDetail() {
const [atemschutz, setAtemschutz] = useState<AtemschutzUebersicht | null>(null); const [atemschutz, setAtemschutz] = useState<AtemschutzUebersicht | null>(null);
const [atemschutzLoading, setAtemschutzLoading] = useState(false); const [atemschutzLoading, setAtemschutzLoading] = useState(false);
// Personal equipment data
const [personalEquipment, setPersonalEquipment] = useState<PersoenlicheAusruestung[]>([]);
const [personalEquipmentLoading, setPersonalEquipmentLoading] = useState(false);
// FDISK-synced sub-section data // FDISK-synced sub-section data
const [befoerderungen, setBefoerderungen] = useState<Befoerderung[]>([]); const [befoerderungen, setBefoerderungen] = useState<Befoerderung[]>([]);
const [untersuchungen, setUntersuchungen] = useState<Untersuchung[]>([]); const [untersuchungen, setUntersuchungen] = useState<Untersuchung[]>([]);
@@ -272,6 +280,18 @@ function MitgliedDetail() {
.finally(() => setAtemschutzLoading(false)); .finally(() => setAtemschutzLoading(false));
}, [userId]); }, [userId]);
// Load personal equipment for this user
useEffect(() => {
if (!userId) return;
const canView = hasPermission('persoenliche_ausruestung:view') || hasPermission('persoenliche_ausruestung:view_all');
if (!canView) return;
setPersonalEquipmentLoading(true);
personalEquipmentApi.getByUserId(userId)
.then(setPersonalEquipment)
.catch(() => setPersonalEquipment([]))
.finally(() => setPersonalEquipmentLoading(false));
}, [userId]);
// Load FDISK-synced sub-section data // Load FDISK-synced sub-section data
useEffect(() => { useEffect(() => {
if (!userId) return; if (!userId) return;
@@ -842,6 +862,44 @@ function MitgliedDetail() {
</Card> </Card>
</Grid> </Grid>
{/* Personal equipment */}
{(hasPermission('persoenliche_ausruestung:view') || hasPermission('persoenliche_ausruestung:view_all')) && (
<Grid item xs={12} md={6}>
<Card>
<CardHeader
avatar={<SecurityIcon color="primary" />}
title="Persönliche Ausrüstung"
/>
<CardContent>
{personalEquipmentLoading ? (
<CircularProgress size={24} />
) : personalEquipment.length === 0 ? (
<Typography color="text.secondary" variant="body2">
Keine persönlichen Gegenstände erfasst
</Typography>
) : (
personalEquipment.map((item) => (
<Box key={item.id} sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', py: 0.75, borderBottom: '1px solid', borderColor: 'divider' }}>
<Box>
<Typography variant="body2" fontWeight={500}>{item.bezeichnung}</Typography>
{item.kategorie && (
<Typography variant="caption" color="text.secondary">{item.kategorie}</Typography>
)}
</Box>
<Chip
label={ZUSTAND_LABELS[item.zustand]}
color={ZUSTAND_COLORS[item.zustand]}
size="small"
variant="outlined"
/>
</Box>
))
)}
</CardContent>
</Card>
</Grid>
)}
{/* Driving licenses */} {/* Driving licenses */}
<Grid item xs={12} md={6}> <Grid item xs={12} md={6}>
<Card> <Card>

View File

@@ -0,0 +1,402 @@
import { useState, useMemo } from 'react';
import {
Autocomplete,
Box,
Chip,
Container,
MenuItem,
Stack,
TextField,
Typography,
} from '@mui/material';
import { Add as AddIcon } from '@mui/icons-material';
import CheckroomIcon from '@mui/icons-material/Checkroom';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import DashboardLayout from '../components/dashboard/DashboardLayout';
import { personalEquipmentApi } from '../services/personalEquipment';
import { ausruestungsanfrageApi } from '../services/ausruestungsanfrage';
import { membersService } from '../services/members';
import { usePermissionContext } from '../contexts/PermissionContext';
import { useNotification } from '../contexts/NotificationContext';
import ChatAwareFab from '../components/shared/ChatAwareFab';
import { FormDialog, PageHeader } from '../components/templates';
import {
ZUSTAND_LABELS,
ZUSTAND_COLORS,
} from '../types/personalEquipment.types';
import type {
PersoenlicheAusruestungZustand,
CreatePersoenlicheAusruestungPayload,
} from '../types/personalEquipment.types';
import type { AusruestungArtikel } from '../types/ausruestungsanfrage.types';
const ZUSTAND_OPTIONS = Object.entries(ZUSTAND_LABELS) as [PersoenlicheAusruestungZustand, string][];
function PersoenlicheAusruestungPage() {
const queryClient = useQueryClient();
const { hasPermission } = usePermissionContext();
const { showSuccess, showError } = useNotification();
const canViewAll = hasPermission('persoenliche_ausruestung:view_all');
const canCreate = hasPermission('persoenliche_ausruestung:create');
const [dialogOpen, setDialogOpen] = useState(false);
const [filterZustand, setFilterZustand] = useState<string>('');
const [filterUser, setFilterUser] = useState<string>('');
const [search, setSearch] = useState('');
// Form state
const [formBezeichnung, setFormBezeichnung] = useState<string | AusruestungArtikel | null>(null);
const [formKategorie, setFormKategorie] = useState('');
const [formUserId, setFormUserId] = useState<{ id: string; name: string } | null>(null);
const [formBenutzerName, setFormBenutzerName] = useState('');
const [formGroesse, setFormGroesse] = useState('');
const [formZustand, setFormZustand] = useState<PersoenlicheAusruestungZustand>('gut');
const [formNotizen, setFormNotizen] = useState('');
// Data queries
const { data: items, isLoading } = useQuery({
queryKey: ['persoenliche-ausruestung', 'all'],
queryFn: () => canViewAll ? personalEquipmentApi.getAll() : personalEquipmentApi.getMy(),
staleTime: 2 * 60 * 1000,
});
const { data: catalogItems } = useQuery({
queryKey: ['ausruestungsanfrage-items-catalog'],
queryFn: () => ausruestungsanfrageApi.getItems(),
staleTime: 10 * 60 * 1000,
});
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]);
const createMutation = useMutation({
mutationFn: (data: CreatePersoenlicheAusruestungPayload) => personalEquipmentApi.create(data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['persoenliche-ausruestung'] });
showSuccess('Persönliche Ausrüstung erstellt');
setDialogOpen(false);
resetForm();
},
onError: () => {
showError('Fehler beim Erstellen');
},
});
const resetForm = () => {
setFormBezeichnung(null);
setFormKategorie('');
setFormUserId(null);
setFormBenutzerName('');
setFormGroesse('');
setFormZustand('gut');
setFormNotizen('');
};
const handleCreate = () => {
const bezeichnung = typeof formBezeichnung === 'string'
? formBezeichnung
: formBezeichnung?.bezeichnung ?? '';
if (!bezeichnung.trim()) return;
const payload: CreatePersoenlicheAusruestungPayload = {
bezeichnung: bezeichnung.trim(),
kategorie: formKategorie || undefined,
artikel_id: typeof formBezeichnung === 'object' && formBezeichnung ? formBezeichnung.id : undefined,
user_id: formUserId?.id || undefined,
benutzer_name: formBenutzerName || undefined,
groesse: formGroesse || undefined,
zustand: formZustand,
notizen: formNotizen || undefined,
};
createMutation.mutate(payload);
};
// Filter logic
const filtered = useMemo(() => {
let result = items ?? [];
if (filterZustand) {
result = result.filter((i) => i.zustand === filterZustand);
}
if (filterUser) {
result = result.filter((i) => i.user_id === filterUser);
}
if (search.trim()) {
const s = search.toLowerCase();
result = result.filter((i) =>
i.bezeichnung.toLowerCase().includes(s) ||
(i.kategorie ?? '').toLowerCase().includes(s) ||
(i.user_display_name ?? i.benutzer_name ?? '').toLowerCase().includes(s)
);
}
return result;
}, [items, filterZustand, filterUser, search]);
return (
<DashboardLayout>
<Container maxWidth="lg">
<PageHeader
title="Persönliche Ausrüstung"
breadcrumbs={[{ label: 'Persönliche Ausrüstung' }]}
/>
{/* Filters */}
<Box sx={{ display: 'flex', gap: 2, mb: 3, flexWrap: 'wrap', alignItems: 'center' }}>
<TextField
size="small"
placeholder="Suche…"
value={search}
onChange={(e) => setSearch(e.target.value)}
sx={{ minWidth: 200 }}
/>
<TextField
size="small"
select
label="Zustand"
value={filterZustand}
onChange={(e) => setFilterZustand(e.target.value)}
sx={{ minWidth: 140 }}
>
<MenuItem value="">Alle</MenuItem>
{ZUSTAND_OPTIONS.map(([key, label]) => (
<MenuItem key={key} value={key}>{label}</MenuItem>
))}
</TextField>
{canViewAll && (
<Autocomplete
size="small"
options={memberOptions}
getOptionLabel={(o) => o.name}
value={memberOptions.find((m) => m.id === filterUser) ?? null}
onChange={(_e, v) => setFilterUser(v?.id ?? '')}
renderInput={(params) => <TextField {...params} label="Benutzer" />}
sx={{ minWidth: 200 }}
/>
)}
<Typography variant="body2" color="text.secondary" sx={{ ml: 'auto' }}>
{filtered.length} {filtered.length === 1 ? 'Eintrag' : 'Einträge'}
</Typography>
</Box>
{/* Table */}
<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': {
cursor: 'pointer',
'&:hover': { bgcolor: 'action.hover' },
},
}}
>
<thead>
<tr>
<th>Bezeichnung</th>
<th>Kategorie</th>
{canViewAll && <th>Benutzer</th>}
<th>Größe</th>
<th>Zustand</th>
<th>Anschaffung</th>
</tr>
</thead>
<tbody>
{isLoading ? (
<tr>
<td colSpan={canViewAll ? 6 : 5}>
<Typography color="text.secondary" sx={{ py: 4, textAlign: 'center' }}>
Lade Daten
</Typography>
</td>
</tr>
) : filtered.length === 0 ? (
<tr>
<td colSpan={canViewAll ? 6 : 5}>
<Box sx={{ display: 'flex', flexDirection: 'column', alignItems: 'center', py: 6 }}>
<CheckroomIcon sx={{ fontSize: 48, color: 'text.disabled', mb: 1 }} />
<Typography color="text.secondary">
Keine Einträge gefunden
</Typography>
</Box>
</td>
</tr>
) : (
filtered.map((item) => (
<tr key={item.id}>
<td>
<Typography variant="body2" fontWeight={500}>
{item.bezeichnung}
</Typography>
{item.artikel_bezeichnung && (
<Typography variant="caption" color="text.secondary">
{item.artikel_bezeichnung}
</Typography>
)}
</td>
<td>
<Typography variant="body2">{item.kategorie ?? '—'}</Typography>
</td>
{canViewAll && (
<td>
<Typography variant="body2">
{item.user_display_name ?? item.benutzer_name ?? '—'}
</Typography>
</td>
)}
<td>
<Typography variant="body2">{item.groesse ?? '—'}</Typography>
</td>
<td>
<Chip
label={ZUSTAND_LABELS[item.zustand]}
color={ZUSTAND_COLORS[item.zustand]}
size="small"
variant="outlined"
/>
</td>
<td>
<Typography variant="body2">
{item.anschaffung_datum
? new Date(item.anschaffung_datum).toLocaleDateString('de-AT')
: '—'}
</Typography>
</td>
</tr>
))
)}
</tbody>
</Box>
</Box>
</Container>
{/* FAB */}
{canCreate && (
<ChatAwareFab
onClick={() => setDialogOpen(true)}
aria-label="Persönliche Ausrüstung hinzufügen"
>
<AddIcon />
</ChatAwareFab>
)}
{/* Create Dialog */}
<FormDialog
open={dialogOpen}
onClose={() => { setDialogOpen(false); resetForm(); }}
title="Persönliche Ausrüstung hinzufügen"
onSubmit={handleCreate}
submitLabel="Erstellen"
isSubmitting={createMutation.isPending}
>
<Stack spacing={2} sx={{ mt: 1 }}>
<Autocomplete
freeSolo
options={catalogItems ?? []}
getOptionLabel={(o) => typeof o === 'string' ? o : o.bezeichnung}
value={formBezeichnung}
onChange={(_e, v) => {
setFormBezeichnung(v);
if (v && typeof v !== 'string' && v.kategorie) {
setFormKategorie(v.kategorie);
}
}}
onInputChange={(_e, v) => {
if (typeof formBezeichnung === 'string' || formBezeichnung === null) {
setFormBezeichnung(v);
}
}}
renderInput={(params) => (
<TextField {...params} label="Bezeichnung" required size="small" />
)}
size="small"
/>
{canViewAll && (
<Autocomplete
options={memberOptions}
getOptionLabel={(o) => o.name}
value={formUserId}
onChange={(_e, v) => setFormUserId(v)}
renderInput={(params) => (
<TextField {...params} label="Benutzer" size="small" />
)}
size="small"
/>
)}
{!canViewAll && (
<TextField
label="Benutzer (Name)"
size="small"
value={formBenutzerName}
onChange={(e) => setFormBenutzerName(e.target.value)}
/>
)}
<TextField
label="Kategorie"
size="small"
value={formKategorie}
onChange={(e) => setFormKategorie(e.target.value)}
/>
<TextField
label="Größe"
size="small"
value={formGroesse}
onChange={(e) => setFormGroesse(e.target.value)}
/>
<TextField
label="Zustand"
select
size="small"
value={formZustand}
onChange={(e) => setFormZustand(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={formNotizen}
onChange={(e) => setFormNotizen(e.target.value)}
/>
</Stack>
</FormDialog>
</DashboardLayout>
);
}
export default PersoenlicheAusruestungPage;

View File

@@ -122,6 +122,23 @@ export const ausruestungsanfrageApi = {
await api.patch(`/api/ausruestungsanfragen/positionen/${positionId}/geliefert`, { geliefert }); await api.patch(`/api/ausruestungsanfragen/positionen/${positionId}/geliefert`, { geliefert });
}, },
// ── Item assignment ──
assignItems: async (
anfrageId: number,
assignments: Array<{
positionId: number;
typ: 'ausruestung' | 'persoenlich' | 'keine';
fahrzeugId?: string;
standort?: string;
userId?: string;
benutzerName?: string;
groesse?: string;
kategorie?: string;
}>,
): Promise<void> => {
await api.post(`/api/ausruestungsanfragen/requests/${anfrageId}/assign`, { assignments });
},
updatePositionZurueckgegeben: async (positionId: number, altes_geraet_zurueckgegeben: boolean): Promise<void> => { updatePositionZurueckgegeben: async (positionId: number, altes_geraet_zurueckgegeben: boolean): Promise<void> => {
await api.patch(`/api/ausruestungsanfragen/positionen/${positionId}/zurueckgegeben`, { altes_geraet_zurueckgegeben }); await api.patch(`/api/ausruestungsanfragen/positionen/${positionId}/zurueckgegeben`, { altes_geraet_zurueckgegeben });
}, },

View File

@@ -0,0 +1,64 @@
import { api } from './api';
import type {
PersoenlicheAusruestung,
CreatePersoenlicheAusruestungPayload,
UpdatePersoenlicheAusruestungPayload,
} from '../types/personalEquipment.types';
async function unwrap<T>(
promise: ReturnType<typeof api.get<{ success: boolean; data: T }>>
): Promise<T> {
const response = await promise;
if (response.data?.data === undefined || response.data?.data === null) {
throw new Error('Invalid API response');
}
return response.data.data;
}
export const personalEquipmentApi = {
async getAll(params?: { user_id?: string; kategorie?: string; zustand?: string }): Promise<PersoenlicheAusruestung[]> {
const qs = new URLSearchParams();
if (params?.user_id) qs.set('user_id', params.user_id);
if (params?.kategorie) qs.set('kategorie', params.kategorie);
if (params?.zustand) qs.set('zustand', params.zustand);
return unwrap(api.get(`/api/persoenliche-ausruestung?${qs.toString()}`));
},
async getMy(): Promise<PersoenlicheAusruestung[]> {
return unwrap(api.get('/api/persoenliche-ausruestung/my'));
},
async getByUserId(userId: string): Promise<PersoenlicheAusruestung[]> {
return unwrap(api.get(`/api/persoenliche-ausruestung/user/${userId}`));
},
async getById(id: string): Promise<PersoenlicheAusruestung> {
return unwrap(api.get(`/api/persoenliche-ausruestung/${id}`));
},
async create(data: CreatePersoenlicheAusruestungPayload): Promise<PersoenlicheAusruestung> {
const response = await api.post<{ success: boolean; data: PersoenlicheAusruestung }>(
'/api/persoenliche-ausruestung',
data,
);
if (response.data?.data === undefined || response.data?.data === null) {
throw new Error('Invalid API response');
}
return response.data.data;
},
async update(id: string, data: UpdatePersoenlicheAusruestungPayload): Promise<PersoenlicheAusruestung> {
const response = await api.patch<{ success: boolean; data: PersoenlicheAusruestung }>(
`/api/persoenliche-ausruestung/${id}`,
data,
);
if (response.data?.data === undefined || response.data?.data === null) {
throw new Error('Invalid API response');
}
return response.data.data;
},
async delete(id: string): Promise<void> {
await api.delete(`/api/persoenliche-ausruestung/${id}`);
},
};

View File

@@ -111,6 +111,9 @@ export interface AusruestungAnfragePosition {
eigenschaften?: AusruestungPositionEigenschaft[]; eigenschaften?: AusruestungPositionEigenschaft[];
ist_ersatz: boolean; ist_ersatz: boolean;
altes_geraet_zurueckgegeben: boolean; altes_geraet_zurueckgegeben: boolean;
zuweisung_typ?: 'ausruestung' | 'persoenlich' | 'keine' | null;
zuweisung_ausruestung_id?: string | null;
zuweisung_persoenlich_id?: string | null;
} }
export interface AusruestungAnfrageFormItem { export interface AusruestungAnfrageFormItem {

View File

@@ -0,0 +1,65 @@
// Personal Equipment (Persönliche Ausrüstung) — Frontend Types
export type PersoenlicheAusruestungZustand = 'gut' | 'beschaedigt' | 'abgaengig' | 'verloren';
export const ZUSTAND_LABELS: Record<PersoenlicheAusruestungZustand, string> = {
gut: 'Gut',
beschaedigt: 'Beschädigt',
abgaengig: 'Abgängig',
verloren: 'Verloren',
};
export const ZUSTAND_COLORS: Record<PersoenlicheAusruestungZustand, 'success' | 'warning' | 'error' | 'default'> = {
gut: 'success',
beschaedigt: 'warning',
abgaengig: 'error',
verloren: 'default',
};
export interface PersoenlicheAusruestung {
id: string;
bezeichnung: string;
kategorie?: string;
artikel_id?: number;
artikel_bezeichnung?: string;
user_id?: string;
user_display_name?: string;
benutzer_name?: string;
groesse?: string;
seriennummer?: string;
inventarnummer?: string;
anschaffung_datum?: string;
zustand: PersoenlicheAusruestungZustand;
notizen?: string;
anfrage_id?: number;
erstellt_am: string;
aktualisiert_am: string;
}
export interface CreatePersoenlicheAusruestungPayload {
bezeichnung: string;
kategorie?: string;
artikel_id?: number;
user_id?: string;
benutzer_name?: string;
groesse?: string;
seriennummer?: string;
inventarnummer?: string;
anschaffung_datum?: string;
zustand?: PersoenlicheAusruestungZustand;
notizen?: string;
}
export interface UpdatePersoenlicheAusruestungPayload {
bezeichnung?: string;
kategorie?: string | null;
artikel_id?: number | null;
user_id?: string | null;
benutzer_name?: string | null;
groesse?: string | null;
seriennummer?: string | null;
inventarnummer?: string | null;
anschaffung_datum?: string | null;
zustand?: PersoenlicheAusruestungZustand;
notizen?: string | null;
}