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

@@ -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();