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:
@@ -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');
|
||||||
|
|||||||
@@ -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)
|
||||||
// -------------------------------------------------------------------------
|
// -------------------------------------------------------------------------
|
||||||
|
|||||||
177
backend/src/controllers/personalEquipment.controller.ts
Normal file
177
backend/src/controllers/personalEquipment.controller.ts
Normal 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();
|
||||||
@@ -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;
|
||||||
@@ -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;
|
||||||
|
|||||||
39
backend/src/routes/personalEquipment.routes.ts
Normal file
39
backend/src/routes/personalEquipment.routes.ts
Normal 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;
|
||||||
@@ -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,
|
||||||
};
|
};
|
||||||
|
|||||||
200
backend/src/services/personalEquipment.service.ts
Normal file
200
backend/src/services/personalEquipment.service.ts
Normal 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,
|
||||||
|
};
|
||||||
@@ -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(
|
||||||
|
|||||||
@@ -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={
|
||||||
|
|||||||
@@ -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;
|
||||||
@@ -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';
|
||||||
|
|||||||
@@ -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 />,
|
||||||
|
|||||||
@@ -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,18 +23,17 @@ 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 display="flex" alignItems="center" sx={{ px: 2.5, pb: 2.5 }}>
|
||||||
<Box sx={{ flex: GOLDEN_RATIO }}>
|
<Box sx={{ flex: GOLDEN_RATIO }}>
|
||||||
<Typography variant="caption" textTransform="uppercase" color="text.secondary" sx={{ letterSpacing: '0.06em', fontWeight: 600 }}>
|
<Typography variant="h3" fontWeight={800} sx={{ lineHeight: 1.1, letterSpacing: '-0.02em' }}>
|
||||||
{title}
|
|
||||||
</Typography>
|
|
||||||
<Typography variant="h3" fontWeight={800} sx={{ lineHeight: 1.1, mt: 0.5, letterSpacing: '-0.02em' }}>
|
|
||||||
{value}
|
{value}
|
||||||
</Typography>
|
</Typography>
|
||||||
{trend && (
|
{trend && (
|
||||||
@@ -66,19 +66,5 @@ export const StatCard: React.FC<StatCardProps> = ({
|
|||||||
</Box>
|
</Box>
|
||||||
</Box>
|
</Box>
|
||||||
</Box>
|
</Box>
|
||||||
)}
|
</WidgetCard>
|
||||||
</CardContent>
|
);
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Card sx={{ height: '100%' }}>
|
|
||||||
{onClick ? (
|
|
||||||
<CardActionArea onClick={onClick} sx={{ height: '100%' }}>
|
|
||||||
{content}
|
|
||||||
</CardActionArea>
|
|
||||||
) : (
|
|
||||||
content
|
|
||||||
)}
|
|
||||||
</Card>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|||||||
@@ -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'];
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 /> },
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
402
frontend/src/pages/PersoenlicheAusruestung.tsx
Normal file
402
frontend/src/pages/PersoenlicheAusruestung.tsx
Normal 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;
|
||||||
@@ -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 });
|
||||||
},
|
},
|
||||||
|
|||||||
64
frontend/src/services/personalEquipment.ts
Normal file
64
frontend/src/services/personalEquipment.ts
Normal 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}`);
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -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 {
|
||||||
|
|||||||
65
frontend/src/types/personalEquipment.types.ts
Normal file
65
frontend/src/types/personalEquipment.types.ts
Normal 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;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user